Files
Aislo/resources/tester/test_m02_cf_model.py
T
eomsangdonandClaude Opus 5.5 6ba9d76605 feat(m02-cf): 단가산출 다이어그램 Model · Eval 속 채움 · 시험
- 상자 더하기(종류별 기본 색 · 단위 - · 함수 = 빈 시트 하나 · 출력 = 노무비) · 지우기(선도) · 자리 · 색 · 단위(함수 · 출력 안쪽 단위도) · updateBox
- 알약 이름 규칙(브레인 정함) — 입력 = element.name · 표 · variable.name / 함수 = inputs[].port · output.name / 출력 = 값
- 선 규칙 — 입력 → 함수 · 출력 / 함수 → 함수 · 출력 · 출력에서 나감 · 입력으로 들어감 · 같은 상자 · 순환 · 같은 받는 자리 두 번 · 없는 알약은 막고 까닭
- 계산 — 함수 상자를 차례대로(직렬 · 병렬) 받는 칸 · 변수 칸에 값을 넣고 우리 스프레드시트 엔진으로 셈 · 출력 상자 비용 종류별 원가 합(노무비 · 재료비 · 경비 · 값들 · 계)
- 검사 — 빈 받는 자리 · 출력 칸 없음 · 출력 상자 없음 · 값 없는 입력 · 순환 · 오류 칸
- 시험 resources/tester/test_m02_cf_model.py 11개(엔진 그대로 Node)

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

275 lines
13 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 == ["시멘트", "표", "길이"]