Files
Aislo/resources/tester/test_m01_sl_model.py
T
eomsangdonandClaude Opus 5.5 ebf51d8b35 feat(m01-sl): 표 상자 이음매 — 블록은 표 알약 칸부터 · 줄면 옛 줄 지움 · 머리 줄 바뀜 알림
- 표 블록 왼위 = 그 표 상자의 「표」 바깥 값 알약 칸(A1 · E1 무엇이든) · applyOutside 가 그 칸부터 머리 줄 포함 채움
- 표가 줄었으면 블록 열 아래 남은 옛 값 칸을 지움(찾기 판 수식은 블록 열 밖이라 그대로)
- answersToValues(cells, 답, sl) — 시트 머리 줄과 받은 머리 줄이 다르면 쉬운 말
- addNode(table) = 빈 시트(Table 이 블록 · 찾기 판 · 알약을 채움 — 틀 칸이 블록과 겹치지 않게)
- 시험 15개(Table_Build 와 잇는 시험 더함)

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

414 lines
19 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: "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 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":"노무"}'
# 표 상자 이음매 — Table_Build(sub3) 가 짠 품셈 표 상자에 서버 꼴 블록(머리 줄 + 줄 · A1 부터)을 넣고 찾기
TABLE = r"""
const T = loadTs(path.join(ROOT, "M01_MasterData", "SheetLogic", "M01_SL_Table_Build.ts"));
let sl = M.newLogic("표 시험");
let r = M.addNode(sl, "table", "메쌓기"); sl = r.sl; const tb = r.node;
const info = { key: "QF000421", name: "메쌓기", base: "1㎡", source: "", no: "13-4-1",
conds: { 뒷길이: "수", 돌: "고르기" }, vals: { 석공: "인" }, rule: null, notes: [],
rows: [{ 뒷길이: 25, 돌: "깬잡석", 석공: 0.15 }, { 뒷길이: 30, 돌: "깬돌", 석공: 0.26 },
{ 뒷길이: 35, 돌: "깬돌", 석공: 0.3 }] };
sl = T.applyTable(sl, tb.id, info, { cond: ["뒷길이", "돌"], out: ["석공"] },
{ ref: "QF000421", fillBlock: false, outside: true }).sl;
r = M.addNode(sl, "input", "뒷길이"); sl = r.sl; const len = r.node;
r = M.addNode(sl, "input", "돌"); sl = r.sl; const stone = r.node;
sl = E.applyOutside(sl, [{ node: len.id, cell: "B2", value: 35 }, { node: stone.id, cell: "B2", value: "깬돌" }]);
sl = link(sl, len, "뒷길이", sl.nodes.find(n => n.id === tb.id), "뒷길이");
sl = link(sl, stone, "돌", sl.nodes.find(n => n.id === tb.id), "돌");
const ROWS = [["뒷길이", "돌", "석공"], [25, "깬잡석", 0.15], [30, "깬돌", 0.26], [35, "깬돌", 0.3]];
const cells = E.outsideCells(sl);
"""
def test_표_상자_바깥값_블록_찾기_줄면_옛줄_지움_머리바뀜_알림() -> None:
got = _run(
TABLE
+ "const first = E.answersToValues(cells, [{ id: cells[0].id, ok: true, rows: ROWS }], sl);\n"
"const s1 = E.applyOutside(sl, first.values);\n"
"const shrink = E.answersToValues(cells, [{ id: cells[0].id, ok: true, rows: ROWS.slice(0, 3) }], s1);\n"
"const s2 = E.applyOutside(s1, shrink.values);\n"
"const moved = E.answersToValues(cells, [{ id: cells[0].id, ok: true,"
" rows: [['돌', '뒷길이', '석공'], ['깬돌', 30, 0.26]] }], s1);\n"
"out([cells, first.errors, E.evalLogic(s1).pills[tb.id], shrink.errors, sheet(s2, tb.id).칸.A4 ?? null,"
" sheet(s2, tb.id).칸.C4 ?? null, E.evalLogic(s2).pills[tb.id], moved.errors]);"
)
cells, first_err, found, shrink_err, a4, c4, gone, moved_err = got
assert cells == [
{
"id": "n1!A1",
"node": "n1",
"cell": "A1",
"kind": "table",
"ref": "QF000421",
"source": {"kind": "표", "table": "QF000421"},
}
]
assert first_err == [] and found["석공"] == 0.3 and found["표"] == "뒷길이"
assert shrink_err == [] and a4 is None and c4 is None
assert gone["석공"] == "#N/A"
assert moved_err == ["「표 · 메쌓기」 표 머리 줄이 바뀜 — 표를 다시 골라야 함"]