"""802-1 단가산출 수식 문서 — 꼴 · 셈 · 지움 견딤 · 표지 · 거울(파이썬 ↔ TS) 지킴 시험. 꼴 = `resources/master_data/_수식문서_틀.md` · 파이썬 = `master_doc*.py` · TS = `common_util_formula_doc*.ts`. ① 식 풀이 · 글로 되돌림 · 기호 · 이름 바꿈 전파. ② 셈 — 입력 · 선택 · 숫자 · 식 · 만약 · % · 표 조건(범위 규칙) · 요소 · 단가산출 부름 · 묶음 소계 · 합계 · 반올림 · 줄 자리 · 끝전 큰나머지 · 계 끝수 · 조정 · 번지 · 단계 · 합밖 · 결과 문서. ③ 표지 — 정의 필요 · 순환 · 0 나눔 · 식 틀림 · 빈 문서 · 겹침 = 금액 「—」 · 예외 없음. ④ 지움 견딤 — 변수 · 식 줄 · 금액 줄 · 식 글 하나씩 모두 지워도 예외 0. ⑤ 거울 — 같은 문서 · 같은 연결 값으로 파이썬 · TS 답이 같음(실제 M01 문서 표본 + 위 문서들). """ import copy import importlib import json import os import subprocess from decimal import Decimal as D import pytest from M01_MasterData import M01_MasterData_Store as m01 # scripts 길을 둠 md = importlib.import_module("master_doc") mc = importlib.import_module("master_doc_calc") ml = importlib.import_module("master_doc_link") cv = importlib.import_module("master_doc_convert") HERE = os.path.dirname(os.path.abspath(__file__)) COSTS = ("노무비", "재료비", "경비", "계") # ── 시험 연결 ───────────────────────────────────────────────────────── TABLE = { "키": "QX990001", "원문번호": "9-9-9", "조건": {"유형": "고르기", "두께": "범위"}, "범위규칙": "초과·이하", "값칸": {"품": "인", "율": "%"}, "줄": [ {"유형": "보통", "두께": [None, D(10)], "품": D("0.1"), "율": D(5)}, {"유형": "보통", "두께": [D(10), D(20)], "품": D("0.2"), "율": D(6)}, {"유형": "복잡", "두께": [None, D(20)], "품": D("0.3")}, {"유형": "분수", "품": "1/1.30"}, ], } class Fake: """시험 연결 — 요소 {키: 값 | Gap 까닭} · 표 {키: 표} · 문서 {키: 문서}.""" def __init__(self, elements=None, tables=None, docs=None): self.elements = elements or {} self.tables = {"QX990001": TABLE, **(tables or {})} self.docs = docs or {} def element(self, p): key = p.get("키") or json.dumps(p.get("재료"), ensure_ascii=False, sort_keys=True) if key not in self.elements: raise mc.Broken(f"없는 요소 「{key}」") got = self.elements[key] if isinstance(got, str) and got.startswith("빈:"): raise mc.Gap(got[2:]) return got[p["칸"]] if p.get("칸") else got def table(self, p): if p["표"] not in self.tables: raise mc.Broken(f"없는 표 「{p['표']}」") return ml.Links.table(_TableOnly(self.tables[p["표"]]), p) def doc(self, key): return self.docs.get(key) class _TableOnly: def __init__(self, table): self.t = table def _get(self, ref): return self.t def _var(sid, link, key=None, name=""): sign, low, up = md.split_id(sid) return { "열쇠": key or sid, "기호": sign, "아래": low, "위": up, "이름": name or sid, "연결": link, } def _doc(variables=(), lines=(), money=(), **more): out = md.blank("GX990001", "시험") out["변수"] = [_var(*v) if isinstance(v, tuple) else v for v in variables] out["식"] = [{"기호": a, "식": b} for a, b in lines] out["금액"] = list(money) out.update(more) return out def _man(name, qty, price, kind="노무비", **more): return {"이름": name, "단위": "인", "비목": kind, "수량": qty, "단가": price, **more} def _calc(doc, links=None, given=None): return mc.calc(doc, links or Fake(), given) def _money(got): return {k: got[k] for k in COSTS} def SAMPLE(): """합판거푸집 꼴 견본 — 선택 · 입력 · 표(범위) · 요소 · 식 · 만약 · % · 덧줄 꼴 금액.""" return _doc( [ ("S_유형", {"갈래": "선택", "목록": ["보통", "복잡"], "값": "보통"}, "유형"), ("t", {"갈래": "입력", "값": D(15), "범위": [0, 100]}, "두께"), ( "Q^표", { "갈래": "표", "표": "QX990001", "조건": {"유형": "S_유형", "두께": "t"}, "칸": "품", }, ), ("w_형틀", {"갈래": "요소", "키": "LB990001"}), ("w_보통", {"갈래": "요소", "키": "LB990002"}), ("r_할증", {"갈래": "숫자", "값": D(10)}), ("Q_형틀", {"갈래": "식"}), ], [("Q_형틀", "Q^표 × (1 + 만약(S_유형 == '복잡', r_할증, 0)%)")], [ _man("형틀목공", "Q_형틀", "w_형틀", 기호="C_형틀"), _man("보통인부", "0.05", "w_보통"), {"이름": "공구손료", "단위": "식", "비목": "경비", "단가": "노무비 × 3%"}, {"이름": "소모", "단위": "식", "비목": "재료비", "단가": "C_형틀 × 1 ÷ 3"}, ], ) LABOR = {"LB990001": D(274978), "LB990002": D(165545)} # ── ① 꼴 ────────────────────────────────────────────────────────────── @pytest.mark.parametrize( "text, want", [ ("a*b/c", "a × b ÷ c"), ("a/(b*c)", "a ÷ (b × c)"), ("a-(b-c)", "a - (b - c)"), ("(a-b)-c", "a - b - c"), ("-x*2", "-x × 2"), ("-(a+b)", "-(a + b)"), ("x %", "x%"), ("(a+b)%", "(a + b)%"), ("3600×q_s×K×f×E_s÷C_m", "3600 × q_s × K × f × E_s ÷ C_m"), ("만약(S=='보통' 그리고 t<=10, 1, 0)", "만약(S == '보통' 그리고 t <= 10, 1, 0)"), ("a ≤ b", "a <= b"), ("(a= {"값", "표지", "셈됨", "끝수"} if not got["셈됨"]: assert got.get("계") is None and got.get("결과") is None assert got["표지"] or got.get("까닭") assert count > 300 # ── ⑤ 거울 ──────────────────────────────────────────────────────────── def _canon(v): if isinstance(v, (D, int, float)) and not isinstance(v, bool): return mc.plain_text(D(str(v))) if isinstance(v, list): return [_canon(x) for x in v] if isinstance(v, dict): return {k: _canon(v[k]) for k in sorted(v)} return v class Rec: """연결 기록 — 같은 연결 값을 TS 에 넘김.""" def __init__(self, links): self.links, self.log = links, {} def _keep(self, kind, p, fn): key = f"{kind}|{json.dumps(_canon(p), ensure_ascii=False, separators=(',', ':'))}" try: got = fn(p) except mc.Gap as e: self.log[key] = {"빈": str(e)} raise except mc.Broken as e: self.log[key] = {"끊김": str(e)} raise self.log[key] = {"값": got} return got def element(self, p): return self._keep("요소", p, self.links.element) def table(self, p): return self._keep("표", p, self.links.table) def doc(self, key): return self.links.doc(key) def _ts(payload): proc = subprocess.run( ["node", os.path.join(HERE, "helper_802_1_formula_doc.cjs")], input=json.dumps(ml._norm(payload), ensure_ascii=False), capture_output=True, text=True, encoding="utf-8", check=True, ) return json.loads(proc.stdout) def _same(a, b, where=""): if isinstance(a, dict): assert isinstance(b, dict) and set(a) == set(b), (where, sorted(a), sorted(b or {})) for k in a: _same(a[k], b[k], f"{where}.{k}") elif isinstance(a, list): assert isinstance(b, list) and len(a) == len(b), where for i, (x, y) in enumerate(zip(a, b)): _same(x, y, f"{where}[{i}]") elif isinstance(a, (D, int, float)) and not isinstance(a, bool): assert isinstance(b, (int, float)) and not isinstance(b, bool), (where, a, b) assert float(a) == pytest.approx(b, rel=1e-9, abs=1e-9), (where, a, b) else: assert a == b, (where, a, b) def test_거울_파이썬_TS_같은_답(): links, _ = _real_docs(()) picks = sorted(_ROWS)[::30] + list(REAL) for key in picks: links.doc(key) rec = Rec(links) want = {key: mc.calc(links.docs[key], rec) for key in picks} all_docs = links.docs fakes = { "SAMPLE": (SAMPLE(), Fake(LABOR)), "빈": (md.blank(), Fake()), "순환": (_doc([], [("a", "b"), ("b", "a")], [_man("갑", "1", "a")]), Fake()), "0나눔": (_doc([], [("a", "1 ÷ 0")], [_man("갑", "1", "a")]), Fake()), } for name, (doc, links) in fakes.items(): one = Rec(links) want[name] = mc.calc(doc, one) rec.log.update(one.log) all_docs[name] = doc texts = [ "a*b/c", "-(a+b)%", "만약(S=='x', 1, 2)", "단계.'소계'.계 × 2", "a +", "C_{m 3}^표 ÷ 2", ] cases = [ {"조건": {"유형": "보통", "두께": D(t)}, "칸": "품"} for t in ("5", "10", "10.5", "20", "25") ] + [{"조건": {"유형": "분수"}, "칸": "품"}, {"조건": {"유형": "복잡"}, "칸": "율"}] got = _ts( { "문서들": all_docs, "대상": list(want), "기록": rec.log, "글": texts, "표": [{"표": TABLE, **c} for c in cases], } ) for key, answer in want.items(): _same(ml._norm(answer), got["답"][key], key) for text, ts_text in zip(texts, got["글"]): try: assert md.text_of(md.parse(text)) == ts_text except md.DocError: assert ts_text == {"틀림": True} for case, ts_value in zip(cases, got["표"]): try: py = {"값": ml.Links.table(_TableOnly(TABLE), {"표": "QX990001", **case})} except mc.Gap: py = {"빈": True} _same(ml._norm(py), ts_value, str(case)) assert len(want) >= 60