Files
Aislo/resources/tester/test_stmate_smt_lang.py
T
eomsangdonandClaude Opus 5.5 3d8fddfc34 feat(stmate-test): 보고서·제잡비·식언어·수량산출·화면 묶음 합침 · 재계산 후 저장 무변경
- 보고서: 원본 STMATE 엑셀과 셀 대조(목록표·자재단가대비·중기·일위대가·산근 목록·설계내역서·원가계산서) · 조달청 XML 공식 XSD 검증 · PM_Prt 화면
- 제잡비: 원본 계산 순서 4형식(T/S/TA/SA) · 실무 7건 저장 결과키 전수 1원 · 요율표 조회 · 선택지 계산 · 제잡비·제비율 화면
- 식언어·수량: 함수·조건식·금액변수·문법 검사·수량변수·QCODE · 산근·구성·중기공식 편집 화면 · 순환 알림
- 화면: 원본 메뉴 체계·단축키 · 설계내역서 편집(행 넣기·지우기·이동·레벨·보관함) · 초기값 · 단가목록표
- 저장: 초기값(Start8x.Ini) 바뀐 키 저장 · 노임 환산 직종 = 초기값 CONLD · 산근·구성 결과에 적용단가 칸 채우지 않음(원본 0)
- DBF 글 칸 앞 빈칸 보존 · 시험 도우미 표준입력 utf8(한글 조각 깨짐 원인)
- 실무 5건 재계산 후 저장 = 바뀐 칸 0 · 시험 332건 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 19:12:04 +09:00

294 lines
11 KiB
Python

"""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"]}