From 03736cba181cb946d5230ac7cae1a4419a9fb9e6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 19:47:53 +0900 Subject: [PATCH] =?UTF-8?q?feat(m02-df):=20=ED=9D=90=EB=A6=84=EB=8F=84=20M?= =?UTF-8?q?odel=20=C2=B7=20Eval=20=EB=84=93=ED=9E=98=20=E2=80=94=20?= =?UTF-8?q?=EC=84=A0=20=EA=B7=9C=EC=B9=99=20=C2=B7=20=EC=9E=AC=EB=A3=8C=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=C2=B7=20=EB=8B=A4=EB=A5=B8=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=C2=B7=20=EB=81=9D=EC=88=98=20=C2=B7=20=ED=9D=90?= =?UTF-8?q?=EB=A6=84=20=EB=94=B0=EB=9D=BC=EA=B0=80=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EB=81=8A=EA=B8=B4=20=EA=B3=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 선 규칙(lookup · gate · scale · round)을 엑셀 수식으로 바꿔 엔진 한 벌로 셈(M02_DF_Rule) · 선 위 규칙 글 · 막힌 갈래는 빈 값 - 재료 요소 고정 · 선택 자리 · 조건 찾기(알약 이름 = 자리) · 다른 로직 입력(받는 알약 = 설계 때 정할 것 · 그 로직으로 셈) - 끝수 func.output.round · output.round · 기댐(선 · 규칙 조건 · 재료 조건)으로 차례 · 순환 막음 · setRule - traceBack · traceForward · designInputs · breaks(빈 받는 자리 · 통과 안 됨 · 표에 없는 조건 · 맞는 재료 없음 · 조건 겹침 · 조건 안 맞음 · 오류) - 시험 test_m02_df_model.py 22개(견본 셋 · 규칙 수식 · 흐름 · 끊긴 곳 · 설계 때 정할 것) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk --- M02_MasterTemplete/DataFlow/M02_DF_Eval.ts | 350 +++++++++++++++----- M02_MasterTemplete/DataFlow/M02_DF_Model.ts | 221 ++++++++++-- M02_MasterTemplete/DataFlow/M02_DF_Rule.ts | 199 ++++++++++- resources/tester/test_m02_df_model.py | 246 ++++++++++++++ 4 files changed, 900 insertions(+), 116 deletions(-) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts b/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts index f471e0145..cb0516e43 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts @@ -1,7 +1,9 @@ /* ============================================================================= * M02_DF_Eval.ts - * 단가산출 다이어그램 계산 · 검사 — 함수 상자는 차례대로 시트를 우리 스프레드시트 엔진으로 셈 - * (받는 칸에 앞 값을 넣음) · 출력 상자를 비용 종류별로 모아 원가 · DOM 없음 + * 단가산출 흐름도 계산 · 검사 — 상자를 기댐 차례대로(입력 → 선 규칙 → 함수 · 다른 로직 → 출력) 셈 + * 함수 상자 = 시트를 우리 스프레드시트 엔진으로 셈(받는 칸에 앞 값) · 선 규칙 · 끝수도 같은 엔진(`M02_DF_Rule`) + * 출력 상자를 비용 종류별로 모아 원가 · 끊긴 곳(빈 받는 자리 · 통과 안 됨 · 표에 없는 조건 · 맞는 재료 없음 · + * 조건 겹침 · 조건 안 맞음 · 오류) · DOM 없음 * 엔진을 정적으로 가져옴 — 화면(Entry)은 DataFlow 를 import() 로 늦게 불러 첫 묶음에 안 들어가게 * ========================================================================== */ @@ -9,19 +11,50 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { parseA1, toA1 } from "../../A00_Common/spreadsheet/spreadsheet_address"; import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc"; import type { CalcValue, Cell, Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types"; -import { OUTPUT_PORT, portsIn, portsOut, type CfBox, type CfLogic } from "./M02_DF_Model"; +import { + OUTPUT_PORT, + depends, + designInputs, + elementPort, + portsIn, + portsOut, + ruleSources, + type CfBox, + type CfLink, + type CfLogic, +} from "./M02_DF_Model"; +import { applyRound, applyRule, ruleLabel } from "./M02_DF_Rule"; /** 알약 값 — 수 · 글 · 참거짓 · 빈 값 · 표(머리 줄 포함 칸 덩어리) */ export type CfValue = number | string | boolean | null | (number | string | null)[][]; -/** 입력 값 묶음 — 상자 id → 알약 이름 → 값(입력 상자 요소 값 · 표 · 변수 값 · 함수 상자 변수 값) */ +/** 입력 값 묶음 — 상자 id → 알약 이름 → 값(입력 상자 요소 값 · 표 · 변수 값 · 함수 상자 변수 값 · 다른 로직 출력) */ export type CfValues = Record>; type Scalar = number | string | boolean | null; +/** 끊긴 곳 하나 — 상자 · 알약 · 선 위에 표시할 것 */ +export interface CfBreak { + kind: + | "빈 받는 자리" + | "통과 안 됨" + | "표에 없는 조건" + | "맞는 재료 없음" + | "조건 겹침" + | "조건 안 맞음" + | "오류"; + box: string; + port?: string; + link?: string; + /** 쉬운 말 */ + text: string; +} + export interface CfEvalResult { /** 상자 id → 내보내는 알약 이름 → 값 */ ports: Record>; + /** 상자 id → 받는 알약 이름 → 선 규칙을 지나 들어온 값 */ + arrived?: Record>; /** 출력 상자 id → 값 */ outputs: Record; cost: { @@ -34,6 +67,14 @@ export interface CfEvalResult { }; /** 쉬운 말 */ errors: string[]; + /** 끊긴 곳(셈 때 생긴 것) */ + breaks?: CfBreak[]; +} + +/** 다른 로직 입력을 셀 때 쓸 로직들(저장 · 견본 key → 로직) */ +export interface CfEvalOptions { + logics?: Record; + depth?: number; } const TEXT = { @@ -58,6 +99,40 @@ const TEXT = { "Pill 「{pill}」 of 「{box}」 is not placed in a cell", ], NoValue: ["「{box}」 의 「{pill}」 값이 아직 없음", "「{pill}」 of 「{box}」 has no value yet"], + /** {from} {box} {rule} */ + NotPassed: [ + "「{from}」 → 「{box}」 선이 조건({rule})을 통과하지 않음", + "Line 「{from}」 → 「{box}」 doesn't pass ({rule})", + ], + NoRow: [ + "「{from}」 → 「{box}」 선 — 표에 맞는 줄이 없음", + "Line 「{from}」 → 「{box}」 — no matching table row", + ], + ManyRows: [ + "「{from}」 → 「{box}」 선 — 표에 맞는 줄이 여럿", + "Line 「{from}」 → 「{box}」 — several table rows match", + ], + RuleError: [ + "「{from}」 → 「{box}」 선 규칙을 셀 수 없음", + "Line 「{from}」 → 「{box}」 rule can't be worked out", + ], + /** {box} {pill} */ + NoMaterial: [ + "「{box}」 의 「{pill}」 — 조건에 맞는 재료가 없음", + "「{pill}」 of 「{box}」 — no material matches", + ], + Overlap: [ + "「{box}」 의 「{pill}」 에서 나간 조건 선이 둘 이상 통과함", + "Several condition lines from 「{pill}」 of 「{box}」 pass", + ], + NoneMatch: [ + "「{box}」 의 「{pill}」 에서 나간 조건 선이 하나도 통과하지 않음", + "No condition line from 「{pill}」 of 「{box}」 passes", + ], + NoLogic: [ + "「{box}」 이 부르는 로직을 찾을 수 없음", + "The logic used by 「{box}」 can't be found", + ], } as const; const tl = (key: keyof typeof TEXT, fill: Record = {}): string => @@ -68,23 +143,22 @@ const tl = (key: keyof typeof TEXT, fill: Record = {}): string = /* ── 차례 ───────────────────────────────────────────────────────────────── */ -/** 함수 상자 차례 — 앞 함수가 먼저 · 돌고 도는 상자는 loop 로 */ -function order(lg: CfLogic): { funcs: CfBox[]; loop: CfBox[] } { - const funcs = lg.boxes.filter((b) => b.kind === "function"); +/** 상자 차례 — 기댐(선 · 규칙 조건 · 재료 조건)대로 · 돌고 도는 상자는 loop */ +function order(lg: CfLogic): { boxes: CfBox[]; loop: CfBox[] } { + const deps = depends(lg); const done: CfBox[] = []; - const left = new Set(funcs.map((f) => f.id)); + const left = new Set(lg.boxes.map((b) => b.id)); for (let moved = true; moved && left.size;) { moved = false; - for (const f of funcs) { - if (!left.has(f.id)) continue; - const waits = lg.links.some((l) => l.to.box === f.id && left.has(l.from.box)); - if (waits) continue; - done.push(f); - left.delete(f.id); + for (const b of lg.boxes) { + if (!left.has(b.id)) continue; + if (deps.some((d) => d.to === b.id && d.from !== b.id && left.has(d.from))) continue; + done.push(b); + left.delete(b.id); moved = true; } } - return { funcs: done, loop: funcs.filter((f) => left.has(f.id)) }; + return { boxes: done, loop: lg.boxes.filter((b) => left.has(b.id)) }; } /* ── 칸 ─────────────────────────────────────────────────────────────────── */ @@ -113,16 +187,18 @@ const fromCalc = (v: CalcValue | undefined): Scalar => { return v.오류; }; -/* ── 계산 ───────────────────────────────────────────────────────────────── */ +/* ── 상자마다 ───────────────────────────────────────────────────────────── */ /** 입력 상자 알약 값 — 넘긴 값 · 없으면 변수 예시값 · 사용자 요소 값 */ -function inputPorts(lg: CfLogic, box: CfBox, values: CfValues): Record { - const given = values[box.id] ?? {}; - const pills = portsOut(box); +function inputPorts( + lg: CfLogic, + box: CfBox, + given: Record, +): Record { const out: Record = {}; const inp = box.input; if (!inp) return out; - // portsOut 차례 = 요소 · 표 · 변수 + const pills = portsOut(box); const sources: CfValue[] = [ ...(inp.mode === "group" ? inp.elements.map((e) => lg.userElements.find((u) => u.id === e.ref)?.value ?? null) @@ -134,16 +210,16 @@ function inputPorts(lg: CfLogic, box: CfBox, values: CfValues): Record, + arrive: Record, given: Record, errors: string[], ): Scalar { const func = box.func!; const sheet: Sheet = structuredClone(func.sheet); - for (const i of func.inputs) put(sheet.칸, i.cell, incoming[i.port] ?? given[i.port] ?? null); + for (const i of func.inputs) put(sheet.칸, i.cell, arrive[i.port] ?? given[i.port] ?? null); for (const v of func.variables) if (v.cell) put(sheet.칸, v.cell, v.name in given ? given[v.name] : v.example); const values = recalcWorkbook({ 종류: "통합문서", 판: 1, 열: "DF", 서식: [{}], 시트: [sheet] }); @@ -154,79 +230,203 @@ function runFunc( } const at = func.output.cell.toUpperCase(); const c = sheet.칸[at]; - if (!c) return null; - return c.식 !== undefined ? fromCalc(values[sheet.id]?.[at]) : (c.값 ?? null); + const got = !c ? null : c.식 !== undefined ? fromCalc(values[sheet.id]?.[at]) : (c.값 ?? null); + return typeof got === "number" && func.output.round ? applyRound(func.output.round, got) : got; } -/** 화면 계산 */ -export function evalLogic(lg: CfLogic, values: CfValues = {}): CfEvalResult { +/* ── 계산 ───────────────────────────────────────────────────────────────── */ + +/** 화면 계산 — opts.logics 가 있으면 다른 로직 입력을 그 로직으로 셈(없으면 values 의 값) */ +export function evalLogic( + lg: CfLogic, + values: CfValues = {}, + opts: CfEvalOptions = {}, +): CfEvalResult { const errors: string[] = []; + const found: CfBreak[] = []; const ports: CfEvalResult["ports"] = {}; - for (const b of lg.boxes) if (b.kind === "input") ports[b.id] = inputPorts(lg, b, values); - const into = (box: string): Record => - Object.fromEntries( - lg.links - .filter((l) => l.to.box === box) - .map((l) => [l.to.name, ports[l.from.box]?.[l.from.name] ?? null]), + const arrived: NonNullable = {}; + const name = (id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? id; + const valueOf = (box: string, pill: string): CfValue => ports[box]?.[pill] ?? null; + const passed = new Map(); + /** 선 규칙에서 막힌 상자 — 그 함수는 셈하지 않고 빈 값을 넘김(갈래가 끊김) */ + const blocked = new Set(); + /** 선 하나로 들어오는 값 — 규칙이 있으면 엔진으로 */ + const through = (l: CfLink): CfValue => { + const v = valueOf(l.from.box, l.from.name); + if (!l.rule) return v; + if (v === null && (l.rule.kind === "scale" || l.rule.kind === "round")) return null; + const conds = Object.fromEntries( + ruleSources(l.rule).map((p) => [p.name, valueOf(p.box, p.name)]), ); - const { funcs, loop } = order(lg); - for (const f of loop) errors.push(tl("Loop", { box: f.name })); - for (const f of funcs) - ports[f.id] = { [f.func!.output.name]: runFunc(f, into(f.id), values[f.id] ?? {}, errors) }; + const res = applyRule(l.rule, v, conds); + if (l.rule.kind === "gate") passed.set(l.id, res.why === null); + const fill = { from: name(l.from.box), box: name(l.to.box), rule: ruleLabel(l.rule) }; + if (res.why === "통과 안 됨") + found.push({ + kind: "통과 안 됨", + box: l.to.box, + port: l.to.name, + link: l.id, + text: tl("NotPassed", fill), + }); + else if (res.why) { + const kind = res.why === "표에 없는 조건" ? "표에 없는 조건" : "오류"; + const key = + res.why === "표에 없는 조건" + ? "NoRow" + : res.why === "표에 여럿 맞음" + ? "ManyRows" + : "RuleError"; + const text = tl(key, fill); + found.push({ kind, box: l.to.box, port: l.to.name, link: l.id, text }); + errors.push(text); + } + if (res.why) blocked.add(l.to.box); + return res.why ? null : res.value; + }; + const arrive = (box: CfBox): Record => { + const got = Object.fromEntries( + lg.links.filter((l) => l.to.box === box.id).map((l) => [l.to.name, through(l)]), + ); + arrived[box.id] = got; + return got; + }; + const { boxes, loop } = order(lg); + for (const b of loop) errors.push(tl("Loop", { box: b.name })); const outputs: CfEvalResult["outputs"] = {}; const cost: CfEvalResult["cost"] = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 }; - for (const b of lg.boxes.filter((x) => x.kind === "output")) { - const v = into(b.id)[OUTPUT_PORT] ?? null; - const scalar: Scalar = Array.isArray(v) ? null : v; - outputs[b.id] = scalar; - const kind = b.output?.cost ?? "값"; - if (kind === "값") cost.값들[b.name] = scalar; - else if (typeof scalar === "number" && Number.isFinite(scalar)) cost[kind] += scalar; - else if (scalar !== null) errors.push(tl("NotNumber", { box: b.name })); + for (const b of boxes) { + const given = values[b.id] ?? {}; + if (b.kind === "input" && b.input?.mode !== "logic") { + ports[b.id] = inputPorts(lg, b, given); + for (const e of b.input?.elements ?? []) + if (e.mode === "rule" && ports[b.id][elementPort(e)] === null) { + const text = tl("NoMaterial", { box: b.name, pill: elementPort(e) }); + found.push({ kind: "맞는 재료 없음", box: b.id, port: elementPort(e), text }); + errors.push(text); + } + } else if (b.kind === "input") ports[b.id] = otherLogic(b, arrive(b), given, opts, errors); + else if (b.kind === "function") { + const got = arrive(b); + ports[b.id] = { + [b.func!.output.name]: blocked.has(b.id) ? null : runFunc(b, got, given, errors), + }; + } else { + const v = arrive(b)[OUTPUT_PORT] ?? null; + let scalar: Scalar = Array.isArray(v) ? null : v; + const round = b.output?.round; + if (typeof scalar === "number" && round) scalar = applyRound(round, scalar); + outputs[b.id] = scalar; + const kind = b.output?.cost ?? "값"; + if (kind === "값") cost.값들[b.name] = scalar; + else if (typeof scalar === "number" && Number.isFinite(scalar)) cost[kind] += scalar; + else if (scalar !== null) errors.push(tl("NotNumber", { box: b.name })); + } } cost.계 = cost.노무비 + cost.재료비 + cost.경비; - return { ports, outputs, cost, errors }; + gates(lg, passed, name, found, errors); + return { ports, arrived, outputs, cost, errors, breaks: found }; } -/** 검사 — 빈 받는 자리 · 출력 칸 없음 · 출력 상자 없음 · 값 없는 입력 · 순환 · 오류(쉬운 말 목록) */ -export function checkLogic(lg: CfLogic, values: CfValues = {}): string[] { - const out: string[] = []; +/** 다른 로직 입력 — 그 로직을 받은 값(설계 때 정할 것)으로 셈 · 없으면 넘긴 값 */ +function otherLogic( + box: CfBox, + arrive: Record, + given: Record, + opts: CfEvalOptions, + errors: string[], +): Record { + const ref = box.input?.logic; + const out: Record = {}; + const other = ref ? opts.logics?.[ref.key] : undefined; + if (!other || (opts.depth ?? 0) > 8) { + if (ref && opts.logics && !other) errors.push(tl("NoLogic", { box: box.name })); + for (const p of ref?.outputs ?? []) out[p.name] = given[p.name] ?? null; + return out; + } + const inner: CfValues = {}; + for (const d of designInputs(other)) { + const v = d.port in arrive ? arrive[d.port] : given[d.port]; + if (v !== undefined && v !== null) (inner[d.box] ??= {})[d.port] = v; + } + const ev = evalLogic(other, inner, { logics: opts.logics, depth: (opts.depth ?? 0) + 1 }); + for (const e of ev.errors) errors.push(`「${box.name}」 · ${e}`); + for (const p of ref?.outputs ?? []) { + const ob = other.boxes.find((b) => b.kind === "output" && b.name === p.name); + out[p.name] = ob ? (ev.outputs[ob.id] ?? null) : (given[p.name] ?? null); + } + return out; +} + +/** 한 알약에서 나간 조건 선(둘 이상) — 겹침 · 안 맞음 */ +function gates( + lg: CfLogic, + passed: Map, + name: (id: string) => string, + found: CfBreak[], + errors: string[], +): void { + const groups = new Map(); + for (const l of lg.links) + if (l.rule?.kind === "gate") { + const k = `${l.rule.from.box}|${l.rule.from.name}|${l.from.box}|${l.from.name}`; + groups.set(k, [...(groups.get(k) ?? []), l]); + } + for (const list of groups.values()) { + if (list.length < 2) continue; + const n = list.filter((l) => passed.get(l.id)).length; + if (n === 1) continue; + const g = list[0].rule as Extract; + const fill = { box: name(g.from.box), pill: g.from.name }; + const text = tl(n > 1 ? "Overlap" : "NoneMatch", fill); + found.push({ + kind: n > 1 ? "조건 겹침" : "조건 안 맞음", + box: g.from.box, + port: g.from.name, + text, + }); + errors.push(text); + } +} + +/* ── 검사 ───────────────────────────────────────────────────────────────── */ + +/** 끊긴 곳 목록 — 빈 받는 자리(다른 로직의 받는 알약은 설계 때 정할 것이라 뺌) + 셈 때 생긴 것 */ +export function breaks(lg: CfLogic, values: CfValues = {}, opts: CfEvalOptions = {}): CfBreak[] { + const out: CfBreak[] = []; for (const b of lg.boxes) { + if (b.kind === "input") continue; for (const p of portsIn(b)) if (!lg.links.some((l) => l.to.box === b.id && l.to.name === p.name)) - out.push(tl("EmptyIn", { box: b.name, pill: p.name })); + out.push({ + kind: "빈 받는 자리", + box: b.id, + port: p.name, + text: tl("EmptyIn", { box: b.name, pill: p.name }), + }); + } + return [...out, ...(evalLogic(lg, values, opts).breaks ?? [])]; +} + +/** 검사 — 빈 받는 자리 · 출력 칸 없음 · 칸에 안 놓인 받는 알약 · 출력 상자 없음 · 값 없는 입력 · 순환 · 오류(쉬운 말 목록) */ +export function checkLogic(lg: CfLogic, values: CfValues = {}, opts: CfEvalOptions = {}): string[] { + const out: string[] = []; + for (const b of lg.boxes) { + if (b.kind !== "input") + for (const p of portsIn(b)) + if (!lg.links.some((l) => l.to.box === b.id && l.to.name === p.name)) + out.push(tl("EmptyIn", { box: b.name, pill: p.name })); if (b.kind === "function" && !parseA1(b.func?.output.cell ?? "")) out.push(tl("NoCell", { box: b.name })); for (const i of b.func?.inputs ?? []) if (!parseA1(i.cell)) out.push(tl("NoInCell", { box: b.name, pill: i.port })); } if (!lg.boxes.some((b) => b.kind === "output")) out.push(tl("NoOutput")); - const ev = evalLogic(lg, values); - for (const b of lg.boxes.filter((x) => x.kind === "input")) + const ev = evalLogic(lg, values, opts); + for (const b of lg.boxes.filter((x) => x.kind === "input" && x.input?.mode !== "logic")) for (const [pill, v] of Object.entries(ev.ports[b.id] ?? {})) if (v === null && lg.links.some((l) => l.from.box === b.id && l.from.name === pill)) out.push(tl("NoValue", { box: b.name, pill })); - return [...out, ...ev.errors]; -} - -/** 끊긴 곳 하나 — 상자 · 알약 · 선 위에 표시할 것 */ -export interface CfBreak { - kind: - | "빈 받는 자리" - | "통과 안 됨" - | "표에 없는 조건" - | "맞는 재료 없음" - | "조건 겹침" - | "조건 안 맞음" - | "오류"; - box: string; - port?: string; - link?: string; - /** 쉬운 말 */ - text: string; -} - -/** 끊긴 곳 목록(넓힘 · 준비 중) */ -export function breaks(_lg: CfLogic, _values: CfValues = {}): CfBreak[] { - throw new Error("준비 중"); + return [...new Set([...out, ...ev.errors])]; } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Model.ts b/M02_MasterTemplete/DataFlow/M02_DF_Model.ts index 6191c361d..73a18d287 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Model.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Model.ts @@ -193,6 +193,7 @@ const TEXT = { Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"], Taken: ["이미 선이 들어온 자리", "This spot already has a line"], Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"], + CondPill: ["규칙의 조건 알약이 없음", "The rule's condition pill doesn't exist"], } as const; const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; @@ -309,57 +310,100 @@ export function setUnit(lg: CfLogic, id: string, unit: string): CfLogic { /* ── 알약 ───────────────────────────────────────────────────────────────── */ -/** 내보내는 알약(이름 규칙 · 브레인 정함) — 입력 = 요소면 element.name · 표면 「표」 · 변수면 variable.name - * 함수 = func.output.name 하나 · 출력 = 없음 */ +/** 요소 알약 이름 — 재료 선택 자리 · 조건 찾기는 자리 이름(slot) · 나머지는 element.name */ +export const elementPort = (e: CfElementRef): string => + e.mode && e.mode !== "fixed" && e.slot?.trim() ? e.slot.trim() : e.name; + +/** 내보내는 알약(이름 규칙 · 브레인 정함) — 입력 = 요소(elementPort) · 표면 「표」 · 변수면 variable.name + * · 다른 로직이면 그 로직 출력 · 함수 = func.output.name 하나 · 출력 = 없음 */ export function portsOut(box: CfBox): CfPill[] { if (box.kind === "function" && box.func) return [{ name: box.func.output.name, unit: box.func.output.unit || box.unit }]; if (box.kind !== "input" || !box.input) return []; const inp = box.input; + if (inp.mode === "logic") return (inp.logic?.outputs ?? []).map((p) => ({ ...p })); return [ ...(inp.mode === "group" - ? inp.elements.map((e) => ({ name: e.name, unit: e.unit || "-" })) + ? inp.elements.map((e) => ({ name: elementPort(e), unit: e.unit || "-" })) : []), ...(inp.mode === "group" && inp.table ? [{ name: TABLE_PORT, unit: "-" }] : []), ...inp.variables.map((v) => ({ name: v.name, unit: v.unit || "-" })), ]; } -/** 받는 알약 — 함수 = func.inputs[].port · 출력 = 「값」 하나 · 입력 = 없음 */ +/** 받는 알약 — 함수 = func.inputs[].port · 출력 = 「값」 하나 · 다른 로직 입력 = 그 로직의 설계 때 정할 것 · 그 밖 입력 = 없음 */ export function portsIn(box: CfBox): CfPill[] { if (box.kind === "function" && box.func) return box.func.inputs.map((i) => ({ name: i.port, unit: "-" })); if (box.kind === "output") return [{ name: OUTPUT_PORT, unit: box.output?.unit ?? box.unit }]; + if (box.input?.mode === "logic") return (box.input.logic?.designs ?? []).map((p) => ({ ...p })); return []; } +/** 알약 쪽 — 함수 출력 이름이면 out · 출력 상자는 in · 다른 로직 입력은 출력 이름이면 out */ +export function portDir(box: CfBox, name: string): "in" | "out" { + if (box.kind === "output") return "in"; + if (portsOut(box).some((p) => p.name === name)) return "out"; + return "in"; +} + /* ── 선 ─────────────────────────────────────────────────────────────────── */ -/** from 상자에서 선을 따라 to 상자에 닿나 */ +/** 선 규칙이 기대는 알약(조건 찾기 · 조건 통과) */ +export function ruleSources(rule: CfRule | undefined): CfPort[] { + if (!rule) return []; + if (rule.kind === "lookup") return rule.conds.map((c) => c.from); + if (rule.kind === "gate") return [rule.from]; + return []; +} + +/** 상자 사이 기댐 — 선 + 선 규칙의 조건 알약 + 재료 조건 찾기의 알약(→ 그 상자) */ +export function depends(lg: CfLogic): { from: string; to: string }[] { + const out: { from: string; to: string }[] = []; + for (const l of lg.links) { + out.push({ from: l.from.box, to: l.to.box }); + for (const p of ruleSources(l.rule)) out.push({ from: p.box, to: l.to.box }); + } + for (const b of lg.boxes) + for (const e of b.input?.elements ?? []) + if (e.mode === "rule" && e.rule) out.push({ from: e.rule.from.box, to: b.id }); + return out; +} + +/** from 상자에서 기댐을 따라 to 상자에 닿나 */ export function reaches(lg: CfLogic, from: string, to: string): boolean { + const deps = depends(lg); const seen = new Set(); const walk = (id: string): boolean => { if (id === to) return true; if (seen.has(id)) return false; seen.add(id); - return lg.links.filter((l) => l.from.box === id).some((l) => walk(l.to.box)); + return deps.filter((d) => d.from === id).some((d) => walk(d.to)); }; return walk(from); } -/** 선 잇기 — 입력 → 함수 · 출력 / 함수 → 함수 · 출력 · 출력에서 나감 없음 · 순환 · 같은 받는 자리 두 번 막음 +/** 선 잇기 — 입력 → 함수 · 출력 · 다른 로직 입력 / 함수 → 함수 · 출력 · 다른 로직 입력 · 출력에서 나감 없음 · + * 순환 · 같은 받는 자리 두 번 막음 · 규칙(rule)은 함께 붙임(조건 알약이 없으면 막음) * 받는 쪽이 함수 상자이고 to.name 의 받는 알약이 없으면 새로 만듦(to.name 이 빈 글이면 보낸 알약 이름 · * 같은 이름이 있으면 「이름 (2)」) · func.inputs 에 칸 없이(`cell: ""`) 더함 — 칸은 함수 판에서 놓음 */ -export function link(lg: CfLogic, from: CfPort, to: CfPort): { lg: CfLogic; why: string | null } { +export function link( + lg: CfLogic, + from: CfPort, + to: CfPort, + rule?: CfRule, +): { lg: CfLogic; why: string | null } { const a = boxOf(lg, from.box); const b = boxOf(lg, to.box); if (!a || !b) return { lg, why: tl("NoPill") }; if (a.kind === "output") return { lg, why: tl("FromOutput") }; - if (b.kind === "input") return { lg, why: tl("ToInput") }; + if (b.kind === "input" && b.input?.mode !== "logic") return { lg, why: tl("ToInput") }; if (a.id === b.id) return { lg, why: tl("Self") }; if (!portsOut(a).some((p) => p.name === from.name)) return { lg, why: tl("NoPill") }; const has = portsIn(b).some((p) => p.name === to.name); if (!has && b.kind !== "function") return { lg, why: tl("NoPill") }; + const why = ruleWhy(lg, b.id, rule); + if (why) return { lg, why }; if (reaches(lg, b.id, a.id)) return { lg, why: tl("Loop") }; if (has && lg.links.some((l) => l.to.box === to.box && l.to.name === to.name)) return { lg, why: tl("Taken") }; @@ -376,13 +420,37 @@ export function link(lg: CfLogic, from: CfPort, to: CfPort): { lg: CfLogic; why: "l", next.links.map((l) => l.id), ); - return { - lg: { - ...next, - links: [...next.links, { id, from: { ...from }, to: { box: to.box, name: port } }], - }, - why: null, - }; + const made: CfLink = { id, from: { ...from }, to: { box: to.box, name: port } }; + if (rule) made.rule = clone(rule); + return { lg: { ...next, links: [...next.links, made] }, why: null }; +} + +/** 규칙의 조건 알약 검사 — 없거나 돌고 돌면 까닭 */ +function ruleWhy(lg: CfLogic, target: string, rule: CfRule | undefined): string | null { + for (const p of ruleSources(rule)) { + const box = boxOf(lg, p.box); + if (!box || !portsOut(box).some((x) => x.name === p.name)) return tl("CondPill"); + if (box.id === target || reaches(lg, target, box.id)) return tl("Loop"); + } + return null; +} + +/** 선 규칙 바꾸기 · 빼기(null) — 조건 알약이 없거나 돌고 돌면 막고 까닭 */ +export function setRule( + lg: CfLogic, + id: string, + rule: CfRule | null, +): { lg: CfLogic; why: string | null } { + const l = lg.links.find((x) => x.id === id); + if (!l) return { lg, why: tl("NoPill") }; + const why = rule ? ruleWhy(lg, l.to.box, rule) : null; + if (why) return { lg, why }; + const links = lg.links.map((x) => { + if (x.id !== id) return x; + const { rule: _old, ...rest } = x; + return rule ? { ...rest, rule: clone(rule) } : rest; + }); + return { lg: { ...lg, links }, why: null }; } /** 같은 이름이면 「이름 (2)」 「이름 (3)」 */ @@ -403,23 +471,116 @@ export function unlink(lg: CfLogic, id: string): CfLogic { return updateBox(next, box.id, { func: { ...box.func, inputs } }); } -/* ── 흐름(넓힘 · 준비 중) ───────────────────────────────────────────────── */ +/* ── 흐름 따라가기 ──────────────────────────────────────────────────────── */ -const notReady = (): never => { - throw new Error("준비 중"); -}; - -/** 그 알약 값이 온 길 — 출처 사슬(가까운 것부터) */ -export function traceBack(_lg: CfLogic, _port: CfPort): CfStep[] { - return notReady(); +/** 그 알약 값이 온 길 — 출처 사슬(가까운 것부터 · 한 번씩) */ +export function traceBack(lg: CfLogic, port: CfPort): CfStep[] { + const steps: CfStep[] = []; + const seen = new Set(); + const visit = (p: CfPort, dir: "in" | "out", via?: CfLink): void => { + const box = boxOf(lg, p.box); + const key = `${p.box}|${p.name}|${dir}`; + if (!box || seen.has(key)) return; + seen.add(key); + steps.push({ + box: p.box, + port: p.name, + dir, + ...(via ? { link: via.id } : {}), + ...(via?.rule ? { rule: clone(via.rule) } : {}), + }); + if (dir === "in") { + for (const l of lg.links.filter((x) => x.to.box === p.box && x.to.name === p.name)) { + visit(l.from, "out"); + for (const c of ruleSources(l.rule)) visit(c, "out"); + } + return; + } + // 내보내는 알약 — 함수 · 다른 로직은 받는 알약들에서 옴 · 재료 조건 찾기는 그 알약에서 옴 + for (const q of box.kind === "function" || box.input?.mode === "logic" ? portsIn(box) : []) + visit( + { box: box.id, name: q.name }, + "in", + lg.links.find((l) => l.to.box === box.id && l.to.name === q.name), + ); + for (const e of box.input?.elements ?? []) + if (e.mode === "rule" && e.rule && elementPort(e) === p.name) visit(e.rule.from, "out"); + }; + const box = boxOf(lg, port.box); + if (!box) return []; + const dir = portDir(box, port.name); + visit( + port, + dir, + dir === "in" + ? lg.links.find((l) => l.to.box === port.box && l.to.name === port.name) + : undefined, + ); + return steps; } -/** 그 알약 값이 가는 길 */ -export function traceForward(_lg: CfLogic, _port: CfPort): CfStep[] { - return notReady(); +/** 그 알약 값이 가는 길 — 선 · 선 규칙의 조건으로 쓰이는 곳 · 재료 조건 찾기 · 함수 · 다른 로직을 지나 출력까지 */ +export function traceForward(lg: CfLogic, port: CfPort): CfStep[] { + const steps: CfStep[] = []; + const seen = new Set(); + const visit = (p: CfPort, dir: "in" | "out", via?: CfLink): void => { + const box = boxOf(lg, p.box); + const key = `${p.box}|${p.name}|${dir}`; + if (!box || seen.has(key)) return; + seen.add(key); + steps.push({ + box: p.box, + port: p.name, + dir, + ...(via ? { link: via.id } : {}), + ...(via?.rule ? { rule: clone(via.rule) } : {}), + }); + if (dir === "in") { + // 받는 알약 → 그 상자의 내보내는 알약 + for (const q of box.kind === "function" || box.input?.mode === "logic" ? portsOut(box) : []) + visit({ box: box.id, name: q.name }, "out"); + return; + } + for (const l of lg.links.filter((x) => x.from.box === p.box && x.from.name === p.name)) + visit(l.to, "in", l); + // 이 알약을 조건으로 쓰는 선 · 재료 조건 찾기 + for (const l of lg.links.filter((x) => + ruleSources(x.rule).some((c) => c.box === p.box && c.name === p.name), + )) + visit(l.to, "in", l); + for (const b of lg.boxes) + for (const e of b.input?.elements ?? []) + if (e.mode === "rule" && e.rule?.from.box === p.box && e.rule.from.name === p.name) + visit({ box: b.id, name: elementPort(e) }, "out"); + }; + const box = boxOf(lg, port.box); + if (!box) return []; + visit(port, portDir(box, port.name)); + return steps; } -/** 설계 때 정할 것 — 변수 · 재료 자리(선택) · 다른 로직의 받는 알약(선이 없을 때) */ -export function designInputs(_lg: CfLogic): CfDesign[] { - return notReady(); +/* ── 설계 때 정할 것 ─────────────────────────────────────────────────────── */ + +/** 설계 때 정할 것 — 변수(입력 · 함수 상자) · 재료 선택 자리 · 다른 로직의 받는 알약(선이 없을 때) */ +export function designInputs(lg: CfLogic): CfDesign[] { + const out: CfDesign[] = []; + for (const b of lg.boxes) { + const vars = b.input?.variables ?? b.func?.variables ?? []; + for (const v of vars) + out.push({ box: b.id, port: v.name, kind: "변수", unit: v.unit || "-", variable: clone(v) }); + for (const e of b.input?.mode === "group" ? b.input.elements : []) + if (e.mode === "choice") + out.push({ + box: b.id, + port: elementPort(e), + kind: "재료 자리", + unit: e.unit || "-", + element: clone(e), + }); + if (b.input?.mode === "logic") + for (const d of b.input.logic?.designs ?? []) + if (!lg.links.some((l) => l.to.box === b.id && l.to.name === d.name)) + out.push({ box: b.id, port: d.name, kind: "다른 로직", unit: d.unit || "-" }); + } + return out; } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts b/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts index e8f410de8..99b13b959 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts @@ -1,33 +1,210 @@ /* ============================================================================= * M02_DF_Rule.ts * 선 규칙 · 끝수 → 엑셀 수식 — 우리 스프레드시트 엔진 한 벌로 셈 · 선 위 규칙 글(쉬운 말) · DOM 없음 + * + * 한 번 셈 = 시트 하나: A1 부터 보낸 값(표면 머리 줄 포함 덩어리) · 그 오른쪽에 조건 값 · 맨 끝 칸에 규칙 수식 + * lookup = 줄마다 「맞음」 칸(같음 · 범위는 「이름·아래」 < 값 ≤ 「이름·위」 · 빈 끝은 열림) → 맞은 줄 수 · INDEX/MATCH + * gate = IF(조건, 보낸 값, NA()) · scale = × ÷ (1 ± %) · round = ROUND · ROUNDUP · ROUNDDOWN * ========================================================================== */ -import type { CfRound, CfRule } from "./M02_DF_Model"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { colName } from "../../A00_Common/spreadsheet/spreadsheet_address"; +import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc"; +import type { CalcValue, Cell } from "../../A00_Common/spreadsheet/spreadsheet_types"; +import type { CfGateOp, CfRound, CfRule } from "./M02_DF_Model"; + +type Scalar = number | string | boolean | null; /** 규칙을 지난 값 — 통과 안 됨 · 표에 없는 조건 · 둘 이상 맞음은 value null + why */ export interface CfRuleResult { - value: number | string | boolean | null; + value: Scalar; why: "통과 안 됨" | "표에 없는 조건" | "표에 여럿 맞음" | "오류" | null; /** 받는 칸에 들어갈 엑셀 수식(보이기 · 시험) */ formula: string; } +const TEXT = { + Lookup: ["표에서 찾기", "Look up in table"], + Gate: ["일 때만", "only when"], + Places: ["소수 {n}자리", "{n} decimals"], + Round: ["반올림", "round"], + Up: ["올림", "round up"], + Down: ["버림", "round down"], +} as const; + +const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +/** 엑셀 비교 기호 */ +const OP: Record = { + "=": "=", + "≠": "<>", + "≥": ">=", + "≤": "<=", + ">": ">", + "<": "<", +}; +const ROUND_FN: Record = { + 반올림: "ROUND", + 올림: "ROUNDUP", + 버림: "ROUNDDOWN", +}; + /** 선 위에 늘 보일 규칙 글 */ -export function ruleLabel(_rule: CfRule): string { - throw new Error("준비 중"); +export function ruleLabel(rule: CfRule): string { + switch (rule.kind) { + case "lookup": { + const conds = rule.conds.map((c) => `${c.col} = ${c.from.name}`).join(" · "); + return `${tl("Lookup")}${conds ? ` (${conds})` : ""} → ${rule.col}`; + } + case "gate": + return `${rule.from.name} ${rule.op} ${rule.value} ${tl("Gate")}`; + case "scale": + return rule.op === "+%" || rule.op === "-%" + ? `${rule.op[0]}${rule.value}%` + : `${rule.op} ${rule.value}`; + case "round": + return `${tl("Places").replace("{n}", String(rule.places))} ${roundWord(rule.method)}`; + } } -/** 규칙 적용 — value = 보낸 알약 값(표면 머리 줄 포함 칸 덩어리) · conds = 조건 알약 값(lookup · gate) */ +const roundWord = (m: CfRound["method"]): string => + m === "반올림" ? tl("Round") : m === "올림" ? tl("Up") : tl("Down"); + +/* ── 엔진 한 번 ─────────────────────────────────────────────────────────── */ + +const fromCalc = (v: CalcValue | undefined): Scalar => { + if (!v) return null; + if ("수" in v) return Number(v.수); + if ("글" in v) return v.글; + if ("참" in v) return v.참; + return v.오류; +}; + +const cellOf = (v: unknown): Cell | null => + v === null || v === undefined || v === "" + ? null + : typeof v === "number" || typeof v === "string" || typeof v === "boolean" + ? { 값: v } + : { 값: String(v) }; + +/** 칸들을 시트 하나로 셈 → 읽을 칸 값 */ +function run(칸: Record, read: string[]): Scalar[] { + const id = "rule"; + const values = recalcWorkbook({ + 종류: "통합문서", + 판: 1, + 열: "DF", + 서식: [{}], + 시트: [{ id, 이름: "규칙", 칸 }], + }); + return read.map((a) => + 칸[a]?.식 !== undefined ? fromCalc(values[id]?.[a]) : (칸[a]?.값 ?? null), + ); +} + +const isError = (v: Scalar): boolean => typeof v === "string" && /^#[A-Z/0!?]+$/.test(v); +const lit = (v: number | string): string => + typeof v === "number" ? String(v) : `"${String(v).replace(/"/g, '""')}"`; + +/* ── 규칙 ───────────────────────────────────────────────────────────────── */ + +/** 규칙 적용 — value = 보낸 알약 값(표면 머리 줄 포함 칸 덩어리) · conds = 조건 알약 이름 → 값(lookup · gate) */ export function applyRule( - _rule: CfRule, - _value: unknown, - _conds: Record = {}, + rule: CfRule, + value: unknown, + conds: Record = {}, ): CfRuleResult { - throw new Error("준비 중"); + if (rule.kind === "lookup") return lookup(rule, value, conds); + const 칸: Record = {}; + const a1 = cellOf(Array.isArray(value) ? null : value); + if (a1) 칸.A1 = a1; + let formula: string; + if (rule.kind === "gate") { + const c1 = cellOf(conds[rule.from.name]); + if (c1) 칸.C1 = c1; + formula = `IF(C1${OP[rule.op]}${lit(rule.value)},A1,NA())`; + } else if (rule.kind === "scale") { + const v = rule.value; + formula = + rule.op === "×" + ? `A1*${v}` + : rule.op === "÷" + ? `A1/${v}` + : rule.op === "+%" + ? `A1*(1+${v}/100)` + : `A1*(1-${v}/100)`; + } else formula = `${ROUND_FN[rule.method]}(A1,${rule.places})`; + 칸.B1 = { 식: formula }; + const [got] = run(칸, ["B1"]); + if (rule.kind === "gate" && got === "#N/A") return { value: null, why: "통과 안 됨", formula }; + if (isError(got)) return { value: null, why: "오류", formula }; + return { value: got, why: null, formula }; +} + +/** 표에서 찾기 — 줄마다 맞음 칸 · 맞은 줄 수 · INDEX/MATCH(범위 칸 「이름·아래」 「이름·위」 · 초과 · 이하) */ +function lookup( + rule: Extract, + value: unknown, + conds: Record, +): CfRuleResult { + const block = Array.isArray(value) ? (value as unknown[][]) : []; + const head = (block[0] ?? []).map((h) => String(h ?? "")); + const rows = block.length - 1; + const W = head.length; + const 칸: Record = {}; + block.forEach((line, r) => + line.forEach((x, c) => { + const cell = cellOf(x); + if (cell) 칸[`${colName(c)}${r + 1}`] = cell; + }), + ); + const at = (name: string): string | null => { + const i = head.indexOf(name); + return i < 0 ? null : colName(i); + }; + const out = at(rule.col); + // 조건 값 = 1행 · 블록 오른쪽 한 칸 띄고 + const tests: ((r: number) => string)[] = []; + let bad = !out || rows < 1; + rule.conds.forEach((c, i) => { + const ref = `$${colName(W + 1 + i)}$1`; + const cv = cellOf(conds[c.from.name]); + if (cv) 칸[`${colName(W + 1 + i)}1`] = cv; + const same = at(c.col); + const low = at(`${c.col}·아래`); + const high = at(`${c.col}·위`); + if (same) tests.push((r) => `${same}${r}=${ref}`); + else if (low && high) + tests.push( + (r) => + `AND(OR(ISBLANK(${low}${r}),${low}${r}<${ref}),OR(ISBLANK(${high}${r}),${ref}<=${high}${r}))`, + ); + else bad = true; + }); + const hc = colName(W + 1 + rule.conds.length); + const last = rows + 1; + for (let r = 2; r <= last; r++) + 칸[`${hc}${r}`] = { + 식: tests.length ? `IF(AND(${tests.map((t) => t(r)).join(",")}),1,0)` : "1", + }; + const range = `${hc}2:${hc}${Math.max(2, last)}`; + const count = `${colName(W + 2 + rule.conds.length)}1`; + const formula = out ? `INDEX(${out}2:${out}${Math.max(2, last)},MATCH(1,${range},0))` : "NA()"; + 칸[count] = { 식: `SUM(${range})` }; + 칸.ZZ1 = { 식: formula }; + if (bad) return { value: null, why: "오류", formula }; + const [n, got] = run(칸, [count, "ZZ1"]); + if (n === 0) return { value: null, why: "표에 없는 조건", formula }; + if (typeof n === "number" && n > 1) return { value: null, why: "표에 여럿 맞음", formula }; + if (isError(got)) return { value: null, why: "오류", formula }; + return { value: got, why: null, formula }; } /** 끝수 적용 — 엔진 ROUND · ROUNDUP · ROUNDDOWN */ -export function applyRound(_round: CfRound, _value: number): number { - throw new Error("준비 중"); +export function applyRound(round: CfRound, value: number): number { + const [got] = run( + { A1: { 값: value }, B1: { 식: `${ROUND_FN[round.method]}(A1,${round.places})` } }, + ["B1"], + ); + return typeof got === "number" ? got : value; } diff --git a/resources/tester/test_m02_df_model.py b/resources/tester/test_m02_df_model.py index 6081bd45a..c8b0663bd 100644 --- a/resources/tester/test_m02_df_model.py +++ b/resources/tester/test_m02_df_model.py @@ -346,3 +346,249 @@ def test_함수_출력_기본_이름은_겹치지_않음_받는_알약은_번호 ["결과 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]