Files
Aislo/resources/tester/test_m01_text.py
T
eomsangdonandClaude Sonnet 5 4c58783e0f feat(m01): /text 재료 줄 요소조각 = 잡힌 품목 이름 · MT 참조 (PLAN 8-5)
- 검색어로 찾은 줄 · 고름으로 바꾼 줄 · 대표 줄 모두 — 글 = 자재품목 이름(지역 포함) · 참조 = 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
2026-09-24 22:11:38 +09:00

547 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"