"""M02 단가산출 다이어그램 Model · Eval 시험(35-7) — Node 로 `M02_DF_Model.ts` · `M02_DF_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" / "DataFlow" 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", "DataFlow", "M02_DF_Model.ts")); const E = loadTs(path.join(ROOT, "M02_MasterTemplete", "DataFlow", "M02_DF_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_DF_Model.ts", "M02_DF_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)"] # ── 흐름도 넓힘(35-9) — 견본 셋 ───────────────────────────────────────────── RULE = r""" const R = loadTs(path.join(ROOT, "M02_MasterTemplete", "DataFlow", "M02_DF_Rule.ts")); const rjoin = (lg, a, ap, b, bp, rule) => { const r = M.link(lg, { box: a.id, name: ap }, { box: b.id, name: bp }, rule); if (r.why) throw new Error(r.why); return r.lg; }; const vars = (lg, box, list) => M.updateBox(lg, box.id, { input: { mode: "variable", elements: [], variables: list } }); const num = (name, example, unit = "-") => ({ name, type: "number", unit, example }); """ # 견본 가 — 재료 조건 찾기: 관경(변수) → 흄관(규격에 관경 포함) 단가 · 수량 × 단가 → 재료비 · 거푸집 = 선택 자리 PIPE = ( RULE + r""" let lg = M.newLogic("흄관 조건 찾기"); let r = add(lg, "input"); lg = r.lg; const dia = r.box; lg = vars(lg, dia, [num("관경", 300, "㎜"), num("길이", 12, "m")]); r = add(lg, "input"); lg = r.lg; const mat = r.box; lg = M.updateBox(lg, mat.id, { input: { mode: "group", group: "재료", variables: [], elements: [ { ref: "", name: "흄관", spec: "", unit: "m", mode: "rule", slot: "흄관 단가", scope: { 구분: "관·수로", 상세구분: "흄관" }, rule: { field: "규격", op: "포함", from: { box: dia.id, name: "관경" } } }, { ref: "", name: "거푸집", spec: "", unit: "㎡", mode: "choice", slot: "거푸집 자리", default: "MT000123" } ] } }); r = add(lg, "function"); lg = r.lg; const f = r.box; lg = func(lg, f, { B9: { 식: "B1*B2" } }, [{ port: "길이", cell: "B1" }, { port: "흄관 단가", cell: "B2" }], { cell: "B9", name: "재료값" }); r = add(lg, "output"); lg = r.lg; const o = r.box; lg = M.updateBox(lg, o.id, { output: { cost: "재료비", unit: "원" } }); lg = join(lg, dia, "길이", f, "길이"); lg = join(lg, mat, "흄관 단가", f, "흄관 단가"); lg = join(lg, f, "재료값", o, "값"); """ ) def test_가_재료_조건_찾기_흐름() -> None: got = _run( PIPE + "const ok = E.evalLogic(lg, { [mat.id]: { '흄관 단가': 52000 } });\n" "const none = E.breaks(lg, {});\n" "out([M.portsOut(lg.boxes[1]).map(p => p.name), ok.cost.재료비, ok.arrived[f.id], none," " M.traceBack(lg, { box: o.id, name: '값' }), M.designInputs(lg).map(d => [d.kind, d.port])," " M.reaches(lg, dia.id, f.id)]);" ) pills, cost, arrived, none, back, designs, dep = got assert pills == ["흄관 단가", "거푸집 자리"] # 재료 자리 = 알약 이름 assert cost == 12 * 52000 and arrived == {"길이": 12, "흄관 단가": 52000} assert [(b["kind"], b["port"]) for b in none] == [("맞는 재료 없음", "흄관 단가")] assert none[0]["text"] == "「입력 2」 의 「흄관 단가」 — 조건에 맞는 재료가 없음" # 출력 값 ← 함수 재료값 ← 길이 · 흄관 단가 ← (조건) 관경 assert [(s["box"], s["port"], s["dir"]) for s in back] == [ ("b4", "값", "in"), ("b3", "재료값", "out"), ("b3", "길이", "in"), ("b1", "길이", "out"), ("b3", "흄관 단가", "in"), ("b2", "흄관 단가", "out"), ("b1", "관경", "out"), ] assert designs == [["변수", "관경"], ["변수", "길이"], ["재료 자리", "거푸집 자리"]] assert dep is True # 관경 → (재료 조건) → 함수 # 견본 나 — 조건 통과로 둘로 가르기(지반 토사 · 암) + 표에서 찾기 + 바꾸기 · 끝수 SPLIT = ( RULE + r""" let lg = M.newLogic("지반 가르기"); let r = add(lg, "input"); lg = r.lg; const v = r.box; lg = vars(lg, v, [{ name: "지반", type: "choice", unit: "-", choices: ["토사", "암"], example: "토사" }, num("높이", 4.5, "m"), num("품", 0.5, "인")]); r = add(lg, "input"); lg = r.lg; const t = r.box; lg = M.updateBox(lg, t.id, { input: { mode: "group", group: "소요량", elements: [], variables: [], table: { key: "QF000422" } } }); const fn = (out) => { r = add(lg, "function"); lg = r.lg; const b = r.box; lg = func(lg, b, { B9: { 식: "B1*100000" } }, [{ port: "가", cell: "B1" }], { cell: "B9", name: out }); return b; }; const soil = fn("토사 노임"), rock = fn("암 노임"); r = add(lg, "function"); lg = r.lg; const rate = r.box; lg = func(lg, rate, { B9: { 식: "B1" } }, [{ port: "증가율", cell: "B1" }], { cell: "B9", name: "증가율 값" }); r = add(lg, "output"); lg = r.lg; const o1 = r.box; r = add(lg, "output"); lg = r.lg; const o2 = r.box; r = add(lg, "output"); lg = r.lg; const o3 = r.box; lg = M.updateBox(lg, o3.id, { output: { cost: "값", unit: "%" } }); const gate = (value, op = "=") => ({ kind: "gate", from: { box: v.id, name: "지반" }, op, value }); lg = rjoin(lg, v, "품", soil, "가", gate("토사")); lg = rjoin(lg, v, "품", rock, "가", gate("암")); lg = rjoin(lg, t, "표", rate, "증가율", { kind: "lookup", conds: [{ col: "높이", from: { box: v.id, name: "높이" } }], col: "증가율" }); lg = rjoin(lg, soil, "토사 노임", o1, "값", { kind: "scale", op: "+%", value: 10 }); lg = rjoin(lg, rock, "암 노임", o2, "값", { kind: "round", places: -3, method: "버림" }); lg = join(lg, rate, "증가율 값", o3, "값"); const TABLE = [["높이·아래", "높이·위", "증가율"], [3, 4, 30], [4, 5.5, 40], [5.5, 7.5, 60]]; const V = (soilKind, h = 4.5) => ({ [v.id]: { 지반: soilKind, 높이: h }, [t.id]: { 표: TABLE } }); """ ) def test_나_조건_통과로_둘로_가르기_표에서_찾기() -> None: got = _run( SPLIT + "const a = E.evalLogic(lg, V('토사'));\n" "const b = E.evalLogic(lg, V('암', 3.5));\n" "out([a.outputs, a.breaks.map(x => [x.kind, x.link]), b.outputs, b.breaks.map(x => x.kind)," " E.breaks(lg, V('토사', 9)).map(x => [x.kind, x.text])]);" ) a_out, a_breaks, b_out, b_breaks, far = got # 토사: 0.5 × 100000 × 1.1 · 암 줄은 통과 안 됨 · 높이 4.5 → 증가율 40 assert a_out == {"b6": 55000, "b7": None, "b8": 40} assert a_breaks == [["통과 안 됨", "l2"], ["통과 안 됨", "l5"]] # 암 갈래 + 그 아래로 번진 쉼 assert b_out == { "b6": None, "b7": 50000, "b8": 30, } # 암 50,000 → 천 원 버림 그대로 · 높이 3.5 → 30 assert b_breaks == ["통과 안 됨", "통과 안 됨"] # 토사 갈래 + 번진 쉼 assert far == [ ["통과 안 됨", "「입력」 → 「함수 2」 선이 조건(지반 = 암 일 때만)을 통과하지 않음"], ["표에 없는 조건", "「입력 2」 → 「함수 3」 선 — 표에 맞는 줄이 없음"], ["통과 안 됨", "「함수 2」 → 「출력 2」 — 앞 조건이 안 맞아 쉬는 갈래"], ] def test_나_조건_선_겹침_안맞음() -> None: got = _run( SPLIT + "const l2 = lg.links[1].id;\n" "const overlap = M.setRule(lg, l2, { kind: 'gate', from: { box: v.id, name: '지반' }, op: '≠', value: '암' }).lg;\n" "out([E.breaks(overlap, V('토사')).map(x => [x.kind, x.text]), E.breaks(lg, V('자갈')).map(x => x.kind)]);" ) overlap, none = got assert overlap == [["조건 겹침", "「입력」 의 「지반」 에서 나간 조건 선이 둘 이상 통과함"]] assert none == ["통과 안 됨", "통과 안 됨", "통과 안 됨", "통과 안 됨", "조건 안 맞음"] def test_나_규칙_글과_엑셀_수식() -> None: got = _run( SPLIT + "out([lg.links.map(l => l.rule ? R.ruleLabel(l.rule) : null)," " lg.links.map(l => l.rule ? R.applyRule(l.rule, 1000, { 지반: '토사', 높이: 4.5 }).formula : null).slice(0, 2)," " R.applyRule({ kind: 'scale', op: '+%', value: 10 }, 200).formula, R.applyRule({ kind: 'scale', op: '÷', value: 4 }, 10).value," " R.applyRule({ kind: 'round', places: 1, method: '올림' }, 1.21).value, R.applyRound({ places: 0, method: '버림' }, 9.9)]);" ) labels, gates, scale_f, div, up, down = got assert labels == [ "지반 = 토사 일 때만", "지반 = 암 일 때만", "표에서 찾기 (높이 = 높이) → 증가율", "+10%", "소수 -3자리 버림", None, ] assert gates == ['IF(C1="토사",A1,NA())', 'IF(C1="암",A1,NA())'] assert scale_f == "A1*(1+10/100)" and div == 2.5 and up == 1.3 and down == 9 def test_나_흐름_따라가기_앞으로() -> None: got = _run( SPLIT + "out(M.traceForward(lg, { box: v.id, name: '지반' }).map(s => [s.box, s.port, s.dir, s.rule ? s.rule.kind : null]));" ) assert got == [ ["b1", "지반", "out", None], ["b3", "가", "in", "gate"], ["b3", "토사 노임", "out", None], ["b6", "값", "in", "scale"], ["b4", "가", "in", "gate"], ["b4", "암 노임", "out", None], ["b7", "값", "in", "round"], ] def test_규칙_조건_알약_없으면_막음_순환() -> None: got = _run( SPLIT + "const bad = M.link(lg, { box: v.id, name: '품' }, { box: rate.id, name: '' }, { kind: 'gate', from: { box: v.id, name: '없음' }, op: '=', value: 1 }).why;\n" "const loop = M.setRule(lg, lg.links[0].id, { kind: 'gate', from: { box: soil.id, name: '토사 노임' }, op: '>', value: 0 }).why;\n" "const off = M.setRule(lg, lg.links[3].id, null).lg.links[3];\n" "out([bad, loop, off]);" ) bad, loop, off = got assert bad == "규칙의 조건 알약이 없음" assert loop == "이으면 돌고 도는 선이 됨" assert "rule" not in off # 견본 다 — 다른 로직 잇기: 안쪽 로직(면적 × 계수 = 작업량 값) · 바깥 로직이 길이를 면적으로 넘기고 작업량을 받아 경비 LOGIC = ( RULE + r""" let inner = M.newLogic("작업량"); let r = add(inner, "input"); inner = r.lg; const ia = r.box; inner = vars(inner, ia, [num("면적", 1, "㎡"), num("계수", 3)]); r = add(inner, "function"); inner = r.lg; const ifn = r.box; inner = func(inner, ifn, { B9: { 식: "B1*B2" } }, [{ port: "면적", cell: "B1" }, { port: "계수", cell: "B2" }], { cell: "B9", name: "작업량 값" }); r = add(inner, "output"); inner = r.lg; const io = r.box; inner = M.updateBox(inner, io.id, { name: "작업량", output: { cost: "값", unit: "㎥/시간" } }); inner = join(inner, ia, "면적", ifn, "면적"); inner = join(inner, ia, "계수", ifn, "계수"); inner = join(inner, ifn, "작업량 값", inner.boxes.find(b => b.id === io.id), "값"); let lg = M.newLogic("바깥"); r = add(lg, "input"); lg = r.lg; const len = r.box; lg = vars(lg, len, [num("길이", 7, "m")]); r = add(lg, "input"); lg = r.lg; const other = r.box; lg = M.updateBox(lg, other.id, { input: { mode: "logic", elements: [], variables: [], logic: { key: "DF-0001", name: "작업량", outputs: [{ name: "작업량", unit: "㎥/시간" }], designs: M.designInputs(inner).map(d => ({ name: d.port, unit: d.unit })) } } }); r = add(lg, "function"); lg = r.lg; const f = r.box; lg = func(lg, f, { B9: { 식: "B1*1000" } }, [{ port: "작업량", cell: "B1" }], { cell: "B9", name: "경비 값" }); r = add(lg, "output"); lg = r.lg; const o = r.box; lg = M.updateBox(lg, o.id, { output: { cost: "경비", unit: "원" } }); lg = join(lg, len, "길이", other, "면적"); lg = join(lg, other, "작업량", f, "작업량"); lg = join(lg, f, "경비 값", o, "값"); const LOGICS = { "DF-0001": inner }; """ ) def test_다_다른_로직_잇기() -> None: got = _run( LOGIC + "const a = E.evalLogic(lg, {}, { logics: LOGICS });\n" "const b = E.evalLogic(lg, { [len.id]: { 길이: 2 }, [other.id]: { 계수: 10 } }, { logics: LOGICS });\n" "const c = E.evalLogic(lg, { [other.id]: { 작업량: 5 } });\n" "out([M.portsIn(lg.boxes[1]).map(p => p.name), M.portsOut(lg.boxes[1]).map(p => p.name), a.ports[other.id], a.cost.경비," " b.cost.경비, c.cost.경비, M.designInputs(lg).map(d => [d.kind, d.port])," " M.link(lg, { box: len.id, name: '길이' }, { box: other.id, name: '없음' }).why," " M.traceBack(lg, { box: o.id, name: '값' }).map(s => [s.box, s.port]), E.evalLogic(lg, {}, { logics: {} }).errors]);" ) ins, outs, ports, a, b, c, designs, no_pill, back, missing = got assert ins == ["면적", "계수"] and outs == ["작업량"] assert ports == {"작업량": 21} # 길이 7 → 면적 · 계수 예시값 3 assert (a, b, c) == (21000, 20000, 5000) assert designs == [["변수", "길이"], ["다른 로직", "계수"]] # 면적은 선이 있어 빠짐 assert no_pill == "알약이 없음" assert back == [ ["b4", "값"], ["b3", "경비 값"], ["b3", "작업량"], ["b2", "작업량"], ["b2", "면적"], ["b1", "길이"], ["b2", "계수"], ] assert missing == ["「입력 2」 이 부르는 로직을 찾을 수 없음"] def test_끝수_함수_출력_출력_상자() -> None: got = _run( ONE + "lg = M.updateBox(lg, f.id, { func: { ...lg.boxes[2].func, output: { ...lg.boxes[2].func.output, round: { places: -3, method: '버림' } } } });\n" "const a = E.evalLogic(lg, V).cost.노무비;\n" "lg = M.updateBox(lg, o.id, { output: { cost: '노무비', unit: '원', round: { places: -4, method: '올림' } } });\n" "out([a, E.evalLogic(lg, V).cost.노무비]);" ) assert got == [537000, 540000] def test_다_다른_로직_받는_알약_예시값_차례() -> None: got = _run( LOGIC + "lg = M.updateBox(lg, other.id, { input: { ...lg.boxes[1].input, logic: { ...lg.boxes[1].input.logic, examples: { 계수: 10, 면적: 99 } } } });\n" "const a = E.evalLogic(lg, {}, { logics: LOGICS }).cost.경비;\n" "const b = E.evalLogic(lg, { [other.id]: { 계수: 2 } }, { logics: LOGICS }).cost.경비;\n" "const noEx = M.updateBox(lg, other.id, { input: { ...lg.boxes[1].input, logic: { ...lg.boxes[1].input.logic, examples: undefined } } });\n" "out([a, b, E.evalLogic(noEx, {}, { logics: LOGICS }).cost.경비," " M.designInputs(lg).map(d => [d.kind, d.port, d.example]), M.designInputs(noEx).map(d => d.example)]);" ) a, b, c, designs, plain = got # 면적 = 이어진 선(길이 7) > 예시값 99 · 계수 = 상자 예시값 10 · 넘긴 값 2 가 이김 · 없으면 그 로직 예시값 3 assert (a, b, c) == (70000, 14000, 21000) assert designs == [["변수", "길이", 7], ["다른 로직", "계수", 10]] assert plain == [7, None] def test_표_범위칸_숫자하나는_같음() -> None: got = _run( RULE + "const T = [['높이·아래', '높이·위', '석공'], [4, 4, 0.5], [4, 6, 0.6], [null, 3, 0.4]];\n" "const rule = { kind: 'lookup', conds: [{ col: '높이', from: { box: 'b1', name: '높이' } }], col: '석공' };\n" "out([4, 5, 2, 3.5, 6].map(h => { const r = R.applyRule(rule, T, { 높이: h }); return [r.value, r.why]; }));" ) # 4 = 「4」 줄만(같음) · 4 는 「4~6」(초과) 에 안 듦 · 빈 아래 끝은 열림 assert got == [[0.5, None], [0.6, None], [0.4, None], [None, "표에 없는 조건"], [0.6, None]] def test_쉬는_갈래와_문제_무게() -> None: got = _run( SPLIT + "const a = E.breaks(lg, V('토사'));\n" "const b = E.breaks(lg, V('자갈', 9));\n" "out([a.map(x => [x.kind, x.level, x.box, x.text]), b.filter(x => x.level === '문제').map(x => x.kind)," " E.evalLogic(lg, V('토사')).outputs]);" ) a, b_problems, outs = got assert a == [ [ "통과 안 됨", "쉼", "b4", "「입력」 → 「함수 2」 선이 조건(지반 = 암 일 때만)을 통과하지 않음", ], ["통과 안 됨", "쉼", "b7", "「함수 2」 → 「출력 2」 — 앞 조건이 안 맞아 쉬는 갈래"], ] # 하나도 안 맞음 · 표에 없는 조건만 문제 — 쉬는 갈래 넷은 문제로 안 셈 assert b_problems == ["표에 없는 조건", "조건 안 맞음"] assert outs["b7"] is None # 쉬는 갈래 끝 출력은 빈 값(0 아님) def _ground() -> dict: return json.loads((CF / "_sample" / "ground.json").read_text(encoding="utf-8")) def _server(lg: dict, values: dict) -> dict: from M02_MasterTemplete.DataFlow import M02_DF_Compute as cp return cp.run(lg, values, lambda key: {}, []) def test_만나는_상자는_살아_있는_선이_있으면_셈_서버와_같음() -> None: lg = _ground() soil = {"in-ground": {"지반": "토사"}} rock = {"in-ground": {"지반": "암"}} got = _run( f"const lg = {json.dumps(lg, ensure_ascii=False)};\n" f"const s = E.evalLogic(lg, {json.dumps(soil, ensure_ascii=False)});\n" f"const r = E.evalLogic(lg, {json.dumps(rock, ensure_ascii=False)});\n" "out([s.outputs, s.breaks.map(b => [b.kind, b.level, b.link]), r.outputs, r.breaks.map(b => [b.kind, b.level, b.link])," " s.ports['fn-sum'], r.ports['fn-sum']]);" ) s_out, s_breaks, r_out, r_breaks, s_sum, r_sum = got assert s_out == {"out-exp": 2000} # 굴삭기 갈래만 · 브레이커 갈래는 쉼(빈 칸) assert s_breaks == [["통과 안 됨", "쉼", "l2"], ["통과 안 됨", "쉼", "l6"]] assert [b for b in s_breaks if b[1] == "문제"] == [] assert r_breaks == [["통과 안 됨", "쉼", "l1"], ["통과 안 됨", "쉼", "l5"]] assert r_out["out-exp"] == r_sum["1㎥ 경비"] and r_out["out-exp"] != 2000 # 브레이커 갈래 값 assert s_sum == {"1㎥ 경비": 2000} # 서버(Compute) = 화면(Eval) for values, screen_out, screen_breaks in ((soil, s_out, s_breaks), (rock, r_out, r_breaks)): srv = _server(lg, values) assert {k: float(v) for k, v in srv["outputs"].items()} == { k: float(v) for k, v in screen_out.items() } assert [[b["kind"], b["level"], b["link"]] for b in srv["breaks"]] == screen_breaks # ── 36-0b 선 조건 여러 줄(그리고 · 또는) · 값 목록 ───────────────────────────── def test_조건_여러_줄_그리고_또는_글_식_서버같음() -> None: lg = _ground() two = { "kind": "gate", "conds": [ {"from": {"box": "in-ground", "name": "지반"}, "op": "=", "value": "토사"}, {"from": {"box": "in-ground", "name": "굴착량"}, "op": "≥", "value": 50}, ], "join": "그리고", "from": {"box": "in-ground", "name": "지반"}, "op": "=", "value": "토사", } either = {**two, "join": "또는"} cases = [ (two, {"in-ground": {"지반": "토사", "굴착량": 100}}), (two, {"in-ground": {"지반": "토사", "굴착량": 10}}), (either, {"in-ground": {"지반": "암", "굴착량": 100}}), (either, {"in-ground": {"지반": "암", "굴착량": 10}}), ] got = _run( RULE + f"const base = {json.dumps(lg, ensure_ascii=False)};\n" + f"const cases = {json.dumps(cases, ensure_ascii=False)};\n" "out(cases.map(([rule, vals]) => {\n" " const lg = { ...base, links: base.links.map(l => l.id === 'l1' ? { ...l, rule } : l) };\n" " const ev = E.evalLogic(lg, vals);\n" " return [R.ruleLabel(rule), R.applyRule(rule, 1, { 지반: vals['in-ground'].지반, 굴착량: vals['in-ground'].굴착량 }).formula," " ev.ports['fn-exc'], ev.breaks.filter(b => b.link === 'l1').map(b => b.kind)];\n" "}));" ) labels = [g[0] for g in got] assert labels[0] == "지반 = 토사 그리고 굴착량 ≥ 50 일 때만" assert labels[2] == "지반 = 토사 또는 굴착량 ≥ 50 일 때만" assert got[0][1] == 'IF(AND(C1="토사",D1>=50),A1,NA())' assert got[2][1] == 'IF(OR(C1="토사",D1>=50),A1,NA())' passed = [g[3] == [] for g in got] assert passed == [True, False, True, False] # 서버(Compute) = 화면(Eval) for (rule, values), (_, _, ports, breaks) in zip(cases, got): srv_lg = { **lg, "links": [{**ln, "rule": rule} if ln["id"] == "l1" else ln for ln in lg["links"]], } srv = _server(srv_lg, values) exc = srv["ports"].get("fn-exc") or {} assert {k: (None if v is None else float(v)) for k, v in exc.items()} == { k: (None if v is None else float(v)) for k, v in ports.items() } assert [b["kind"] for b in srv["breaks"] if b.get("link") == "l1"] == breaks def test_옛_한_줄_꼴과_makeGate() -> None: got = _run( RULE + "const old = { kind: 'gate', from: { box: 'b1', name: '지반' }, op: '=', value: '토사' };\n" "const made = M.makeGate([{ from: { box: 'b1', name: '지반' }, op: '≠', value: '암' }, { from: { box: 'b1', name: '높이' }, op: '<', value: 3 }], '또는');\n" "out([M.gateConds(old), R.ruleLabel(old), made.from, made.op, made.value, M.ruleSources(made).map(p => p.name)," " R.applyRule(made, 7, { 지반: '암', 높이: 2 }).value, R.applyRule(made, 7, { 지반: '암', 높이: 5 }).why]);" ) conds, label, first_from, first_op, first_value, sources, ok, blocked = got assert conds == { "conds": [{"from": {"box": "b1", "name": "지반"}, "op": "=", "value": "토사"}], "join": "그리고", } assert label == "지반 = 토사 일 때만" assert (first_from, first_op, first_value) == ({"box": "b1", "name": "지반"}, "≠", "암") assert sources == ["지반", "높이"] assert (ok, blocked) == (7, "통과 안 됨") def test_값_목록() -> None: got = _run( PIPE + "lg = M.updateBox(lg, dia.id, { input: { ...lg.boxes[0].input, variables: [...lg.boxes[0].input.variables," " { name: '지반', type: 'choice', unit: '-', choices: ['토사', '암'], example: '토사' }] } });\n" "lg = M.updateBox(lg, mat.id, { input: { ...lg.boxes[1].input, elements: lg.boxes[1].input.elements.map((e, i) => i === 1 ? { ...e, spec: '12mm' } : e) } });\n" "r = add(lg, 'input'); lg = r.lg; const tb = r.box;\n" "lg = M.updateBox(lg, tb.id, { input: { mode: 'group', group: '소요량', elements: [], variables: [], table: { key: 'QF1' } } });\n" "const vals = { [tb.id]: { 표: [['돌', '석공'], ['깬돌', 0.2], ['견치돌', 0.3], ['깬돌', 0.25]] } };\n" "const v = (box, name, opts) => M.valueChoices(lg, { box, name }, opts);\n" "out([v(dia.id, '지반'), v(dia.id, '관경'), v(mat.id, '거푸집 자리'), v(mat.id, '흄관 단가')," " v(tb.id, '표', { col: '돌', values: vals }), v(tb.id, '표', { col: '없음', values: vals }), v(tb.id, '표')," " v(f.id, '재료값'), v('없는 상자', '지반')]);" ) assert got == [["토사", "암"], None, ["12mm"], None, ["깬돌", "견치돌"], None, None, None, None] def test_조건_여러_줄_겹침_안맞음() -> None: got = _run( SPLIT + "const both = (op1, v1) => M.makeGate([{ from: { box: v.id, name: '지반' }, op: op1, value: v1 }, { from: { box: v.id, name: '높이' }, op: '≥', value: 3 }]);\n" "let g = M.setRule(lg, lg.links[0].id, both('=', '토사')).lg;\n" "g = M.setRule(g, lg.links[1].id, both('≠', '암')).lg;\n" "const over = E.breaks(g, V('토사')).filter(b => b.level === '문제').map(b => b.kind);\n" "const none = E.breaks(g, V('암', 2)).filter(b => b.level === '문제').map(b => b.kind);\n" "out([over, none]);" ) over, none = got assert "조건 겹침" in over assert "조건 안 맞음" in none