- master_text.py — 조각(요소·표·로직·값·기호·글) 생성기 추가, 이름글·글·금액·
까닭은 그대로 두고 줄마다 「조각」 칸만 더함
- 만약() 은 계산되면 걸린 가지만 · 계산 안 되면(FormulaError 따위) 양쪽 다
내보내고 깊이를 적어 접을 수 있게 함
- 자리 표시({…}) 든 요소 참조는 이름만 적고 참조(키) 자리는 비움
- mf.run 이 막혀도(재료 단가 없음 따위) row 구조만으로 이름 식·조각을 짓고
글·금액만 비움 — 로직 부르는 줄(비목 미정)은 세 비목에 다 걸쳐 보임
- test_m01_text.py — 새 시험 6개(계약 확인·요소 알약·자리 표시·만약 둘·
계산 막힘 no-calc 경로), 기존 10개 그대로 통과
- 검증: pytest resources/tester/test_m01_text.py 16 passed · 13-4-1·12-4·
12-17-1 금액이 앞과 같음(calc 엔드포인트와 대조, 기존 시험 그대로) ·
ruff format/check 통과
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
243 lines
12 KiB
Python
243 lines
12 KiB
Python
"""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(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:
|
|
got = client.post("/api/m01/text", json={"key": STACK, "inputs": {}}).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:
|
|
node = mt.mf.parse("만약(1 > 0, 2, 3)")
|
|
pieces = mt._pieces(node, node, {}, mt.mf.Master({}), {})
|
|
assert pieces == [{"kind": "값", "글": "2"}]
|
|
assert not any("깊이" in p for p in pieces)
|
|
|
|
|
|
def test_조각_만약은_계산_안_되면_양쪽_다_깊이가_붙는다() -> None:
|
|
"""PLAN 2-2 ① — 계산 안 되면(모르는 이름) 양쪽 다 · 깊이를 적어 접을 수 있게."""
|
|
node = mt.mf.parse("만약(모르는이름 > 0, 2, 3)")
|
|
pieces = mt._pieces(node, node, {}, mt.mf.Master({}), {})
|
|
vals = [(p["kind"], p["글"], p.get("깊이")) for p in pieces]
|
|
assert vals == [("값", "2", 1), ("기호", "/", None), ("값", "3", 1)]
|
|
|
|
|
|
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]
|