- 상자 더하기(종류별 기본 색 · 단위 - · 함수 = 빈 시트 하나 · 출력 = 노무비) · 지우기(선도) · 자리 · 색 · 단위(함수 · 출력 안쪽 단위도) · 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
275 lines
13 KiB
Python
275 lines
13 KiB
Python
"""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 == ["시멘트", "표", "길이"]
|