"""식으로 새 로직 만들기 — 변수 뽑기 · 검사 · 단위 · 로직 틀로 옮기기 (`_화면_계약.md` 7장). 견본 셋 — 자재비 식(제대로 도는 것) · 단위가 어긋난 식 · 서로 물고 도는 식. 해석기 `resources/master_data/scripts/master_draft.py` · 서버 길 `POST /api/m01/logic/draft`. """ from __future__ import annotations 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 from M01_MasterData import M01_MasterData_Store_Make as make sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "master_data/scripts")) import master_draft as md # noqa: E402 REAL = store.FOLDER LABOR = "LB000002" # 보통인부 # ① 자재비 식 — 넓이 → 품 → 보통인부 한 줄 + 잡재료비 덧줄 GOOD = """넓이 = 길이 * 폭 인부 = 넓이 * 품 잡재료비 = 노무비 * 0.05 공사비 = 인부 * 보통인부 + 잡재료비 """ GOOD_정함 = { "길이": {"무엇": "설계입력", "단위": "m", "설명": "쌓을 길이"}, "폭": {"무엇": "설계입력", "단위": "m", "설명": "쌓을 폭"}, "품": {"무엇": "고정값", "값": "0.15", "단위": "인/㎡"}, "보통인부": {"무엇": "마스터요소", "요소": LABOR, "단위": "인", "비목": "노무비"}, "잡재료비": {"비목": "재료비"}, } GOOD_머리 = {"이름": "시험 자재비", "결과단위": "원/㎡", "출처": "자체"} # ② 단위가 어긋난 식 — 길이(m) 와 넓이(㎡) 를 더함 BAD_UNIT = """넓이 = 길이 * 폭 틀린값 = 길이 + 넓이 """ BAD_UNIT_정함 = {"길이": {"무엇": "설계입력", "단위": "m"}, "폭": {"무엇": "설계입력", "단위": "m"}} # ③ 서로 물고 도는 식 LOOP = """가 = 나 + 1 나 = 가 * 2 끝 = 나 """ @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) monkeypatch.setattr(store, "FOLDER", tmp_path) app = FastAPI() app.include_router(router_module.router) return TestClient(app) def _post(client: TestClient, body: dict, want: int = 200) -> dict: res = client.post("/api/m01/logic/draft", json=body) assert res.status_code == want, res.text return res.json() def _var(got: dict, name: str) -> dict: return next(v for v in got["변수"] if v["이름"] == name) # ── ① 변수 뽑기 · 정할 것 ───────────────────────────────────────────── def test_변수를_뽑고_정할_것을_알려_준다() -> None: got = md.read(GOOD) # 아직 아무것도 안 정함 assert got["결과"] == "공사비" and got["다됨"] is False assert _var(got, "넓이")["갈래"] == "정의됨" and _var(got, "넓이")["정의줄"] == 1 assert _var(got, "길이")["갈래"] == "미정" and _var(got, "길이")["처음"] == 1 assert _var(got, "보통인부")["처음"] == 4 # 처음 나온 줄 assert _var(got, "길이")["정할것"] == ["무엇", "단위"] assert _var(got, "노무비")["갈래"] == "비목합" and _var(got, "노무비")["정할것"] == [] 정함 = dict(GOOD_정함, 보통인부={"무엇": "마스터요소", "요소": LABOR, "단위": "인"}) assert _var(md.read(GOOD, 정함), "보통인부")["정할것"] == ["비목"] assert md.read(GOOD, GOOD_정함)["다됨"] is True def test_표_찾기는_변수_하나로_두고_자리만_만든다() -> None: 정함 = {"석공": {"무엇": "표찾기", "단위": "인/㎡"}} assert _var(md.read("품 = 넓이 * 석공", 정함), "석공")["정할것"] == ["찾기"] 이은 = { "석공": { "무엇": "표찾기", "단위": "인/㎡", "찾기": {"표": "QF000421", "값칸": "석공", "조건": {"뒷길이": "뒷길이"}}, } } assert _var(md.read("품 = 넓이 * 석공", 이은), "석공")["정할것"] == [] assert md.find_text(이은["석공"]["찾기"]) == "찾기(QF000421, 뒷길이=뒷길이).석공" # ── ② 단위 검사 ─────────────────────────────────────────────────────── def test_단위가_어긋난_줄과_자리를_짚는다() -> None: got = md.read(BAD_UNIT, BAD_UNIT_정함) bad = [x for x in got["문제"] if x["갈래"] == "단위"] assert len(bad) == 1 and bad[0]["줄"] == 2 assert BAD_UNIT.split("\n")[1][bad[0]["자리"]] == "+" # 짚은 자리가 그 셈 기호 assert "m + ㎡" in bad[0]["말"] def test_곱셈은_단위를_곱하고_나눗셈은_나눈다() -> None: 정함 = {"길이": {"무엇": "설계입력", "단위": "m"}, "폭": {"무엇": "설계입력", "단위": "m"}} assert _var(md.read("넓이 = 길이 * 폭", 정함), "넓이")["단위"] == "㎡" 부피 = {**정함, "높이": {"무엇": "설계입력", "단위": "m"}} assert _var(md.read("부피 = 길이 * 폭 * 높이", 부피), "부피")["단위"] == "㎥" 나눔 = { "부피": {"무엇": "설계입력", "단위": "㎥"}, "시간당": {"무엇": "설계입력", "단위": "㎥/hr"}, } assert _var(md.read("시간 = 부피 / 시간당", 나눔), "시간")["단위"] == "hr" assert _var(md.read(GOOD, GOOD_정함), "공사비")["단위"] == "원" # 인 × 원/인 = 원 def test_만약과_최소는_갈래마다_단위가_같아야_한다() -> None: 정함 = {"길이": {"무엇": "설계입력", "단위": "m"}, "넓이": {"무엇": "설계입력", "단위": "㎡"}} got = md.read("값 = 만약(길이 > 3, 길이, 넓이)", 정함) assert [x["갈래"] for x in got["문제"]] == ["단위"] and "만약" in got["문제"][0]["말"] assert md.read("값 = 최소(길이, 넓이)", 정함)["문제"][0]["갈래"] == "단위" assert md.read("값 = 최소(길이, 길이 * 2)", 정함)["문제"] == [] # ── ③ 그 밖 검사 ────────────────────────────────────────────────────── def test_서로_물고_도는_식을_잡는다() -> None: kinds = [x["갈래"] for x in md.read(LOOP)["문제"]] assert "돌고돎" in kinds 말 = next(x["말"] for x in md.read(LOOP)["문제"] if x["갈래"] == "돌고돎") assert "가" in 말 and "나" in 말 assert md.read("가 = 가 + 1\n끝 = 가")["문제"][0]["갈래"] == "돌고돎" def test_두_번_정의_안_쓰임_결과_하나() -> None: 두번 = md.read("가 = 1\n가 = 2\n끝 = 가")["문제"] assert 두번[0]["갈래"] == "두번정의" and 두번[0]["줄"] == 2 안씀 = md.read("가 = 1\n남 = 5\n끝 = 가")["문제"] assert [x["갈래"] for x in 안씀] == ["안쓰임"] and 안씀[0]["줄"] == 2 assert "결과는 「끝」 하나" in 안씀[0]["말"] assert md.read("노무비 = 5\n끝 = 노무비")["문제"][0]["갈래"] == "이름" assert md.read("이건 식이 아님")["문제"][0]["갈래"] == "못읽음" # ── ⑤ 로직 틀로 옮기기 ──────────────────────────────────────────────── def test_다_정하면_로직_틀로_옮긴다() -> None: row, bad = md.옮기기(GOOD, GOOD_정함, GOOD_머리) assert bad == [] assert [x["이름"] for x in row["입력"]] == ["길이", "폭"] assert row["입력"][0]["설명"] == "쌓을 길이" and row["입력"][0]["단위"] == "m" assert [x["이름"] for x in row["중간"]] == ["품", "넓이", "인부"] # 쓰는 차례대로 assert row["중간"][0]["식"] == "0.15" and row["중간"][1]["식"] == "길이 * 폭" assert row["호표"] == [ { "종류": "인력", "요소": LABOR, "이름": "보통인부", "단위": "인", "수량": "인부", "비목": "노무비", } ] assert row["덧줄"] == [{"이름": "잡재료비", "식": "노무비 * 0.05", "비목": "재료비"}] assert row["결과단위"] == "원/㎡" and "결과" not in row 검사 = store.cm.mf.check_logic(store.cm.master(REAL), {**row, "키": "GX000001"})[0] assert 검사 == [] # 옮긴 줄이 지금 로직 검사를 그대로 통과 def test_돈_아닌_로직은_호표_없이_결과_식() -> None: 정함 = { "부피": {"무엇": "설계입력", "단위": "㎥"}, "시간당": {"무엇": "설계입력", "단위": "㎥/hr"}, } row, bad = md.옮기기("시간 = 부피 / 시간당", 정함, {"이름": "작업 시간", "결과단위": "hr"}) assert bad == [] and row["결과"] == {"식": "부피 / 시간당"} and row["중간"] == [] assert "호표" not in row def test_덜_정했으면_안_옮긴다() -> None: row, bad = md.옮기기(GOOD, {}, GOOD_머리) assert row == {} and any(x["갈래"] == "덜정함" for x in bad) # ── 서버 길 ─────────────────────────────────────────────────────────── def test_서버_길이_변수와_문제를_돌려_준다(client: TestClient) -> None: got = _post(client, {"text": GOOD, "decided": GOOD_정함}) assert got["다됨"] is True and got["logic"] is None and got["결과"] == "공사비" assert _var(got, "보통인부")["단위"] == "원/인" # 마스터요소는 식에서 단가 미리 = _post(client, {"text": GOOD, "decided": GOOD_정함, "head": GOOD_머리}) assert 미리["logic"]["호표"][0]["요소"] == LABOR assert not (store.FOLDER / make.OWN).is_file() # 미리보기는 파일에 안 씀 틀림 = _post(client, {"text": BAD_UNIT, "decided": BAD_UNIT_정함}) assert any(x["갈래"] == "단위" for x in 틀림["문제"]) and 틀림["다됨"] is False def test_서버_길이_저장까지_하고_그_로직이_돈다(client: TestClient) -> None: done = _post( client, {"text": GOOD, "decided": GOOD_정함, "head": GOOD_머리, "save": True, "owner": "현장"}, ) assert done["key"].startswith("GX") and done["file"] == make.OWN assert done["logic"]["구분"] == "자체" and done["logic"]["소유"] == "현장" res = client.post("/api/m01/calc", json={"key": done["key"], "inputs": {"길이": 2, "폭": 3}}) assert res.status_code == 200, res.text calc = res.json() assert calc["ok"] is True, calc 노무 = Decimal(str(calc["sums"]["노무비"])) assert 노무 > 0 and Decimal(str(calc["sums"]["재료비"])) == (노무 * Decimal("0.05")) assert Decimal(str(calc["middle"]["넓이"])) == 6 def test_저장은_문제가_있으면_아무것도_안_쓴다(client: TestClient) -> None: res = client.post( "/api/m01/logic/draft", json={"text": LOOP, "decided": {}, "head": GOOD_머리, "save": True}, ) assert res.status_code == 422, res.text assert res.json()["detail"]["errors"] assert not (store.FOLDER / make.OWN).is_file()