Files
Aislo/resources/tester/test_stmate_file_project.py
T
eomsangdonandClaude Opus 5.5 e987602f24 feat(stmate-test): 무손실 저장 — 손대지 않은 저장 = 원본 STC 바이트 동일 · 고친 칸만 다시 씀
- DBF 고친 칸만 다시 씀(한글 깨짐 · 2칸을 1칸으로 · 3칸 87.745% 덮어쓰기 없앰) · 단가 → 내역 행 → 소계 → 직공비 다시 셈 · 행 끼우기·지우기
- SIxMemo(COSTN.SMT · PRJLST.SMT) 블록 구조 유지 · 넘치면 뒤에 붙이고 가리키는 값 갱신
- STC 다시 묶기(같은 파일은 원본 바이트) · 검사 · 자동 백업·복원 · 새 공사 · 복사등록(차수 인계) · 해당분 압축
- BDQTY 역변환(쓰기 준비) · 모듈 dbf_rw · dbf_memo · dbf_crypt · stc_archive · stc_save · stc_read · stc_project · api_file · scr_file
- 지킴 시험 test_stmate_file_roundtrip · file_project · file_payload — 실무 STC 7건 왕복

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

174 lines
8.1 KiB
Python

"""STMATE 재현 앱 공사 단위 파일 작업 — STC 검사 · 해당분만 압축 · 복사등록(차수/계약 변환) · 새 공사 · 백업/복원.
정답은 실무 STC 자체 — 소광 신설(BOQ11+BOQ12 통합형) → 변경1차(분리형) 짝으로 차수 인계 규칙(16번 §4.6) 대조.
쓰기는 임시 폴더(server.WORK_DBF_ROOT 바꿈)에서만.
"""
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_file # noqa: E402
import server # noqa: E402
from dbf_memo import SixMemo, ini_items # noqa: E402
from dbf_rw import DbfTable # noqa: E402
from stc_archive import build_stc, check_stc, files_of, read_entries # noqa: E402
from stc_project import copy_register, new_project, pack_selected # noqa: E402
from stc_save import _leaf, boq_spans # noqa: E402
DOCS = ROOT / "resources/knowledge/original/실무문서"
STCS = sorted(DOCS.rglob("*.STC"))
IDS = [p.stem[:16] for p in STCS]
SOGWANG = DOCS / "25년 산불진화임도(기번8)(울진소광)"
JOINT = next(SOGWANG.glob("2025년*.STC")) # BOQ11(당초) + BOQ12(현재안)
SPLIT = next(SOGWANG.glob("변경*.STC")) # 분리형 변경1차
def _files(p):
return files_of(read_entries(p.read_bytes()))
def _memo_text(files, i):
t = DbfTable(files["PRJLST.DBF"])
return SixMemo(files["PRJLST.SMT"]).read(t.raw(i, "PRJ_MEMO")).decode("cp949")
@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_check_stc_accepts_real_files(stc):
res = check_stc(stc.read_bytes())
assert res["ok"], res["problems"]
assert res["tables"]["PRJLST.DBF"]["nrec"] >= 1
assert not check_stc(b"PK-not-a-zip")["ok"]
def test_pack_selected_carries_current_plan_into_split_file():
src = _files(JOINT)
packed = pack_selected(src, "12")
assert packed["BOQ11.DBF"] == src["BOQ12.DBF"] and "BOQ12.DBF" not in packed
pt, st = DbfTable(packed["PRJLST.DBF"]), DbfTable(src["PRJLST.DBF"])
assert len(pt) == 1 and pt.get(0, "PCODE") == "11"
for f in pt.order:
if f not in ("PCODE", "PRJ_MEMO"):
assert pt.raw(0, f) == st.raw(1, f), f
assert _memo_text(packed, 0) == _memo_text(src, 1)
assert SixMemo(packed["PRJLST.SMT"]).next_free == -(-len(packed["PRJLST.SMT"]) // 64)
# 16번 §4.6 — 변경1차 파일의 차수1 직접비 = 앞 파일 BOQ12 공사 직접비
real = DbfTable(_files(SPLIT)["PRJLST.DBF"])
for c in ("MCOST1", "LCOST1", "SCOST1", "TCOST1"):
assert float(pt.get(0, c)) == float(real.get(0, c)), c
assert check_stc(build_stc(read_entries(JOINT.read_bytes()), packed))["ok"]
def test_copy_register_change_mode_opens_next_stage():
base = pack_selected(_files(JOINT), "12")
out, pcode, _log = copy_register(base, "11", "change")
assert pcode == "12" and out["BOQ11.DBF"] == base["BOQ11.DBF"]
a, b = DbfTable(base["BOQ11.DBF"]), DbfTable(out["BOQ12.DBF"])
assert len(a) == len(b)
for i in range(len(a)):
for f in ("QTY", "MAMT", "LAMT", "SAMT", "TAMT", "M_AMT", "T_AMT", "BDCODE"):
assert b.raw(i, f"{f}2") == a.raw(i, f"{f}1"), (i, f)
assert b.raw(i, f"{f}1") == a.raw(i, f"{f}1"), (i, f)
pt = DbfTable(out["PRJLST.DBF"])
real = DbfTable(_files(SPLIT)["PRJLST.DBF"])
assert len(pt) == 2 and pt.get(1, "PCODE") == "12"
assert pt.get(1, "K_ORD") == real.get(0, "K_ORD") == "1"
assert pt.get(1, "TCOST1") == pt.get(1, "TCOST2") == real.get(0, "TCOST1")
secs = {s for s, _k, _v in ini_items(_memo_text(out, 1))}
assert {"1_KANJUB", "2_KANJUB"} <= secs
one = {k[2:]: v for s, k, v in ini_items(_memo_text(out, 1)) if s == "1_KANJUB"}
two = {k[2:]: v for s, k, v in ini_items(_memo_text(out, 1)) if s == "2_KANJUB"}
assert one == two
assert _memo_text(out, 0) == _memo_text(base, 0) # 원래 공사 메모 그대로
def test_copy_register_contract_mode_applies_rates_except_excluded():
src = _files(STCS[0])
same, _, log = copy_register(src, "11", "contract", rates={"M": 100, "L": 100, "S": 100})
assert DbfTable(same["BOQ12.DBF"]).recs == DbfTable(src["BOQ11.DBF"]).recs and not log
out, pcode, _ = copy_register(src, "11", "contract", rates={"M": 87.745, "L": 90, "S": 0})
a, b = DbfTable(src["BOQ11.DBF"]), DbfTable(out[f"BOQ{pcode}.DBF"])
spans, excl = boq_spans(a)
for i in range(len(a)):
if not _leaf(a.get(i, "BDCODE1")):
continue
if i in excl:
assert b.recs[i] == a.recs[i]
continue
assert int(float(b.get(i, "MAMT1"))) == int(float(a.get(i, "MAMT1") or 0) * 0.87745 + 1e-9)
assert float(b.get(i, "SAMT1") or 0) == 0 # 0% = 공내역
for r, x, y, isx in spans:
tot = sum(
int(float(b.get(k, "T_AMT1") or 0))
for k in range(x, y + 1)
if _leaf(b.get(k, "BDCODE1")) and (isx or k not in excl)
)
assert int(float(b.get(r, "T_AMT1") or 0)) == tot
direct = sum(
int(float(b.get(k, "T_AMT1") or 0))
for k in range(len(b))
if _leaf(b.get(k, "BDCODE1")) and k not in excl
)
assert int(float(DbfTable(out["PRJLST.DBF"]).get(1, "TCOST1"))) == direct
def test_new_project_opens_empty(work):
out = new_project(_files(STCS[0]), "새 임도 공사", "2026-01")
p = work / "new.STC"
p.write_bytes(build_stc(read_entries(STCS[0].read_bytes()), out))
assert check_stc(p.read_bytes())["ok"]
load = server.api_load(server.LoadReq(source_path=str(p)))
assert load["costn_count"] == 0 and load["boq_count"] == 0
assert load["prjlst"]["PNAME"] == "새 임도 공사" and load["prjlst"]["PCODE"] == "11"
assert float(load["prjlst"]["TCOST1"] or 0) == 0
smt = load["prjlst_smt"]
assert smt and all(float(v) == 0 for k, v in smt.items() if k.startswith("1_C_"))
assert any(k.startswith("1_R_") and float(v) > 0 for k, v in smt.items()) # 요율 설정은 틀에서
def test_file_api_endpoints(work):
src = str(JOINT)
assert api_file.api_file_check(api_file.PathReq(source_path=src))["ok"]
r = api_file.api_file_pack(api_file.PackReq(source_path=src, pcode="12", target_name="pk"))
assert Path(r["saved_stc"]).parent == work and check_stc(Path(r["saved_stc"]).read_bytes())["ok"]
r2 = api_file.api_file_copy_register(
api_file.CopyRegReq(source_path=r["saved_stc"], mode="change", target_folder_name="cr")
)
assert r2["new_pcode"] == "12" and (work / "cr" / "BOQ12.DBF").exists()
load = server.api_load(server.LoadReq(source_path=str(work / "cr")))
assert sorted(load["boq_tables"]) == ["BOQ.DBF", "BOQ11.DBF", "BOQ12.DBF"]
r3 = api_file.api_file_new(
api_file.NewPrjReq(template_path=src, pname="새 공사", target_folder_name="nw")
)
assert Path(r3["saved_stc"]).exists()
api_file.api_file_new(api_file.NewPrjReq(template_path=src, pname="새 공사2", target_folder_name="nw"))
bks = api_file.api_file_backups("nw")["backups"]
assert len(bks) == 1
api_file.api_file_restore(api_file.RestoreReq(backup_path=bks[0]["path"], target_folder_name="nw"))
assert server.api_load(server.LoadReq(source_path=str(work / "nw")))["prjlst"]["PNAME"] == "새 공사"
with pytest.raises(Exception):
api_file.api_file_restore(api_file.RestoreReq(backup_path=src, target_folder_name="x"))
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_load_reads_side_tables_and_all_settings(stc):
load = server.api_load(server.LoadReq(source_path=str(stc)))
assert load["kongjong_rows"] and all("CODE" in r for r in load["kongjong_rows"])
assert load["costa_count"] == 0 and load["qcode_rows"] == []
rows = load["prjlst_smt_all"]
assert len(rows) == len(load["prjlst_all"])
first = rows[0]
assert first["key_count"] >= len(load["prjlst_smt"]) > 100 and "KANJUB" in first["sections"]
for k, v in load["prjlst_smt"].items(): # 첫 공사 읽기(parse_prjlst_smt)와 같은 값
assert any(s.get(k) == v for s in first["sections"].values()), k
assert "START" in load["start_ini_sections"]