"""M01 읽는 식 줄(`POST /text`) — 계약 `resources/master_data/_화면_계약.md` 3장. 견본 셋: 산림 13-4-1 메쌓기 · 12-4 합판거푸집 · 12-17-1 펌프카 타설. """ 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 sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "master_data/scripts")) import master_text as mt # noqa: E402 REAL = store.FOLDER STACK = "GF000219" # 13-4-1 메쌓기(인력) — 증가율이 걸린 줄 FORM = "GF000160" # 12-4 합판거푸집 — 재료 여럿 · 수량 0 줄 PUMP = "GF000182" # 12-17-1 펌프카 타설 — 하위 로직이 세 비목에 걸리고 덧줄이 있음 IDLE = "GC000999" # 일반전정 — 세 비목에 걸린 장비 줄이 수량 0 이라 몫이 모두 0 @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 _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 _both(client: TestClient, key: str) -> tuple[dict, dict]: body = {"key": key, "inputs": _inputs(key)} text = client.post("/api/m01/text", json=body) calc = client.post("/api/m01/calc", json=body) assert text.status_code == 200 and calc.status_code == 200, text.text return text.json(), calc.json() def _all_lines(got: dict) -> list[dict]: return [one for group in got["groups"] for one in group["줄"]] def test_자릿수는_화면과_같다() -> None: assert mt.fmt(Decimal("31500")) == "31,500" assert mt.fmt(Decimal("2803.50")) == "2,803.5" assert mt.fmt(Decimal("1") / Decimal(3)) == "0.3333" assert mt.fmt(Decimal("-1234.5")) == "-1,234.5" def test_메쌓기_줄은_수로만_적히고_계가_시험계산과_같다(client: TestClient) -> None: got, calc = _both(client, STACK) assert got["ok"] is True and Decimal(str(got["계"])) == Decimal(str(calc["sums"]["계"])) rows = _all_lines(got) assert [one["이름"] for one in rows] == ["석공", "보통인부"] for one in rows: assert "찾기(" not in one["글"] and "만약(" not in one["글"] and "로직(" not in one["글"] assert one["글"].count("=") == 1 and " * " in one["글"] assert "%" in rows[0]["글"] # 증가율은 따로 줄이 아니라 이 줄의 식에 녹아 있음 assert rows[0]["글"].startswith("268,908 * ") def test_합판거푸집은_비목마다_묶이고_안_쓰는_줄에_까닭이_있다(client: TestClient) -> None: got, calc = _both(client, FORM) assert [g["비목"] for g in got["groups"]] == ["노무비", "재료비"] for group in got["groups"]: assert Decimal(str(group["소계"])) == Decimal(str(calc["sums"][group["비목"]])) assert sum(Decimal(str(one["금액"])) for one in group["줄"]) == Decimal(str(group["소계"])) why = [one for one in _all_lines(got) if one.get("까닭")] assert why and all("0" in one["까닭"] for one in why) def test_펌프카는_하위로직이_비목마다_한_줄씩이고_덧줄이_섞인다(client: TestClient) -> None: got, calc = _both(client, PUMP) assert [g["비목"] for g in got["groups"]] == ["노무비", "재료비", "경비"] assert Decimal(str(got["계"])) == Decimal(str(calc["sums"]["계"])) pump = [one for one in _all_lines(got) if one["이름"] == "콘크리트펌프차"] assert len(pump) == 3 # 노무비 · 재료비 · 경비 몫이 따로 extra = next(one for one in _all_lines(got) if one["이름"].startswith("공구손료")) assert ( extra["글"].endswith(f"= {mt.fmt_money(Decimal(str(extra['금액'])))}") and "%" in extra["글"] ) def test_몫이_모두_0인_장비_줄도_글에_남는다(client: TestClient) -> None: got, calc = _both(client, IDLE) idle = [one for one in _all_lines(got) if one["이름"] == "고소작업차"] assert len(idle) == 1 # 비목 셋 몫이 다 0 이라도 줄이 사라지지 않음 assert Decimal(str(idle[0]["금액"])) == 0 and "수량 0" in idle[0]["까닭"] assert idle[0]["글"].endswith(" * 0 = 0") for group in got["groups"]: assert Decimal(str(group["소계"])) == Decimal(str(calc["sums"][group["비목"]])) def test_이름_꼴_식이_값_꼴과_짝이_된다(client: TestClient) -> None: """왼쪽 칸(이름 꼴)과 오른쪽 칸(값 꼴)이 같은 개수·같은 차례 · 짝 번호는 1 부터.""" for key in (STACK, PUMP, FORM): got, _ = _both(client, key) rows = _all_lines(got) assert [one["짝"] for one in rows] == list(range(1, len(rows) + 1)) for one in rows: assert one["이름글"].count(" = ") == 1 assert not any(x in one["이름글"] for x in ("찾기(", "로직(", "만약(")) def test_이름_꼴_식은_요소_이름과_별칭으로_적힌다(client: TestClient) -> None: """13-4-1 석공 줄 — 「석공노임 * 메쌓기인력.석공 * (1 + 증가율 %) = 석공비」.""" got, _ = _both(client, STACK) mason = _all_lines(got)[0] table = next(one for one in got["별칭"] if one["참조"] == "QF000421") assert mason["이름글"] == f"석공노임 * {table['이름']}.석공 * (1 + 증가율 %) = 석공비" assert mason["글"].startswith("268,908 * ") # 같은 줄의 값 꼴 pump, _ = _both(client, PUMP) lines = _all_lines(pump) split = [one for one in lines if one["이름"] == "콘크리트펌프차"] assert [one["이름글"].split(" = ")[-1] for one in split] == [ "콘크리트펌프차노무비", "콘크리트펌프차재료비", "콘크리트펌프차경비", ] extra = next(one for one in lines if one["이름"].startswith("공구손료")) assert extra["이름글"] == "노무비 * 5 % = 공구손료및경장비" def test_입력_없어도_이름_식은_보임(client: TestClient) -> None: """값을 고르기 전(PLAN 2-5) — 멈추지 않고 왼쪽 이름 식은 그대로, 글·금액만 빔.""" got = client.post("/api/m01/text", json={"key": STACK, "inputs": {}}).json() assert got["ok"] is True one = got["groups"][0]["줄"][0] assert one["이름글"] and "석공노임" in one["이름글"] assert one["글"] == "" and one["금액"] is None def test_고른_값이_틀리면_그대로_막힘(client: TestClient) -> None: """값을 넣었는데 틀린 경우(고르기 밖)는 지금처럼 멈춤 — 안 고른 것과 다름.""" bad = dict(_inputs(STACK), 뒷길이=999) got = client.post("/api/m01/text", json={"key": STACK, "inputs": bad}).json() assert got["ok"] is False and "고르기" in got["reason"] def test_끝수는_줄이_아니라_소계에_붙는다(client: TestClient) -> None: got = client.get("/api/m01/logic", params={"key": STACK}).json() row = dict(got["logic"], 끝수={"대상": "계", "자리": 0, "방법": "버림"}) body = {"key": STACK, "inputs": _inputs(STACK), "row": row, "file": got["file"]} cut = client.post("/api/m01/text", json=body).json() assert cut["ok"] is True group = cut["groups"][0] assert group["끝수"] == "원 미만 버림" assert Decimal(str(group["소계"])) == Decimal(str(group["소계"])).to_integral_value() assert not any("버림" in one["글"] for one in group["줄"]) def test_이름_꼴_찾기_별칭이_표_열쇠_그대로면_밑이름으로_풀지_않는다(client: TestClient) -> None: """GC001008 덧줄 — 별칭 이름이 QC001882 뿐이라 이름 식도 별칭 목록의 그 이름을 씀.""" got, _ = _both(client, "GC001008") extra = next(one for one in _all_lines(got) if one["이름"].startswith("공구손료")) assert "QC001882.요율 %" in extra["이름글"] assert "잔디깎기공구손료" not in extra["이름글"] assert any(one["이름"] == "QC001882" for one in got["별칭"]) # ── PLAN 2-1 · 2-2 — 조각(알약) ───────────────────────────────────────── def test_조각이_이름글_금액_까닭_곁에_칸으로_더해진다(client: TestClient) -> None: """지금 주던 이름글·글·금액·까닭은 그대로 두고 조각 칸만 더함(계약 확인).""" got, calc = _both(client, STACK) mason = _all_lines(got)[0] assert mason["이름"] == "석공" and mason["글"].startswith("268,908 * ") assert Decimal(str(got["계"])) == Decimal(str(calc["sums"]["계"])) assert isinstance(mason["조각"], list) and mason["조각"] kinds = {p["kind"] for p in mason["조각"]} assert kinds <= {"요소", "표", "로직", "값", "기호", "글"} 표 = next(p for p in mason["조각"] if p["kind"] == "표") assert ( 표["참조"]["테이블"] == "QF" and 표["참조"]["키"] == "QF000421" and 표["참조"]["열"] == "석공" ) assert any(p["kind"] == "글" and p["글"] == "%" for p in mason["조각"]) def test_조각_요소는_알약이고_참조에_테이블과_키가_있다() -> None: files = { "재료_test.json": { "그룹": "재료", "줄": [{"키": "MT000001", "이름": "테스트자재", "값": Decimal("100")}], } } master = mt.mf.Master(files) piece = mt._elem_piece(master, "MT000001") assert piece == { "kind": "요소", "글": "테스트자재단가", "참조": {"테이블": "MT", "키": "MT000001"}, } def test_조각_자리_표시_든_요소_참조는_이름만_있고_참조는_빔() -> None: """PLAN 2-2 ② — 자리 표시(`{…}`) 든 요소 참조는 값(참조)을 못 구해 이름만.""" master = mt.mf.Master({}) piece = mt._elem_piece(master, "LB:{직종}") assert piece["kind"] == "요소" and "참조" not in piece assert piece["글"] # 이름 자리는 비지 않음(못 풀어도 원문 그대로) def test_조각_만약은_쉬운_말로_안_걸린_갈래가_흐리다() -> None: """PLAN 3-3 — 「조건 이면 A · 아니면 B」 · 안 걸린 갈래 조각에 흐림.""" env = {"폼타이": "사용"} node = mt.mf.parse("만약(폼타이 == '사용', 2.14 / 10, 0)") pieces = mt._pieces(node, node, env, mt.mf.Master({}), {}) assert [p["글"] for p in pieces] == ["폼타이 가 사용 이면", "2.14", "÷", "10", "· 아니면", "0"] assert [bool(p.get("흐림")) for p in pieces] == [False, False, False, False, True, True] off = mt._pieces(node, node, {"폼타이": "아님"}, mt.mf.Master({}), {}) assert [bool(p.get("흐림")) for p in off] == [False, True, True, True, False, False] def test_조각_만약은_조건_입력이_비면_두_갈래_다_흐림_없음() -> None: """조건이 안 셈해지면(모르는 이름) 두 갈래 다 · 흐림 없음 · 깊이를 적어 접을 수 있게.""" node = mt.mf.parse("만약(폼타이 == '사용', 2, 3)") pieces = mt._pieces(node, node, {}, mt.mf.Master({}), {}) assert [p["글"] for p in pieces] == ["폼타이 가 사용 이면", "2", "· 아니면", "3"] assert not any("흐림" in p for p in pieces) assert [p.get("깊이") for p in pieces] == [None, 1, 1, 1] assert mt.named(node, node, {}, mt.mf.Master({})) == "만약(폼타이 == '사용', 2, 3)" def test_조각_로직_부르는_줄_990개_세_비목에_다_걸치고_계산_전엔_금액이_빈다( client: TestClient, ) -> None: """PLAN 2-2 ③ — 계산이 막히면(재료 단가 없음 따위) 이름 식·조각은 구조로 남고 금액만 빔. 비목이 안 정해진 로직 부르는 줄은 세 비목에 다 걸쳐 보여야 하나, 이 견본(STACK)은 호표 둘 다 비목이 「노무비」로 정해진 인력 줄이라 한 비목에만 걸림 — 그 좁은 경우 확인.""" got = client.get("/api/m01/logic", params={"key": STACK}).json() row = dict(got["logic"]) row["호표"] = [dict(row["호표"][0], 요소="LB099999"), dict(row["호표"][1])] body = {"key": STACK, "inputs": _inputs(STACK), "row": row, "file": got["file"]} text = client.post("/api/m01/text", json=body) assert text.status_code == 200, text.text out = text.json() assert out["ok"] is True and out["계"] is None rows = _all_lines(out) assert [one["이름"] for one in rows] == ["석공", "보통인부"] for one in rows: assert one["금액"] is None and one["글"] == "" and "까닭" not in one assert one["조각"] # 수량 식은 깨진 요소와 무관해 구조로 그대로 남음 assert [g["소계"] for g in out["groups"]] == [None] def test_합판거푸집_표_조각_글은_줄마다_다르고_참조에_찾기_조건이_있다(client: TestClient) -> None: """PLAN 3-3 — 글 = 「찾은 줄 · 칸」 · 참조.행 = 찾기 조건 · 중간 값은 글에 이름.""" got, _ = _both(client, FORM) tables = [p for one in _all_lines(got) for p in one["조각"] if p["kind"] == "표"] assert len(tables) == 7 and len({p["글"] for p in tables}) == 7 plywood = next(p for p in tables if p["글"] == "합판 · 1회사용") assert plywood["참조"]["행"] == {"구분": "합판"} and plywood["참조"]["열"] == "기준수량_1회사용" rate = next(p for p in _all_lines(got)[0]["조각"] if p["kind"] == "값") assert rate["글"] == "인력율" and rate["값"] == "1.31" tie = _all_lines(got)[-1]["조각"] assert tie[0]["글"] == "폼타이 가 사용 이면" and [bool(p.get("흐림")) for p in tie][1] is True def test_값_없이_열어도_텍스트가_멈추지_않는다(client: TestClient) -> None: got = client.post("/api/m01/text", json={"key": FORM, "inputs": {}}).json() assert got["ok"] is True tie = _all_lines(got)[-1] assert "만약(폼타이 == '사용'" in tie["이름글"] and not any("흐림" in p for p in tie["조각"]) def test_모든_로직을_값_없이_돌려도_멈추지_않는다() -> None: files = store.cm.load(folder=REAL) master = store.cm.master(REAL) stops = [] for rows in master.index.values(): for key in rows: if key[:2] in mt.mf.mk.GROUP_OF and mt.mf.mk.GROUP_OF[key[:2]] == "로직": try: mt.lines(files, key, {}) except mt._CALC_ERRORS as e: stops.append((key, str(e))) assert not stops, stops[:3] def test_글_안의_돈은_정수_계산_값은_소수_그대로(client: TestClient) -> None: assert mt.fmt_money(Decimal("79482.678")) == "79,483" assert mt.fmt_money(Decimal("0.4")) == "0" got, _ = _both(client, FORM) first = _all_lines(got)[0] assert first["글"] == "275,790 * 0.22 * 1.31 = 79,483" # 수량·비율은 소수 그대로 assert Decimal(str(first["금액"])) == Decimal("79482.678") # 값은 그대로 def test_자동값으로_모든_로직을_돌려도_멈추지_않는다() -> None: """빈 줄(후보 0 · 값 빈 품목) — 그 줄만 빈 글 · 로직은 계속(PLAN 3-2).""" files = store.cm.load(folder=REAL) master = store.cm.master(REAL) stops = [] for rows in master.index.values(): for key in rows: if mt.mf.mk.GROUP_OF.get(key[:2]) != "로직": continue given = {n: store._dec(v) for n, v in store.auto(key)["값"].items()} try: mt.lines(files, key, given) except mt._CALC_ERRORS as e: stops.append((key, str(e))) assert not stops, stops[:3] def test_빈_줄은_빈_글이고_소계는_빈_줄_빼고_더한다(client: TestClient) -> None: key = "GC000027" given = {n: str(v) for n, v in store.auto(key)["값"].items()} got = client.post("/api/m01/text", json={"key": key, "inputs": given}).json() calc = client.post("/api/m01/calc", json={"key": key, "inputs": given}).json() assert got["ok"] is True blank = [one for one in _all_lines(got) if one["금액"] is None] assert blank and all(one["글"] == "" and one["까닭"] for one in blank) assert Decimal(str(got["계"])) == Decimal(str(calc["sums"]["계"]))