Files
Aislo/resources/tester/test_stmate_conv_win8.py
T
eomsangdonandClaude Opus 5.5 47a04b6df8 feat(stmate-test): REA MCP(Ghidra) 파일 입출력 — JDL 탭 줄·묶음 내보내기·검사 · 구버전→Win8 변환 · 엑셀 역등록 구성 줄 · 자재 집계표 엑셀 · 그림 코드
- JDL 줄 = 탭 구분 + \x05/\x07 덧붙임(검사기 0x504d20 · 고정폭 표는 죽은 고리 — 34 §17.9 바로잡음) · 단가 목록 7종·Main·GongJong 머리·Global.Ini 상수 키 · zip 받기
- 구버전 판 가리기(Chk_Data 0x459280) · 7.x/DOS 표별 필드 대응 · B→X 조건 = 단위 X
- 엑셀 역등록 PRPT「속성」·PRIN「대상」·PROUT「저장」 = PR/RR 줄만 · 원본 BDQTY 4건 일치
- 자재 = 재료비 수량집계표 엑셀 내보내기(주요건설자재현황 시트)
- 그림 코드 = COSTN I 코드 IFORM · SIZE = 그림 클래스 이름
- 지식DB 37번

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 15:58:17 +09:00

327 lines
11 KiB
Python

"""STMATE 재현 앱 — 구버전(DOS·7.x) → Win8 변환 지킴 시험(합성 자료 · 지식DB REA 분석/37 §2 규칙).
근거: ST_U_Archive Chk_Data 0x459280 · ST_U_Convert ST_ConvToWin8 0x459928 · COSTN 0x4882a4/0x490cb8 ·
BDQTY 0x489448 · PRJLST 0x48c890/0x49379c · KONGJONG 0x48cbf0 · BOQ 0x48d58c/0x493e78 · Start8x.Ini 0x48e07c
틀 = 저장소 work_dbf 표본 STC(읽기만) · 구버전 표본이 없어 규칙에서 만든 합성 표로 시험.
"""
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"))
from bdqty import decode_bdqty # noqa: E402
from conv_win8 import KONG_DOS, convert # noqa: E402
from conv_win8_ini import detect, ini_dict, read_mem # noqa: E402
from dbf_rw import DbfTable # noqa: E402
from smt_parser import build_dbf_bytes # noqa: E402
from stc_read import Source # noqa: E402
TPL = next((ROOT / "tools/stmate-test/work_dbf").glob("*.STC"), None)
def C(n, ln=10):
return {"name": n, "type": "C", "len": ln, "dec": 0}
def N(n, ln=12, dec=2):
return {"name": n, "type": "N", "len": ln, "dec": dec}
def L(n):
return {"name": n, "type": "L", "len": 1, "dec": 0}
def dbf(fields, rows):
return build_dbf_bytes(fields, rows)
COSTN_F = [C("CODE", 6), C("NAME", 30), C("SIZE", 30), C("UNIT", 10), C("BQCODE", 16)]
COSTN_F += [
N("AMT"),
N("LAMT"),
N("MAMT"),
N("SAMT"),
N("D_AMT"),
N("D_LAMT"),
C("GUN", 1),
L("UC"),
]
COSTN_F += [N("AMT_1D"), C("PG_1D", 12), N("AMT_6D"), C("PG_D", 12)]
COSTN_ROWS = [
{
"CODE": "M00001",
"NAME": "철근",
"UNIT": "TON",
"AMT": 1000,
"MAMT": 1000,
"D_AMT": 900,
"GUN": "H",
},
{
"CODE": "M00001"[:1] + "00002",
"NAME": "잡재료",
"UNIT": "M%",
"AMT": 5,
"MAMT": 5,
"GUN": "H",
},
{
"CODE": "B00003",
"NAME": "굴삭기",
"UNIT": " x ",
"AMT": 50,
"LAMT": 30,
"AMT_1D": 7,
"PG_1D": "12",
},
{"CODE": "B00004", "NAME": "대가", "UNIT": "M3", "AMT": 80, "AMT_6D": 3, "PG_D": "1"},
{"CODE": "Q00001", "NAME": "버림", "UNIT": "식", "AMT": 1},
{"CODE": "L00001", "NAME": "보통인부", "UNIT": "인", "AMT": 150, "LAMT": 150, "UC": "T"},
]
BDQ_F = [
C("BDCODE", 6),
C("CODE", 6),
C("T_NAME", 20),
N("QTY", 14, 4),
C("BIMOK", 1),
L("DEVD"),
L("T_EXIST"),
]
BDQ_ROWS = [
{"BDCODE": "B00003", "CODE": "L00001", "QTY": 1.5, "T_EXIST": "T"},
{"BDCODE": "B00004", "CODE": "B00003", "QTY": 2},
{"BDCODE": "B00004", "CODE": "T00002", "QTY": 0},
{"BDCODE": "B00004", "CODE": "Z99999", "QTY": 9},
{"BDCODE": "M00001", "CODE": "L00001", "QTY": 1},
]
PRJ_F = [C("PNUM", 20), C("PNAME", 40), N("PCODE", 3, 0), N("PCLASS", 2, 0), N("MCOST", 14, 0)]
PRJ_F += [
N("T_COST", 14, 0),
N("MCOST1", 14, 0),
N("T_COST3", 14, 0),
N("K_ORD", 2, 0),
N("XXXNM", 1, 0),
]
PRJ_ROWS = [
{"PNUM": "A-1", "PNAME": "옛 공사", "PCODE": 11, "PCLASS": 0, "MCOST": 100, "T_COST": 300},
{
"PNUM": "A-2",
"PNAME": "변경",
"PCODE": 12,
"PCLASS": 6,
"MCOST1": 50,
"T_COST3": 9,
"K_ORD": 2,
},
]
BOQ_F = [C("ITNUM", 10), C("NAME", 30), C("UNIT", 6), N("QTY", 14, 3), N("AMT"), C("BDCODE", 6)]
BOQ_F += [N("T_AMOUNT"), N("QTY1", 14, 3), L("EXIST"), C("UNIT1", 6), N("SORTCODE", 13, 0)]
BOQ_ROWS = [
{"ITNUM": "1", "NAME": "토공", "QTY": 0, "SORTCODE": 1},
{
"ITNUM": "",
"NAME": "굴착",
"UNIT": "M3",
"QTY": 12.5,
"AMT": 80,
"BDCODE": "B00003",
"T_AMOUNT": 1000,
},
{"ITNUM": "", "NAME": "옛 줄", "QTY1": 3, "EXIST": "T", "UNIT1": "식", "SORTCODE": 3},
]
START_INI = "[START]\r\nARENM=B\r\nACDNM=7\r\nJMOK1=단가A\r\n[PRINT]\r\nCBFONT=돋움\r\n[PROJECT]\r\nPMODE={pm}\r\n"
def old_7x(pmode=1):
return {
"START.INI": START_INI.format(pm=pmode).encode("cp949"),
"FORM.DBF": dbf([C("CODE", 6)], []),
"KANJUB2.DBF": dbf([C("X", 1)], []),
"IMG.DB": b"",
"COSTN.DBF": dbf(COSTN_F, COSTN_ROWS),
"BDQTY.DBF": dbf(BDQ_F, BDQ_ROWS),
"PRJLST.DBF": dbf(PRJ_F, PRJ_ROWS),
"GRASS.DBF": dbf([C("BDCODE", 6)], []),
"KONGJONG.DBF": dbf(
[C("DESCRIP", 30), C("DESSIZE", 30)], [{"DESCRIP": "토공", "DESSIZE": "가"}]
),
"BOQ11.DBF": dbf(BOQ_F, BOQ_ROWS),
"CONLD.DBF": dbf(
[C("BDCODE", 6)], [{"BDCODE": "B00003"}, {"BDCODE": "M00001"}, {"BDCODE": "B00003"}]
),
"MEMO.INI": b"[A]\r\nB=1\r\n",
}
def mem_var(name, typ, val):
head = bytearray(32)
head[: len(name)] = name.encode()
head[11] = ord(typ) | 0x80
if typ == "C":
body = val.encode("cp949") + b"\x00"
head[16], head[17] = len(body) & 0xFF, len(body) >> 8
elif typ == "N":
body = struct.pack("<d", val)
head[16] = 8
else:
body = bytes([1 if val else 0])
head[16] = 1
return bytes(head) + body
def old_dos():
mem = mem_var("ARENM", "C", "A->") + mem_var("LANNM", "N", 2) + mem_var("EX1NM", "N", 1500)
mem += mem_var("SUBNM", "C", "버림") + mem_var("TOGNM", "L", True) + b"\x1a"
f = {
k: v
for k, v in old_7x().items()
if k not in ("START.INI", "IMG.DB", "KONGJONG.DBF", "MEMO.INI")
}
f["START.MEM"] = mem
f["COSTN1.DBF"] = dbf(
[C("CODE", 6), N("AMT_A"), C("PG_D", 12)], [{"CODE": "M00001", "AMT_A": 11, "PG_D": "77"}]
)
f["BOQ11.DBF"] = dbf(
[C("ITNUM", 10), C("DESCRIP", 30), C("DESSIZE", 30), N("QTY", 14, 3), C("BDCODE", 6)],
[{"DESCRIP": "굴착", "DESSIZE": "토사", "QTY": 4, "BDCODE": "B00003"}],
)
return f
@pytest.fixture(scope="module")
def tpl():
if TPL is None:
pytest.skip("틀 STC 없음")
return Source(str(TPL)).files
def test_detect_version_rules():
assert detect(old_7x(1)) == 121 and detect(old_7x(3)) == 123
assert detect(old_dos()) == 111
win8 = {"START8X.INI": b"[PROJECT]\r\nPMODE=1\r\n", "RATE.DBF": b"", "QCODE.DBF": b""}
win8 |= {k: b"" for k in ("COSTN.DBF", "BDQTY.DBF", "PRJLST.DBF", "GRASS.DBF")}
assert detect(win8) == 131 and detect(win8 | {"STINI.MEM": b""}) == 141
lacking = {k: v for k, v in old_7x().items() if k != "GRASS.DBF"}
assert detect(lacking) == 100 and detect({"A.TXT": b""}) == 0
jodal = {
k: b""
for k in ("GLOBAL.INI", "M.TXT", "L.TXT", "E.TXT", "W.TXT", "UNIT.TXT", "UNITPART.TXT")
}
jodal |= {
k: b"" for k in ("J.TXT", "JPART.TXT", "JUNIT.TXT", "DANGA.TXT", "DANGA.DBF", "DANGA.DBT")
}
jodal |= {k: b"" for k in ("GONGJONG.TXT", "MAIN.TXT", "MAINPART.TXT")}
assert detect(jodal) == 200
def _rows(b):
t = DbfTable(b)
return [t.row(i) for i in range(len(t))]
def test_7x_costn_rules(tpl):
out, rep = convert(old_7x(), tpl)
assert rep["kind"] == "7.x" and rep["b_to_x"] == {"B00003": "X00003"}
rows = {r["CODE"]: r for r in _rows(out["COSTN.DBF"])}
assert set(rows) == {"M00001", "M00002", "X00003", "B00004", "L00001"} # Q 코드 빠짐
m = rows["M00001"]
assert float(m["TAMT1"]) == 1000 and float(m["MAMT1"]) == 1000 and float(m["TAMT2"]) == 900
if "GUN" in m:
assert m["GUN"] == "Z" # 'H…' → 'Z'
pct = rows["M00002"]
assert float(pct["TAMT1"]) == 0 and pct["UNIT"] == "M%" # % 단위 = 앞 5칸만
x = rows["X00003"]
assert float(x["TAMT1"]) == 50 and float(x["LAMT1"]) == 30
assert float(x.get("AMT_11", 7) or 0) == 7 and x.get("PG_11", "12") == "12"
assert float(rows["B00004"].get("AMT_61", 3) or 0) == 3 and rows["B00004"].get("PG_1", "1") in (
"1",
"1.0",
)
assert rows["L00001"]["UC"] == "T"
def test_7x_bdqty_prj_kong_boq_ini(tpl):
out, rep = convert(old_7x(), tpl)
bd = decode_bdqty(out["BDQTY.DBF"])
got = [(r["BDCODE"], r["CODE"]) for r in bd]
assert got == [("X00003", "L00001"), ("B00004", "X00003"), ("B00004", "T00002")]
assert float(bd[0]["QTY1"]) == 1.5 and str(bd[0].get("EXIST1", "1")).strip() in ("1", "1.0")
prj = _rows(out["PRJLST.DBF"])
assert [r["PCODE"] for r in prj] == ["11", "12"]
assert float(prj[0]["MCOST1"]) == 100 and float(prj[0]["TCOST1"]) == 300
assert float(prj[1]["MCOST2"]) == 50 and float(prj[1]["TCOST4"]) == 9
assert float(prj[1]["K_ORD"]) == 2 and float(prj[0]["K_ORD"]) == 0
kong = _rows(out["KONGJONG.DBF"])
assert [(r["CODE"], r["NAME"], r["SIZE"]) for r in kong] == [("1", "토공", "가")]
boq = _rows(out["BOQ11.DBF"])
assert (
boq[1]["NAME1"] == "굴착"
and boq[1]["BDCODE1"] == "X00003"
and float(boq[1]["QTY1"]) == 12.5
)
assert float(boq[1]["T_AMT1"]) == 1000 and float(boq[2]["QTY2"]) == 3
assert str(boq[2]["EXIST1"]).strip() == "1" and boq[2]["UNIT2"] == "식"
ini = ini_dict(out["START8X.INI"].decode("cp949"))
assert (
ini["START"]["ARENM"] == "2"
and ini["START"]["BPNNM"] == "7"
and ini["START"]["JMOK1"] == "단가A"
)
assert ini["START"]["JMOK2"] == "물가 자료" and ini["PRINT"]["FONT"] == "돋움"
assert ini["EQUIPMENT"]["CONLD"] == "X00003" and out["STINI.MEM"] == out["START8X.INI"]
assert out["MEMO.INI"] == b"[A]\r\nB=1\r\n"
def test_dos_rules(tpl):
out, rep = convert(old_dos(), tpl)
assert rep["kind"] == "DOS" and rep["version"] == 111
rows = {r["CODE"]: r for r in _rows(out["COSTN.DBF"])}
if "AMT_12" in rows["M00001"]:
assert float(rows["M00001"]["AMT_12"]) == 11 and rows["M00001"]["PG_11"] == "77"
assert (
"X00003" in rows and float(rows.get("B00004", {}).get("AMT_61", 0) or 0) == 0
) # DOS 는 AMT_n 없음
kong = _rows(out["KONGJONG.DBF"])
assert [r["NAME"] for r in kong] == [n.rstrip() for n in KONG_DOS] and kong[0]["CODE"] == "1"
boq = _rows(out["BOQ11.DBF"])
assert boq[0]["NAME1"] == "굴착" and boq[0]["SIZE1"] == "토사" and boq[0]["BDCODE1"] == "X00003"
ini = ini_dict(out["START8X.INI"].decode("cp949"))
assert (
ini["START"]["ARENM"] == "A->"
and ini["START"]["SUBNM"] == ""
and ini["PRINT"]["LANNM"] == "2.000000"
)
assert ini["PRINT"]["TOGNM"] == "1" and ini["EQUIPMENT"]["EX1NM"] == "1500.000000"
assert ini["EQUIPMENT"]["CONLD"] == "B00003" and ini["PROJECT"]["PMODE"] == "1"
assert "STINI.MEM" not in out or out["STINI.MEM"] != out["START8X.INI"]
def test_read_mem_types():
v = read_mem(mem_var("A", "C", "가나") + mem_var("B", "N", 1.25) + mem_var("C", "L", False))
assert v == {"A": "가나", "B": "1.250000", "C": "0"}
def test_already_win8_rejected(tpl):
with pytest.raises(ValueError, match="이미"):
convert({k: v for k, v in tpl.items()} | {"RATE.DBF": b"x", "QCODE.DBF": b"x"}, tpl)
def test_api_win8_writes_to_temp(tpl, tmp_path, monkeypatch):
import base64
import api_conv as A
import server
monkeypatch.setattr(server, "WORK_DBF_ROOT", tmp_path)
files = {k: base64.b64encode(v).decode() for k, v in old_7x().items()}
chk = A.api_win8_check(A.Win8Req(files=files))
assert chk == {"version": 121, "kind": "7.x"}
r = A.api_win8(A.Win8Req(files=files, template_path=str(TPL), target_folder_name="w8"))
assert Path(r["saved_dir"]).parent == tmp_path and r["report"]["boq_tables"] == ["BOQ11.DBF"]
got = Source(r["saved_stc"]).files
assert len(DbfTable(got["COSTN.DBF"])) == 5 and "START8X.INI" in got