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