Files
Aislo/resources/tester/test_m02_cf_model.py
T
eomsangdonandClaude Opus 5.5 1004768037 feat(m02-cf): 함수 상자에 놓으면 받는 알약을 새로 만들어 이음
- link: 받는 쪽이 함수 상자이고 그 이름 받는 알약이 없으면 새로 만듦(빈 이름이면 보낸 알약 이름 · 같으면 「이름 (2)」) · func.inputs 에 칸 없이 더함
- unlink · removeBox: 받는 알약이 칸에 안 놓였고 다른 선도 없으면 같이 지움
- 검사: 칸에 놓지 않은 받는 알약 알림
- 시험 13개

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

325 lines
16 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.
"""M02 단가산출 다이어그램 Model · Eval 시험(35-7) — Node 로 `M02_CF_Model.ts` · `M02_CF_Eval.ts` 와 진짜 스프레드시트 엔진을 돌림.
· 견본 둘은 이 시험 안에서 만듦 — 마스터 · `_data` 에 기대지 않음 · 저장 안 함
· 입력(인력 석공 노임) + 변수(면적) → 함수(품 × 면적 × 노임) → 출력 노무비 · 직렬 · 병렬 · 순환 막음 · 선 규칙 · 출력 하나 · 원가 합 · 검사
· 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]
CF = ROOT / "M02_MasterTemplete" / "CostFlow"
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, "M02_MasterTemplete", "CostFlow", "M02_CF_Model.ts"));
const E = loadTs(path.join(ROOT, "M02_MasterTemplete", "CostFlow", "M02_CF_Eval.ts"));
const out = (x) => console.log(JSON.stringify(x));
const add = (lg, kind, x = 0, y = 0) => M.addBox(lg, kind, x, y);
const join = (lg, a, ap, b, bp) => {
const r = M.link(lg, { box: a.id, name: ap }, { box: b.id, name: bp });
if (r.why) throw new Error(r.why);
return r.lg;
};
/** 함수 상자 시트 · 받는 칸 · 변수 · 출력 칸 한 번에 */
const func = (lg, box, cells, inputs, output, variables = []) =>
M.updateBox(lg, box.id, { func: { ...box.func, sheet: { ...box.func.sheet, 칸: cells }, inputs, variables,
output: { ...box.func.output, ...output } } });
"""
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 lg = M.newLogic("견본 하나", "원/㎡");
let r = add(lg, "input"); lg = r.lg; let man = r.box;
lg = M.updateBox(lg, man.id, { input: { mode: "group", group: "인력",
elements: [{ ref: "LB000033", name: "석공", spec: "", unit: "인" }], variables: [] } });
r = add(lg, "input"); lg = r.lg; let area = r.box;
lg = M.updateBox(lg, area.id, { input: { mode: "variable", elements: [],
variables: [{ name: "면적", type: "number", unit: "㎡", example: 10 }] } });
r = add(lg, "function"); lg = r.lg; let f = r.box;
lg = func(lg, f, { A2: { 값: "노임" }, A3: { 값: "면적" }, A4: { 값: "품" }, A5: { 값: "노무비" }, B5: { 식: "B4*B3*B2" } },
[{ port: "노임", cell: "B2" }, { port: "면적", cell: "B3" }], { cell: "B5", name: "노무비", unit: "원" },
[{ name: "품", type: "number", unit: "인/㎡", example: 0.2, cell: "B4" }]);
r = add(lg, "output"); lg = r.lg; let o = r.box;
lg = join(lg, man, "석공", f, "노임");
lg = join(lg, area, "면적", f, "면적");
lg = join(lg, f, "노무비", o, "값");
const V = { [man.id]: { 석공: 268908 } };
"""
# 견본 둘 — 직렬 · 병렬: f1 = 면적 × 2 · f2 = 노임 × 0.1 · f3 = f1 + f2(병렬 모음) · f4 = f3 × 1.1(직렬)
TWO = r"""
let lg = M.newLogic("견본 둘");
let r = add(lg, "input"); lg = r.lg; const inp = r.box;
lg = M.updateBox(lg, inp.id, { input: { mode: "group", group: "인력",
elements: [{ ref: "LB000002", name: "보통인부", spec: "", unit: "인" }],
variables: [{ name: "면적", type: "number", unit: "㎡", example: 5 }] } });
const fn = (cells, inputs) => { r = add(lg, "function"); lg = r.lg; const b = r.box;
lg = func(lg, b, cells, inputs, { cell: "B9", name: "결과" }); return b; };
const f1 = fn({ B9: { 식: "B1*2" } }, [{ port: "가", cell: "B1" }]);
const f2 = fn({ B9: { 식: "B1*0.1" } }, [{ port: "가", cell: "B1" }]);
const f3 = fn({ B9: { 식: "B1+B2" } }, [{ port: "가", cell: "B1" }, { port: "나", cell: "B2" }]);
const f4 = fn({ B9: { 식: "ROUND(B1*1.1,0)" } }, [{ port: "가", cell: "B1" }]);
lg = join(lg, inp, "면적", f1, "가");
lg = join(lg, inp, "보통인부", f2, "가");
lg = join(lg, f1, "결과", f3, "가");
lg = join(lg, f2, "결과", f3, "나");
lg = join(lg, f3, "결과", f4, "가");
r = add(lg, "output"); lg = r.lg; const oExp = r.box; lg = M.updateBox(lg, oExp.id, { output: { cost: "경비", unit: "원" } });
r = add(lg, "output"); lg = r.lg; const oVal = r.box; lg = M.updateBox(lg, oVal.id, { output: { cost: "값", unit: "㎡" } });
r = add(lg, "output"); lg = r.lg; const oMat = r.box; lg = M.updateBox(lg, oMat.id, { output: { cost: "재료비", unit: "원" } });
lg = join(lg, f4, "결과", oExp, "값");
lg = join(lg, f1, "결과", oVal, "값");
lg = join(lg, inp, "면적", oMat, "값");
const V = { [inp.id]: { 보통인부: 172068 } };
"""
def test_견본하나_입력_변수_함수_출력_노무비() -> None:
got = _run(
ONE
+ "out([E.evalLogic(lg, V), E.checkLogic(lg, V), lg.boxes.map(b => [b.id, b.kind, b.name, b.color, b.unit])]);"
)
ev, check, boxes = got
assert ev["ports"]["b3"] == {"노무비": 537816} # 0.2 × 10 × 268,908
assert ev["outputs"] == {"b4": 537816}
assert ev["cost"] == {"노무비": 537816, "재료비": 0, "경비": 0, "값들": {}, "계": 537816}
assert ev["errors"] == [] and check == []
assert boxes == [
["b1", "input", "입력", "#2f80ed", "-"],
["b2", "input", "입력 2", "#2f80ed", "-"],
["b3", "function", "함수", "#7b4fd6", "-"],
["b4", "output", "출력", "#27ae60", "-"],
]
def test_변수값_바꾸면_값이_바뀜() -> None:
got = _run(
ONE
+ "out(E.evalLogic(lg, { ...V, [area.id]: { 면적: 20 }, [f.id]: { 품: 0.1 } }).cost.계);"
)
assert got == 0.1 * 20 * 268908
def test_견본둘_직렬_병렬_원가_합() -> None:
got = _run(
TWO
+ "const ev = E.evalLogic(lg, V);\nout([ev.ports, ev.cost, ev.errors, E.checkLogic(lg, V)]);"
)
ports, cost, errors, check = got
f1, f2, f3, f4 = (ports[k]["결과"] for k in ("b2", "b3", "b4", "b5"))
assert (f1, f2, f3, f4) == (10, 17206.8, 17216.8, 18938)
assert cost == {"노무비": 0, "재료비": 5, "경비": 18938, "값들": {"출력 2": 10}, "계": 18943}
assert errors == [] and check == []
def test_선_규칙() -> None:
got = _run(
TWO
+ "const t = (a, ap, b, bp) => M.link(lg, { box: a.id, name: ap }, { box: b.id, name: bp }).why;\n"
"out([t(f4, '결과', f3, '나'), t(f3, '결과', f1, '가'), t(oExp, '값', f1, '가'), t(f1, '결과', inp, '면적'),"
" t(f3, '결과', f3, '가'), t(f2, '결과', f3, '가'), t(f1, '없음', f4, '가'), t(inp, '면적', oExp, '값'),"
" M.link(lg, { box: f1.id, name: '결과' }, { box: f3.id, name: '가' }).lg === lg]);"
)
assert got == [
"이으면 돌고 도는 선이 됨",
"이으면 돌고 도는 선이 됨",
"출력 상자에서는 선이 나가지 않음",
"입력 상자로는 선이 들어가지 않음",
"같은 상자끼리는 못 이음",
"이미 선이 들어온 자리",
"알약이 없음",
"이미 선이 들어온 자리",
True,
]
def test_알약_출력은_하나() -> None:
got = _run(
TWO
+ "lg = M.updateBox(lg, inp.id, { input: { ...lg.boxes[0].input, table: { key: 'QF000421' } } });\n"
"out([M.portsOut(lg.boxes[0]), M.portsIn(lg.boxes[0]), M.portsOut(lg.boxes[3]), M.portsIn(lg.boxes[3]),"
" M.portsOut(lg.boxes.find(b => b.id === oExp.id)), M.portsIn(lg.boxes.find(b => b.id === oExp.id))]);"
)
in_out, in_in, f_out, f_in, o_out, o_in = got
assert in_out == [
{"name": "보통인부", "unit": "인"},
{"name": "표", "unit": "-"},
{"name": "면적", "unit": "㎡"},
]
assert in_in == [] and f_out == [{"name": "결과", "unit": "-"}]
assert f_in == [{"name": "가", "unit": "-"}, {"name": "나", "unit": "-"}]
assert o_out == [] and o_in == [{"name": "값", "unit": "원"}]
def test_선_끊기_상자_지우기_검사() -> None:
got = _run(
TWO + "const cut = lg.links.find(l => l.to.box === f3.id && l.to.name === '나').id;\n"
"const a = M.unlink(lg, cut);\n"
"const b = M.removeBox(lg, f3.id);\n"
"const c = M.removeBox(M.removeBox(M.removeBox(lg, oExp.id), oVal.id), oMat.id);\n"
"out([E.evalLogic(a, V).ports[f3.id], E.checkLogic(a, V), b.links.length, lg.links.length,"
" E.checkLogic(b, V), E.checkLogic(c, V), E.checkLogic(lg, {})]);"
)
f3_after, check_a, n_b, n_all, check_b, check_c, check_novalue = got
assert f3_after == {"결과": 10}
assert check_a == ["「함수 3」 의 받는 알약 「나」 에 선이 없음"]
assert (n_b, n_all) == (5, 8)
assert check_b == [
"「함수 4」 의 받는 알약 「가」 에 선이 없음",
]
assert check_c == ["출력 상자가 없음"]
assert check_novalue == ["「입력」 의 「보통인부」 값이 아직 없음"]
def test_오류칸_순환칸_숫자아닌_출력() -> None:
got = _run(
ONE
+ "lg = M.updateBox(lg, f.id, { func: { ...lg.boxes[2].func, sheet: { ...lg.boxes[2].func.sheet,"
" 칸: { ...lg.boxes[2].func.sheet.칸, B5: { 식: 'B4/0' } } } } });\n"
"const a = E.evalLogic(lg, V);\n"
"lg = M.updateBox(lg, f.id, { func: { ...lg.boxes[2].func, sheet: { ...lg.boxes[2].func.sheet,"
" 칸: { ...lg.boxes[2].func.sheet.칸, B5: { 식: 'B6' }, B6: { 식: 'B5' } } } } });\n"
"const b = E.evalLogic(lg, V);\n"
"out([a.errors, a.cost.계, b.errors]);"
)
a_err, total, b_err = got
assert a_err == ["「함수」 B5 칸 계산이 막힘 — #DIV/0!", "「출력」 에 숫자가 아닌 값이 들어옴"]
assert total == 0
assert "「함수」 B5 칸 계산이 막힘 — #CYCLE! 돌고 도는 참조" in b_err
def test_자리_색_단위() -> None:
got = _run(
ONE
+ "let g = M.moveBox(lg, f.id, 120, 40); g = M.setColor(g, f.id, '#ff0000'); g = M.setUnit(g, f.id, '원/㎡');\n"
"g = M.setUnit(g, o.id, ' ');\n"
"const fb = g.boxes.find(b => b.id === f.id), ob = g.boxes.find(b => b.id === o.id);\n"
"out([fb.x, fb.y, fb.color, fb.unit, fb.func.output.unit, ob.unit, ob.output.unit, lg.boxes[2].x]);"
)
assert got == [120, 40, "#ff0000", "원/㎡", "원/㎡", "-", "-", 0]
def test_사용자_요소_값() -> None:
got = _run(
ONE
+ "lg = { ...lg, userElements: [{ id: 'U1', group: '인력', name: '석공', spec: '', unit: '인', value: 300000 }] };\n"
"lg = M.updateBox(lg, man.id, { input: { ...lg.boxes[0].input, elements: [{ ref: 'U1', name: '석공', spec: '', unit: '인' }] } });\n"
"out(E.evalLogic(lg, {}).cost.노무비);"
)
assert got == 0.2 * 10 * 300000
def test_화면_글에_금지_낱말_없음() -> None:
for name in ("M02_CF_Model.ts", "M02_CF_Eval.ts"):
src = (CF / 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(
"let lg = M.newLogic(); let r = add(lg, 'input'); lg = r.lg;\n"
"lg = M.updateBox(lg, r.box.id, { input: { mode: 'group', group: '재료',"
" elements: [{ ref: 'MT1', name: '시멘트', spec: '40kg', unit: '포' }], table: { key: 'QF000421', row: 2 },"
" variables: [{ name: '길이', type: 'number', unit: 'm', example: 3 }] } });\n"
"out(M.portsOut(lg.boxes[0]).map(p => p.name));"
)
assert got == ["시멘트", "표", "길이"]
def test_새_함수_상자에_놓으면_받는_알약이_생김() -> None:
got = _run(
ONE + "r = add(lg, 'function'); lg = r.lg; const g = r.box;\n"
"let a = M.link(lg, { box: man.id, name: '석공' }, { box: g.id, name: '' });\n"
"let b = M.link(a.lg, { box: area.id, name: '면적' }, { box: g.id, name: '' });\n"
"let c = M.link(b.lg, { box: f.id, name: '노무비' }, { box: g.id, name: '석공' });\n"
"let d = M.link(c.lg, { box: f.id, name: '노무비' }, { box: g.id, name: '' });\n"
"const gb = (x) => x.boxes.find(y => y.id === g.id);\n"
"const inputs = gb(d.lg).func.inputs;\n"
"const check = E.checkLogic(d.lg, V).filter(x => x.includes('칸에 놓지 않음'));\n"
"const placed = M.updateBox(d.lg, g.id, { func: { ...gb(d.lg).func, inputs: inputs.map(i => i.port === '면적' ? { ...i, cell: 'B2' } : i) } });\n"
"const cutAll = placed.links.filter(l => l.to.box === g.id).reduce((x, l) => M.unlink(x, l.id), placed);\n"
"const removed = M.removeBox(d.lg, man.id);\n"
"out([a.why, b.why, c.why, d.why, inputs, M.portsIn(gb(d.lg)).map(p => p.name), d.lg.links.slice(-2).map(l => l.to),"
" check, gb(cutAll).func.inputs, gb(removed).func.inputs.map(i => i.port),"
" M.link(lg, { box: man.id, name: '석공' }, { box: o.id, name: '없음' }).why]);"
)
a, b, c, d, inputs, ports, last_to, check, cut_all, removed, to_output = got
assert (a, b, c, d) == (None, None, "이미 선이 들어온 자리", None)
assert inputs == [
{"port": "석공", "cell": ""},
{"port": "면적", "cell": ""},
{"port": "노무비", "cell": ""},
]
assert ports == ["석공", "면적", "노무비"]
assert last_to == [{"box": "b5", "name": "면적"}, {"box": "b5", "name": "노무비"}]
assert check == [
"「함수 2」 의 받는 알약 「석공」 을 칸에 놓지 않음",
"「함수 2」 의 받는 알약 「면적」 을 칸에 놓지 않음",
"「함수 2」 의 받는 알약 「노무비」 을 칸에 놓지 않음",
]
assert cut_all == [{"port": "면적", "cell": "B2"}] # 칸에 놓인 것만 남음
assert removed == ["면적", "노무비"] # 석공 상자를 지우면 칸 없는 「석공」 받는 알약도 지움
assert to_output == "알약이 없음" # 출력 상자는 받는 알약을 새로 만들지 않음
def test_같은_이름_받는_알약은_번호() -> None:
got = _run(
ONE + "r = add(lg, 'function'); lg = r.lg; const g = r.box;\n"
"let x = M.link(lg, { box: man.id, name: '석공' }, { box: g.id, name: '' }).lg;\n"
"x = M.link(x, { box: f.id, name: '노무비' }, { box: g.id, name: '' }).lg;\n"
"r = add(x, 'input'); x = r.lg; const m2 = r.box;\n"
"x = M.updateBox(x, m2.id, { input: { mode: 'group', group: '인력', elements: [{ ref: 'LB1', name: '석공', spec: '', unit: '인' }], variables: [] } });\n"
"x = M.link(x, { box: m2.id, name: '석공' }, { box: g.id, name: '' }).lg;\n"
"x = M.link(x, { box: area.id, name: '면적' }, { box: g.id, name: '할증' }).lg;\n"
"out(x.boxes.find(y => y.id === g.id).func.inputs.map(i => i.port));"
)
assert got == ["석공", "노무비", "석공 (2)", "할증"]