From 6ba9d766057f0464b158c88406b67bf3f86fc8da Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:04:05 +0900 Subject: [PATCH] =?UTF-8?q?feat(m02-cf):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20Mod?= =?UTF-8?q?el=20=C2=B7=20Eval=20=EC=86=8D=20=EC=B1=84=EC=9B=80=20=C2=B7=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상자 더하기(종류별 기본 색 · 단위 - · 함수 = 빈 시트 하나 · 출력 = 노무비) · 지우기(선도) · 자리 · 색 · 단위(함수 · 출력 안쪽 단위도) · updateBox - 알약 이름 규칙(브레인 정함) — 입력 = element.name · 표 · variable.name / 함수 = inputs[].port · output.name / 출력 = 값 - 선 규칙 — 입력 → 함수 · 출력 / 함수 → 함수 · 출력 · 출력에서 나감 · 입력으로 들어감 · 같은 상자 · 순환 · 같은 받는 자리 두 번 · 없는 알약은 막고 까닭 - 계산 — 함수 상자를 차례대로(직렬 · 병렬) 받는 칸 · 변수 칸에 값을 넣고 우리 스프레드시트 엔진으로 셈 · 출력 상자 비용 종류별 원가 합(노무비 · 재료비 · 경비 · 값들 · 계) - 검사 — 빈 받는 자리 · 출력 칸 없음 · 출력 상자 없음 · 값 없는 입력 · 순환 · 오류 칸 - 시험 resources/tester/test_m02_cf_model.py 11개(엔진 그대로 Node) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk --- M02_MasterTemplete/CostFlow/M02_CF_Eval.ts | 182 ++++++++++++- M02_MasterTemplete/CostFlow/M02_CF_Model.ts | 197 +++++++++++--- resources/tester/test_m02_cf_model.py | 274 ++++++++++++++++++++ 3 files changed, 609 insertions(+), 44 deletions(-) create mode 100644 resources/tester/test_m02_cf_model.py diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Eval.ts b/M02_MasterTemplete/CostFlow/M02_CF_Eval.ts index 1cd3f877b..4d0679995 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Eval.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_Eval.ts @@ -2,9 +2,14 @@ * M02_CF_Eval.ts * 단가산출 다이어그램 계산 · 검사 — 함수 상자는 차례대로 시트를 우리 스프레드시트 엔진으로 셈 * (받는 칸에 앞 값을 넣음) · 출력 상자를 비용 종류별로 모아 원가 · DOM 없음 + * 엔진을 정적으로 가져옴 — 화면(Entry)은 CostFlow 를 import() 로 늦게 불러 첫 묶음에 안 들어가게 * ========================================================================== */ -import type { CfLogic } from "./M02_CF_Model"; +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_CF_Model"; /** 알약 값 — 수 · 글 · 참거짓 · 빈 값 · 표(머리 줄 포함 칸 덩어리) */ export type CfValue = number | string | boolean | null | (number | string | null)[][]; @@ -12,33 +17,188 @@ export type CfValue = number | string | boolean | null | (number | string | null /** 입력 값 묶음 — 상자 id → 알약 이름 → 값(입력 상자 요소 값 · 표 · 변수 값 · 함수 상자 변수 값) */ export type CfValues = Record>; +type Scalar = number | string | boolean | null; + export interface CfEvalResult { /** 상자 id → 내보내는 알약 이름 → 값 */ ports: Record>; /** 출력 상자 id → 값 */ - outputs: Record; + outputs: Record; cost: { 노무비: number; 재료비: number; 경비: number; /** 비용 아닌 출력(값) — 출력 상자 이름 → 값 */ - 값들: Record; + 값들: Record; 계: number; }; /** 쉬운 말 */ errors: string[]; } -const notReady = (): never => { - throw new Error("준비 중"); +const TEXT = { + /** {box} {cell} {err} */ + CellError: [ + "「{box}」 {cell} 칸 계산이 막힘 — {err}", + "「{box}」 cell {cell} can't be worked out — {err}", + ], + Cycle: ["돌고 도는 참조", "circular reference"], + /** {box} */ + Loop: ["「{box}」 이 돌고 도는 선에 걸림", "「{box}」 is in a loop of lines"], + NotNumber: ["「{box}」 에 숫자가 아닌 값이 들어옴", "「{box}」 got a value that isn't a number"], + /** {box} {pill} */ + EmptyIn: [ + "「{box}」 의 받는 알약 「{pill}」 에 선이 없음", + "Pill 「{pill}」 of 「{box}」 has no line", + ], + NoCell: ["「{box}」 의 출력 칸이 정해지지 않음", "Output cell of 「{box}」 is not set"], + NoOutput: ["출력 상자가 없음", "There is no output box"], + NoValue: ["「{box}」 의 「{pill}」 값이 아직 없음", "「{pill}」 of 「{box}」 has no value yet"], +} as const; + +const tl = (key: keyof typeof TEXT, fill: Record = {}): string => + Object.entries(fill).reduce( + (s, [k, v]) => s.replace(`{${k}}`, v), + TEXT[key][currentLanguageIndex === 1 ? 1 : 0] as string, + ); + +/* ── 차례 ───────────────────────────────────────────────────────────────── */ + +/** 함수 상자 차례 — 앞 함수가 먼저 · 돌고 도는 상자는 loop 로 */ +function order(lg: CfLogic): { funcs: CfBox[]; loop: CfBox[] } { + const funcs = lg.boxes.filter((b) => b.kind === "function"); + const done: CfBox[] = []; + const left = new Set(funcs.map((f) => f.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); + moved = true; + } + } + return { funcs: done, loop: funcs.filter((f) => left.has(f.id)) }; +} + +/* ── 칸 ─────────────────────────────────────────────────────────────────── */ + +function putValue(칸: Record, cell: string, value: Scalar): void { + const style = 칸[cell]?.서식; + if (value === null) { + if (style) 칸[cell] = { 서식: style }; + else delete 칸[cell]; + } else 칸[cell] = { 값: value, ...(style ? { 서식: style } : {}) }; +} + +/** 칸에 값 넣기 — 표는 cell 을 왼위로 펼침 */ +function put(칸: Record, cell: string, value: CfValue): void { + const at = parseA1(cell); + if (!at) return; + if (!Array.isArray(value)) return putValue(칸, toA1(at.r, at.c), value); + value.forEach((line, dr) => line.forEach((x, dc) => putValue(칸, toA1(at.r + dr, at.c + dc), x))); +} + +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.오류; }; -/** 화면 계산 */ -export function evalLogic(_lg: CfLogic, _values: CfValues = {}): CfEvalResult { - return notReady(); +/* ── 계산 ───────────────────────────────────────────────────────────────── */ + +/** 입력 상자 알약 값 — 넘긴 값 · 없으면 변수 예시값 · 사용자 요소 값 */ +function inputPorts(lg: CfLogic, box: CfBox, values: CfValues): Record { + const given = values[box.id] ?? {}; + const pills = portsOut(box); + const out: Record = {}; + const inp = box.input; + if (!inp) return out; + // portsOut 차례 = 요소 · 표 · 변수 + const sources: CfValue[] = [ + ...(inp.mode === "group" + ? inp.elements.map((e) => lg.userElements.find((u) => u.id === e.ref)?.value ?? null) + : []), + ...(inp.mode === "group" && inp.table ? [null] : []), + ...inp.variables.map((v) => v.example), + ]; + pills.forEach((p, i) => (out[p.name] = p.name in given ? given[p.name] : (sources[i] ?? null))); + return out; } -/** 검사 — 빈 받는 자리 · 출력 하나 아님 · 순환 · 오류(쉬운 말 목록) */ -export function checkLogic(_lg: CfLogic, _values: CfValues = {}): string[] { - return notReady(); +/** 함수 상자 하나 — 받는 칸 · 변수 칸에 값을 넣고 시트 하나를 셈 */ +function runFunc( + box: CfBox, + incoming: 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 v of func.variables) + if (v.cell) put(sheet.칸, v.cell, v.name in given ? given[v.name] : v.example); + const values = recalcWorkbook({ 종류: "통합문서", 판: 1, 열: "CF", 서식: [{}], 시트: [sheet] }); + for (const [cell, v] of Object.entries(values[sheet.id] ?? {})) + if ("오류" in v) { + const err = v.오류 === "#CYCLE!" ? `${v.오류} ${tl("Cycle")}` : v.오류; + errors.push(tl("CellError", { box: box.name, cell, err })); + } + const at = func.output.cell.toUpperCase(); + const c = sheet.칸[at]; + if (!c) return null; + return c.식 !== undefined ? fromCalc(values[sheet.id]?.[at]) : (c.값 ?? null); +} + +/** 화면 계산 */ +export function evalLogic(lg: CfLogic, values: CfValues = {}): CfEvalResult { + const errors: string[] = []; + 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 { 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 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 })); + } + cost.계 = cost.노무비 + cost.재료비 + cost.경비; + return { ports, outputs, cost, errors }; +} + +/** 검사 — 빈 받는 자리 · 출력 칸 없음 · 출력 상자 없음 · 값 없는 입력 · 순환 · 오류(쉬운 말 목록) */ +export function checkLogic(lg: CfLogic, values: CfValues = {}): string[] { + const out: string[] = []; + for (const b of lg.boxes) { + 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 })); + } + 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")) + 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]; } diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Model.ts b/M02_MasterTemplete/CostFlow/M02_CF_Model.ts index bc61a1ae1..949ad705a 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Model.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_Model.ts @@ -4,6 +4,7 @@ * DOM 없음 · 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 「상자 · 선 약속」 그대로 * ========================================================================== */ +import { currentLanguageIndex } from "@ui/ui_template_locale"; import type { Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types"; export type CfKind = "input" | "function" | "output"; @@ -105,60 +106,190 @@ export interface CfPill { unit: string; } -const notReady = (): never => { - throw new Error("준비 중"); +const TEXT = { + Input: ["입력", "Input"], + Func: ["함수", "Function"], + Output: ["출력", "Output"], + Result: ["결과", "Result"], + Value: ["값", "Value"], + NoPill: ["알약이 없음", "No such pill"], + FromOutput: ["출력 상자에서는 선이 나가지 않음", "Lines can't leave an output box"], + ToInput: ["입력 상자로는 선이 들어가지 않음", "Lines can't enter an input box"], + Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"], + Taken: ["이미 선이 들어온 자리", "This spot already has a line"], + Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"], +} as const; + +const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +/** 종류별 기본 색 */ +export const BOX_COLOR: Record = { + input: "#2f80ed", + function: "#7b4fd6", + output: "#27ae60", }; -export function newLogic(_name?: string, _resultUnit?: string): CfLogic { - return notReady(); +/** 알약 이름 규칙(브레인 정함) — 화면 말이 아니라 열쇠라 바꾸지 않음 */ +export const OUTPUT_PORT = "값"; +export const TABLE_PORT = "표"; + +const clone = (v: T): T => structuredClone(v); + +/** 다음 번호 — 있는 id(「b3」)보다 하나 큰 것 */ +function nextId(prefix: string, ids: string[]): string { + const nums = ids + .filter((x) => x.startsWith(prefix)) + .map((x) => Number(x.slice(prefix.length))) + .filter((n) => Number.isInteger(n)); + return `${prefix}${Math.max(0, ...nums) + 1}`; } -/** 상자 더하기 — 종류별 기본 색 · 단위 「-」 · 함수는 빈 시트 하나 · 출력은 노무비 */ +/** 같은 이름이면 「 2」 「 3」 */ +function uniqueName(used: string[], base: string): string { + if (!used.includes(base)) return base; + for (let n = 2; ; n++) if (!used.includes(`${base} ${n}`)) return `${base} ${n}`; +} + +const boxOf = (lg: CfLogic, id: string): CfBox | undefined => lg.boxes.find((b) => b.id === id); + +/** 상자 하나 고치기 — 나머지는 그대로(받은 것은 안 바꿈) */ +export function updateBox( + lg: CfLogic, + id: string, + patch: Partial>, +): CfLogic { + return { + ...lg, + boxes: lg.boxes.map((b) => (b.id === id ? { ...clone(b), ...clone(patch) } : b)), + }; +} + +/* ── 만들기 · 상자 ───────────────────────────────────────────────────────── */ + +export function newLogic(name = "", resultUnit = "원/㎡"): CfLogic { + return { key: null, name, resultUnit, boxes: [], links: [], userElements: [] }; +} + +/** 상자 더하기 — 종류별 기본 색 · 단위 「-」 · 함수는 빈 시트 하나(A1 결과 · B1 출력 칸) · 출력은 노무비 */ export function addBox( - _lg: CfLogic, - _kind: CfKind, - _x: number, - _y: number, + lg: CfLogic, + kind: CfKind, + x: number, + y: number, ): { lg: CfLogic; box: CfBox } { - return notReady(); + const id = nextId( + "b", + lg.boxes.map((b) => b.id), + ); + const base = tl(kind === "input" ? "Input" : kind === "function" ? "Func" : "Output"); + const name = uniqueName( + lg.boxes.map((b) => b.name), + base, + ); + const box: CfBox = { id, kind, name, color: BOX_COLOR[kind], unit: "-", x, y }; + if (kind === "input") box.input = { mode: "variable", elements: [], variables: [] }; + if (kind === "function") + box.func = { + sheet: { id: `s-${id}`, 이름: name, 칸: { A1: { 값: tl("Result") } } }, + inputs: [], + variables: [], + output: { cell: "B1", name: tl("Result"), unit: "-" }, + }; + if (kind === "output") box.output = { cost: "노무비", unit: "-" }; + return { lg: { ...lg, boxes: [...lg.boxes, box] }, box }; } /** 상자 지우기 — 이어진 선도 지움 */ -export function removeBox(_lg: CfLogic, _id: string): CfLogic { - return notReady(); +export function removeBox(lg: CfLogic, id: string): CfLogic { + return { + ...lg, + boxes: lg.boxes.filter((b) => b.id !== id), + links: lg.links.filter((l) => l.from.box !== id && l.to.box !== id), + }; } -export function moveBox(_lg: CfLogic, _id: string, _x: number, _y: number): CfLogic { - return notReady(); +export function moveBox(lg: CfLogic, id: string, x: number, y: number): CfLogic { + return updateBox(lg, id, { x, y }); } -export function setColor(_lg: CfLogic, _id: string, _color: string): CfLogic { - return notReady(); +export function setColor(lg: CfLogic, id: string, color: string): CfLogic { + return updateBox(lg, id, { color }); } -export function setUnit(_lg: CfLogic, _id: string, _unit: string): CfLogic { - return notReady(); +/** 단위 — 상자 오른쪽 위 = 그 상자 출력의 단위 · 함수 · 출력 상자는 안쪽 단위도 같이 */ +export function setUnit(lg: CfLogic, id: string, unit: string): CfLogic { + const box = boxOf(lg, id); + if (!box) return lg; + const u = unit.trim() || "-"; + const patch: Partial = { unit: u }; + if (box.func) patch.func = { ...box.func, output: { ...box.func.output, unit: u } }; + if (box.output) patch.output = { ...box.output, unit: u }; + return updateBox(lg, id, patch); } -/** 내보내는 알약 — 입력 = 요소 · 표 · 변수마다 · 함수 = 출력 하나 · 출력 = 없음 */ -export function portsOut(_box: CfBox): CfPill[] { - return notReady(); +/* ── 알약 ───────────────────────────────────────────────────────────────── */ + +/** 내보내는 알약(이름 규칙 · 브레인 정함) — 입력 = 요소면 element.name · 표면 「표」 · 변수면 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; + return [ + ...(inp.mode === "group" + ? inp.elements.map((e) => ({ name: e.name, unit: e.unit || "-" })) + : []), + ...(inp.mode === "group" && inp.table ? [{ name: TABLE_PORT, unit: "-" }] : []), + ...inp.variables.map((v) => ({ name: v.name, unit: v.unit || "-" })), + ]; } -/** 받는 알약 — 함수 = 입력 알약 · 출력 = 하나 · 입력 = 없음 */ -export function portsIn(_box: CfBox): CfPill[] { - return notReady(); +/** 받는 알약 — 함수 = 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 }]; + return []; } -/** 선 잇기 — 규칙에 어긋나면 막고 까닭(쉬운 말) */ -export function link( - _lg: CfLogic, - _from: CfPort, - _to: CfPort, -): { lg: CfLogic; why: string | null } { - return notReady(); +/* ── 선 ─────────────────────────────────────────────────────────────────── */ + +/** from 상자에서 선을 따라 to 상자에 닿나 */ +export function reaches(lg: CfLogic, from: string, to: string): boolean { + 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 walk(from); } -export function unlink(_lg: CfLogic, _id: string): CfLogic { - return notReady(); +/** 선 잇기 — 입력 → 함수 · 출력 / 함수 → 함수 · 출력 · 출력에서 나감 없음 · 순환 · 같은 받는 자리 두 번 막음 */ +export function link(lg: CfLogic, from: CfPort, to: CfPort): { 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 (a.id === b.id) return { lg, why: tl("Self") }; + if (!portsOut(a).some((p) => p.name === from.name) || !portsIn(b).some((p) => p.name === to.name)) + return { lg, why: tl("NoPill") }; + if (reaches(lg, b.id, a.id)) return { lg, why: tl("Loop") }; + if (lg.links.some((l) => l.to.box === to.box && l.to.name === to.name)) + return { lg, why: tl("Taken") }; + const id = nextId( + "l", + lg.links.map((l) => l.id), + ); + return { + lg: { ...lg, links: [...lg.links, { id, from: { ...from }, to: { ...to } }] }, + why: null, + }; +} + +export function unlink(lg: CfLogic, id: string): CfLogic { + return { ...lg, links: lg.links.filter((l) => l.id !== id) }; } diff --git a/resources/tester/test_m02_cf_model.py b/resources/tester/test_m02_cf_model.py new file mode 100644 index 000000000..8072c83af --- /dev/null +++ b/resources/tester/test_m02_cf_model.py @@ -0,0 +1,274 @@ +"""M02 단가산출 다이어그램 Model · Eval 시험(35-7) — Node 로 `M02_CF_Model.ts` · `M02_CF_Eval.ts` 와 진짜 스프레드시트 엔진을 돌림. + +· 견본 둘은 이 시험 안에서 만듦 — 마스터 · `_data` 에 기대지 않음 · 저장 안 함 +· 입력(인력 석공 노임) + 변수(면적) → 함수(품 × 면적 × 노임) → 출력 노무비 · 직렬 · 병렬 · 순환 막음 · 선 규칙 · 출력 하나 · 원가 합 · 검사 +· TypeScript 를 그 자리에서 CommonJS 로 옮겨 부름(`helper_spreadsheet_engine.cjs` 와 같은 길) +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +CF = ROOT / "M02_MasterTemplete" / "CostFlow" + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") + +LOADER = r""" +const fs = require("fs"); +const path = require("path"); +const ROOT = process.argv[1]; +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full).exports; + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText; + const mod = { exports: {} }; + cache.set(full, mod); + const req = (r) => { + if (r === "@ui/ui_template_locale") return { currentLanguageIndex: 0 }; + if (r.endsWith(".css")) return {}; + if (r.startsWith("@ui/")) return loadTs(path.join(ROOT, "ui_template", `${r.slice(4)}.ts`)); + if (r.startsWith(".")) return loadTs(path.join(path.dirname(full), r.endsWith(".ts") ? r : `${r}.ts`)); + return require(r); + }; + new Function("exports", "module", "require", js)(mod.exports, mod, req); + return mod.exports; +} +const M = loadTs(path.join(ROOT, "M02_MasterTemplete", "CostFlow", "M02_CF_Model.ts")); +const E = loadTs(path.join(ROOT, "M02_MasterTemplete", "CostFlow", "M02_CF_Eval.ts")); +const out = (x) => console.log(JSON.stringify(x)); +const add = (lg, kind, x = 0, y = 0) => M.addBox(lg, kind, x, y); +const join = (lg, a, ap, b, bp) => { + const r = M.link(lg, { box: a.id, name: ap }, { box: b.id, name: bp }); + if (r.why) throw new Error(r.why); + return r.lg; +}; +/** 함수 상자 시트 · 받는 칸 · 변수 · 출력 칸 한 번에 */ +const func = (lg, box, cells, inputs, output, variables = []) => + M.updateBox(lg, box.id, { func: { ...box.func, sheet: { ...box.func.sheet, 칸: cells }, inputs, variables, + output: { ...box.func.output, ...output } } }); +""" + + +def _run(body: str) -> object: + result = subprocess.run( + ["node", "-e", LOADER + body, str(ROOT)], + capture_output=True, text=True, encoding="utf-8", timeout=120, cwd=ROOT, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout.strip().splitlines()[-1]) + + +# 견본 하나 — 입력(인력 석공) + 입력(변수 면적) → 함수(품 × 면적 × 노임) → 출력 노무비 +ONE = r""" +let lg = M.newLogic("견본 하나", "원/㎡"); +let r = add(lg, "input"); lg = r.lg; let man = r.box; +lg = M.updateBox(lg, man.id, { input: { mode: "group", group: "인력", + elements: [{ ref: "LB000033", name: "석공", spec: "", unit: "인" }], variables: [] } }); +r = add(lg, "input"); lg = r.lg; let area = r.box; +lg = M.updateBox(lg, area.id, { input: { mode: "variable", elements: [], + variables: [{ name: "면적", type: "number", unit: "㎡", example: 10 }] } }); +r = add(lg, "function"); lg = r.lg; let f = r.box; +lg = func(lg, f, { A2: { 값: "노임" }, A3: { 값: "면적" }, A4: { 값: "품" }, A5: { 값: "노무비" }, B5: { 식: "B4*B3*B2" } }, + [{ port: "노임", cell: "B2" }, { port: "면적", cell: "B3" }], { cell: "B5", name: "노무비", unit: "원" }, + [{ name: "품", type: "number", unit: "인/㎡", example: 0.2, cell: "B4" }]); +r = add(lg, "output"); lg = r.lg; let o = r.box; +lg = join(lg, man, "석공", f, "노임"); +lg = join(lg, area, "면적", f, "면적"); +lg = join(lg, f, "노무비", o, "값"); +const V = { [man.id]: { 석공: 268908 } }; +""" + +# 견본 둘 — 직렬 · 병렬: f1 = 면적 × 2 · f2 = 노임 × 0.1 · f3 = f1 + f2(병렬 모음) · f4 = f3 × 1.1(직렬) +TWO = r""" +let lg = M.newLogic("견본 둘"); +let r = add(lg, "input"); lg = r.lg; const inp = r.box; +lg = M.updateBox(lg, inp.id, { input: { mode: "group", group: "인력", + elements: [{ ref: "LB000002", name: "보통인부", spec: "", unit: "인" }], + variables: [{ name: "면적", type: "number", unit: "㎡", example: 5 }] } }); +const fn = (cells, inputs) => { r = add(lg, "function"); lg = r.lg; const b = r.box; + lg = func(lg, b, cells, inputs, { cell: "B9", name: "결과" }); return b; }; +const f1 = fn({ B9: { 식: "B1*2" } }, [{ port: "가", cell: "B1" }]); +const f2 = fn({ B9: { 식: "B1*0.1" } }, [{ port: "가", cell: "B1" }]); +const f3 = fn({ B9: { 식: "B1+B2" } }, [{ port: "가", cell: "B1" }, { port: "나", cell: "B2" }]); +const f4 = fn({ B9: { 식: "ROUND(B1*1.1,0)" } }, [{ port: "가", cell: "B1" }]); +lg = join(lg, inp, "면적", f1, "가"); +lg = join(lg, inp, "보통인부", f2, "가"); +lg = join(lg, f1, "결과", f3, "가"); +lg = join(lg, f2, "결과", f3, "나"); +lg = join(lg, f3, "결과", f4, "가"); +r = add(lg, "output"); lg = r.lg; const oExp = r.box; lg = M.updateBox(lg, oExp.id, { output: { cost: "경비", unit: "원" } }); +r = add(lg, "output"); lg = r.lg; const oVal = r.box; lg = M.updateBox(lg, oVal.id, { output: { cost: "값", unit: "㎡" } }); +r = add(lg, "output"); lg = r.lg; const oMat = r.box; lg = M.updateBox(lg, oMat.id, { output: { cost: "재료비", unit: "원" } }); +lg = join(lg, f4, "결과", oExp, "값"); +lg = join(lg, f1, "결과", oVal, "값"); +lg = join(lg, inp, "면적", oMat, "값"); +const V = { [inp.id]: { 보통인부: 172068 } }; +""" + + +def test_견본하나_입력_변수_함수_출력_노무비() -> None: + got = _run( + ONE + + "out([E.evalLogic(lg, V), E.checkLogic(lg, V), lg.boxes.map(b => [b.id, b.kind, b.name, b.color, b.unit])]);" + ) + ev, check, boxes = got + assert ev["ports"]["b3"] == {"노무비": 537816} # 0.2 × 10 × 268,908 + assert ev["outputs"] == {"b4": 537816} + assert ev["cost"] == {"노무비": 537816, "재료비": 0, "경비": 0, "값들": {}, "계": 537816} + assert ev["errors"] == [] and check == [] + assert boxes == [ + ["b1", "input", "입력", "#2f80ed", "-"], + ["b2", "input", "입력 2", "#2f80ed", "-"], + ["b3", "function", "함수", "#7b4fd6", "-"], + ["b4", "output", "출력", "#27ae60", "-"], + ] + + +def test_변수값_바꾸면_값이_바뀜() -> None: + got = _run( + ONE + + "out(E.evalLogic(lg, { ...V, [area.id]: { 면적: 20 }, [f.id]: { 품: 0.1 } }).cost.계);" + ) + assert got == 0.1 * 20 * 268908 + + +def test_견본둘_직렬_병렬_원가_합() -> None: + got = _run( + TWO + + "const ev = E.evalLogic(lg, V);\nout([ev.ports, ev.cost, ev.errors, E.checkLogic(lg, V)]);" + ) + ports, cost, errors, check = got + f1, f2, f3, f4 = (ports[k]["결과"] for k in ("b2", "b3", "b4", "b5")) + assert (f1, f2, f3, f4) == (10, 17206.8, 17216.8, 18938) + assert cost == {"노무비": 0, "재료비": 5, "경비": 18938, "값들": {"출력 2": 10}, "계": 18943} + assert errors == [] and check == [] + + +def test_선_규칙() -> None: + got = _run( + TWO + + "const t = (a, ap, b, bp) => M.link(lg, { box: a.id, name: ap }, { box: b.id, name: bp }).why;\n" + "out([t(f4, '결과', f3, '나'), t(f3, '결과', f1, '가'), t(oExp, '값', f1, '가'), t(f1, '결과', inp, '면적')," + " t(f3, '결과', f3, '가'), t(f2, '결과', f3, '가'), t(f1, '없음', f4, '가'), t(inp, '면적', oExp, '값')," + " M.link(lg, { box: f1.id, name: '결과' }, { box: f3.id, name: '가' }).lg === lg]);" + ) + assert got == [ + "이으면 돌고 도는 선이 됨", + "이으면 돌고 도는 선이 됨", + "출력 상자에서는 선이 나가지 않음", + "입력 상자로는 선이 들어가지 않음", + "같은 상자끼리는 못 이음", + "이미 선이 들어온 자리", + "알약이 없음", + "이미 선이 들어온 자리", + True, + ] + + +def test_알약_출력은_하나() -> None: + got = _run( + TWO + + "lg = M.updateBox(lg, inp.id, { input: { ...lg.boxes[0].input, table: { key: 'QF000421' } } });\n" + "out([M.portsOut(lg.boxes[0]), M.portsIn(lg.boxes[0]), M.portsOut(lg.boxes[3]), M.portsIn(lg.boxes[3])," + " M.portsOut(lg.boxes.find(b => b.id === oExp.id)), M.portsIn(lg.boxes.find(b => b.id === oExp.id))]);" + ) + in_out, in_in, f_out, f_in, o_out, o_in = got + assert in_out == [ + {"name": "보통인부", "unit": "인"}, + {"name": "표", "unit": "-"}, + {"name": "면적", "unit": "㎡"}, + ] + assert in_in == [] and f_out == [{"name": "결과", "unit": "-"}] + assert f_in == [{"name": "가", "unit": "-"}, {"name": "나", "unit": "-"}] + assert o_out == [] and o_in == [{"name": "값", "unit": "원"}] + + +def test_선_끊기_상자_지우기_검사() -> None: + got = _run( + TWO + "const cut = lg.links.find(l => l.to.box === f3.id && l.to.name === '나').id;\n" + "const a = M.unlink(lg, cut);\n" + "const b = M.removeBox(lg, f3.id);\n" + "const c = M.removeBox(M.removeBox(M.removeBox(lg, oExp.id), oVal.id), oMat.id);\n" + "out([E.evalLogic(a, V).ports[f3.id], E.checkLogic(a, V), b.links.length, lg.links.length," + " E.checkLogic(b, V), E.checkLogic(c, V), E.checkLogic(lg, {})]);" + ) + f3_after, check_a, n_b, n_all, check_b, check_c, check_novalue = got + assert f3_after == {"결과": 10} + assert check_a == ["「함수 3」 의 받는 알약 「나」 에 선이 없음"] + assert (n_b, n_all) == (5, 8) + assert check_b == [ + "「함수 4」 의 받는 알약 「가」 에 선이 없음", + ] + assert check_c == ["출력 상자가 없음"] + assert check_novalue == ["「입력」 의 「보통인부」 값이 아직 없음"] + + +def test_오류칸_순환칸_숫자아닌_출력() -> None: + got = _run( + ONE + + "lg = M.updateBox(lg, f.id, { func: { ...lg.boxes[2].func, sheet: { ...lg.boxes[2].func.sheet," + " 칸: { ...lg.boxes[2].func.sheet.칸, B5: { 식: 'B4/0' } } } } });\n" + "const a = E.evalLogic(lg, V);\n" + "lg = M.updateBox(lg, f.id, { func: { ...lg.boxes[2].func, sheet: { ...lg.boxes[2].func.sheet," + " 칸: { ...lg.boxes[2].func.sheet.칸, B5: { 식: 'B6' }, B6: { 식: 'B5' } } } } });\n" + "const b = E.evalLogic(lg, V);\n" + "out([a.errors, a.cost.계, b.errors]);" + ) + a_err, total, b_err = got + assert a_err == ["「함수」 B5 칸 계산이 막힘 — #DIV/0!", "「출력」 에 숫자가 아닌 값이 들어옴"] + assert total == 0 + assert "「함수」 B5 칸 계산이 막힘 — #CYCLE! 돌고 도는 참조" in b_err + + +def test_자리_색_단위() -> None: + got = _run( + ONE + + "let g = M.moveBox(lg, f.id, 120, 40); g = M.setColor(g, f.id, '#ff0000'); g = M.setUnit(g, f.id, '원/㎡');\n" + "g = M.setUnit(g, o.id, ' ');\n" + "const fb = g.boxes.find(b => b.id === f.id), ob = g.boxes.find(b => b.id === o.id);\n" + "out([fb.x, fb.y, fb.color, fb.unit, fb.func.output.unit, ob.unit, ob.output.unit, lg.boxes[2].x]);" + ) + assert got == [120, 40, "#ff0000", "원/㎡", "원/㎡", "-", "-", 0] + + +def test_사용자_요소_값() -> None: + got = _run( + ONE + + "lg = { ...lg, userElements: [{ id: 'U1', group: '인력', name: '석공', spec: '', unit: '인', value: 300000 }] };\n" + "lg = M.updateBox(lg, man.id, { input: { ...lg.boxes[0].input, elements: [{ ref: 'U1', name: '석공', spec: '', unit: '인' }] } });\n" + "out(E.evalLogic(lg, {}).cost.노무비);" + ) + assert got == 0.2 * 10 * 300000 + + +def test_화면_글에_금지_낱말_없음() -> None: + for name in ("M02_CF_Model.ts", "M02_CF_Eval.ts"): + src = (CF / name).read_text(encoding="utf-8") + block = src[src.index("const TEXT = {") : src.index("} as const;")] + korean = [ko for _, ko in re.findall(r'(\w+):\s*\[\s*"([^"]*)"', block)] + assert korean + for text in korean: + for bad in ("키", "식", "호표", "덧줄", "비목", "정본", "자체"): + assert bad not in text, (name, bad, text) + + +def test_알약_이름_규칙() -> None: + got = _run( + "let lg = M.newLogic(); let r = add(lg, 'input'); lg = r.lg;\n" + "lg = M.updateBox(lg, r.box.id, { input: { mode: 'group', group: '재료'," + " elements: [{ ref: 'MT1', name: '시멘트', spec: '40kg', unit: '포' }], table: { key: 'QF000421', row: 2 }," + " variables: [{ name: '길이', type: 'number', unit: 'm', example: 3 }] } });\n" + "out(M.portsOut(lg.boxes[0]).map(p => p.name));" + ) + assert got == ["시멘트", "표", "길이"]