Files
Aislo/resources/tester/test_stmate_ui_code.py
eomsangdonandClaude Opus 5.5 086750b6d4 feat(stmate-test): 2차 화면·코드관리 합침 · % 줄(조달수수료) 계산·저장 재계산 · 줄 고정 단가 보존 · 장부
- 코드 등록·삭제·변경·병합·데이타병합·일괄변경·검색·동일코드·풀어넣기 끝전 등(ui_code · ui_merge · api_ui_code)
- 코드 붙은 % 줄 = 같은 상위 공종 앞 말단 줄 비목 합 × 율(server pct_row_base · calc.js) · 저장 때 다시 셈(stc_save _pct_rows) — 실무 7건 저장값 그대로
- 내역 줄 단가 ≠ 단가목록이면 줄 고정 단가(fixedUnit)
- 메뉴 94개 눌러 오류 0 · 시험 502 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:10:16 +09:00

195 lines
8.4 KiB
Python

"""STMATE 재현 앱 코드 관리(신규등록·코드변경·+α·항목교체·삭제) · 단가 일괄 손질 지킴 시험.
계산 = tools/stmate-test/ui_code.js · ui_code_amt.js · ui_merge.js(node)
저장 = api_ui_code.py(/api/ui/save) → 작업본 다시 불러 대조.
자료 = 실무 STC 7건(server.api_load) — 정답은 원본 저장값(참조 개수·구성행·직공비)에서 읽음.
"""
import json
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 api_ui_code # noqa: E402
import server # noqa: E402
HELPER = Path(__file__).with_name("helper_stmate_ui_code.cjs")
STCS = sorted((ROOT / "resources/knowledge/original/실무문서").rglob("*.STC"))
IDS = [p.stem[:16] for p in STCS]
_LOAD: dict = {}
def load(stc):
if stc not in _LOAD:
_LOAD[stc] = server.api_load(server.LoadReq(source_path=str(stc)))
return _LOAD[stc]
def node(data, mode, **extra):
raw = json.dumps(dict({"load": data, "mode": mode}, **extra), ensure_ascii=False, default=str)
out = subprocess.check_output(["node", str(HELPER)], input=raw, text=True, encoding="utf-8")
return json.loads(out)
@pytest.fixture
def work(tmp_path, monkeypatch):
monkeypatch.setattr(server, "WORK_DBF_ROOT", tmp_path)
return tmp_path
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_code_ops_keep_direct_cost_and_refs(stc):
r = node(load(stc), "ops")
a, c, rp = r["add"], r["change"], r["replace"]
assert a["ok"] and a["comps"][0] == a["comps"][1] > 0 and a["t"] == r["t0"]
assert c["ok"] and c["bad"] and c["t"] == r["t0"]
for (old, new), b in zip(c["map"].items(), c["before"].values()):
assert c["after"][new] == b and sum(b.values()) > 0 # 참조 개수 그대로 옮김
assert all(sum(g.values()) == 0 for g in c["gone"]) # 옛 코드는 어디에도 안 남음
assert rp["ok"] and rp["t"] == r["t0"] and rp["refused"] and rp["del"]
assert r["plus"]["ok"] and r["plus"]["same"] and r["plus"]["compOk"]
assert r["unused"]["plus"] >= r["unused"]["minus"] and r["needs"]
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_ui_save_writes_structure_and_reloads(stc, work):
data = load(stc)
r = node(data, "ops")
p = r["payload"]
res = api_ui_code.ui_save(api_ui_code.UiSaveReq(**p))
back = server.api_load(server.LoadReq(source_path=res["saved_dir"]))
codes = {x["CODE"].strip() for x in back["costn_rows"]}
new_b = r["add"]["code"]
assert new_b in codes and r["add"]["src"] not in codes # 복사본 남고 원래 것 지움
for old, new in r["change"]["map"].items():
assert new in codes and old not in codes
boq = sum(1 for x in back["boq_rows"] if x["BDCODE1"].strip() == new)
comp = sum(1 for x in back["bdqty_rows"] if x.get("CODE", "").strip() == new)
b = r["final"][new] # 항목교체·삭제 뒤 화면 자료의 참조 개수
assert (boq, comp) == (b["boq"], b["comp"])
memo = [k for k, m in back["costn_memos"].items() if f"&{new}" in m["text"]]
assert len(memo) == b["memo"]
assert (
sum(1 for x in back["bdqty_rows"] if x.get("BDCODE", "").strip() == new_b)
== r["finalComps"]
)
assert node(back, "direct")["direct"] == r["t0"] # 다시 불러도 직공비 그대로
@pytest.mark.parametrize("stc", STCS[:3], ids=IDS[:3])
def test_ui_save_without_struct_changes_nothing(stc, work):
data = load(stc)
files = server.read_project_files(str(stc))
struct = [{"_recno": r["_recno"], "CODE": r["CODE"]} for r in data["costn_rows"]]
p = {"source_path": str(stc), "costn_struct": struct, "costn_deleted": [], "boq_code_rows": []}
out, log = api_ui_code.apply_struct(files, p)
assert log == [] and all(out[k] is files[k] for k in files) # 코드·칸 그대로면 원래 바이트
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_amount_tools(stc):
r = node(load(stc), "amt")
ch = r["chg"]
assert ch["nagSkipped"] and ch["idem"] == 0
if ch["first"]:
assert ch["first"][0] == ch["first"][1]
assert (
r["hal"]["twice"] == 0 and r["hal"]["back"] == r["hal"]["n"]
) # 기준단가 기준 — 같은 할증 두 번 = 한 번
assert r["init"]["cleared"] == r["init"]["filled"] and r["jdLeft"] == 0
assert all(r["jd"]) # 같은 명칭·규격 자료에서 원래 호환규정코드를 다시 찾음
assert (
r["mrg"]["same"] and r["mrg"]["n"] >= r["mrg"]["uniq"]
) # 자료파일 업데이트 — 조달코드 되살림
for q, s, q2 in r["loss"]:
assert abs(s - round(s)) < 1e-6 and abs(q2 - q) < 1e-9 # 계(10^-7) 정수 · 왕복 같음
assert all(n > 0 for n in r["special"]) and len(r["special"]) == 14
PAIRS = [(0, 1), (1, 4), (4, 0)] # 복사처 ← 복사원(다른 원설계 프로젝트)
@pytest.mark.parametrize("a,b", PAIRS)
def test_merge_code_from_other_project(a, b, work):
dst, src = load(STCS[a]), load(STCS[b])
r = node(dst, "merge", src=src)
assert (
r["steps"] == r["n"] > 0 and r["kidsOk"] and r["t1"] == r["t0"]
) # 가져와도 내역 직공비 그대로
assert r["acts"]["new"] + r["acts"]["map"] == r["steps"]
res = api_ui_code.ui_save(api_ui_code.UiSaveReq(**r["payload"]))
back = server.api_load(server.LoadReq(source_path=res["saved_dir"]))
top = r["top"]
row = next(x for x in back["costn_rows"] if x["CODE"].strip() == top["dest"])
assert row["NAME"].strip() and abs(float(row["TAMT1"] or 0) - r["price"][1]) < 1
kids = [x for x in back["bdqty_rows"] if x.get("BDCODE", "").strip() == top["dest"]]
assert len(kids) == sum(
1 for x in src["bdqty_rows"] if x.get("BDCODE", "").strip() == r["pick"]
)
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_expand_with_surcharge_rounding_and_loss_row(stc):
r = node(load(stc), "expand")
if r.get("skip"):
pytest.skip("구성 있는 일위대가 내역 행 없음")
assert r["ok"] and r["body"] == r["want"] # 비목별 끝전·노무 할증 1.2
assert r["prS"] == r["prWant"] > 0 # 공구손료 = 풀린 줄 전체 금액 × 3% → 경비(원 미만 절사)
def test_project_data_search_finds_names_across_projects(monkeypatch):
paths = [str(p) for p in STCS[:3]]
projs = [{"name": p.stem, "path": str(p)} for p in STCS[:3]]
monkeypatch.setattr(server, "discover_sources", lambda: {"projects": projs})
a = load(STCS[0])
row = next(r for r in a["costn_rows"] if r["CODE"].startswith("L") and r["NAME"].strip())
words = row["NAME"].split()
res = api_ui_code.ui_search(api_ui_code.SearchReq(kind="LMS", kw=" ".join(words), paths=paths))
hits = {(r["project"], r["CODE"]) for r in res["rows"]}
assert (STCS[0].stem, row["CODE"].strip()) in hits and not res["errors"]
assert all(
all(w.lower() in f"{r['NAME']} {r['SIZE']}".lower() for w in words) for r in res["rows"]
)
boq = api_ui_code.ui_search(api_ui_code.SearchReq(kind="BOQ", kw=words[0], paths=paths))
assert all(words[0].lower() in f"{r['NAME1']} {r['SIZE1']}".lower() for r in boq["rows"])
jd = next(r for r in a["costn_rows"] if r["JDCODE"].strip())
res = api_ui_code.ui_search(
api_ui_code.SearchReq(
kind=jd["CODE"][0] in "LMS" and "LMS" or "XBD",
kw=jd["JDCODE"].strip(),
field="jdcode",
paths=paths[:1],
)
)
assert jd["CODE"].strip() in {r["CODE"] for r in res["rows"]}
OTHER_BOQ = [
(stc, str(r["PCODE"]).strip())
for stc in STCS
for r in server.api_load(server.LoadReq(source_path=str(stc)))["prjlst_all"]
if str(r["PCODE"]).strip() != "11"
and f"BOQ{str(r['PCODE']).strip()}.DBF" in server.read_project_files(str(stc))
]
@pytest.mark.parametrize("stc,pcode", OTHER_BOQ, ids=[f"{s.stem[:10]}-{p}" for s, p in OTHER_BOQ])
def test_open_other_boq_table(stc, pcode):
d = api_ui_code.ui_load_boq(api_ui_code.LoadBoqReq(source_path=str(stc), pcode=pcode))
assert d["boq_table_name"] == f"BOQ{pcode}.DBF" and d["prjlst"]["PCODE"].strip() == pcode
assert d["sim_state"]["projectMeta"]["pcode"].strip() == pcode
t = node(d, "direct")["direct"]
assert abs(t - float(d["prjlst"]["TCOST1"] or 0)) <= 1 # 그 공사 저장 직접비(TCOST1)
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_unit_rounding_per_kind(stc):
r = node(load(stc), "units")
assert (
r["bad"] == 0 and r["changed"] == r["want"]
) # 노무비만 10원 미만 절사 · 재료비·경비 그대로