- 검색어로 찾은 줄 · 고름으로 바꾼 줄 · 대표 줄 모두 — 글 = 자재품목 이름(지역 포함) · 참조 = MT · 그 키 - 새 master_text_item.py(700줄 안 넘기려 나눔) · 기계 · 인력 요소조각 그대로 · 금액 변화 0 - 시험 2개 추가(결속선 GF000159 · 고름) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1MKKZKpUHTPKb513FneU8
547 lines
28 KiB
Python
547 lines
28 KiB
Python
"""M01 읽는 식 줄(`POST /text`) — 계약 `resources/master_data/_화면_계약.md` 3장.
|
||
|
||
견본 셋: 산림 13-4-1 메쌓기 · 12-4 합판거푸집 · 12-17-1 펌프카 타설.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
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 덧줄 — 긴 표 제목(잔디깎기 공구손료·경장비 기계경비 요율)은 첫 + 끝 낱말."""
|
||
got, _ = _both(client, "GC001008")
|
||
extra = next(one for one in _all_lines(got) if one["이름"].startswith("공구손료"))
|
||
assert "잔디깎기요율.요율 %" in extra["이름글"]
|
||
assert "QC001882" not in extra["이름글"]
|
||
assert any(one["이름"] == "잔디깎기요율" for one in got["별칭"])
|
||
|
||
|
||
def test_짧은_이름은_규격_코드를_떼고_길면_줄인다() -> None:
|
||
short = mt.mc.short
|
||
assert short("바퀴숫돌 평면용 A24 QWV1호 A205×25×25 KSL 6501") == "바퀴숫돌평면용"
|
||
assert short("크롤러드릴(공기식)(17㎥/min)") == "크롤러드릴공기식"
|
||
assert short("콘크리트 피니셔(중앙분리대용) 105.9㎾") == "콘크리트피니셔"
|
||
assert short("1급 기준점 측량") == "기준점측량" # 숫자로 시작하지 않음
|
||
assert short("잔디깎기 연간 시공횟수별 시공량 할증율") == "잔디깎기할증율"
|
||
|
||
|
||
def test_이름글_별칭은_찾은_줄_값으로_갈리고_표_키_번호가_없다() -> None:
|
||
"""번호(가스압접1) · 표 키(QC000568) 대신 표 제목 + 찾은 줄 값 · 되돌리면 원래 부르기."""
|
||
files = store.cm.load(folder=REAL)
|
||
master = store.cm.master(REAL)
|
||
want = {
|
||
"GC000337": "낙석방지망설치재료_철망.수량",
|
||
"GC000360": "가스압접_용접공.수량",
|
||
"GC000350": "터널바닥암반청소_굴착기_02.",
|
||
"GF000185": "구체콘크리트펌프카타설_레미콘.",
|
||
}
|
||
for key, part in want.items():
|
||
got = mt.lines(files, key, dict(store.cm.시험입력(master.logic(key))))
|
||
names = [one["이름글"] for one in _all_lines(got)]
|
||
assert any(part in one for one in names), (key, names)
|
||
assert not any(re.search(r"\b[A-Z]{2}\d{6}", one) for one in names), (key, names)
|
||
raw_of = {m["이름"]: m["원문"] for m in got["별칭"]}
|
||
for one in names: # 이름글 속 별칭은 모두 원문 부르기로 되돌려짐
|
||
back = mt.mc._unmask(one, raw_of)
|
||
left = [n for n in raw_of if re.search(rf"(?<![\w.]){re.escape(n)}(?!\w)", back)]
|
||
assert not left, (key, back)
|
||
assert any("찾기(" in mt.mc._unmask(one, raw_of) for one in names)
|
||
|
||
|
||
def test_긴_품명은_앞_핵심만_겹치면_꼬리_2() -> None:
|
||
files = store.cm.load(folder=REAL)
|
||
master = store.cm.master(REAL)
|
||
for key in ("GC000325", "GC000360"):
|
||
got = mt.lines(files, key, dict(store.cm.시험입력(master.logic(key))))
|
||
names = [one["이름글"] for one in _all_lines(got)]
|
||
assert not any(len(w) > 20 for one in names for w in re.findall(r"[^\W\d]\w*", one))
|
||
if key == "GC000325":
|
||
assert any(one.startswith("콘크리트피니셔노무비단가 * ") for one in names)
|
||
else:
|
||
assert "보통인부노임_2 * 0 = 보통인부비_2" in names
|
||
|
||
|
||
# ── 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_자리_표시_든_참조는_이름_입력값으로_읽히고_참조는_풀린_키() -> None:
|
||
"""GF000081 잡품 — 「MO:휘발유:{지역}」 날글 대신 「휘발유 · 전국평균」 · 참조 = 풀린 MO 키."""
|
||
files = store.cm.load(folder=REAL)
|
||
master = store.cm.master(REAL)
|
||
row = master.logic("GF000081")
|
||
|
||
def pills(given: dict) -> tuple[list, str]:
|
||
got = mt.lines(files, "GF000081", given)
|
||
one = next(o for o in _all_lines(got) if o["이름"] == "잡품")
|
||
return [p for p in one["조각"] if p["kind"] == "요소"], one["이름글"]
|
||
|
||
filled, name_text = pills(dict(store.cm.시험입력(row)))
|
||
assert [p["글"] for p in filled] == ["휘발유 · 전국평균"]
|
||
assert filled[0]["참조"]["키"] == "MO000001" and "MO:" not in name_text
|
||
blank, name_text = pills({})
|
||
assert [p["글"] for p in blank] == ["휘발유 · 지역"] and "참조" not in blank[0]
|
||
assert "{" not in name_text and "MO:" not in name_text
|
||
|
||
|
||
def test_자리_표시_참조가_조각_요소조각_이름식에_날글로_남지_않는다() -> None:
|
||
files = store.cm.load(folder=REAL)
|
||
master = store.cm.master(REAL)
|
||
for key in ("GC000138", "GC000211", "GC000267", "GC000559", "GF000081"):
|
||
for given in (dict(store.cm.시험입력(master.logic(key))), {}):
|
||
for one in _all_lines(mt.lines(files, key, given)):
|
||
shown = [one["이름글"], *(p["글"] for p in one["조각"] + one["요소조각"])]
|
||
assert not any(re.search(r"[A-Z]{2}:|[{}]", t) for t in shown), (key, shown)
|
||
|
||
|
||
def test_표_조각_참조_줄은_엔진이_찾은_줄의_차례다() -> None:
|
||
"""GC000010 크레인 — 줄에 없는 조건 칸은 맞음이라 흉내 내면 틀림 · GF000160 합판도."""
|
||
files = store.cm.load(folder=REAL)
|
||
master = store.cm.master(REAL)
|
||
for key in ("GC000010", "GF000160"):
|
||
got = mt.lines(files, key, dict(store.cm.시험입력(master.logic(key))))
|
||
tables = [p for one in _all_lines(got) for p in one["조각"] if p["kind"] == "표"]
|
||
assert tables and all("줄" in p["참조"] for p in tables), key
|
||
for p in tables:
|
||
rows = master.get(p["참조"]["키"])["줄"]
|
||
hit = rows[p["참조"]["줄"]]
|
||
assert hit is mt.mf.find(master.get(p["참조"]["키"]), p["참조"]["행"], p["참조"]["열"])
|
||
got = mt.lines(files, "GC000010", dict(store.cm.시험입력(master.logic("GC000010"))))
|
||
pill = next(
|
||
p
|
||
for one in _all_lines(got)
|
||
for p in one["조각"]
|
||
if p["kind"] == "표" and p["참조"]["키"] == "QC000031" and "크레인" in p["글"]
|
||
)
|
||
assert master.get("QC000031")["줄"][pill["참조"]["줄"]]["구분"] == "크레인"
|
||
|
||
|
||
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"]["계"]))
|
||
|
||
|
||
def test_곱_뒤_덧셈_묶음은_괄호로_이름_식_값_식_모두() -> None:
|
||
"""GC000467 촌락지대가 — 노임 * (아홉 항 합) · 첫 항에만 곱하는 것처럼 읽히지 않게."""
|
||
files = store.cm.load(folder=REAL)
|
||
row = store.cm.master(REAL).logic("GC000467")
|
||
given = {n: store._dec(v) for n, v in store.auto("GC000467")["값"].items()}
|
||
got = mt.lines(files, "GC000467", given)
|
||
one = _all_lines(got)[0]
|
||
assert one["글"].startswith("295,138 * (1 + 1 + 0.5") and " = 3,984,363" in one["글"]
|
||
left = one["이름글"].split(" = ")[0]
|
||
assert left.startswith("중급기술자건설노임 * (") and left.endswith(".수량)")
|
||
assert Decimal(str(one["금액"])) == Decimal("295138") * Decimal("13.5")
|
||
blank = mt.lines(files, "GC000467", {})
|
||
assert _all_lines(blank)[0]["이름글"].split(" = ")[0].count("(") == 1 # 값 없이도 그대로
|
||
assert row # 로직이 있음
|
||
|
||
|
||
def test_이름글_번호_별칭은_별칭표로_되돌려진다() -> None:
|
||
"""이름글 속 별칭(가1~가9)은 별칭표 이름 그대로여야 식으로 되돌려짐(찾은 줄 이름 X)."""
|
||
files = store.cm.load(folder=REAL)
|
||
given = {n: store._dec(v) for n, v in store.auto("GC000467")["값"].items()}
|
||
got = mt.lines(files, "GC000467", given)
|
||
left = _all_lines(got)[0]["이름글"].split(" = ")[0].split(" * ", 1)[1].strip("()")
|
||
raw_of = {m["이름"]: m["원문"] for m in got["별칭"]}
|
||
back = mt.mc._unmask(left, raw_of)
|
||
assert back.count("찾기(") == 9 and "촌락지대가" not in back
|
||
|
||
|
||
def test_요소조각_로직_부르기는_이름과_인자_글_날식_없음() -> None:
|
||
"""GC001093 트럭 줄 — 로직 조각(키 GC000268) + 인자 글(기계 이름 · 지역 지금 값)."""
|
||
files = store.cm.load(folder=REAL)
|
||
row = store.cm.master(REAL).logic("GC001093")
|
||
given = dict(store.cm.시험입력(row))
|
||
for got in (mt.lines(files, "GC001093", given), mt.lines(files, "GC001093", {})):
|
||
lines = _all_lines(got)
|
||
assert all("요소조각" in one for one in lines)
|
||
truck = [one for one in lines if one["이름"].startswith("트럭")]
|
||
assert truck
|
||
for one in truck:
|
||
head, args = one["요소조각"]
|
||
assert head["kind"] == "로직" and head["참조"]["키"] == "GC000268"
|
||
assert args["kind"] == "글" and "덤프트럭" in args["글"]
|
||
assert "GC000268" not in args["글"] and "(" not in args["글"].split("덤프트럭")[0]
|
||
labor = lines[0]["요소조각"]
|
||
assert labor[0]["kind"] == "요소" and labor[0]["글"] == "배관공(수도)"
|
||
|
||
|
||
def test_값_식_작은_수는_유효숫자로_보인_수끼리_곱해도_금액과_맞음() -> None:
|
||
"""GC001064 수량 0.001667 — 넷째 자리로 잘라 456 이 465 로 읽히던 것."""
|
||
files = store.cm.load(folder=REAL)
|
||
row = store.cm.master(REAL).logic("GC001064")
|
||
got = mt.lines(files, "GC001064", dict(store.cm.시험입력(row)))
|
||
first = _all_lines(got)[0]["글"]
|
||
assert first == "273,308 * 0.001667 = 456"
|
||
assert mt.fmt(Decimal("0.00166667")) == "0.001667" and mt.fmt(Decimal("12.34567")) == "12.3457"
|
||
assert mt.fmt_price(Decimal("3.5")) == "3.5" and mt.fmt_price(Decimal("3500.4")) == "3,500"
|
||
|
||
|
||
def _text_of(key: str, **given) -> list[dict]:
|
||
auto = {k: v for k, v in (store.auto(key).get("값") or {}).items() if v is not None}
|
||
return _all_lines(store.text(key, {**auto, **given}))
|
||
|
||
|
||
def test_빈_줄을_부른_덧줄_값_조각은_None_대신_그_줄_이름() -> None:
|
||
line = next(x for x in _text_of("GF000185") if x["이름"] == "콘크리트다짐")
|
||
texts = [p["글"] for p in line["조각"]]
|
||
assert "None" not in texts and "구체콘크리트(철근) 레미콘" in texts
|
||
|
||
|
||
def test_만약이_고른_갈래가_덧셈이면_곱셈_안에서_괄호로_묶임() -> None:
|
||
line = next(x for x in _text_of("GC000376", 면="BOX 형상 내면") if x["이름"] == "도장공")
|
||
assert line["글"] == "263,017 * 0.031 * (1 + 60 %) = 13,046"
|
||
outer = next(x for x in _text_of("GC000376", 면="외면") if x["이름"] == "도장공")
|
||
assert "(" not in outer["글"]
|
||
|
||
|
||
def test_수량_식_안_로직_조각은_그_로직_이름() -> None:
|
||
line = next(x for x in _text_of("GC000111") if x["이름"] == "리퍼 기계경비")
|
||
pill = next(p for p in line["조각"] if p["kind"] == "로직")
|
||
assert pill["글"] == "굴착기(무한궤도) 시간당 기계경비" and pill["참조"]["키"] == "GC000267"
|
||
|
||
|
||
def test_표에_없는_입력_조합은_그_줄만_비우고_글은_그대로() -> None:
|
||
"""/calc 와 같은 빈 줄 규칙 — 풀리지 않는 중간값은 /text 도 멈추지 않음."""
|
||
got = store.text("GC001000", {**store.auto("GC001000")["값"], "시공구분": "기계시공"})
|
||
lines = _all_lines(got)
|
||
blank = [x for x in lines if x["금액"] is None]
|
||
assert blank and all(x["이름글"] and x["조각"] for x in blank)
|
||
assert len(blank) < len(lines) or got["계"] is not None
|
||
calc = store.calc("GC001000", {**store.auto("GC001000")["값"], "시공구분": "기계시공"})
|
||
assert got["계"] == calc["sums"]["계"]
|
||
|
||
|
||
def _mat_piece(lines: list[dict], name: str) -> dict:
|
||
return next(x for x in lines if x["이름"] == name)["요소조각"][0]
|
||
|
||
|
||
def test_재료_줄_요소조각은_잡힌_품목_이름과_MT_참조() -> None:
|
||
piece = _mat_piece(_text_of("GF000159"), "결속선 0.9㎜")
|
||
assert piece["글"].startswith("어닐링철선(결속선)") and piece["글"].endswith("서울")
|
||
assert piece["참조"] == {"테이블": "MT", "키": "MT000209"}
|
||
auto = {k: v for k, v in store.auto("GF000159")["값"].items() if v is not None}
|
||
assert store.text("GF000159", auto)["계"] == store.calc("GF000159", auto)["sums"]["계"]
|
||
|
||
|
||
def test_고름으로_바꾼_재료_줄_요소조각은_고른_품목() -> None:
|
||
import check_master as cm
|
||
import master_pick as mp
|
||
|
||
auto = {k: v for k, v in store.auto("GF000159")["값"].items() if v is not None}
|
||
items = cm.master().logic("GF000159")["호표"]
|
||
at = next(i for i, h in enumerate(items) if h.get("이름") == "결속선 0.9㎜")
|
||
with mp.choosing({at: "MT000174"}):
|
||
got = _all_lines(store.text("GF000159", auto))
|
||
assert _mat_piece(got, "결속선 0.9㎜")["참조"]["키"] == "MT000174"
|