Files
Aislo/resources/tester/test_m01_sl_model.py
T
eomsangdonandClaude Opus 5.5 67e4c27658 feat(m01-sl): 시트 로직 Model · Eval 속 채움 · 시험
- 상자 더하기(종류별 틀 시트 · 칸 A 이름 · B 값 · C 설명) · 지우기 · 같은 이름은 「 (2)」
- 선 잇기 = 받는 입력 알약 칸에 「'시트'!칸」 참조 수식(여러 선은 +) · 끊기 = 그 참조 뺌 · 순환 · 같은 상자 · 두 번 · 없는 알약은 막고 까닭
- 알약 정하기 · 빼기(이름 · 칸이 바뀌면 선이 따라감)
- 바깥 값 칸 목록 · 넣기(표는 칸 덩어리) · 화면 계산 = 우리 스프레드시트 엔진 recalcWorkbook · 결과 노무비 · 재료비 · 경비 · 계
- 검사: 빈 입력 알약 · 안 받은 바깥 값 · 결과 상자 없음 / 선 없음 · 오류 칸 · 순환(#CYCLE!)
- 시험 resources/tester/test_m01_sl_model.py 10개(엔진 그대로 Node)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-01 13:04:15 +09:00

264 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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: "B3", 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: "B3", value: 200000 }, { node: man.id, cell: "B3", 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).칸.B2, 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 == {"식": "'보통 인부'!B5"}
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 == {"식": "석공!B5+보통인부!B5"}
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 == {"식": "보통인부!B5"} 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 == [
{"node": "n2", "cell": "B3", "kind": "price", "ref": "LB000002"},
{"node": "n4", "cell": "E1", "kind": "table", "ref": "QF000421"},
{"node": "n5", "cell": "B2", "kind": "logic", "ref": "GF000114"},
]
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).칸.B2;\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).칸.B2 ?? 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 == [["수량", "B2"], ["단가", "B3"], ["할증", "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 == {"식": "보통인부!B5"} 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)