"""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원 미만 절사 · 재료비·경비 그대로