"""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":"노무"}'