Files
Aislo/resources/tester/test_m02_df_model.py
T
eomsangdonandClaude Opus 5.5 dad45e997b feat(m02-df): 다른 로직 입력 받는 알약 예시값 자리
- CfLogicRef.examples(받는 알약 이름 → 값) · Eval 값 차례 = 이어진 선 > 넘긴 값 > 상자 예시값 > 그 로직 자기 예시값
- CfDesign.example — 변수 = variable.example · 다른 로직 = examples(없으면 null)
- 시험 23개

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

612 lines
31 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_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"]]
assert b_out == {
"b6": None,
"b7": 50000,
"b8": 30,
} # 암 50,000 → 천 원 버림 그대로 · 높이 3.5 → 30
assert b_breaks == ["통과 안 됨"]
assert far == [
["통과 안 됨", "「입력」 → 「함수 2」 선이 조건(지반 = 암 일 때만)을 통과하지 않음"],
["표에 없는 조건", "「입력 2」 → 「함수 3」 선 — 표에 맞는 줄이 없음"],
]
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]