Files
Aislo/resources/tester/test_stmate_file_roundtrip.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

262 lines
11 KiB
Python

"""STMATE 재현 앱 파일 저장 무손실 — 실무 STC 7건 열기 → 저장 왕복과 고친 칸만 바뀌는지.
정답은 원본 STC 자체(코드에 값을 박지 않음). 쓰기는 임시 폴더(server.WORK_DBF_ROOT 바꿈)에서만.
근거: STC_왕복검증.md R2·R3 · SIxMemo_구조.md · 16_골든셋_회귀_결과.md §4
"""
import struct
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
from dbf_crypt import BdqtyCodec, covers # noqa: E402
from dbf_memo import SixMemo, ptr_unpack, ptr_valid # noqa: E402
from dbf_rw import DbfTable # noqa: E402
from smt_parser import extract_costn_memos, parse_dbf_bytes, parse_prjlst_smt # noqa: E402
from stc_archive import build_stc, files_of, read_entries # noqa: E402
from stc_save import _leaf, apply_save, boq_spans # noqa: E402
DOCS = ROOT / "resources/knowledge/original/실무문서"
STCS = sorted(DOCS.rglob("*.STC"))
IDS = [p.stem[:16] for p in STCS]
def _files(p):
return files_of(read_entries(p.read_bytes()))
def _full_payload(files):
"""화면이 보내는 꼴 — 불러온 값 전부(글자 칸은 strip 된 값)."""
_, crows, _ = parse_dbf_bytes(files["COSTN.DBF"])
_, brows, _ = parse_dbf_bytes(files["BOQ11.DBF"])
_, prows, _ = parse_dbf_bytes(files["PRJLST.DBF"])
clean = lambda r: {k: v for k, v in r.items() if not k.endswith("_HEX") and k not in ("IFORM", "PRJ_MEMO", "XCODE")} # noqa: E731,E501
memos = extract_costn_memos(files["COSTN.DBF"], files["COSTN.SMT"])
return {
"costn_updates": [clean(r) for r in crows],
"boq_updates": [clean(r) for r in brows],
"prjlst_updates": {k: v for k, v in clean(prows[0]).items() if not k.startswith("_")},
"smt_updates": parse_prjlst_smt(files["PRJLST.SMT"], prows[0]["PRJ_MEMO_HEX"]),
"costn_memo_updates": {c: m["text"] for c, m in memos.items()},
}
@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_stc_rebuild_is_byte_identical(stc):
b = stc.read_bytes()
ents = read_entries(b)
assert build_stc(ents, files_of(ents)) == b
for name, data in files_of(ents).items():
if name.endswith(".DBF"):
assert DbfTable(data).to_bytes() == data, name
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_api_save_without_edits_reproduces_stc(stc, work):
req = server.SaveDbfReq(source_path=str(stc), target_folder_name="rt", **_full_payload(_files(stc)))
res = server.api_save(req)
assert res["change_count"] == 0 and res["changed_files"] == []
assert Path(res["saved_stc"]).read_bytes() == stc.read_bytes()
for e in read_entries(stc.read_bytes()):
assert (work / "rt" / e["name"]).read_bytes() == e["data"], e["name"]
server.api_save(req) # 두 번째 저장 — 앞 작업본 백업
assert list((work / "_backup").glob("rt__*.STC"))
def _changed_cells(a: bytes, b: bytes):
ta, tb = DbfTable(a), DbfTable(b)
assert len(ta) == len(tb)
out = set()
for i in range(len(ta)):
for f in ta.order:
if ta.raw(i, f) != tb.raw(i, f):
out.add((i, f))
return out
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_boq_qty_edit_changes_only_derived_cells(stc):
f = _files(stc)
bt = DbfTable(f["BOQ11.DBF"])
i = next(
k
for k in range(len(bt))
if _leaf(bt.get(k, "BDCODE1")) and float(bt.get(k, "TAMT1") or 0) > 0
and not bt.get(k, "UNIT1").endswith("%")
)
q = float(bt.get(i, "QTY1") or 0) + 1
out, log = apply_save(f, {"boq_updates": [{"_recno": i, "QTY1": q, "NAME1": bt.get(i, "NAME1")}]})
spans, _excl = boq_spans(bt)
span_rows = {r for r, a, b, _x in spans if a <= i <= b}
allowed = {(i, c) for c in ("QTY1", "M_AMT1", "L_AMT1", "S_AMT1", "T_AMT1")}
allowed |= {(r, c) for r in span_rows for c in ("M_AMT1", "L_AMT1", "S_AMT1", "T_AMT1")}
cells = _changed_cells(f["BOQ11.DBF"], out["BOQ11.DBF"])
assert (i, "QTY1") in cells and cells <= allowed
nt = DbfTable(out["BOQ11.DBF"])
assert float(nt.get(i, "QTY1")) == q
assert nt.get(i, "NAME1") == bt.get(i, "NAME1") # 한글 그대로
# 소계 행 = 구간 말단 합 · PRJLST TCOST1 = 직접비
for r, a, b, isx in spans:
tot = sum(
int(float(nt.get(k, "T_AMT1") or 0))
for k in range(a, b + 1)
if _leaf(nt.get(k, "BDCODE1")) and (isx or k not in _excl)
)
assert int(float(nt.get(r, "T_AMT1") or 0)) == tot
pt, pt0 = DbfTable(out["PRJLST.DBF"]), DbfTable(f["PRJLST.DBF"])
direct = sum(
int(float(nt.get(k, "T_AMT1") or 0))
for k in range(len(nt))
if _leaf(nt.get(k, "BDCODE1")) and k not in _excl
)
assert int(float(pt.get(0, "TCOST1"))) == direct
assert {c for _r, c in _changed_cells(f["PRJLST.DBF"], out["PRJLST.DBF"])} <= {
"MCOST1", "LCOST1", "SCOST1", "TCOST1"
}
assert pt.get(0, "PNAME") == pt0.get(0, "PNAME")
for k in f: # 다른 파일은 그대로
if k not in ("BOQ11.DBF", "PRJLST.DBF"):
assert out[k] == f[k], k
assert {x["table"] for x in log} <= {"BOQ11.DBF", "PRJLST.DBF"}
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_costn_price_edit_propagates_to_linked_boq_rows(stc):
f = _files(stc)
ct, bt = DbfTable(f["COSTN.DBF"]), DbfTable(f["BOQ11.DBF"])
crow = {ct.get(i, "CODE"): i for i in range(len(ct))}
linked = [
k
for k in range(len(bt))
if bt.get(k, "BDCODE1") in crow
and all(
float(bt.get(k, f"{c}AMT1") or 0) == float(ct.get(crow[bt.get(k, "BDCODE1")], f"{c}AMT1") or 0)
for c in "MLS"
)
and float(bt.get(k, "MAMT1") or 0) > 0
]
if not linked:
pytest.skip("단가가 같은 연결 행 없음")
code = bt.get(linked[0], "BDCODE1")
m1 = float(ct.get(crow[code], "MAMT1")) + 10
out, _log = apply_save(f, {"costn_updates": [{"CODE": code, "MAMT1": m1}]})
cc = _changed_cells(f["COSTN.DBF"], out["COSTN.DBF"])
assert cc == {(crow[code], "MAMT1")}
nt = DbfTable(out["BOQ11.DBF"])
for k in range(len(bt)):
if bt.get(k, "BDCODE1") == code:
want = m1 if k in linked else float(bt.get(k, "MAMT1") or 0)
assert float(nt.get(k, "MAMT1")) == want
k = linked[0]
assert int(float(nt.get(k, "M_AMT1"))) == int(float(nt.get(k, "QTY1")) * m1 + 1e-9)
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_costn_memo_edit_keeps_sixmemo_layout(stc):
f = _files(stc)
memos = extract_costn_memos(f["COSTN.DBF"], f["COSTN.SMT"])
codes = sorted(memos, key=lambda c: memos[c]["blockNo"])
grow, shrink = codes[0], codes[-1]
upd = {grow: memos[grow]["text"] + "\r\n'추가 줄' 1'개' =" * 40, shrink: memos[shrink]["text"][:20]}
out, _ = apply_save(f, {"costn_memo_updates": upd})
smt, smt0 = out["COSTN.SMT"], f["COSTN.SMT"]
assert smt[4:16] == smt0[4:16]
m = SixMemo(smt)
assert m.next_free == -(-len(smt) // 64) # 다음 빈 블록 = 파일 끝 블록
new = extract_costn_memos(out["COSTN.DBF"], smt)
for c, v in memos.items():
nl = lambda t: t.replace("\r\n", "\n").replace("\r", "\n") # noqa: E731
assert nl(new[c]["text"]) == nl(upd.get(c, v["text"])), c
# 늘어난 메모는 뒤에 붙고 줄어든 메모는 제자리 · 다른 메모 블록 바이트 그대로
assert new[grow]["blockNo"] >= len(smt0) // 64
assert new[shrink]["blockNo"] == memos[shrink]["blockNo"]
for c, v in memos.items():
if c not in upd:
st = v["blockNo"] * 64
assert smt[st : st + v["byteLen"]] == smt0[st : st + v["byteLen"]]
cc = _changed_cells(f["COSTN.DBF"], out["COSTN.DBF"])
assert {c for _r, c in cc} == {"IFORM"}
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_prjlst_smt_update_writes_valid_memo(stc):
f = _files(stc)
_, prows, _ = parse_dbf_bytes(f["PRJLST.DBF"])
kv = parse_prjlst_smt(f["PRJLST.SMT"], prows[0]["PRJ_MEMO_HEX"])
key = next(k for k in kv if k.startswith("1_R_"))
upd = {key: float(kv[key]) + 0.5, "1_Z_FILE_TEST": 7}
out, _ = apply_save(f, {"smt_updates": upd})
pt = DbfTable(out["PRJLST.DBF"])
ptr = pt.raw(0, "PRJ_MEMO")
assert ptr_valid(ptr) and ptr_unpack(ptr)[0] == ptr_unpack(bytes.fromhex(prows[0]["PRJ_MEMO_HEX"]))[0]
kv2 = parse_prjlst_smt(out["PRJLST.SMT"], ptr.hex())
assert float(kv2[key]) == float(kv[key]) + 0.5 and kv2["1_Z_FILE_TEST"] == "7"
assert {k: v for k, v in kv2.items() if k not in upd} == {k: v for k, v in kv.items() if k not in upd}
assert struct.unpack("<I", out["PRJLST.SMT"][:4])[0] == -(-len(out["PRJLST.SMT"]) // 64)
if len(pt) > 1: # 둘째 공사 메모는 그대로
m0, m1 = SixMemo(f["PRJLST.SMT"]), SixMemo(out["PRJLST.SMT"])
assert m0.read(DbfTable(f["PRJLST.DBF"]).raw(1, "PRJ_MEMO")) == m1.read(pt.raw(1, "PRJ_MEMO"))
def test_korean_edit_and_unencodable_text(work):
stc = STCS[0]
f = _files(stc)
out, _ = apply_save(f, {"prjlst_updates": {"PNAME": "임도 시설 공사(변경)"}})
assert DbfTable(out["PRJLST.DBF"]).get(0, "PNAME") == "임도 시설 공사(변경)"
with pytest.raises(Exception):
server.api_save(
server.SaveDbfReq(source_path=str(stc), target_folder_name="bad", prjlst_updates={"PNAME": "工事🙂"})
)
def test_boq_insert_and_delete_rows():
stc = STCS[0]
f = _files(stc)
_, brows, _ = parse_dbf_bytes(f["BOQ11.DBF"])
leaf = next(r for r in brows if _leaf(r["BDCODE1"]) and float(r["TAMT1"] or 0) > 0)
k = leaf["_recno"]
ups = []
for r in brows:
ups.append({"_recno": r["_recno"]})
if r["_recno"] == k:
ups.append({"_new": True, "BDCODE1": leaf["BDCODE1"], "NAME1": "끼운 행", "QTY1": 2})
out, log = apply_save(f, {"boq_updates": ups, "boq_deleted": []})
nt = DbfTable(out["BOQ11.DBF"])
assert len(nt) == len(brows) + 1 and nt.get(k + 1, "NAME1") == "끼운 행"
s0, s1, s2 = (float(nt.get(j, "SORTCODE")) for j in (k, k + 1, k + 2))
assert s0 < s1 < s2
assert int(float(nt.get(k + 1, "M_AMT1"))) == int(2 * float(leaf["MAMT1"]))
out2, _ = apply_save(out, {"boq_deleted": [k + 1]})
assert DbfTable(out2["BOQ11.DBF"]).recs == DbfTable(f["BOQ11.DBF"]).recs
assert any(x["field"] == "_insert" for x in log)
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_bdqty_codec_is_inverse(stc):
b = _files(stc)["BDQTY.DBF"]
t, codec = DbfTable(b), BdqtyCodec()
n = 0
for i in range(min(len(t), 60)):
for name in t.order:
fd = t.fields[name]
if covers(fd.off, fd.len):
c = bytes(t.recs[i][fd.off : fd.off + fd.len])
assert codec.encode(codec.decode(c, fd.off), fd.off) == c
n += 1
assert n > 0
ct = DbfTable(b, codec=codec)
name = next(x for x in ct.order if ct.fields[x].type == "N" and covers(ct.fields[x].off, ct.fields[x].len))
v = float(ct.get(0, name) or 0)
assert ct.set(0, name, v + 1) and float(ct.get(0, name)) == v + 1
assert ct.set(0, name, v) and ct.to_bytes()[32:] == b[32:]