- 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
612 lines
31 KiB
Python
612 lines
31 KiB
Python
"""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]
|