"""STMATE 재현 앱 단가산출근거 식 언어 — 함수·자리 처리·계표시·손료계산(금액변수·누계번지)·수량변수·문법 검사. 정답 = 원본 편집기 문법 안내문(REA 30 TWM_EDIT_SAN Memo_Use)의 예와 결과 설명 · 실무 STC 7건의 산근 원문. 글 안의 수치를 읽어 대조(코드에 박지 않음) · 단가표·수량코드는 예마다 만든 것. """ import json import re import subprocess import sys from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "tools/stmate-test")) import server # noqa: E402 HELPER = Path(__file__).with_name("helper_stmate_qty_run.cjs") DFM = ( ROOT / "resources/knowledge/original/경쟁사 프로그램/STmate/REA 분석/" "30_수량문자열_수신편집기_작업자료_20261026/ST_EdSan/resource-TWM_EDIT_SAN.dfm.json" ) DOCS = ROOT / "resources/knowledge/original/실무문서" STC = sorted(DOCS.rglob("*.STC")) def call(*calls): out = subprocess.check_output( ["node", str(HELPER)], input=json.dumps({"calls": [{"fn": f, "args": a} for f, *a in calls]}, ensure_ascii=False), text=True, encoding="utf-8", ) res = json.loads(out) for r in res: assert not (isinstance(r, dict) and "__error" in r), r return res def memo_use() -> list[str]: nodes = json.loads(DFM.read_text(encoding="utf-8"))["nodes"] node = next(n for n in nodes if n.get("name") == "Memo_Use") return node["properties"]["Lines.Strings"] def test_rounding_examples_from_grammar_text(): """(3) A=123.784, B=873.496 · C=A/B=0.14 · D=round(A/B,4)=0.1417 · E=A/B=[?:4R] · F=[?:2U] · H=int · I=[?:0C].""" text = "\n".join(memo_use()) a = re.search(r"A=([\d.]+),\s*B=([\d.]+)", text) rows = re.findall(r"\b([C-I])=([A-Za-z()/,\d ]+?)=(\[\?:\d[RUC]\]|[\d.]+)", text) paren = dict( m.groups() for ln in memo_use() if (m := re.search(r"\(([EFI])\).*\[\?:\d[RUC]\].*\(([\d.]+)\)\s*$", ln)) ) assert a and {k for k, *_ in rows} >= set("CDEFHI") and set(paren) == set("EFI"), (rows, paren) lines = [f"A={a[1]}, B={a[2]}"] + [ f"{k}={e}=" + (t if t.startswith("[") else "") for k, e, t in rows ] (ev,) = call(("evaluate", "\n".join(lines), {}, {"assignDigits": 2})) for k, _e, t in rows: want = float(paren[k] if t.startswith("[") else t) assert abs(ev["vars"][k] - want) < 1e-9, (k, ev["vars"][k], want) def test_functions_and_left_to_right(): """함수 목록(cBoxFunc) 꼴 그대로 평가 · 곱·나눗셈 왼쪽부터 · RXXNM 18 §2.1.""" nodes = json.loads(DFM.read_text(encoding="utf-8"))["nodes"] items = next(n for n in nodes if n.get("name") == "cBoxFunc")["properties"]["Items.Strings"] funcs = [s.strip() for s in items if "(" in s] res = call(*[("expr", f, {}) for f in funcs]) for f, r in zip(funcs, res): assert r["error"] is None and r["value"] is not None, (f, r) by = dict(zip(funcs, (r["value"] for r in res))) import math assert by["IIF(1>0, 2, 3)"] == 2 and by["LOG(10,1)"] == 0 and by["SQRT(1)"] == math.sqrt(1) daily = 267360 (r,) = call(("expr", f"{daily}*1/8*16/12*25/20", {})) step = daily for op, n in [("/", 8), ("*", 16), ("/", 12), ("*", 25), ("/", 20)]: step = step / n if op == "/" else step * n assert r["value"] == step def test_sonryo_amount_and_total_variables(): """(9) 손료계산 ①~⑧ — 금액번지 A01(①+②) · 제외(->) · 누계번지 T01(①+③) · PA01 · PT01 · PC_ · PS_.""" lines = [ln for ln in memo_use() if re.match(r"\s*[①-⑧]\s*(\[|\(=|&)", ln)][:8] body = [re.sub(r"^\s*[①-⑧]\s*", "", ln).split(" or ")[0].split("→")[0].strip() for ln in lines] prices = { "L00084": {"M": 0, "L": 150000, "S": 0, "unit": "인"}, "L00087": {"M": 0, "L": 120000, "S": 0, "unit": "인"}, "L00088": {"M": 0, "L": 90000, "S": 0, "unit": "인"}, } text = "\n".join(b.replace("&L84&", "&L84&").replace("&PS_:M&", "&PS_:M&") for b in body) (ev,) = call(("evaluate", text, prices, {})) amt = [ln for ln in ev["lines"] if ln["kind"] == "amount"] tot = lambda ln: ln["amounts"]["M"] + ln["amounts"]["L"] + ln["amounts"]["S"] # noqa: E731 one, two, three, five, six, seven, eight = amt assert ev["errors"] == [] assert two["excluded"] and not one["excluded"] assert ev["labels"]["A01"]["L"] == pytest.approx(one["amounts"]["L"] + two["amounts"]["L"]) assert ev["labels"]["T01"]["L"] == pytest.approx(one["amounts"]["L"] + three["amounts"]["L"]) trunc1 = lambda v: int(v * 10 + 1e-9) / 10 # noqa: E731 assert five["amounts"]["S"] == pytest.approx(trunc1(ev["labels"]["A01"]["L"] * 0.1)) assert six["amounts"]["S"] == pytest.approx(trunc1(ev["labels"]["T01"]["L"] * 0.1)) assert seven["amounts"]["M"] == pytest.approx( trunc1((five["amounts"]["S"] + six["amounts"]["S"]) * 0.1) ) allsum = tot(one) + tot(three) + tot(five) + tot(six) + tot(seven) assert eight["amounts"]["M"] == pytest.approx(trunc1(allsum * 0.1)) assert ev["T"] == int(allsum + tot(eight)) def test_section_markers_exclusion_and_grammar_errors(): prices = { "M00001": {"M": 1000, "L": 0, "S": 0, "unit": "M2"}, "L00001": {"M": 0, "L": 2000, "S": 0, "unit": "인"}, } text = "\n".join( ["&M00001& * 2 =", "(=)", "&L00001& * 1 =", "(-=)", "&M00001& * 3 =", "(====)", "(=====)"] ) (ev,) = call(("evaluate", text, prices, {})) assert (ev["M"], ev["L"]) == (5000, 0) and ev["errors"] == [] bad = "\n".join( [ "&M00001& + &L00001& =", "&M00001& ^ 2 =", "x = y + 1", "'열린 따옴표", "&Z99999& * 1 =", "(======)", "~Q1.QTY1 * 2", "&M00001& * IMG(I00001) IMG(I00002)", ] ) (ev,) = call(("evaluate", bad, prices, {"selfCode": "D00001"})) lines = {e["lineNo"] for e in ev["errors"]} assert lines == set(range(1, 9)), ev["errors"] (ev,) = call( ( "evaluate", "&D00001& * 1 =", {"D00001": {"M": 1, "L": 0, "S": 0, "unit": "M3"}}, {"selfCode": "D00001"}, ) ) assert any("순환" in e["msg"] for e in ev["errors"]) def test_quantity_variables_and_code_normalization(): """~Q1.QTY1~ → Q00001 · QTY0~9 직접 · 그 밖 XCODE 「이름=값」 · 빈 값 0 · 없는 이름 오류(REA 30).""" q = [{"CODE": "Q00001", "QTY1": "12.5", "XCODE": "절토량=120.5\r\n빈칸=\r\n"}] prices = {"L00001": {"M": 0, "L": 1000, "S": 0, "unit": "인"}} text = "\n".join( [ "A = ~Q1.QTY1~", "B = ~Q00001.절토량~", "C = ~q01.빈칸~", "&L00001& * A =", "D = ~Q00001.없음~", "E = ~Q00002.QTY1~", ] ) (ev,) = call(("evaluate", text, prices, {"qcode": q})) assert ev["vars"]["A"] == 12.5 and ev["vars"]["B"] == 120.5 and ev["vars"]["C"] == 0 assert ev["L"] == 12500 assert {e["lineNo"] for e in ev["errors"]} == {5, 6} n = call( ("normQCode", "Q1"), ("normQCode", "q000001"), ("normQCode", "Q1234567"), ("normQCode", "X1"), ) assert n == ["Q00001", "Q00001", None, None] def test_code_insert_lines_zero_skip_and_result_strip(): row = { "NAME": "굴삭기", "SIZE": "0.7㎥", "UNIT": "HR", "MAMT1": "7701", "LAMT1": "55700", "SAMT1": "0", } prices = {"X00001": {"M": 7701, "L": 55700, "S": 0, "unit": "HR"}} full, skip, gen = call( ("codeLines", "X00001", row, {"op": "/", "qtyVar": "Q"}), ("codeLines", "X00001", row, {"op": "*", "qtyVar": "T", "skipZero": True}), ( "codeLines", "M00009", {"NAME": "합판", "SIZE": "12mm", "UNIT": "M2"}, {"trans": 3, "qty": 2}, ), ) assert len(full) == 3 and len(skip) == 2 and all("* T =" in s for s in skip) assert "'합판 12mm'" in gen[0] and "'M2'" in gen[0] (ev,) = call(("evaluate", "Q = 15.71\n" + "\n".join(full), prices, {})) assert ev["L"] == int(55700 / 15.71 * 10) / 10 // 1 or ev["L"] == int( int(55700 / 15.71 * 10) / 10 ) marks = call(*[("marker", lv, ex) for lv in range(1, 6) for ex in (False, True)]) stripped = call( ( "stripResults", "\n".join( [ "소계", "총합계(제외금액)", "&M00001& * 2 = 2,000 '원'", "&M00001& * 2 = 2,000", "그냥 글", ] ), ) )[0].split("\r\n") assert stripped[0] == marks[0] and stripped[1] == marks[9] assert ( stripped[2] == "&M00001& * 2 ='원'" and stripped[3] == "&M00001& * 2 =" and stripped[4] == "'그냥 글'" ) @pytest.mark.parametrize("path", STC, ids=lambda p: p.stem[:20]) def test_real_memos_check_clean_and_strip_keeps_amounts(path): """실무 산근 원문 — 문법 오류 0 · 금액결과 삭제 뒤에도 금액 그대로 · 참조 그래프 순환 없음.""" d = server.api_load(server.LoadReq(source_path=str(path))) load = { k: d[k] for k in ("costn_rows", "costn_memos", "start_ini", "bdqty_rows", "boq_rows", "sim_state") } smt, cyc = call(("smtAll", load), ("cascadeCycles", load)) errs = {c: v["errors"] for c, v in smt.items() if v["errors"]} assert not errs, list(errs.items())[:3] moved = {c: (v["sum"], v["stripped"]) for c, v in smt.items() if v["sum"] != v["stripped"]} assert not moved, list(moved.items())[:3] assert cyc == [] def test_cycles_composition_devd_rr_and_result_options(): (cyc,) = call( ( "cycles", { "B00001": ["B00002"], "B00002": ["D00001"], "D00001": ["B00001"], "B00003": ["L00001"], }, ) ) assert len(cyc) == 1 and set(cyc[0]) == {"B00001", "B00002", "D00001"} table = { "L00001": {"M": 0, "L": 100000, "S": 0, "unit": "인"}, "M00001": {"M": 3000, "L": 0, "S": 0, "unit": "M3"}, } rows = [ {"CODE": "L00001", "QTY1": "4", "DEVD1": "T", "PRPT": "1"}, {"CODE": "M00001", "QTY1": "2", "DEVD1": "F", "PRPT": "0"}, {"CODE": "RR", "QTY1": "0.03", "PRIN": "2", "PROUT": "1"}, {"CODE": "PR", "QTY1": "10", "PRIN": "2", "PROUT": "3"}, ] ev, adj, dv1, dv2, dv0 = call( ("composition", rows, table, {}), ("composition", rows, table, {"adj": {"M": 1, "L": 1.15, "S": 1}, "sbmok": "3"}), ("devd", 4, "인"), ("devd", 3, "%"), ("devd", 0, "인"), ) lab = 100000 / 4 assert (ev["L"], ev["M"], ev["S"]) == (int(lab), int(6000 + lab * 0.03), int(lab * 0.1)) assert ( adj["M"] == 0 and adj["L"] == 0 and adj["S"] == ev["M"] + int(ev["L"] * 1.15 + 1e-9) + ev["S"] ) assert dv1 == 0.25 and dv2 == round(100 / 3, 7) and dv0 == 0 def test_labor_rx_branches(): ini = { "CONLD": "L00008L00009", "CONTD": "L00030", "RXXNM": "1/8*16/12*25/20", "RXTNM": "1/8*16/12*25/20*1.2", } each, forced = call(("laborMap", ini, 0, ""), ("laborMap", ini, 2, "")) assert each == {"L00008": ini["RXXNM"], "L00009": ini["RXXNM"], "L00030": ini["RXTNM"]} assert set(forced.values()) == {ini["RXTNM"]}