"""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)", "할증"] def test_함수_출력_기본_이름은_겹치지_않음_받는_알약은_번호() -> None: got = _run( "let lg = M.newLogic(); let r = add(lg, 'function'); lg = r.lg; const f1 = r.box;\n" "r = add(lg, 'function'); lg = r.lg; const f2 = r.box;\n" "r = add(lg, 'function'); lg = r.lg; const f3 = r.box;\n" "lg = M.removeBox(lg, f2.id); r = add(lg, 'function'); lg = r.lg; const f4 = r.box;\n" "lg = M.link(lg, { box: f1.id, name: '결과 1' }, { box: f3.id, name: '' }).lg;\n" "lg = M.link(lg, { box: f4.id, name: '결과 2' }, { box: f3.id, name: '' }).lg;\n" "r = add(lg, 'function'); lg = r.lg; const f5 = r.box;\n" "lg = M.updateBox(lg, f5.id, { func: { ...f5.func, output: { ...f5.func.output, name: '결과 1' } } });\n" "lg = M.link(lg, { box: f5.id, name: '결과 1' }, { box: f3.id, name: '' }).lg;\n" "out([[f1, f3, f4, f5].map(b => [b.func.output.name, b.func.sheet.칸.A1.값])," " lg.boxes.find(b => b.id === f3.id).func.inputs.map(i => i.port)]);" ) names, ports = got assert names == [ ["결과 1", "결과 1"], ["결과 3", "결과 3"], ["결과 2", "결과 2"], ["결과 4", "결과 4"], ] assert ports == ["결과 1", "결과 2", "결과 1 (2)"]