- addNode(item) = M01_SL_Item moneyLayout(노무)과 같은 칸 — B1 수량 · B2~B4 노무/재료/경비 단가(노무 B2 만 바깥 값) · B5 단가 · B6 금액 · B7~B9 노무비/재료비/경비 · B10 사용 수량 · 머리 줄 없음
- 출력 알약 단가 · 금액 · 노무비 · 재료비 · 경비 · 사용 수량 · node.ref = {"what":"노무"}
- 시험 14개(틀 칸 대조 더함)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
364 lines
16 KiB
Python
364 lines
16 KiB
Python
"""M01 시트 로직 Model · Eval 시험(35-5) — Node 로 `M01_SL_Model.ts` · `M01_SL_Eval.ts` 와 진짜 스프레드시트 엔진을 돌림.
|
||
|
||
· 견본 시트 로직 둘은 이 시험 안에서 만듦 — 마스터 · `_data` 에 기대지 않음 · 저장 안 함
|
||
· 입력값 → 계산 → 항목 → 결과 · 선 잇기 / 끊기 → 값 바뀜 · 순환 막음 · 바깥 값 넣기 · 알약 · 상자 지우기 · 검사
|
||
· 엔진 · Model 은 TypeScript 를 그 자리에서 CommonJS 로 옮겨 부름(`helper_spreadsheet_engine.cjs` 와 같은 길)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
SL = ROOT / "M01_MasterData" / "SheetLogic"
|
||
|
||
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||
|
||
LOADER = r"""
|
||
const fs = require("fs");
|
||
const path = require("path");
|
||
const ROOT = process.argv[1];
|
||
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
|
||
const cache = new Map();
|
||
function loadTs(file) {
|
||
const full = path.resolve(file);
|
||
if (cache.has(full)) return cache.get(full).exports;
|
||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
||
}).outputText;
|
||
const mod = { exports: {} };
|
||
cache.set(full, mod);
|
||
const req = (r) => {
|
||
if (r === "@ui/ui_template_locale") return { currentLanguageIndex: 0 };
|
||
if (r.endsWith(".css")) return {};
|
||
if (r.startsWith("@ui/")) return loadTs(path.join(ROOT, "ui_template", `${r.slice(4)}.ts`));
|
||
if (r.startsWith(".")) return loadTs(path.join(path.dirname(full), r.endsWith(".ts") ? r : `${r}.ts`));
|
||
return require(r);
|
||
};
|
||
new Function("exports", "module", "require", js)(mod.exports, mod, req);
|
||
return mod.exports;
|
||
}
|
||
const M = loadTs(path.join(ROOT, "M01_MasterData", "SheetLogic", "M01_SL_Model.ts"));
|
||
const E = loadTs(path.join(ROOT, "M01_MasterData", "SheetLogic", "M01_SL_Eval.ts"));
|
||
const out = (x) => console.log(JSON.stringify(x));
|
||
const sheet = (sl, id) => M.nodeSheet(sl, id);
|
||
const link = (sl, a, ap, b, bp) => {
|
||
const r = M.connect(sl, { node: a.id, pill: ap }, { node: b.id, pill: bp });
|
||
if (r.why) throw new Error(r.why);
|
||
return r.sl;
|
||
};
|
||
"""
|
||
|
||
|
||
def _run(body: str) -> object:
|
||
result = subprocess.run(
|
||
["node", "-e", LOADER + body, str(ROOT)],
|
||
capture_output=True, text=True, encoding="utf-8", timeout=120, cwd=ROOT,
|
||
) # fmt: skip
|
||
assert result.returncode == 0, result.stderr
|
||
return json.loads(result.stdout.strip().splitlines()[-1])
|
||
|
||
|
||
# 견본 하나 — 품(입력값) → 보통 인부(항목 · 노무) → 결과
|
||
ONE = r"""
|
||
let sl = M.newLogic("시험 하나", "원/㎡");
|
||
let r = M.addNode(sl, "input", "품"); sl = r.sl; const pum = r.node;
|
||
r = M.addNode(sl, "item", "보통 인부"); sl = r.sl; const man = r.node; man.ref = "LB000002";
|
||
r = M.addNode(sl, "result", "결과"); sl = r.sl; const res = r.node;
|
||
sl = E.applyOutside(sl, [{ node: pum.id, cell: "B2", value: 0.5 }, { node: man.id, cell: "B2", value: 100000 }]);
|
||
sl = link(sl, pum, "품", man, "수량");
|
||
sl = link(sl, man, "노무비", res, "노무비");
|
||
"""
|
||
|
||
# 견본 둘 — 품 · 할증률(고정값) → 계산(곱) → 석공 · 보통인부 두 항목 → 결과 노무비(두 선을 더함)
|
||
TWO = r"""
|
||
let sl = M.newLogic("시험 둘");
|
||
let r = M.addNode(sl, "input", "품"); sl = r.sl; const pum = r.node;
|
||
r = M.addNode(sl, "const", "할증률"); sl = r.sl; const rate = r.node;
|
||
r = M.addNode(sl, "calc", "할증 품"); sl = r.sl; const calc = r.node;
|
||
r = M.addNode(sl, "item", "석공"); sl = r.sl; const stone = r.node;
|
||
r = M.addNode(sl, "item", "보통인부"); sl = r.sl; const man = r.node;
|
||
r = M.addNode(sl, "result", "결과"); sl = r.sl; const res = r.node;
|
||
sl = E.applyOutside(sl, [
|
||
{ node: pum.id, cell: "B2", value: 0.2 }, { node: rate.id, cell: "B2", value: 1.3 },
|
||
{ node: stone.id, cell: "B2", value: 200000 }, { node: man.id, cell: "B2", value: 100000 },
|
||
]);
|
||
sl = link(sl, pum, "품", calc, "값1");
|
||
sl = link(sl, rate, "할증률", calc, "값2");
|
||
sl = link(sl, calc, "결과", stone, "수량");
|
||
sl = link(sl, pum, "품", man, "수량");
|
||
sl = link(sl, stone, "노무비", res, "노무비");
|
||
sl = link(sl, man, "노무비", res, "노무비");
|
||
"""
|
||
|
||
|
||
def test_입력값_항목_결과_수식과_값() -> None:
|
||
got = _run(
|
||
ONE
|
||
+ "out([sheet(sl, man.id).칸.B1, sheet(sl, res.id).칸.B2, E.evalLogic(sl), sl.edges, sl.nodes.map(n => [n.kind, n.stage]),"
|
||
" sl.workbook.시트.map(s => s.이름)]);"
|
||
)
|
||
qty, total_in, ev, edges, kinds, names = got
|
||
assert qty == {"식": "품!B2"}
|
||
assert total_in == {"식": "'보통 인부'!B7"}
|
||
assert ev["result"] == {"노무비": 50000, "재료비": None, "경비": None, "계": 50000}
|
||
assert ev["pills"]["n2"]["금액"] == 50000 and ev["pillsIn"]["n2"]["수량"] == 0.5
|
||
assert ev["errors"] == []
|
||
assert edges == [
|
||
{"id": "e1", "from": {"node": "n1", "pill": "품"}, "to": {"node": "n2", "pill": "수량"}},
|
||
{
|
||
"id": "e2",
|
||
"from": {"node": "n2", "pill": "노무비"},
|
||
"to": {"node": "n3", "pill": "노무비"},
|
||
},
|
||
]
|
||
assert kinds == [["input", 0], ["item", 3], ["result", 4]]
|
||
assert names == ["품", "보통 인부", "결과"]
|
||
|
||
|
||
def test_계산_두_항목_더함_값바뀜() -> None:
|
||
got = _run(
|
||
TWO + "const a = E.evalLogic(sl);\n"
|
||
"const sl2 = E.applyOutside(sl, [{ node: rate.id, cell: 'B2', value: 1.5 }]);\n"
|
||
"out([a.result, a.pills[calc.id], sheet(sl, res.id).칸.B2, E.evalLogic(sl2).result]);"
|
||
)
|
||
a, c, cell, b = got
|
||
# 석공 0.2×1.3×200000 = 52000 · 보통인부 0.2×100000 = 20000
|
||
assert a == {"노무비": 72000, "재료비": None, "경비": None, "계": 72000}
|
||
assert c == {"결과": 0.26}
|
||
assert cell == {"식": "석공!B7+보통인부!B7"}
|
||
assert b["계"] == 0.2 * 1.5 * 200000 + 20000
|
||
|
||
|
||
def test_선_끊기_다시_잇기() -> None:
|
||
got = _run(
|
||
TWO + "const cut = sl.edges.find(e => e.from.node === stone.id).id;\n"
|
||
"const a = M.disconnect(sl, cut);\n"
|
||
"const b = M.disconnect(M.disconnect(sl, cut), sl.edges.find(e => e.from.node === man.id && e.to.node === res.id).id);\n"
|
||
"const c = link(a, stone, '노무비', res, '경비');\n"
|
||
"out([sheet(a, res.id).칸.B2, E.evalLogic(a).result, sheet(b, res.id).칸.B2 ?? null, E.checkLogic(b),"
|
||
" E.evalLogic(c).result, a.edges.length]);"
|
||
)
|
||
cell_a, ra, cell_b, check_b, rc, n_edges = got
|
||
assert cell_a == {"식": "보통인부!B7"} and ra["계"] == 20000
|
||
assert cell_b is None
|
||
assert "결과 상자에 이어진 선이 없음" in check_b
|
||
assert not any("입력 알약" in x for x in check_b)
|
||
assert rc == {"노무비": 20000, "재료비": None, "경비": 52000, "계": 72000}
|
||
assert n_edges == 5
|
||
|
||
|
||
def test_순환_같은상자_두번_없는알약_막음() -> None:
|
||
got = _run(
|
||
TWO
|
||
+ "let r2 = M.addNode(sl, 'calc', '다른 계산'); const sl3 = r2.sl; const other = r2.node;\n"
|
||
"const s4 = link(sl3, calc, '결과', other, '값1');\n"
|
||
"const tries = [\n"
|
||
" M.connect(s4, { node: other.id, pill: '결과' }, { node: calc.id, pill: '값1' }).why,\n"
|
||
" M.connect(s4, { node: calc.id, pill: '결과' }, { node: calc.id, pill: '값2' }).why,\n"
|
||
" M.connect(s4, { node: calc.id, pill: '결과' }, { node: other.id, pill: '값1' }).why,\n"
|
||
" M.connect(s4, { node: calc.id, pill: '없음' }, { node: other.id, pill: '값2' }).why,\n"
|
||
" M.connect(s4, { node: res.id, pill: '계' }, { node: pum.id, pill: '품' }).why,\n"
|
||
"];\n"
|
||
"const same = M.connect(s4, { node: other.id, pill: '결과' }, { node: calc.id, pill: '값1' }).sl === s4;\n"
|
||
"out([tries, same]);"
|
||
)
|
||
tries, same = got
|
||
assert tries == [
|
||
"이으면 돌고 도는 선이 됨",
|
||
"같은 상자끼리는 못 이음",
|
||
"이미 이어져 있음",
|
||
"알약이 없음",
|
||
"알약이 없음",
|
||
]
|
||
assert same is True
|
||
|
||
|
||
def test_엔진_순환은_오류로_알림() -> None:
|
||
got = _run(TWO + "sheet(sl, calc.id).칸.B3 = { 식: 'B4' };\nout(E.evalLogic(sl).errors);")
|
||
assert got and all("돌고 도는 참조" in e for e in got)
|
||
assert got[0].startswith("「할증 품」 ")
|
||
|
||
|
||
def test_바깥값_칸목록과_표_덩어리() -> None:
|
||
got = _run(
|
||
ONE
|
||
+ "r = M.addNode(sl, 'table', '메쌓기 표'); sl = r.sl; const tb = r.node; tb.ref = 'QF000421';\n"
|
||
"sl.nodes.find(n => n.id === tb.id).ref = 'QF000421';\n"
|
||
"sl = M.setPill(sl, tb.id, 'E1', 'out', '표', { outside: true });\n"
|
||
"r = M.addNode(sl, 'logic', '작업량'); sl = r.sl; const lg = r.node;\n"
|
||
"sl.nodes.find(n => n.id === lg.id).ref = 'GF000114';\n"
|
||
"const cells = E.outsideCells(sl);\n"
|
||
"sl = E.applyOutside(sl, [{ node: tb.id, cell: 'E1', value: [['뒷길이', '석공'], [25, 0.15], [30, null]] },"
|
||
" { node: lg.id, cell: 'B2', value: 12.5 }]);\n"
|
||
"const s = sheet(sl, tb.id).칸;\n"
|
||
"out([cells, s.E1, s.F2, s.E3, s.F3 ?? null, E.evalLogic(sl).pills[lg.id], E.checkLogic(sl)]);"
|
||
)
|
||
cells, e1, f2, e3, f3, lg, check = got
|
||
assert cells == [
|
||
{"id": "n2!B2", "node": "n2", "cell": "B2", "kind": "price", "ref": "LB000002"},
|
||
{
|
||
"id": "n4!E1",
|
||
"node": "n4",
|
||
"cell": "E1",
|
||
"kind": "table",
|
||
"ref": "QF000421",
|
||
"source": {"kind": "표", "table": "QF000421"},
|
||
},
|
||
{
|
||
"id": "n5!B2",
|
||
"node": "n5",
|
||
"cell": "B2",
|
||
"kind": "logic",
|
||
"ref": "GF000114",
|
||
"source": {"kind": "로직", "key": "GF000114", "inputs": {}},
|
||
},
|
||
]
|
||
assert (e1, f2, e3, f3) == ({"값": "뒷길이"}, {"값": 0.15}, {"값": 30}, None)
|
||
assert lg == {"결과": 12.5}
|
||
assert "「메쌓기 표」의 입력 알약 「조건」이 비어 있음" in check
|
||
|
||
|
||
def test_알약_정하기_이름바꾸면_선이_따라감() -> None:
|
||
got = _run(
|
||
ONE + "let s2 = M.setPill(sl, pum.id, 'B2', 'out', '품셈 품');\n"
|
||
"const e = s2.edges[0].from;\n"
|
||
"s2 = M.setPill(s2, pum.id, 'D2', 'out', '품셈 품');\n"
|
||
"const moved = sheet(s2, man.id).칸.B1;\n"
|
||
"const s3 = M.removePill(s2, pum.id, 'out', '품셈 품');\n"
|
||
"const s4 = M.setPill(sl, man.id, 'C9', 'in', '할증');\n"
|
||
"out([e, moved, s3.edges.length, sheet(s3, man.id).칸.B1 ?? null, s4.nodes[1].pillsIn.map(p => [p.name, p.cell])]);"
|
||
)
|
||
e, moved, n_edges, cleared, ins = got
|
||
assert e == {"node": "n1", "pill": "품셈 품"}
|
||
assert moved == {"식": "품!D2"}
|
||
assert n_edges == 1 and cleared is None
|
||
assert ins == [["수량", "B1"], ["노무 단가", "B2"], ["할증", "C9"]]
|
||
|
||
|
||
def test_상자_지우기() -> None:
|
||
got = _run(
|
||
TWO + "const s2 = M.removeNode(sl, stone.id);\n"
|
||
"out([s2.nodes.map(n => n.name), s2.workbook.시트.map(s => s.이름), s2.edges.length,"
|
||
" sheet(s2, res.id).칸.B2, E.evalLogic(s2).result.계, sl.nodes.length]);"
|
||
)
|
||
names, sheets, n_edges, cell, total, before = got
|
||
assert names == ["품", "할증률", "할증 품", "보통인부", "결과"] and sheets == names
|
||
assert n_edges == 4 and cell == {"식": "보통인부!B7"} and total == 20000
|
||
assert before == 6 # 받은 것은 안 바뀜
|
||
|
||
|
||
def test_같은_이름_상자와_새_번호() -> None:
|
||
got = _run(
|
||
"let sl = M.newLogic(); let r = M.addNode(sl, 'input', '높이'); sl = r.sl;\n"
|
||
"r = M.addNode(sl, 'input', '높이'); sl = r.sl;\n"
|
||
"sl = M.removeNode(sl, 'n1'); r = M.addNode(sl, 'const', '높이'); sl = r.sl;\n"
|
||
"out([sl.nodes.map(n => [n.id, n.name, n.sheetId]), E.checkLogic(sl)]);"
|
||
)
|
||
nodes, check = got
|
||
assert nodes == [["n2", "높이 (2)", "s2"], ["n3", "높이", "s3"]]
|
||
assert check == ["결과 상자가 없음"]
|
||
|
||
|
||
def test_화면_글에_금지_낱말_없음() -> None:
|
||
for name in ("M01_SL_Model.ts", "M01_SL_Eval.ts"):
|
||
src = (SL / name).read_text(encoding="utf-8")
|
||
block = src[src.index("const TEXT = {") : src.index("} as const;")]
|
||
korean = [ko for _, ko in re.findall(r'(\w+):\s*\[\s*"([^"]*)"', block)]
|
||
assert korean
|
||
for text in korean:
|
||
for bad in ("키", "식", "호표", "덧줄", "비목", "정본", "자체"):
|
||
assert bad not in text, (name, bad, text)
|
||
|
||
|
||
def test_상자_이름_바꾸기_수식이_따라감() -> None:
|
||
got = _run(
|
||
TWO + "const a = M.renameNode(sl, stone.id, '석공 (돌쌓기)');\n"
|
||
"const tries = [M.renameNode(sl, stone.id, '보통인부').why, M.renameNode(sl, stone.id, '').why,"
|
||
" M.renameNode(sl, stone.id, '가:나').why, M.renameNode(sl, 'n99', '무엇').why,"
|
||
" M.renameNode(sl, stone.id, 'x'.repeat(32)).why];\n"
|
||
"out([a.why, a.sl.nodes.find(n => n.id === stone.id).name, sheet(a.sl, stone.id).이름,"
|
||
" sheet(a.sl, res.id).칸.B2, E.evalLogic(a.sl).result.계, tries, sl.nodes[3].name]);"
|
||
)
|
||
why, name, sheet_name, cell, total, tries, before = got
|
||
assert why is None and name == sheet_name == "석공 (돌쌓기)"
|
||
assert cell == {"식": "'석공 (돌쌓기)'!B7+보통인부!B7"} and total == 72000
|
||
assert tries == [
|
||
"같은 이름 상자가 있음",
|
||
"이름은 1~31 글자",
|
||
"이름에 못 쓰는 글자 — [ ] : * ? / \\ 와 앞뒤 '",
|
||
"상자가 없음",
|
||
"이름은 1~31 글자",
|
||
]
|
||
assert before == "석공"
|
||
|
||
|
||
def test_상자_이름_못쓰는_글자는_고쳐_만듦() -> None:
|
||
got = _run(
|
||
"let sl = M.newLogic(); let r = M.addNode(sl, 'input', '가:나/다'); sl = r.sl;"
|
||
" r = M.addNode(sl, 'input', 'x'.repeat(40)); sl = r.sl; r = M.addNode(sl, 'input', 'X'.repeat(40)); sl = r.sl;"
|
||
" out(sl.workbook.시트.map(s => s.이름));"
|
||
)
|
||
assert got == ["가·나·다", "x" * 31, "X" * 27 + " (2)"]
|
||
|
||
|
||
def test_바깥값_답을_넣을값으로_범위머리() -> None:
|
||
got = _run(
|
||
ONE + "r = M.addNode(sl, 'table', '증가율 표'); sl = r.sl; const tb = r.node;\n"
|
||
"sl.nodes.find(n => n.id === tb.id).source = { kind: '표', table: 'QF000422' };\n"
|
||
"sl = M.setPill(sl, tb.id, 'E1', 'out', '표', { outside: true });\n"
|
||
"const cells = E.outsideCells(sl);\n"
|
||
"const head = M.rangeHead('높이');\n"
|
||
"const got = E.answersToValues(cells, [\n"
|
||
" { id: 'n2!B2', ok: true, value: 172068 },\n"
|
||
" { id: 'n4!E1', ok: true, rows: [[...head, '증가율'], [3, 4, 30]] },\n"
|
||
"]);\n"
|
||
"const s2 = E.applyOutside(sl, got.values);\n"
|
||
"out([head, got, sheet(s2, tb.id).칸.F1, sheet(s2, tb.id).칸.G2, E.evalLogic(s2).result.계]);"
|
||
)
|
||
head, got_values, f1, g2, total = got
|
||
assert head == ["높이·아래", "높이·위"]
|
||
assert got_values["errors"] == []
|
||
assert got_values["values"][0] == {"node": "n2", "cell": "B2", "value": 172068}
|
||
assert (f1, g2, total) == ({"값": "높이·위"}, {"값": 30}, 86034)
|
||
missing = _run(
|
||
ONE
|
||
+ "out(E.answersToValues(E.outsideCells(sl), [{ id: 'n2!B2', ok: false, reason: '없는 직종' }]));"
|
||
)
|
||
assert missing == {"values": [], "errors": ["B2 칸 바깥 값을 못 받음 없는 직종"]}
|
||
|
||
|
||
def test_항목_틀은_Item_노무_틀과_같은_칸() -> None:
|
||
got = _run(
|
||
"let sl = M.newLogic(); const r = M.addNode(sl, 'item', '석공');"
|
||
" out([sheet(r.sl, r.node.id).칸, r.node.pillsIn, r.node.pillsOut.map(p => [p.name, p.cell]), r.node.ref]);"
|
||
)
|
||
cells, ins, outs, ref = got
|
||
assert {k: v for k, v in cells.items() if k.startswith("B")} == {
|
||
"B3": {"값": 0},
|
||
"B4": {"값": 0},
|
||
"B5": {"식": "B2+B3+B4"},
|
||
"B6": {"식": "ROUND(B1*B5,1)"},
|
||
"B7": {"식": "ROUND(B1*B2,1)"},
|
||
"B8": {"식": "ROUND(B1*B3,1)"},
|
||
"B9": {"식": "ROUND(B1*B4,1)"},
|
||
"B10": {"식": "B1"},
|
||
}
|
||
assert [cells[f"A{i}"]["값"] for i in range(1, 11)] == [
|
||
"수량", "노무 단가", "재료 단가", "경비 단가", "단가", "금액", "노무비", "재료비", "경비", "사용 수량",
|
||
] # fmt: skip
|
||
assert ins == [
|
||
{"name": "수량", "cell": "B1", "unit": ""},
|
||
{"name": "노무 단가", "cell": "B2", "unit": "원", "outside": True},
|
||
]
|
||
assert outs == [
|
||
["단가", "B5"], ["금액", "B6"], ["노무비", "B7"], ["재료비", "B8"], ["경비", "B9"], ["사용 수량", "B10"],
|
||
] # fmt: skip
|
||
assert ref == '{"what":"노무"}'
|