feat(stmate-test): 3차 화면·데이터 마무리 — 보호표시 · 공종번호 · 직종·노임 · 제비율표 편집 · 차수 칸 저장 · 계약 변환등록 = 울진 계약 STC · 장부 87.6%

- 시험 615 통과 · 브라우저 화면 13개·메뉴 90개 오류 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 21:00:14 +09:00
co-authored by Claude Opus 5.5
parent e8b76dd65b
commit ffc2401870
36 changed files with 1907 additions and 78 deletions
@@ -0,0 +1,26 @@
// STMATE 재현 앱 산근 편집기 낙찰율 입력(scr_edit_san.setBidRate) → 계산완료(연쇄 재계산) — stdin {load, codes, rate}
globalThis.window = globalThis;
require("../../tools/stmate-test/calc.js");
require("../../tools/stmate-test/smt_eval.js");
require("../../tools/stmate-test/smt_engine.js");
require("../../tools/stmate-test/view_prj_boq.js");
const San = require("../../tools/stmate-test/scr_edit_san.js");
let buf = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (d) => (buf += d));
process.stdin.on("end", () => {
const { load, codes, rate } = JSON.parse(buf);
const by = new Map(load.costn_rows.map((r) => [String(r.CODE).trim(), r]));
codes.forEach((c) => San.setBidRate(by.get(c), rate, true));
window.STMATE_ENGINE.cascadeRecalculateAll({ dbfData: load, state: load.sim_state, uiState: {} });
const cols = ["TAMT1", "MAMT1", "LAMT1", "SAMT1", "TAMT2", "MAMT2", "LAMT2", "SAMT2"];
const out = {};
codes.forEach(
(c) =>
(out[c] = Object.assign(
{ UC: by.get(c).UC },
...cols.map((k) => ({ [k]: Number(by.get(c)[k]) })),
)),
);
process.stdout.write(JSON.stringify(out));
});
+13
View File
@@ -73,6 +73,19 @@ const MODES = {
});
}
if (args.target) out.found = Contract.findRate(tree, args.opts, args.target, args.stage || 1);
if (args.register) {
out.register = Contract.registerRows(
conv,
load.costn_rows,
load.costn_memos,
load.bdqty_rows,
);
const cfg = Contract.defaultContractCfg(
state.kanjubStages[1] || state.kanjubConfig,
comp.stages[1].kanjub,
);
out.register.costw1 = Contract.contractKanjub(conv, args.kanjubCfg || cfg).grandTotal;
}
return out;
},
progress({ load, args }) {
@@ -0,0 +1,21 @@
// STMATE 재현 앱 차수 칸(2·3·4) 고침 → 저장 요청(buildSavePayload) — stdin {load, ops:[{recno, stage, qty?, mls?}]}
globalThis.window = globalThis;
const Calc = require("../../tools/stmate-test/calc.js");
require("../../tools/stmate-test/view_prj_boq.js");
const Change = require("../../tools/stmate-test/change_calc.js");
let buf = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (d) => (buf += d));
process.stdin.on("end", () => {
const { load, ops } = JSON.parse(buf);
const state = load.sim_state;
for (const op of ops || []) {
const it = state.boqItems.find((b) => b._recno === op.recno);
if (op.qty != null) Change.setQty(state, it.id, op.stage, op.qty);
if (op.mls) Change.setUnit(state, it.id, op.stage, op.mls);
}
const ctx = { state, dbfData: load, currentSourcePath: load.source_path };
ctx.uiState = { editedSmtMap: {} };
ctx.computed = Calc.calculateAll(state);
process.stdout.write(JSON.stringify(window.STMATE_VIEW_PRJ_BOQ.buildSavePayload(ctx)));
});
+32 -2
View File
@@ -229,11 +229,41 @@ process.stdin.on("end", () => {
const it = m.items.find((x) => x._recno === leaf._recno);
const q1 = [it.qty, it.qtys[0], Calc.num(leaf.QTY1)];
const r2 = T.roundQty(m, [leaf._recno], "round", 0);
const hal = T.applyHalRate(m, [leaf._recno], { M: 3, L: 0, S: 0 }, "each");
const hal = T.applyHalRate(m, [leaf._recno], { M: 103, L: 0, S: 0 }, "each");
const halOut = [hal.changed, leaf.RM, it.halRate.M];
// 0% = 100%(그대로) · 보호된 견적단가(W · UC=T) 건너뜀
const zero = T.applyHalRate(m, [leaf._recno], { all: 0 }, "all");
const halZero = Object.assign({}, it.halRate);
const w = { BDCODE1: "W00001", UC: "T", _recno: -77, SORTCODE: "1", ENDCODE: "0" };
const wm = { rows: [w], items: [] };
const kept = T.applyHalRate(wm, null, { all: 110 }, "all");
const free = T.applyHalRate(wm, null, { all: 110 }, "all", { notUC: false });
// 기준단가 × 할증율 → 행 1칸 단가(저장) = 계산 원천 단가(calc.js halRate)
const d2 = fresh(),
m2 = modelOf(d2);
const by = cbc(d2);
const lf = d2.boq_rows.find(
(r) =>
B.isLeaf(r) &&
by[B.codeOf(r)] &&
Calc.num(by[B.codeOf(r)].MAMT1) > 0 &&
!/%$/.test(String(r.UNIT1 || "").trim()),
);
const base = (c) =>
by[c] && { M: Calc.num(by[c].MAMT1), L: Calc.num(by[c].LAMT1), S: Calc.num(by[c].SAMT1) };
T.applyHalRate(m2, [lf._recno], { all: 107.5 }, "all", { base });
const cr = Calc.computeAll(d2.sim_state).boqComputed.rows.find((x) => x._recno === lf._recno);
out.halBase = {
row: [Calc.num(lf.MAMT1), Calc.num(lf.LAMT1), Calc.num(lf.SAMT1)],
want: ["M", "L", "S"].map((c) => Math.trunc((base(B.codeOf(lf))[c] * 107.5) / 100 + 1e-9)),
calc: cr ? [cr.mUnit, cr.lUnit, cr.sUnit] : null,
};
out.qty = {
first: [r1.changed, ...q1],
second: [r2.changed, Calc.num(leaf.QTY1)],
hal: [hal.changed, leaf.RM, it.halRate.M],
hal: halOut,
zero: [zero.changed, halZero.M, halZero.L, halZero.S],
w: [kept.changed, kept.kept, free.changed, w.RATE],
};
}
@@ -0,0 +1,77 @@
// STMATE 재현 앱 보호표시(UC) 일괄 손질 제외 · 코드종류별 성분 검사 — stdin = /api/load 결과 + {prot: [코드]}
// 결과 = 손질별 바뀐 코드(보호 없음 · 보호 있음) · 보호해제 자동할증 · 성분 검사 · 저장 요청 COSTN 칸
globalThis.window = globalThis;
const D = "../../tools/stmate-test/";
const Calc = require(D + "calc.js");
require(D + "view_prj_boq.js");
const UA = require(D + "ui_code_amt.js");
const U = require(D + "ui_dan.js");
const PA = require(D + "scr_edit_price.js");
const CK = require(D + "ui_code_check.js");
let buf = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (x) => (buf += x));
process.stdin.on("end", () => {
const src = JSON.parse(buf);
const prot = new Set(src.prot || []);
const rowsOf = (mark) => {
const rows = JSON.parse(JSON.stringify(src.costn_rows));
if (mark) rows.forEach((r) => prot.has(U.codeOf(r)) && (r.UC = "T"));
return rows;
};
const codes = (list) => list.map((x) => x.code).sort();
const ops = {
adoptAll: (rows) => U.adoptAll(rows, "min", { kinds: "MLS", keepUser: false }),
edtHal: (rows) => U.edtHal(rows, { kinds: "MLS", pct: 110 }),
halAll: (rows) => UA.halAll(rows, { kinds: "MLSW", how: "rate", value: 5 }),
chgAmt: (rows) => UA.chgAmt(rows, { kinds: "MLSW", from: 1, to: 1, rate: 103 }),
adoptCand: (rows) => PA.adoptCandidates(rows, { mode: "min", slot: 1 }).changed,
};
const out = { ops: {} };
for (const [k, fn] of Object.entries(ops)) {
const a = fn(rowsOf(false)),
rows = rowsOf(true),
b = fn(rows);
const untouched = [...prot].every((c) => {
const r0 = src.costn_rows.find((r) => U.codeOf(r) === c),
r1 = rows.find((r) => U.codeOf(r) === c);
return ["MAMT1", "LAMT1", "SAMT1", "TAMT1"].every((f) => r0[f] === r1[f]);
});
out.ops[k] = { plain: codes(a), marked: codes(b), untouched };
}
// 보호해제 자동할증 — 그 행만 일괄할증과 같은 값
{
const rows = rowsOf(true);
const c = [...prot][0];
const r = rows.find((x) => U.codeOf(x) === c);
const hal = { how: "rate", value: 5, digits: 0, mode: "trunc" };
const res = U.setProtect(r, false, { autoHal: hal });
const ref = UA.halAll(rowsOf(false), Object.assign({ kinds: c[0], codes: [c] }, hal));
out.autoHal = { uc: r.UC, got: res.hal && res.hal.to, want: ref[0] && ref[0].to };
}
out.bdProtected = src.costn_rows.filter(
(r) => /^[BD]/.test(U.codeOf(r)) && U.isProtected(r),
).length;
// 성분 검사 — 원본 그대로 · 일부러 어긋낸 행
{
out.check = CK.checkComponents(src.costn_rows);
out.checkBoq = Object.values(src.boq_tables || {}).flatMap((rows) => CK.checkBoq(rows));
const rows = rowsOf(false);
const m = rows.find((r) => /^M/.test(U.codeOf(r)) && Number(r.MAMT1) > 0);
const b = rows.find((r) => /^B/.test(U.codeOf(r)) && Number(r.TAMT1) > 0);
m.LAMT1 = "5";
b.TAMT1 = String(Number(b.TAMT1) + 1);
out.broken = { m: U.codeOf(m), b: U.codeOf(b), res: CK.checkComponents(rows) };
}
// 저장 요청 — 보호 켠 행만 COSTN UC 가 바뀜
{
const load = JSON.parse(JSON.stringify(src));
load.costn_rows.forEach((r) => prot.has(U.codeOf(r)) && U.setProtect(r, true));
const ctx = { state: load.sim_state, dbfData: load, currentSourcePath: load.source_path };
ctx.uiState = { editedSmtMap: {} };
ctx.computed = Calc.calculateAll(ctx.state);
out.payload = window.STMATE_VIEW_PRJ_BOQ.buildSavePayload(ctx);
}
process.stdout.write(JSON.stringify(out));
});
+79
View File
@@ -0,0 +1,79 @@
// STMATE 재현 앱 화면 순수 계산 3차(직종·노임 적용코드 wM_Set_JLab · 공종번호 자동입력 wITNum) — stdin {mode, load, ...}
globalThis.window = globalThis;
const D = "../../tools/stmate-test/";
["kanjub_parts.js", "kanjub_calc.js", "calc.js", "smt_eval.js"].forEach((f) => require(D + f));
const S = require(D + "ui_sys.js");
const B = require(D + "ui_boq.js");
const T = require(D + "ui_boq_tools.js");
const MODES = {
// 제잡비 요율 축 콤보 — 화면과 같은 길(setKey → 제비율표 다시 조회 → 다시 셈) · 축마다 총공사비가 바뀐 선택지 수
rateAxes({ load, axes }) {
["kanjub_parts.js", "kanjub_calc.js", "scr_kanjub_axes.js", "scr_kanjub_rate.js"].forEach((f) =>
require(D + f),
);
require(D + "scr_kanjub.js");
const K = window.STMATE_KANJUB_SCREEN,
R = window.StmateRate,
X = window.STMATE_KANJUB_AXES;
const Calc = window.STMATE_CALC;
const m = R.matrixFromRows(load.rate_tables["RATE_R.DBF"] || []);
const all = [...X.COMMON, ...X.OPTN];
const grand = (st) => Calc.calculateAll(st).kanjubOrig.grandTotal;
const g0 = grand(JSON.parse(JSON.stringify(load.sim_state)));
const out = {};
for (const key of axes) {
const ax = all.find((a) => a[0] === key);
out[key] = ax[2].map(([v]) => {
const st = JSON.parse(JSON.stringify(load.sim_state));
const cfg = st.kanjubConfig;
cfg.keys = cfg.keys || {};
K.setKey(cfg, key, v);
if (st.kanjubStages && st.kanjubStages[1]) st.kanjubStages[1] = cfg;
R.applyWithLoopCheck(cfg, m, () => Calc.calculateAll(st).kanjubOrig, { withTbugum: true });
return grand(st) - g0;
});
}
return out;
},
jlab({ load }) {
const ini = load.start_ini || {};
const hw = window.STMATE_SMT_EVAL.hourlyWage;
const j = S.jlab(load.costn_rows, ini, "CONLD", hw);
const add = j.source.length ? j.source[0].code : null;
const added = add ? S.jlabEdit(ini.CONLD, [add], []) : ini.CONLD;
return {
j,
add,
added,
back: S.jlabEdit(added, [], add ? [add] : []),
dup: S.jlabEdit(ini.CONLD, S.splitCodes(ini.CONLD), []),
jcd: Number(String(ini.JCDNM || "0").trim()) || 0,
};
},
itnum({ load, plans }) {
const out = {};
for (const [t, plan] of Object.entries(plans)) {
const rows = load.boq_tables[t].map((r) => Object.assign({}, r));
const keep = rows.map((r) => r.ITNUM);
rows.forEach((r) => (r.ITNUM = ""));
plan.forEach((o) => T.autoItnum(rows, o));
out[t] = rows.map((r, i) => ({
i,
kind: B.rowKind(r).kind,
n: B.rowKind(r).n,
got: rows[i].ITNUM,
want: keep[i],
}));
}
return out;
},
};
let buf = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (x) => (buf += x));
process.stdin.on("end", () => {
const inp = JSON.parse(buf);
process.stdout.write(JSON.stringify(MODES[inp.mode](inp)));
});
@@ -0,0 +1,287 @@
"""STMATE 재현 앱 설계 → 계약자료 변환등록 끝까지(화면 계산 → 저장 → 다시 읽기) — 소광 원설계 STC → 울진 2025 계약 STC.
같은 공사의 설계 원본(22-…소광.STC, PCODE 11 만)을 계약 변환·등록하면 실제 계약 STC(울진 2025)의
PCODE 12 행 · BOQ12 · COSTN 새 코드 행(NAGCD 낙찰율 · 1칸 계약단가 · 2칸 원단가 · 산근 글 · 구성표)과 같아야 함.
입력(사용자 선택) = 적용율(계약 STC COSTN NAGCD 최빈값) · 적용제외 머리(계약에서도 코드가 그대로인 공종의 머리).
근거: 27 §2 · 26 §4 · 16 §4.5 · ui_form_catalog wM_Conv_Mode.
"""
import json
import re
import subprocess
import sys
from collections import Counter
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tools/stmate-test"))
sys.path.insert(0, str(Path(__file__).parent))
import server # noqa: E402
from api_ui_contract import ContractRegReq, contract_register # noqa: E402
from bdqty import decode_bdqty # noqa: E402
from kanjub_cfg import build_kanjub_config # noqa: E402
from smt_parser import extract_costn_memos, parse_dbf_bytes, parse_prjlst_smt # noqa: E402
from test_stmate_kanjub_stc import DOCS, STCS # noqa: E402
HELPER = Path(__file__).with_name("helper_stmate_stage.cjs")
STRUCT = re.compile(r"^(T\d|E\d|C\d|:)$")
DESIGN = str(DOCS / STCS["소광원설계"])
TRUTH = str(DOCS / STCS["울진2025"])
def _f(v):
return float(v or 0)
def _leaves(rows):
return [r for r in rows if r.get("BDCODE1") and not STRUCT.match(r["BDCODE1"])]
@pytest.fixture(scope="module")
def case():
load = server.api_load(server.LoadReq(source_path=DESIGN))
truth = server.api_load(server.LoadReq(source_path=TRUTH))
t12 = truth["boq_tables"]["BOQ12.DBF"]
rate = float(
Counter(r["NAGCD"] for r in truth["costn_rows"] if _f(r["NAGCD"])).most_common(1)[0][0]
)
base = {}
for r in load["costn_rows"]:
m = re.match(r"^([A-Z])(\d+)$", r["CODE"].strip())
if m:
base[m[1]] = max(base.get(m[1], 0), int(m[2]))
kept = {r["BDCODE1"] for r in _leaves(t12)} & {r["BDCODE1"] for r in load["boq_rows"]}
heads, cur = [], None
for r in load["boq_rows"]:
bd = r.get("BDCODE1") or ""
if bd.startswith("T"):
cur = r
elif bd.startswith("E") or bd.startswith("C"):
cur = None
elif cur is not None and bd in kept and cur["_recno"] not in heads:
heads.append(cur["_recno"])
opts = {"rateL": rate, "rateM": rate, "rateS": rate, "excludeHeads": heads}
# 계약 원가설정 = 사용자가 계약 공사에 넣은 설정(계약 행 PRJLST.SMT) — 총공사비 대조용
p12 = next(p for p in truth["prjlst_all"] if p["PCODE"] == "12")
smt = parse_prjlst_smt(server.read_project_files(TRUTH)["PRJLST.SMT"], p12["PRJ_MEMO_HEX"])
args = {"opts": opts, "codeBase": base, "register": True}
args["kanjubCfg"] = build_kanjub_config(smt, 1)
raw = json.dumps({"load": load, "mode": "contract", "args": args}, ensure_ascii=False)
out = json.loads(subprocess.check_output(["node", str(HELPER)], input=raw.encode("utf-8")))
reg = out["register"]
req = ContractRegReq(
source_path=DESIGN,
src_pcode="11",
pname="계약",
boq_rows=reg["boq"],
costn_rows=reg["costn"],
costn_memos=reg["memos"],
bdqty_rows=reg["bdqty"],
costw1=reg["costw1"],
)
files, pcode, _log = contract_register(server.read_project_files(DESIGN), req)
design = {r["CODE"].strip() for r in load["costn_rows"]}
return {
"files": files,
"pcode": pcode,
"truth": truth,
"t12": t12,
"reg": reg,
"design": design,
}
def test_new_contract_table_matches_boq12(case):
assert case["pcode"] == "12"
rows = parse_dbf_bytes(case["files"]["BOQ12.DBF"])[1]
got = [r for r in rows if r.get("BDCODE1") and r["BDCODE1"] != ":"]
exp = [r for r in case["t12"] if r.get("BDCODE1") and r["BDCODE1"] != ":"]
assert len(got) == len(exp)
cols = ("BDCODE1", "QTY1", "MAMT1", "LAMT1", "SAMT1", "T_AMT1", "M_AMT1", "L_AMT1", "S_AMT1")
for g, e in zip(got, exp):
for k in cols:
a, b = g.get(k), e.get(k)
assert (a == b) if k == "BDCODE1" else _f(a) == _f(b), (k, e.get("NAME1"), a, b)
def test_new_prjlst_row_matches_contract_row(case):
prj = parse_dbf_bytes(case["files"]["PRJLST.DBF"])[1]
got = next(p for p in prj if p["PCODE"] == "12")
exp = next(p for p in case["truth"]["prjlst_all"] if p["PCODE"] == "12")
for k in ("CCODE", "PCLASS", "MCOST1", "LCOST1", "SCOST1", "TCOST1", "COSTW1"):
assert _f(got[k]) == _f(exp[k]), k
assert _f(got["COSTW1"]) == case["reg"]["costw1"] > 0
def test_new_costn_rows_nagcd_and_prices(case):
files = case["files"]
rows = {r["CODE"].strip(): r for r in parse_dbf_bytes(files["COSTN.DBF"])[1]}
truth = {r["CODE"].strip(): r for r in case["truth"]["costn_rows"]}
assert sorted(c for c in truth if c not in rows) == [] # 계약 STC 의 코드가 모두 생김
made = [c["CODE"] for c in case["reg"]["costn"]]
assert sorted(made) == sorted(set(truth) - case["design"]) # 새 코드 = 계약 STC 에만 있는 코드
cols = [f"{c}AMT{s}" for s in "12" for c in "TMLS"] + ["NAGCD"]
for c in made:
g, e = rows[c], truth[c]
for k in ("NAME", "SIZE", "UNIT"):
assert g[k] == e[k], (c, k)
for k in cols:
assert _f(g[k]) == _f(e[k]), (c, k, g[k], e[k])
for k in ("UC", "BCONT"):
assert (str(g[k]).strip() == "T") == (str(e[k]).strip() == "T"), (c, k)
memos = extract_costn_memos(files["COSTN.DBF"], files["COSTN.SMT"])
tm = case["truth"]["costn_memos"]
assert {c for c in made if c in memos} == {c for c in made if c in tm}
for c in made:
if c in tm:
assert memos[c]["text"] == tm[c]["text"], c
bq = decode_bdqty(files["BDQTY.DBF"])
tq = case["truth"]["bdqty_rows"]
for c in made:
g = [(q["CODE"], _f(q["QTY1"])) for q in bq if q["BDCODE"].strip() == c]
e = [(q["CODE"], _f(q["QTY1"])) for q in tq if q["BDCODE"].strip() == c]
assert g == e, c
def _conv(path, opts, stage=1):
load = server.api_load(server.LoadReq(source_path=path))
base = {}
for r in load["costn_rows"]:
m = re.match(r"^([A-Z])(\d+)$", r["CODE"].strip())
if m:
base[m[1]] = max(base.get(m[1], 0), int(m[2]))
args = {"opts": opts, "codeBase": base, "register": True, "stage": stage}
raw = json.dumps({"load": load, "mode": "contract", "args": args}, ensure_ascii=False)
return load, json.loads(
subprocess.check_output(["node", str(HELPER)], input=raw.encode("utf-8"))
)
def test_unchecked_options_keep_base_and_make_w_codes():
"""기초단가 적용율 끔 = M·L·S 코드·단가 그대로 · 일위/산근 생성 끔 = B·D·X → 계약단가 W코드(낙찰율 열 없음)."""
r = 87.745
_load, out = _conv(
DESIGN, {"rateL": r, "rateM": r, "rateS": r, "baseRate": False, "makeIlwi": False}
)
leaves = [x for x in out["rows"] if x["kind"] == "leaf" and not x["excluded"]]
base = [x for x in leaves if x["code"][0] in "MLS"]
comp = [x for x in leaves if x["code"][0] in "BDX" and x["dUnit"]]
assert base and comp
assert all(x["newCode"] == x["code"] and x["tUnit"] == x["dUnit"] for x in base)
assert all(x["newCode"][0] == "W" for x in comp)
assert any(x["tUnit"] < x["dUnit"] for x in comp)
rows = {c["CODE"]: c for c in out["register"]["costn"]}
assert {x["newCode"] for x in comp} <= set(rows)
for c in rows.values():
assert c["CODE"][0] == "W" and "NAGCD" not in c
assert [c[f"{k}AMT1"] for k in "TMLS"] == [c[f"{k}AMT2"] for k in "TMLS"]
assert out["register"]["memos"] == {} and out["register"]["bdqty"] == []
def test_change_stage_quantities_registered():
"""변경 수량/단가 적용(cBx_Sel_QTY) — 영덕 변경 2차수 수량·단가로 계약 공사 · 새 표 직접비 = 계약 직접비."""
path = str(DOCS / STCS["영덕변경"])
load, out = _conv(path, {"rateL": 90, "rateM": 90, "rateS": 90}, stage=2)
reg = out["register"]
req = ContractRegReq(
source_path=path,
boq_rows=reg["boq"],
costn_rows=reg["costn"],
costn_memos=reg["memos"],
bdqty_rows=reg["bdqty"],
)
files, pcode, _log = contract_register(server.read_project_files(path), req)
rows = {r["_recno"]: r for r in parse_dbf_bytes(files[f"BOQ{pcode}.DBF"])[1]}
leaves = [x for x in out["rows"] if x["kind"] == "leaf" and x["recno"] is not None]
src = {r["_recno"]: r for r in load["boq_rows"]}
moved = 0
for x in leaves:
g = rows[x["recno"]]
if not x["excluded"]:
assert g["BDCODE1"] == x["newCode"] and _f(g["QTY1"]) == x["qty"], x["name"]
moved += _f(src[x["recno"]]["QTY1"]) != _f(src[x["recno"]]["QTY2"])
assert moved > 0 # 당초와 다른 변경 수량이 실제로 1칸에 들어감
prj = next(p for p in parse_dbf_bytes(files["PRJLST.DBF"])[1] if p["PCODE"] == pcode)
assert _f(prj["TCOST1"]) == out["totals"]["directTotal"]
def test_editor_bid_rate_input_matches_contract_codes():
"""산근·대가 편집기 낙찰율 입력(Ed_NAGCD · Ck_UC) → 계산완료 — 설계 코드 1칸·2칸 = 계약 STC 새 코드(NAGCD) 1칸·2칸."""
design = server.api_load(server.LoadReq(source_path=DESIGN))
truth = server.api_load(server.LoadReq(source_path=TRUTH))
t11, t12 = truth["boq_tables"]["BOQ11.DBF"], truth["boq_tables"]["BOQ12.DBF"]
tc = {r["CODE"].strip(): r for r in truth["costn_rows"]}
pair = {
a["BDCODE1"]: b["BDCODE1"]
for a, b in zip(_leaves(t11), _leaves(t12))
if a["BDCODE1"] != b["BDCODE1"] and _f(tc[b["BDCODE1"]]["NAGCD"]) > 0
}
(rate,) = {_f(tc[c]["NAGCD"]) for c in pair.values()}
raw = json.dumps({"load": design, "codes": list(pair), "rate": rate}, ensure_ascii=False)
helper = Path(__file__).with_name("helper_stmate_edit_bid.cjs")
got = json.loads(subprocess.check_output(["node", str(helper)], input=raw.encode("utf-8")))
assert len(pair) >= 20
for d, c in pair.items():
for k in ("TAMT1", "MAMT1", "LAMT1", "SAMT1", "TAMT2", "MAMT2", "LAMT2", "SAMT2"):
assert got[d][k] == _f(tc[c][k]), (d, c, k)
assert got[d]["UC"] == "T"
def _prj(files):
return parse_dbf_bytes(files["PRJLST.DBF"])[1]
def test_delete_project_drops_row_and_table_only():
"""내역서 삭제(Act_Del_Prj) — 고른 공사 PRJLST 행·BOQ1n 만 빠지고 나머지는 그대로 · 마지막 공사는 거부."""
from api_ui_project import delete_project
files = server.read_project_files(TRUTH)
out, log = delete_project(files, "12")
assert "BOQ12.DBF" not in out and [p["PCODE"] for p in _prj(out)] == ["11"]
before = next(p for p in _prj(files) if p["PCODE"] == "11")
after = _prj(out)[0]
assert {k: v for k, v in after.items() if k != "_recno"} == {
k: v for k, v in before.items() if k != "_recno"
}
assert all(out[k] is files[k] for k in files if k not in ("PRJLST.DBF", "BOQ12.DBF"))
assert {x["table"] for x in log} == {"PRJLST.DBF", "BOQ12.DBF"}
with pytest.raises(ValueError):
delete_project(out, "11")
def test_stage_register_k_ord_matrix():
"""단계 등록 K_ORD — 원본 PCLASS·등록(6 기성 · 7 설계변경)·플래그(0 차수별 · 1 당초/변경) 표(REA 31) 그대로."""
from api_ui_project import new_k_ord, stage_register
files = server.read_project_files(TRUTH) # PCODE 12 = 계약(PCLASS 4 · K_ORD 0)
a, pa, ka = stage_register(files, "12", 7, 0)
ra = next(p for p in _prj(a) if p["PCODE"] == pa)
assert (ka, _f(ra["K_ORD"]), _f(ra["PCLASS"])) == (1, 1, 7)
b12 = parse_dbf_bytes(files["BOQ12.DBF"])[1]
ba = parse_dbf_bytes(a[f"BOQ{pa}.DBF"])[1]
assert [r["T_AMT1"] for r in ba] == [r["T_AMT1"] for r in b12] # 계약 내역이 당초
# 설계변경(PCLASS 7 · K 1) → 차수별 = K+1 · 당초/변경 = 1(마지막 차수 2칸 → 새 1·2칸)
b, pb, kb = stage_register(a, pa, 7, 0)
assert kb == 2 and _f(next(p for p in _prj(b) if p["PCODE"] == pb)["K_ORD"]) == 2
src = parse_dbf_bytes(a[f"BOQ{pa}.DBF"])[1]
c, pc, kc = stage_register(a, pa, 7, 1)
rows = parse_dbf_bytes(c[f"BOQ{pc}.DBF"])[1]
assert kc == 1
for s, n in zip(src, rows):
assert _f(n["QTY1"]) == _f(n["QTY2"]) == _f(s["QTY2"])
rc = next(p for p in _prj(c) if p["PCODE"] == pc)
from dbf_rw import DbfTable
from stc_save import _leaf, boq_spans
_sp, excl = boq_spans(DbfTable(a[f"BOQ{pa}.DBF"]))
leaf2 = sum(_f(r["T_AMT2"]) for i, r in enumerate(src) if _leaf(r["BDCODE1"]) and i not in excl)
assert (
_f(rc["TCOST1"]) == _f(rc["TCOST2"]) == leaf2
) # 새 당초·변경 직접비 = 원본 변경 칸 직접비
# 기성 — 계약에서 1 · 기성에서 K+1 · 설계변경에서 1
assert [new_k_ord(4, 6, 0, 3), new_k_ord(6, 6, 0, 3), new_k_ord(7, 6, 1, 3)] == [1, 4, 1]
assert [new_k_ord(4, 7, 0, 3), new_k_ord(6, 7, 0, 3), new_k_ord(7, 7, 0, 3)] == [1, 1, 4]
d, pd, kd = stage_register(files, "12", 6, 0)
assert kd == 1 and _f(next(p for p in _prj(d) if p["PCODE"] == pd)["PCLASS"]) == 6
@@ -0,0 +1,75 @@
"""STMATE 재현 앱 차수 4벌 칸(QTY·단가 2~4) 고침 → 저장 → 다시 읽기 — 고친 칸만 바뀌고 금액·소계·직접비가 따라감.
설계변경 화면의 차수 수량·변경 단가(change_calc setQty·setUnit) → buildSavePayload → stc_save.apply_save.
근거: 08 §2 · 24 §7(차수 4벌) · 16 §4(행 금액 = ROUNDDOWN(수량×단가) · 계층 집계 · TCOSTn = 직접비).
"""
import json
import subprocess
import sys
from decimal import ROUND_DOWN, Decimal
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_rw import DbfTable # noqa: E402
from stc_save import _leaf, apply_save, boq_spans # noqa: E402
HELPER = Path(__file__).with_name("helper_stmate_stage_save.cjs")
STCS = sorted((ROOT / "resources/knowledge/original/실무문서").rglob("*.STC"))
IDS = [p.stem[:16] for p in STCS]
def _d(v):
return Decimal(str(v or "0").strip() or "0")
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_slot_2_3_4_edits_persist(stc):
load = server.api_load(server.LoadReq(source_path=str(stc)))
bt_name = load["boq_table_name"]
_spans, excl = boq_spans(DbfTable(server.read_project_files(str(stc))[bt_name]))
leaf = next(
r
for r in load["boq_rows"]
if _leaf(r["BDCODE1"].strip())
and _d(r["TAMT1"]) > 0
and not r["UNIT1"].strip().endswith("%")
and r["_recno"] not in excl
)
rec = leaf["_recno"]
q = {n: float(_d(leaf["QTY1"])) + n for n in (2, 3, 4)}
mls = [float(_d(leaf["MAMT1"])) + 7, float(_d(leaf["LAMT1"])) + 11, float(_d(leaf["SAMT1"]))]
ops = [{"recno": rec, "stage": n, "qty": q[n]} for n in (2, 3, 4)]
ops.append({"recno": rec, "stage": 3, "mls": mls})
raw = json.dumps({"load": load, "ops": ops}, default=str, ensure_ascii=False)
p = json.loads(subprocess.check_output(["node", str(HELPER)], input=raw.encode("utf-8")))
files = server.read_project_files(str(stc))
out, log = apply_save(files, p)
bt = DbfTable(out[bt_name])
for n in (2, 3, 4):
assert _d(bt.get(rec, f"QTY{n}")) == Decimal(str(q[n]))
assert [_d(bt.get(rec, f"{c}AMT3")) for c in "MLS"] == [Decimal(str(v)) for v in mls]
for c, v in zip("MLS", mls): # 행 금액 = ROUNDDOWN(수량 × 단가)
want = (Decimal(str(q[3])) * Decimal(str(v))).quantize(Decimal(1), rounding=ROUND_DOWN)
assert _d(bt.get(rec, f"{c}_AMT3")) == want
# 다른 행 원래 칸 그대로 — 고친 행 · 소계(머리·계) 행만 바뀜
spans, _ = boq_spans(bt)
heads = {i for i, *_ in spans}
assert {x["recno"] for x in log if x["table"] == bt_name} <= {rec} | heads
assert not any(x["field"].endswith("1") for x in log if x["table"] == bt_name)
# PRJLST 차수 직접비 = 그 차수 말단 합
prj = DbfTable(out["PRJLST.DBF"])
pi = next(i for i in range(len(prj)) if prj.get(i, "PCODE") == bt_name[3:5])
for n in (3, 4):
if f"TCOST{n}" not in prj.fields:
continue
tot = sum(
_d(bt.get(i, f"T_AMT{n}"))
for i in range(len(bt))
if _leaf(bt.get(i, "BDCODE1")) and i not in excl
)
assert _d(prj.get(pi, f"TCOST{n}")) == tot
+6 -1
View File
@@ -108,7 +108,12 @@ def test_qty_round_and_hal_rate():
_d, r = run("봉화2024")
assert r["qty"]["first"] == [1, 12.34, 12.34, 12.34]
assert r["qty"]["second"] == [1, 12]
assert r["qty"]["hal"] == [1, "3", 3]
# 할증율 = 기준단가에 곱할 백분율(TwBoqRate · 0% = 100%) — 103% → 계산 원천 가산 3%
assert r["qty"]["hal"] == [1, "103", 3]
assert r["qty"]["zero"] == [1, 0, 0, 0]
assert r["qty"]["w"] == [0, 1, 1, "110"] # 보호된 견적단가 수정안함(CBoxNotUC)
hb = r["halBase"]
assert hb["row"] == hb["want"] and hb["calc"] == hb["want"] # 저장 단가 = 화면 계산 단가
@pytest.mark.parametrize("name", list(PROJECTS))
@@ -0,0 +1,90 @@
"""STMATE 재현 앱 보호표시(UC) 일괄 손질 제외 · 단가 성분 검사(TAMT = 재+노+경 · 코드종류별 비목).
보호 = wM_Boxa 단가 격자 「보호」 열(√/공란 = True/False) → 기초·견적단가(M·L·S·W) 행 COSTN UC.
일괄 손질(가격지 채택 · 기준단가 수정 · 일괄할증 · 일괄변경 · 후보 채택)은 보호 행을 건너뜀(22 §3.1 안내문).
B·D 행 UC 는 산근 편집기 「결과값에 낙찰율적용」(Ck_UC) 자리 — 실무 7건 UC=T ⇔ NAGCD>0.
성분 검사 = 10 §3 검증 조건식 — 실무 7건 COSTN·내역 어긋남 0 · 일부러 어긋낸 칸만 잡힘.
"""
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 server # noqa: E402
from stc_save import apply_save # noqa: E402
HERE = Path(__file__).parent
STCS = sorted((ROOT / "resources/knowledge/original/실무문서").rglob("*.STC"))
IDS = [p.stem[:16] for p in STCS]
_CACHE: dict = {}
def _f(v):
return float(v or 0)
def run(stc):
if stc not in _CACHE:
d = server.api_load(server.LoadReq(source_path=str(stc)))
# 보호 대상 = 바뀔 수 있는 기초단가 몇 건(후보 있는 재료 · 노무 · 경비) — 자료에서 고름
mls = [r for r in d["costn_rows"] if r["CODE"][:1] in "MLS" and _f(r["TAMT1"]) > 0]
cand = [r for r in mls if any(_f(r.get(f"AMT_{i}1")) > 0 for i in range(1, 6))]
prot = [r["CODE"].strip() for r in (cand[:3] + [r for r in mls if r not in cand][:2])]
d["prot"] = prot
out = subprocess.check_output(
["node", str(HERE / "helper_stmate_ui_protect.cjs")],
input=json.dumps(d, ensure_ascii=False, default=str),
text=True,
encoding="utf-8",
)
_CACHE[stc] = (d, json.loads(out))
return _CACHE[stc]
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_batch_edits_skip_protected_rows(stc):
d, r = run(stc)
prot = set(d["prot"])
assert prot
moved = 0
for name, o in r["ops"].items():
assert o["untouched"], name # 보호 행 단가 그대로
assert o["marked"] == sorted(set(o["plain"]) - prot), name # 나머지는 보호 없을 때와 같음
moved += len(set(o["plain"]) & prot)
assert moved > 0 # 보호가 없었으면 바뀌었을 행이 실제로 있었음
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_unprotect_auto_hal_and_bd_uc_is_bid_rate_flag(stc):
d, r = run(stc)
a = r["autoHal"]
assert a["uc"] == "F" and a["got"] is not None and a["got"] == a["want"]
assert r["bdProtected"] == 0 # 산근·일위 UC=T 는 보호로 보지 않음
for row in d["costn_rows"]: # 근거 — UC=T ⇔ 낙찰율 열(NAGCD) > 0
if row["CODE"][:1] in "BD":
assert (str(row.get("UC") or "").strip() == "T") == (_f(row.get("NAGCD")) > 0)
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_protect_mark_saves_only_uc(stc):
d, r = run(stc)
files = server.read_project_files(str(stc))
_out, log = apply_save(files, r["payload"])
got = {(x["table"], x["field"]) for x in log}
assert got == {("COSTN.DBF", "UC")}
assert len(log) == len(d["prot"])
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_component_check_clean_on_real_and_catches_broken(stc):
_d, r = run(stc)
assert r["check"] == []
assert r["checkBoq"] == [], r["checkBoq"][:3]
b = r["broken"]
hits = {(x["code"], x["slot"], x["rule"]) for x in b["res"]}
assert hits == {(b["m"], 1, "own"), (b["m"], 1, "sum"), (b["b"], 1, "sum")}
+250
View File
@@ -0,0 +1,250 @@
"""STMATE 재현 앱 화면 3차 — 직종·노임 적용코드(wM_Set_JLab) · 공종번호 자동입력(wITNum 하위 전체공종 반영).
JLab: 적용코드 = Start8x.Ini CONLD(6자리 코드 이어 붙임 · REA 14 TWM_SET_SYS/TWM_SET_JLAB) · 적용단가 = 일 노임 × 환산식
· 추가·삭제 왕복 = 원래 글 · 저장 = Start8x.Ini 그 키만.
wITNum: cBx_Bl_Lev 「하위 전체공종 반영」 = 묶음 안 공종(계표시)·단가코드를 한 차례로 · 직전공종 번호 + 첫 글자 + 번호.
"""
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 server # noqa: E402
from smt_parser import parse_ini_bytes # noqa: E402
from stc_save import apply_save # noqa: E402
HELPER = Path(__file__).with_name("helper_stmate_ui_r3.cjs")
STCS = sorted((ROOT / "resources/knowledge/original/실무문서").rglob("*.STC"))
IDS = [p.stem[:16] for p in STCS]
def node(payload):
raw = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
return json.loads(subprocess.check_output(["node", str(HELPER)], input=raw))
def _f(v):
return float(v or 0)
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_jlab_codes_hourly_and_save(stc):
load = server.api_load(server.LoadReq(source_path=str(stc)))
ini = load["start_ini"]
r = node({"mode": "jlab", "load": load})
conld = ini.get("CONLD", "")
assert [t["code"] for t in r["j"]["target"]] == [
conld[i : i + 6] for i in range(0, len(conld), 6)
]
assert r["j"]["formula"] == ini.get("RXXNM", "").strip()
costn = {x["CODE"].strip(): x for x in load["costn_rows"]}
for t in r["j"]["target"]: # 적용단가 = 일 노임 × 환산식(왼쪽부터) 버림
assert t["price"] == _f(costn.get(t["code"], {}).get("LAMT1"))
assert t["hourly"] <= t["price"]
assert all(s["code"][0] == "L" and s["code"] not in conld for s in r["j"]["source"])
assert r["back"] == conld and r["dup"] == conld
if not r["add"]:
pytest.skip("고를 노무비 코드 없음")
assert r["added"] == conld + r["add"]
files = server.read_project_files(str(stc))
out, log = apply_save(files, {"source_path": str(stc), "start_ini": {"CONLD": r["added"]}})
names = [n for n in ("START8X.INI", "STINI.MEM") if n in files]
assert names and {(x["table"], x["field"]) for x in log} == {(n, "CONLD") for n in names}
for n in names:
assert parse_ini_bytes(out[n])["CONLD"] == r["added"]
# 공종번호 계획 = 사용자가 wITNum 에서 고를 선택지(뿌리 레벨 · 끝 글자 · 대상 · 마디 한도)와 선택 범위(그 머리 묶음)
# under = [레벨, n번째] — 그 레벨 n번째 머리 묶음만 선택 범위로
def _tree(level, last, target="group", **k):
return {"tree": True, "level": level, "last": last, "target": target, **k}
ITNUM_PLANS = {
"13-2024년 산불진화임도 실시": {"BOQ11.DBF": [_tree(4, ".")]},
"2024년 임도시설사업2차(산불": {
"BOQ11.DBF": [
_tree(3, "", "both", maxDepth=3, under=[4, 1]),
_tree(3, "", "both", maxDepth=3, under=[4, 2]),
_tree(3, ".", "both", maxDepth=3, under=[4, 3]),
_tree(3, ".", "both", maxDepth=3, under=[4, 4]),
]
},
"2024년 간선임도사업(기번3)": {"BOQ11.DBF": [_tree(3, ".")]},
"2025년 계류보전사업(기번1 ": {"BOQ11.DBF": [_tree(3, ".")]},
"22-2025년 산불진화임도사업(": {"BOQ11.DBF": [_tree(3, ".")]},
"2025년 산불진화임도 신설사업": {"BOQ11.DBF": [_tree(3, ".")], "BOQ12.DBF": [_tree(3, ".")]},
"변경 1차분-2025년 산불진화임": {"BOQ11.DBF": [_tree(3, ".")]},
}
STRUCT_E = ("E", "C")
def _ranges(rows, plan):
"""under → range(그 머리 행 ~ 짝 닫는 행)."""
out = []
for o in plan:
o = dict(o)
if "under" in o:
lv, nth = o.pop("under")
heads = [i for i, r in enumerate(rows) if (r.get("BDCODE1") or "").strip() == f"T{lv}"]
i = heads[nth - 1]
end = next(
j for j, r in enumerate(rows) if j > i and r.get("SORTCODE") == rows[i]["ENDCODE"]
)
o["range"] = [i, end]
out.append(o)
return out
def _groups(res, rows):
"""번호 붙은 행을 바로 위 머리(부모)별 차례로 묶음."""
stack, groups, parent_of = [], {}, {}
ends = {}
for i, r in enumerate(rows):
bd = (r.get("BDCODE1") or "").strip()
while stack and i > ends[stack[-1]]:
stack.pop()
if bd[:1] in STRUCT_E or not bd or bd == ":":
continue
par = stack[-1] if stack else -1
if res[i]["want"].strip():
groups.setdefault(par, []).append(i)
parent_of[i] = par
if bd[:1] == "T" and len(bd) == 2:
end = next(
(j for j, q in enumerate(rows) if j > i and q.get("SORTCODE") == r["ENDCODE"]), i
)
ends[i] = end
stack.append(i)
return groups, parent_of
def _clean_prefix(res, idx, parent_want):
"""묶음 앞에서부터 원본 규칙 번호인 행 — 숫자 없음·겹침·빈 번호·순서 바뀜·직전공종 번호 다름에서 멈춤."""
import re as _re
ok = []
for k, i in enumerate(idx, 1):
w = res[i]["want"].strip()
m = _re.match(r"^(.*?)(\d+)\.?$", w)
if not m or int(m[2]) != k:
break
head = m[1].strip().rstrip(".")
if head != parent_want.strip().rstrip("."):
break
ok.append(i)
return ok
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_itnum_tree_reproduces_every_rule_number(stc):
load = server.api_load(server.LoadReq(source_path=str(stc)))
plans = next(v for k, v in ITNUM_PLANS.items() if stc.stem.startswith(k))
tables = {t: _ranges(load["boq_tables"][t], p) for t, p in plans.items()}
out = node({"mode": "itnum", "load": load, "plans": tables})
total = checked = 0
for t, res in out.items():
rows = load["boq_tables"][t]
groups, _par = _groups(res, rows)
good = set()
# 뿌리 쪽부터 — 부모 번호가 원본 규칙 번호로 맞았거나 규칙 꼴이 아닌 머리(□ · [1] 등 = 손 입력)일 때 하위 묶음 대조
for par in sorted(groups):
pw = res[par]["want"] if par != -1 else ""
if _re_digit(pw) and par not in good:
continue
for i in _clean_prefix(res, groups[par], pw if _re_digit(pw) else ""):
assert res[i]["got"] == res[i]["want"], (t, i, res[i])
good.add(i)
total += sum(1 for r in res if r["want"].strip() and r["kind"] in ("T", "LEAF"))
checked += len(good)
# 원본 번호 대부분이 규칙 번호 — 나머지는 손으로 고친 번호(□ · 겹침 · 지운 공종 빈자리 뒤)
assert checked >= 0.75 * total, (checked, total)
def _re_digit(s):
"""규칙 꼴 번호(…숫자 · …숫자.) — 숫자 없는 □ · 「[1]」 같은 손 입력 머리는 아님."""
import re as _re
return bool(_re.match(r"^\s*(.*?)(\d+)\.?$", str(s)))
RATE_AXES = [
"I_KONG_TYPE",
"I_KONG_KIKAN",
"I_ANJUN_TYPE",
"I_KOBO_TYPE",
"I_HWAN",
"I_EQIP",
"I_TBUGUM",
"I_ILBAN",
]
def test_rate_axis_combos_recalculate():
"""제잡비 화면 요율 축 콤보(wM_KanJub) — 바꾸면 제비율표(RATE_R) 다시 조회 → 총공사비가 다시 셈해짐(실무 7건 중 하나 이상)."""
moved = {k: 0 for k in RATE_AXES}
for stc in STCS:
load = server.api_load(server.LoadReq(source_path=str(stc)))
if not load["rate_tables"].get("RATE_R.DBF"):
continue
out = node({"mode": "rateAxes", "load": load, "axes": RATE_AXES})
for k, deltas in out.items():
assert all(isinstance(d, (int, float)) for d in deltas), k
moved[k] += sum(1 for d in deltas if d != 0)
assert all(v > 0 for v in moved.values()), moved
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_rate_table_edit_saves_only_changed_cell(stc):
"""제비율표 수정(wM_KAN_RATE) → /api/ui/save rate_tables — 고친 칸만 RATE_R.DBF 에 · 안 고치면 바이트 그대로."""
from api_ui_code import apply_struct
from smt_parser import parse_dbf_bytes
load = server.api_load(server.LoadReq(source_path=str(stc)))
rows = load["rate_tables"].get("RATE_R.DBF")
if not rows:
pytest.skip("RATE_R 없음")
files = server.read_project_files(str(stc))
same, log0 = apply_struct(files, {"rate_tables": {"RATE_R.DBF": rows}})
assert log0 == [] and same["RATE_R.DBF"] is files["RATE_R.DBF"]
edit = [dict(r) for r in rows]
r, k = 20, "RATE_01"
edit[r][k] = str(_f(edit[r][k]) + 1.25)
out, log = apply_struct(files, {"rate_tables": {"RATE_R.DBF": edit}})
assert [(x["table"], x["recno"], x["field"]) for x in log] == [("RATE_R.DBF", r, k)]
back = parse_dbf_bytes(out["RATE_R.DBF"])[1]
assert _f(back[r][k]) == _f(rows[r][k]) + 1.25
assert all(back[i] == rows[i] for i in range(len(rows)) if i != r)
def test_editor_function_list_and_highlight_follow_dfm():
"""산출근거 편집기 함수 단추(cBoxFunc)·문법 강조 낱말(Syn1.KeyWords) = 원본 TWM_EDIT_SAN DFM."""
dfm = (
ROOT / "resources/knowledge/original/경쟁사 프로그램/STmate/REA 분석/"
"30_수량문자열_수신편집기_작업자료_20261026/ST_EdSan/resource-TWM_EDIT_SAN.dfm.json"
)
nodes = {n.get("name"): n for n in json.loads(dfm.read_text(encoding="utf-8"))["nodes"]}
funcs = [x.strip() for x in nodes["cBoxFunc"]["properties"]["Items.Strings"][1:]]
keys = nodes["Syn1"]["properties"]["KeyWords.Strings"]
js = (
"globalThis.window=globalThis;"
"const L=require('../../tools/stmate-test/scr_edit_lang.js');"
"const X=require('../../tools/stmate-test/scr_edit_expr.js');"
"const ks=L.SYN_KEYWORDS.split(' ');"
"process.stdout.write(JSON.stringify({f:X.FUNC_LIST,k:ks,"
"h:ks.map(k=>L.highlight(k.toLowerCase()+'(1)')),v:L.highlight('Qo = 1')}))"
)
out = json.loads(
subprocess.check_output(
["node", "-e", js], cwd=str(HELPER.parent), text=True, encoding="utf-8"
)
)
assert out["f"] == funcs
assert out["k"] == keys
assert all('class="syn-key"' in h for h in out["h"])
assert 'class="syn-var"' in out["v"] and "syn-key" not in out["v"]