"""M01 일위대가 조합(PLAN 4-2) — 담은 단가산출 로직 목록만 두고 미리 보기로 비목별 합계. 계약 `resources/master_data/_화면_계약.md` 9장 · 틀 `_틀.md` 10장 · 엔진 `scripts/master_combo.py`. 못박는 것 - 조합은 이름과 담은 로직 목록만 지님 — 수량·비율·계산 칸을 두지 않음. - 미리 보기 합계 = 담은 로직 저마다의 시험 계산을 비목별로 더한 값(저장 없음). - 검사 — 없는 로직 키 · 같은 로직 두 번 · 빈 조합 · 조합이 조합을 담음. - 시험은 임시 사본에서 돌고, 사본의 조합 줄은 비워 시작 — 정본에 견본 조합이 있어도 없어도 같음. """ from __future__ import annotations import json import shutil import sys from decimal import Decimal from pathlib import Path import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from M01_MasterData import M01_MasterData_Router as router_module from M01_MasterData import M01_MasterData_Store as store sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "master_data/scripts")) import master_combo as mcb # noqa: E402 REAL = store.FOLDER STACK = "GF000219" # 13-4-1 돌쌓기 메쌓기(인력) LAYER = "GF000220" # 13-4-2 돌쌓기 메쌓기(장비) GATHER = "GF000211" # 13-2-1 모래·자갈·약돌 채집(인력) SAMPLE = (STACK, LAYER, GATHER) # 견본 조합 — 로직 셋 @pytest.fixture def client(tmp_path: Path, monkeypatch) -> TestClient: for path in REAL.glob("*.json"): if not path.name.startswith("_") or path.name == store.mk.BOOK.name: shutil.copy(path, tmp_path / path.name) own = tmp_path / mcb.FILE # 정본의 견본 조합이 시험 줄 수에 끼지 않게 사본은 비워 시작 data = json.loads(own.read_text(encoding="utf-8")) data["줄"] = [] own.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") monkeypatch.setattr(store, "FOLDER", tmp_path) app = FastAPI() app.include_router(router_module.router) return TestClient(app) def _get(client: TestClient, url: str, **params) -> dict: res = client.get(url, params=params) assert res.status_code == 200, res.text return res.json() def _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict: res = client.post(url, json=body) assert res.status_code == want, res.text return res.json() def _inputs(key: str) -> dict: """그 로직의 일괄 시험 입력 한 벌(`check_master.시험입력`).""" row = store.cm.master(REAL).logic(key) return {k: str(v) for k, v in store.cm.시험입력(row).items()} def _sums(client: TestClient, key: str, inputs: dict) -> dict: got = _post(client, "/api/m01/calc", {"key": key, "inputs": inputs}) assert got["ok"] is True, got return got["sums"] def _make(client: TestClient, keys=SAMPLE, 이름="돌쌓기 한 벌", owner: str = "현장") -> dict: body = { "이름": 이름, "구분": "자체", "상세구분": "13장 구조물", "단위": "㎡", "담은로직": [{"로직": k, "메모": None} for k in keys], } return _post(client, "/api/m01/combo/new", {"combo": body, "owner": owner}) # ── 정본 파일 ───────────────────────────────────────────────────────── def test_정본_조합_파일은_틀대로다() -> None: """줄 수는 못 박지 않음 — 견본이 있어도 없어도 됨 · 줄은 조합 키(UA)와 열 한 벌만.""" data = store.cm.load([mcb.FILE], folder=REAL)[mcb.FILE] assert data["그룹"] == mcb.GROUP assert all(row["키"].startswith("UA") and set(row) <= set(mcb.COLS) for row in data["줄"]) assert "원문" not in data # 관리자가 만드는 것이라 원문 근거가 없음 def test_조합_테이블ID_는_UA_이고_줄에_원문번호를_두지_않는다() -> None: assert store.mk.table_id(mcb.GROUP, None) == "UA" assert store.mk.GROUP_OF["UA"] == mcb.GROUP assert "UA" in store.mk.NO_NUMBER def test_조합은_요소_그룹이_아니다(client: TestClient) -> None: """조합은 제 길(`/combos`·`/combo`)로만 다룸 — 요소 길(`/groups`·`/rows`)에 끼지 않음. 끼우면 화면이 조합을 여느 요소 표처럼 `/rows` 로 펴 버림(줄 모양이 아주 다름). """ assert mcb.GROUP not in store.cm.mf.GROUPS groups = [g["group"] for g in _get(client, "/api/m01/groups")["groups"]] assert groups == ["인력", "재료", "기계", "소요량", "계수", "환율", "요율", "로직"] assert client.get("/api/m01/groups/일위대가조합/files").status_code == 404 # ── 만들기 · 읽기 ───────────────────────────────────────────────────── def test_조합을_만들면_키를_서버가_주고_담은_로직에_이름이_붙는다(client: TestClient) -> None: made = _make(client) key = made["key"] assert key.startswith("UA"), made assert made["combo"]["소유"] == "현장" assert tuple(made["combo"]) == mcb.COLS # 열 한 벌 · 차례까지 assert "원문번호" not in made["combo"] got = _get(client, "/api/m01/combo", key=key) assert [one["로직"] for one in got["줄"]] == list(SAMPLE) # 담은 차례 그대로 assert got["줄"][0]["이름"] == "돌쌓기 메쌓기(인력)" assert got["줄"][0]["결과단위"] == "원/㎡" assert got["blocked"] is False and got["reasons"] == [] # 수량·비율·계산은 조합에 두지 않음 assert set(got["combo"]["담은로직"][0]) == {"로직", "메모"} def test_목록은_구분_소유_찾기로_거른다(client: TestClient) -> None: _make(client, 이름="돌쌓기 한 벌") _make(client, keys=(GATHER,), 이름="채집만", owner="공용") assert len(_get(client, "/api/m01/combos")["combos"]) == 2 assert [c["이름"] for c in _get(client, "/api/m01/combos", q="채집")["combos"]] == ["채집만"] only = _get(client, "/api/m01/combos", owner="공용")["combos"] assert [c["이름"] for c in only] == ["채집만"] and only[0]["count"] == 1 def test_이_로직을_쓰는_조합을_거꾸로_본다(client: TestClient) -> None: first = _make(client, 이름="돌쌓기 한 벌")["key"] second = _make(client, keys=(STACK,), 이름="메쌓기만")["key"] got = _get(client, "/api/m01/logic/combos", key=STACK) assert [c["키"] for c in got["combos"]] == [first, second] assert got["combos"][0]["차례"] == [1] # 그 조합 안 몇 번째인지 assert _get(client, "/api/m01/logic/combos", key="GF000001")["combos"] == [] # ── 고치기 · 지우기 ─────────────────────────────────────────────────── def test_로직을_빼고_더하고_지울_수_있다(client: TestClient) -> None: key = _make(client)["key"] got = _get(client, "/api/m01/combo", key=key) row = got["combo"] row["담은로직"] = [{"로직": LAYER, "메모": "장비만 남김"}] done = _post( client, "/api/m01/combo/edit", {"key": key, "version": got["version"], "combo": row} ) assert [one["로직"] for one in done["combo"]["담은로직"]] == [LAYER] assert done["combo"]["담은로직"][0]["메모"] == "장비만 남김" assert done["combo"]["키"] == key # 키는 안 바뀜 now = _get(client, "/api/m01/combo", key=key) _post(client, "/api/m01/combo/delete", {"key": key, "version": now["version"]}) assert _get(client, "/api/m01/combos")["combos"] == [] def test_낡은_판본으로_고치면_409(client: TestClient) -> None: key = _make(client)["key"] row = _get(client, "/api/m01/combo", key=key)["combo"] got = _post( client, "/api/m01/combo/edit", {"key": key, "version": "낡은판본", "combo": row}, want=409, ) assert got["detail"] == {"stale": [mcb.FILE]} # ── 검사 ────────────────────────────────────────────────────────────── def test_없는_로직_키는_저장을_막는다(client: TestClient) -> None: got = _post( client, "/api/m01/combo/new", {"combo": {"이름": "엉터리", "담은로직": [{"로직": "GF999999", "메모": None}]}}, want=422, ) assert any("GF999999" in x for x in got["detail"]["errors"]), got def test_같은_로직을_두_번_담으면_막는다(client: TestClient) -> None: got = _post( client, "/api/m01/combo/new", {"combo": {"이름": "두 번", "담은로직": [{"로직": STACK}, {"로직": STACK}]}}, want=422, ) assert any("두 번 담음" in x for x in got["detail"]["errors"]), got def test_빈_조합은_막는다(client: TestClient) -> None: got = _post(client, "/api/m01/combo/new", {"combo": {"이름": "빈 것", "담은로직": []}}, 422) assert any("빈 조합" in x for x in got["detail"]["errors"]), got def test_조합은_조합을_담지_못한다_1단만(client: TestClient) -> None: key = _make(client)["key"] got = _post( client, "/api/m01/combo/new", {"combo": {"이름": "겹친 것", "담은로직": [{"로직": key}]}}, want=422, ) assert any("조합을 담지 못함" in x for x in got["detail"]["errors"]), got def test_이름_없는_조합과_모르는_소유는_400(client: TestClient) -> None: _post(client, "/api/m01/combo/new", {"combo": {"담은로직": [{"로직": STACK}]}}, want=400) _post( client, "/api/m01/combo/new", {"combo": {"이름": "가", "담은로직": [{"로직": STACK}]}, "owner": "아무개"}, want=400, ) def test_이름이_글자_아니면_400(client: TestClient) -> None: """재현: 이름 ["x"] 가 200 으로 저장됨(일감 24 어긋난 것 2) — 이제 400.""" _post( client, "/api/m01/combo/new", {"combo": {"이름": ["x"], "담은로직": [{"로직": STACK}]}}, want=400, ) def test_이름이_너무_길면_막는다(client: TestClient) -> None: got = _post( client, "/api/m01/combo/new", {"combo": {"이름": "가" * 61, "담은로직": [{"로직": STACK}]}}, want=422, ) assert any("너무 김" in x for x in got["detail"]["errors"]), got def test_같은_이름_두_번_담으면_막는다(client: TestClient) -> None: _make(client, keys=(STACK,), 이름="돌쌓기 한 벌") got = _post( client, "/api/m01/combo/new", {"combo": {"이름": "돌쌓기 한 벌", "담은로직": [{"로직": LAYER}]}}, want=422, ) assert any("겹침" in x for x in got["detail"]["errors"]), got def test_메모가_글자_아니면_막는다(client: TestClient) -> None: """재현: 메모 {"수량": 3} 이 200 으로 저장됨(일감 24 어긋난 것 1) — 이제 422.""" got = _post( client, "/api/m01/combo/new", {"combo": {"이름": "메모 흠", "담은로직": [{"로직": STACK, "메모": {"수량": 3}}]}}, want=422, ) assert any("메모」 가 글자 아님" in x for x in got["detail"]["errors"]), got # ── 미리 보기 ───────────────────────────────────────────────────────── def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별로_같다(client: TestClient) -> None: key = _make(client)["key"] each = {k: _inputs(k) for k in SAMPLE} want = { name: sum(Decimal(str(_sums(client, k, each[k])[name])) for k in SAMPLE) for name in ("노무비", "재료비", "경비", "계") } got = _post(client, "/api/m01/combo/preview", {"combo": key, "inputs": each}) assert got["멈춤"] == [], got["멈춤"] assert [one["로직"] for one in got["줄"]] == list(SAMPLE) for name in ("노무비", "재료비", "경비", "계"): assert Decimal(str(got[name])) == want[name], name assert Decimal(str(got["계"])) > 0 # 줄마다도 그 로직 하나의 시험 계산과 같음 for one in got["줄"]: mine = _sums(client, one["로직"], each[one["로직"]]) assert Decimal(str(one["계"])) == Decimal(str(mine["계"])) def test_미리보기는_저장_전_조합_줄도_받고_아무것도_저장하지_않는다(client: TestClient) -> None: before = _get(client, "/api/m01/combos")["combos"] row = {"이름": "저장 안 한 것", "담은로직": [{"로직": STACK, "메모": None}]} got = _post(client, "/api/m01/combo/preview", {"combo": row, "inputs": {STACK: _inputs(STACK)}}) assert got["멈춤"] == [] and Decimal(str(got["계"])) > 0 assert _get(client, "/api/m01/combos")["combos"] == before # 아무것도 안 생김 def test_못_도는_줄은_까닭만_적고_나머지_합계는_낸다(client: TestClient) -> None: key = _make(client, keys=(STACK, LAYER))["key"] only = {STACK: _inputs(STACK)} # LAYER 입력은 안 줌 — 그 줄만 멈춤 got = _post(client, "/api/m01/combo/preview", {"combo": key, "inputs": only}) stuck = [one for one in got["줄"] if not one["ok"]] assert len(stuck) == 1 and stuck[0]["로직"] == LAYER and stuck[0]["까닭"] assert len(got["멈춤"]) == 1 alone = _sums(client, STACK, only[STACK]) assert Decimal(str(got["계"])) == Decimal(str(alone["계"])) # 선 줄만 더함 def test_정본_로직은_조합을_만들어도_안_바뀐다(client: TestClient) -> None: before = _get(client, "/api/m01/logic", key=STACK) key = _make(client)["key"] _post( client, "/api/m01/combo/preview", {"combo": key, "inputs": {k: _inputs(k) for k in SAMPLE}} ) after = _get(client, "/api/m01/logic", key=STACK) assert after["version"] == before["version"] and after["logic"] == before["logic"]