From 67e4c27658efc04224ba2c7c05cf7f3f8a0ab7f0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:04:15 +0900 Subject: [PATCH] =?UTF-8?q?feat(m01-sl):=20=EC=8B=9C=ED=8A=B8=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20Model=20=C2=B7=20Eval=20=EC=86=8D=20=EC=B1=84?= =?UTF-8?q?=EC=9B=80=20=C2=B7=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상자 더하기(종류별 틀 시트 · 칸 A 이름 · B 값 · C 설명) · 지우기 · 같은 이름은 「 (2)」 - 선 잇기 = 받는 입력 알약 칸에 「'시트'!칸」 참조 수식(여러 선은 +) · 끊기 = 그 참조 뺌 · 순환 · 같은 상자 · 두 번 · 없는 알약은 막고 까닭 - 알약 정하기 · 빼기(이름 · 칸이 바뀌면 선이 따라감) - 바깥 값 칸 목록 · 넣기(표는 칸 덩어리) · 화면 계산 = 우리 스프레드시트 엔진 recalcWorkbook · 결과 노무비 · 재료비 · 경비 · 계 - 검사: 빈 입력 알약 · 안 받은 바깥 값 · 결과 상자 없음 / 선 없음 · 오류 칸 · 순환(#CYCLE!) - 시험 resources/tester/test_m01_sl_model.py 10개(엔진 그대로 Node) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk --- M01_MasterData/SheetLogic/M01_SL_Eval.ts | 143 ++++++++- M01_MasterData/SheetLogic/M01_SL_Model.ts | 366 ++++++++++++++++++++-- resources/tester/test_m01_sl_model.py | 263 ++++++++++++++++ 3 files changed, 728 insertions(+), 44 deletions(-) create mode 100644 resources/tester/test_m01_sl_model.py diff --git a/M01_MasterData/SheetLogic/M01_SL_Eval.ts b/M01_MasterData/SheetLogic/M01_SL_Eval.ts index 00dec78e7..0c8b3550d 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Eval.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Eval.ts @@ -2,9 +2,14 @@ * M01_SL_Eval.ts * 시트 로직 화면 계산 — 바깥 값 넣기 → 우리 스프레드시트 엔진(브라우저) → 출력 알약 · 결과 읽기 · 검사 * 저장 때는 서버가 같은 엔진을 Node 로(정함 5) · DOM 없음 + * 엔진을 정적으로 가져옴 — 화면(Entry)은 SheetLogic 을 import() 로 늦게 불러 M01 첫 묶음에 안 들어가게 * ========================================================================== */ -import type { SheetLogic, SlOutside } from "./M01_SL_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 } from "../../A00_Common/spreadsheet/spreadsheet_types"; +import { nodeSheet, type SheetLogic, type SlNode, type SlOutside } from "./M01_SL_Model"; /** 알약 값 — 수 · 글 · 참거짓 · 빈 칸 */ export type SlValue = number | string | boolean | null; @@ -19,31 +24,143 @@ export interface SlOutsideValue { export interface SlEvalResult { /** 상자 id → 출력 알약 이름 → 값 */ pills: Record>; + /** 상자 id → 입력 알약 이름 → 값(화면 알약 위 숫자) */ + pillsIn: Record>; result: { 노무비: number | null; 재료비: number | null; 경비: number | null; 계: number | null }; /** 쉬운 말 */ 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} {pill} */ + EmptyIn: [ + "「{box}」의 입력 알약 「{pill}」이 비어 있음", + "Input pill 「{pill}」 of 「{box}」 is empty", + ], + NoOutside: [ + "「{box}」의 바깥 값 「{pill}」을 아직 안 받음", + "Outside value 「{pill}」 of 「{box}」 not filled yet", + ], + NoResult: ["결과 상자가 없음", "There is no result box"], + NoLine: ["결과 상자에 이어진 선이 없음", "No line reaches the result box"], +} 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, + ); + +/* ── 바깥 값 ────────────────────────────────────────────────────────────── */ /** 바깥 값 칸 목록 — 화면 · 서버가 이 칸에 값을 넣음 */ -export function outsideCells(_sl: SheetLogic): SlOutside[] { - return notReady(); +export function outsideCells(sl: SheetLogic): SlOutside[] { + const kindOf = (n: SlNode): SlOutside["kind"] => + n.kind === "table" ? "table" : n.kind === "logic" ? "logic" : "price"; + return sl.nodes.flatMap((n) => + [...n.pillsIn, ...n.pillsOut] + .filter((p) => p.outside) + .map((p) => ({ node: n.id, cell: p.cell, kind: kindOf(n), ref: n.ref ?? "" })), + ); +} + +/** 칸 하나에 값 — null 이면 값을 뺌(서식은 둠) */ +function putValue(칸: Record, cell: string, value: SlValue): void { + const style = 칸[cell]?.서식; + if (value === null) { + if (style) 칸[cell] = { 서식: style }; + else delete 칸[cell]; + } else 칸[cell] = { 값: value, ...(style ? { 서식: style } : {}) }; } /** 바깥 값 넣기 — 새 시트 로직(받은 것은 안 바꿈) */ -export function applyOutside(_sl: SheetLogic, _values: SlOutsideValue[]): SheetLogic { - return notReady(); +export function applyOutside(sl: SheetLogic, values: SlOutsideValue[]): SheetLogic { + const out = structuredClone(sl); + for (const v of values) { + const sheet = nodeSheet(out, v.node); + const at = parseA1(v.cell); + if (!sheet || !at) continue; + if (!Array.isArray(v.value)) { + putValue(sheet.칸, toA1(at.r, at.c), v.value); + continue; + } + v.value.forEach((line, dr) => + line.forEach((x, dc) => putValue(sheet.칸, toA1(at.r + dr, at.c + dc), x)), + ); + } + return out; } +/* ── 계산 ───────────────────────────────────────────────────────────────── */ + +const fromCalc = (v: CalcValue | undefined): SlValue => { + if (!v) return null; + if ("수" in v) return Number(v.수); + if ("글" in v) return v.글; + if ("참" in v) return v.참; + return v.오류; +}; + +const isNum = (v: SlValue): v is number => typeof v === "number" && Number.isFinite(v); + /** 화면 계산 */ -export function evalLogic(_sl: SheetLogic): SlEvalResult { - return notReady(); +export function evalLogic(sl: SheetLogic): SlEvalResult { + const values = recalcWorkbook(sl.workbook); + const read = (n: SlNode, cell: string): SlValue => { + const c = nodeSheet(sl, n.id)?.칸[cell.toUpperCase()]; + if (!c) return null; + if (c.식 !== undefined) return fromCalc(values[n.sheetId]?.[cell.toUpperCase()]); + return c.값 ?? null; + }; + const pills: SlEvalResult["pills"] = {}; + const pillsIn: SlEvalResult["pillsIn"] = {}; + for (const n of sl.nodes) { + pills[n.id] = Object.fromEntries(n.pillsOut.map((p) => [p.name, read(n, p.cell)])); + pillsIn[n.id] = Object.fromEntries(n.pillsIn.map((p) => [p.name, read(n, p.cell)])); + } + const errors: string[] = []; + for (const n of sl.nodes) + for (const [cell, v] of Object.entries(values[n.sheetId] ?? {})) + if ("오류" in v) { + const err = v.오류 === "#CYCLE!" ? `${v.오류} ${tl("Cycle")}` : v.오류; + errors.push(tl("CellError", { box: n.name, cell, err })); + } + const box = sl.nodes.find((n) => n.kind === "result"); + const num = (v: SlValue | undefined): number | null => (isNum(v ?? null) ? (v as number) : null); + const result: SlEvalResult["result"] = box + ? { + 노무비: num(pillsIn[box.id]["노무비"]), + 재료비: num(pillsIn[box.id]["재료비"]), + 경비: num(pillsIn[box.id]["경비"]), + 계: num(pills[box.id]["계"]), + } + : { 노무비: null, 재료비: null, 경비: null, 계: null }; + return { pills, pillsIn, result, errors }; } -/** 검사 — 빈 입력 알약 · 순환 · 오류 칸(쉬운 말 목록) */ -export function checkLogic(_sl: SheetLogic): string[] { - return notReady(); +/** 검사 — 빈 입력 알약 · 받지 않은 바깥 값 · 순환 · 오류 칸 · 결과 상자 없음(쉬운 말 목록) */ +export function checkLogic(sl: SheetLogic): string[] { + const out: string[] = []; + for (const n of sl.nodes) { + const sheet = nodeSheet(sl, n.id); + for (const p of [...n.pillsIn, ...n.pillsOut]) { + const c = sheet?.칸[p.cell.toUpperCase()]; + const empty = !c || (c.식 === undefined && (c.값 === undefined || c.값 === "")); + if (!empty) continue; + if (p.outside) out.push(tl("NoOutside", { box: n.name, pill: p.name })); + // 결과 상자의 비목 알약은 비어도 0 — 안 쓰는 비목이 늘 있음 + else if (n.pillsIn.includes(p) && n.kind !== "result") + out.push(tl("EmptyIn", { box: n.name, pill: p.name })); + } + } + const box = sl.nodes.find((n) => n.kind === "result"); + if (!box) out.push(tl("NoResult")); + else if (!sl.edges.some((e) => e.to.node === box.id)) out.push(tl("NoLine")); + return [...out, ...evalLogic(sl).errors]; } diff --git a/M01_MasterData/SheetLogic/M01_SL_Model.ts b/M01_MasterData/SheetLogic/M01_SL_Model.ts index dc6d86f02..6d3ba6ca8 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Model.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Model.ts @@ -1,11 +1,15 @@ /* ============================================================================= * M01_SL_Model.ts * 시트 로직 Model — 로직 한 벌 = 통합문서 하나 · 상자 하나 = 시트 하나 · 알약 = 이름 붙은 칸 - * 선 = 받는 입력 알약 칸에 「'시트 이름'!칸」 참조 수식 · DOM 없음 + * 선 = 받는 입력 알약 칸에 「'시트 이름'!칸」 참조 수식(여러 선이면 「+」 로 이음) · DOM 없음 * 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` 정함 1~5 그대로 + * + * 모든 함수는 새 시트 로직을 돌려줌(받은 것은 안 바꿈) · 엔진(A00_Common/spreadsheet)은 가져다 쓰기만 * ========================================================================== */ -import type { Workbook } from "../../A00_Common/spreadsheet/spreadsheet_types"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { quoteSheet } from "../../A00_Common/spreadsheet/spreadsheet_address"; +import type { Cell, Sheet, Workbook } from "../../A00_Common/spreadsheet/spreadsheet_types"; /** 상자 종류 — 입력값 · 고정값 · 표 · 계산 · 항목 · 다른 로직 · 결과 */ export type SlKind = "input" | "const" | "table" | "calc" | "item" | "logic" | "result"; @@ -73,61 +77,361 @@ export interface SlEnd { pill: string; } -const notReady = (): never => { - throw new Error("준비 중"); +const TEXT = { + Name: ["이름", "Name"], + Value: ["값", "Value"], + Note: ["설명", "Note"], + Sample: ["예시값", "Sample value"], + Fixed: ["늘 이 값", "Always this value"], + Cond: ["조건", "Condition"], + Found: ["결과", "Result"], + FromTable: ["표에서 찾은 값", "Value found in the table"], + ValueA: ["값1", "Value 1"], + ValueB: ["값2", "Value 2"], + Times: ["값1 × 값2", "Value 1 × Value 2"], + Qty: ["수량", "Quantity"], + Price: ["단가", "Unit price"], + Amount: ["금액", "Amount"], + QtyNote: ["선으로 받음", "Comes in by a line"], + PriceNote: ["바깥 값 — 열 때 넣음", "Outside value — filled on open"], + LogicNote: ["다른 로직 결과 — 열 때 넣음", "Other logic's result — filled on open"], + Sum: ["계", "Total"], + NoPill: ["알약이 없음", "No such pill"], + Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"], + Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"], + Twice: ["이미 이어져 있음", "Already linked"], +} as const; + +const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +/** 비목 이름(결과 · 항목 칸) — 화면 글 아님 · 알약 이름 열쇠 */ +const COSTS = ["노무비", "재료비", "경비"] as const; + +/* ── 작은 도움 ──────────────────────────────────────────────────────────── */ + +const STAGE: Record = { + input: 0, + const: 0, + table: 1, + logic: 1, + calc: 2, + item: 3, + result: 4, }; +const clone = (v: T): T => structuredClone(v); + +/** 다음 번호 — 있는 id 들(「n3」)보다 하나 큰 것 */ +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)」) */ +function uniqueName(book: Workbook, name: string): string { + const base = name.trim() || "상자"; + const used = new Set(book.시트.map((s) => s.이름)); + if (!used.has(base)) return base; + for (let n = 2; ; n++) if (!used.has(`${base} (${n})`)) return `${base} (${n})`; +} + +const sheetOf = (sl: SheetLogic, node: SlNode): Sheet => { + const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId); + if (!sheet) throw new Error(`없는 시트 ${node.sheetId}`); + return sheet; +}; + +const nodeOf = (sl: SheetLogic, id: string): SlNode | undefined => + sl.nodes.find((n) => n.id === id); + +const pillOf = (node: SlNode, dir: "in" | "out", name: string): SlPill | undefined => + (dir === "in" ? node.pillsIn : node.pillsOut).find((p) => p.name === name); + +/* ── 상자 틀 ─────────────────────────────────────────────────────────────── */ + +type Row = [string, Cell | null, string]; + +/** 종류별 틀 — 1행 머리(이름 · 값 · 설명) · 2행부터 알약 줄 */ +function template(kind: SlKind, name: string): { rows: Row[]; ins: SlPill[]; outs: SlPill[] } { + const v = (값: number | string): Cell => ({ 값 }); + const f = (식: string): Cell => ({ 식 }); + switch (kind) { + case "input": + return { rows: [[name, v(0), tl("Sample")]], ins: [], outs: [{ name, cell: "B2" }] }; + case "const": + return { rows: [[name, v(0), tl("Fixed")]], ins: [], outs: [{ name, cell: "B2" }] }; + case "table": + return { + rows: [ + [tl("Cond"), null, tl("QtyNote")], + [tl("Found"), null, tl("FromTable")], + ], + ins: [{ name: tl("Cond"), cell: "B2" }], + outs: [{ name: tl("Found"), cell: "B3" }], + }; + case "calc": + return { + rows: [ + [tl("ValueA"), null, tl("QtyNote")], + [tl("ValueB"), null, tl("QtyNote")], + [tl("Found"), f("B2*B3"), tl("Times")], + ], + ins: [ + { name: tl("ValueA"), cell: "B2" }, + { name: tl("ValueB"), cell: "B3" }, + ], + outs: [{ name: tl("Found"), cell: "B4" }], + }; + case "item": + return { + rows: [ + [tl("Qty"), null, tl("QtyNote")], + [tl("Price"), null, tl("PriceNote")], + [tl("Amount"), f("B2*B3"), ""], + ["노무비", f("B4"), ""], + ["재료비", v(0), ""], + ["경비", v(0), ""], + ], + ins: [ + { name: tl("Qty"), cell: "B2" }, + { name: tl("Price"), cell: "B3", outside: true }, + ], + outs: [ + { name: tl("Amount"), cell: "B4" }, + ...COSTS.map((c, i) => ({ name: c, cell: `B${5 + i}` })), + ], + }; + case "logic": + return { + rows: [[tl("Found"), null, tl("LogicNote")]], + ins: [], + outs: [{ name: tl("Found"), cell: "B2", outside: true }], + }; + case "result": + return { + rows: [ + ["노무비", null, tl("QtyNote")], + ["재료비", null, tl("QtyNote")], + ["경비", null, tl("QtyNote")], + [tl("Sum"), f("SUM(B2:B4)"), ""], + ], + ins: COSTS.map((c, i) => ({ name: c, cell: `B${2 + i}` })), + outs: [{ name: "계", cell: "B5" }], + }; + } +} + +/* ── 만들기 · 상자 ───────────────────────────────────────────────────────── */ + /** 빈 시트 로직 */ -export function newLogic(_name?: string, _resultUnit?: string): SheetLogic { - return notReady(); +export function newLogic(name = "", resultUnit = "원/㎡"): SheetLogic { + return { + key: null, + name, + resultUnit, + workbook: { 종류: "통합문서", 판: 1, 열: "SL", 서식: [{}], 시트: [] }, + nodes: [], + edges: [], + }; } /** 상자 더하기 — 그 종류 틀(칸 A 이름 · B 값 · C 설명)로 시트를 만듦 */ export function addNode( - _sl: SheetLogic, - _kind: SlKind, - _name: string, + sl: SheetLogic, + kind: SlKind, + name: string, ): { sl: SheetLogic; node: SlNode } { - return notReady(); + const out = clone(sl); + const book = out.workbook; + const sheetId = nextId( + "s", + book.시트.map((s) => s.id), + ); + const title = uniqueName(book, name); + const t = template(kind, title); + const 칸: Record = { + A1: { 값: tl("Name") }, + B1: { 값: tl("Value") }, + C1: { 값: tl("Note") }, + }; + t.rows.forEach(([label, cell, note], i) => { + 칸[`A${i + 2}`] = { 값: label }; + if (cell) 칸[`B${i + 2}`] = cell; + if (note) 칸[`C${i + 2}`] = { 값: note }; + }); + book.시트.push({ id: sheetId, 이름: title, 칸 }); + book.활성 ??= sheetId; + const node: SlNode = { + id: nextId( + "n", + out.nodes.map((n) => n.id), + ), + kind, + name: title, + sheetId, + pillsIn: t.ins, + pillsOut: t.outs, + stage: STAGE[kind], + }; + out.nodes.push(node); + return { sl: out, node }; } /** 상자 지우기 — 시트 · 이어진 선 · 받던 참조 수식을 함께 지움 */ -export function removeNode(_sl: SheetLogic, _node: string): SheetLogic { - return notReady(); +export function removeNode(sl: SheetLogic, id: string): SheetLogic { + const node = nodeOf(sl, id); + if (!node) return sl; + let out = sl; + for (const e of sl.edges.filter((x) => x.from.node === id || x.to.node === id)) + out = disconnect(out, e.id); + out = clone(out); + out.nodes = out.nodes.filter((n) => n.id !== id); + out.workbook.시트 = out.workbook.시트.filter((s) => s.id !== node.sheetId); + if (out.workbook.활성 === node.sheetId) out.workbook.활성 = out.workbook.시트[0]?.id; + return out; +} + +/* ── 선 ─────────────────────────────────────────────────────────────────── */ + +/** 받는 알약 칸 수식 = 그 알약으로 오는 선들의 참조를 「+」 로 · 선이 없으면 칸을 비움 */ +function rewriteTarget(sl: SheetLogic, to: SlEnd): void { + const node = nodeOf(sl, to.node); + const pill = node && pillOf(node, "in", to.pill); + if (!node || !pill) return; + const refs = sl.edges + .filter((e) => e.to.node === to.node && e.to.pill === to.pill) + .map((e) => { + const src = nodeOf(sl, e.from.node); + const out = src && pillOf(src, "out", e.from.pill); + return src && out ? `${quoteSheet(sheetOf(sl, src).이름)}!${out.cell}` : null; + }) + .filter((x): x is string => x !== null); + const sheet = sheetOf(sl, node); + const keepStyle = sheet.칸[pill.cell]?.서식; + if (refs.length) + sheet.칸[pill.cell] = { 식: refs.join("+"), ...(keepStyle ? { 서식: keepStyle } : {}) }; + else if (keepStyle) sheet.칸[pill.cell] = { 서식: keepStyle }; + else delete sheet.칸[pill.cell]; +} + +/** from 상자에서 선을 따라 to 상자에 닿나 */ +function reaches(sl: SheetLogic, 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 sl.edges.filter((e) => e.from.node === id).some((e) => walk(e.to.node)); + }; + return walk(from); } /** 선 잇기 — 받는 알약 칸에 참조 수식 · 순환 · 없는 알약이면 막고 까닭(쉬운 말) */ export function connect( - _sl: SheetLogic, - _from: SlEnd, - _to: SlEnd, + sl: SheetLogic, + from: SlEnd, + to: SlEnd, ): { sl: SheetLogic; why: string | null } { - return notReady(); + const a = nodeOf(sl, from.node); + const b = nodeOf(sl, to.node); + if (!a || !b || !pillOf(a, "out", from.pill) || !pillOf(b, "in", to.pill)) + return { sl, why: tl("NoPill") }; + if (a.id === b.id) return { sl, why: tl("Self") }; + const same = (e: SlEdge): boolean => + e.from.node === from.node && + e.from.pill === from.pill && + e.to.node === to.node && + e.to.pill === to.pill; + if (sl.edges.some(same)) return { sl, why: tl("Twice") }; + if (reaches(sl, b.id, a.id)) return { sl, why: tl("Loop") }; + const out = clone(sl); + out.edges.push({ + id: nextId( + "e", + out.edges.map((e) => e.id), + ), + from: { ...from }, + to: { ...to }, + }); + rewriteTarget(out, to); + return { sl: out, why: null }; } /** 선 끊기 — 그 참조를 받는 칸 수식에서 뺌 */ -export function disconnect(_sl: SheetLogic, _edge: string): SheetLogic { - return notReady(); +export function disconnect(sl: SheetLogic, edgeId: string): SheetLogic { + const edge = sl.edges.find((e) => e.id === edgeId); + if (!edge) return sl; + const out = clone(sl); + out.edges = out.edges.filter((e) => e.id !== edgeId); + rewriteTarget(out, edge.to); + return out; } -/** 알약 정하기 — 같은 칸이나 같은 이름이 있으면 바꿈 */ +/* ── 알약 ───────────────────────────────────────────────────────────────── */ + +/** 알약 정하기 — 같은 칸이나 같은 이름이 있으면 바꿈(이름이 바뀌면 이어진 선도 따라감) */ export function setPill( - _sl: SheetLogic, - _node: string, - _cell: string, - _dir: "in" | "out", - _name: string, - _extra?: Pick, + sl: SheetLogic, + nodeId: string, + cell: string, + dir: "in" | "out", + name: string, + extra: Pick = {}, ): SheetLogic { - return notReady(); + const out = clone(sl); + const node = nodeOf(out, nodeId); + if (!node) return sl; + const list = dir === "in" ? node.pillsIn : node.pillsOut; + const at = list.findIndex((p) => p.cell === cell || p.name === name); + const pill: SlPill = { name, cell: cell.toUpperCase(), ...extra }; + if (at < 0) { + list.push(pill); + return out; + } + const old = list[at]; + list[at] = pill; + for (const e of out.edges) { + const end = dir === "in" ? e.to : e.from; + if (end.node === nodeId && end.pill === old.name) end.pill = name; + } + // 칸이 바뀌면 선이 가리키던 칸도 새로 씀 + if (old.cell !== pill.cell) { + const linked = out.edges.some((e) => e.to.node === nodeId && e.to.pill === name); + if (dir === "in" && linked) { + delete sheetOf(out, node).칸[old.cell]; + rewriteTarget(out, { node: nodeId, pill: name }); + } else if (dir === "out") + for (const e of out.edges.filter((x) => x.from.node === nodeId && x.from.pill === name)) + rewriteTarget(out, e.to); + } + return out; } /** 알약 빼기 — 이어진 선도 끊음 */ export function removePill( - _sl: SheetLogic, - _node: string, - _dir: "in" | "out", - _name: string, + sl: SheetLogic, + nodeId: string, + dir: "in" | "out", + name: string, ): SheetLogic { - return notReady(); + let out = sl; + for (const e of sl.edges) { + const end = dir === "in" ? e.to : e.from; + if (end.node === nodeId && end.pill === name) out = disconnect(out, e.id); + } + out = clone(out); + const node = nodeOf(out, nodeId); + if (!node) return out; + if (dir === "in") node.pillsIn = node.pillsIn.filter((p) => p.name !== name); + else node.pillsOut = node.pillsOut.filter((p) => p.name !== name); + return out; +} + +/** 상자 · 시트 찾기(Eval · 화면이 씀) */ +export function nodeSheet(sl: SheetLogic, nodeId: string): Sheet | null { + const node = nodeOf(sl, nodeId); + return node ? sheetOf(sl, node) : null; } diff --git a/resources/tester/test_m01_sl_model.py b/resources/tester/test_m01_sl_model.py new file mode 100644 index 000000000..03c040d21 --- /dev/null +++ b/resources/tester/test_m01_sl_model.py @@ -0,0 +1,263 @@ +"""M01 시트 로직 Model · Eval 시험(35-5) — Node 로 `M01_SL_Model.ts` · `M01_SL_Eval.ts` 와 진짜 스프레드시트 엔진을 돌림. + +· 견본 시트 로직 둘은 이 시험 안에서 만듦 — 마스터 · `_data` 에 기대지 않음 · 저장 안 함 +· 입력값 → 계산 → 항목 → 결과 · 선 잇기 / 끊기 → 값 바뀜 · 순환 막음 · 바깥 값 넣기 · 알약 · 상자 지우기 · 검사 +· 엔진 · Model 은 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] +SL = ROOT / "M01_MasterData" / "SheetLogic" + +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, "M01_MasterData", "SheetLogic", "M01_SL_Model.ts")); +const E = loadTs(path.join(ROOT, "M01_MasterData", "SheetLogic", "M01_SL_Eval.ts")); +const out = (x) => console.log(JSON.stringify(x)); +const sheet = (sl, id) => M.nodeSheet(sl, id); +const link = (sl, a, ap, b, bp) => { + const r = M.connect(sl, { node: a.id, pill: ap }, { node: b.id, pill: bp }); + if (r.why) throw new Error(r.why); + return r.sl; +}; +""" + + +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 sl = M.newLogic("시험 하나", "원/㎡"); +let r = M.addNode(sl, "input", "품"); sl = r.sl; const pum = r.node; +r = M.addNode(sl, "item", "보통 인부"); sl = r.sl; const man = r.node; man.ref = "LB000002"; +r = M.addNode(sl, "result", "결과"); sl = r.sl; const res = r.node; +sl = E.applyOutside(sl, [{ node: pum.id, cell: "B2", value: 0.5 }, { node: man.id, cell: "B3", value: 100000 }]); +sl = link(sl, pum, "품", man, "수량"); +sl = link(sl, man, "노무비", res, "노무비"); +""" + +# 견본 둘 — 품 · 할증률(고정값) → 계산(곱) → 석공 · 보통인부 두 항목 → 결과 노무비(두 선을 더함) +TWO = r""" +let sl = M.newLogic("시험 둘"); +let r = M.addNode(sl, "input", "품"); sl = r.sl; const pum = r.node; +r = M.addNode(sl, "const", "할증률"); sl = r.sl; const rate = r.node; +r = M.addNode(sl, "calc", "할증 품"); sl = r.sl; const calc = r.node; +r = M.addNode(sl, "item", "석공"); sl = r.sl; const stone = r.node; +r = M.addNode(sl, "item", "보통인부"); sl = r.sl; const man = r.node; +r = M.addNode(sl, "result", "결과"); sl = r.sl; const res = r.node; +sl = E.applyOutside(sl, [ + { node: pum.id, cell: "B2", value: 0.2 }, { node: rate.id, cell: "B2", value: 1.3 }, + { node: stone.id, cell: "B3", value: 200000 }, { node: man.id, cell: "B3", value: 100000 }, +]); +sl = link(sl, pum, "품", calc, "값1"); +sl = link(sl, rate, "할증률", calc, "값2"); +sl = link(sl, calc, "결과", stone, "수량"); +sl = link(sl, pum, "품", man, "수량"); +sl = link(sl, stone, "노무비", res, "노무비"); +sl = link(sl, man, "노무비", res, "노무비"); +""" + + +def test_입력값_항목_결과_수식과_값() -> None: + got = _run( + ONE + + "out([sheet(sl, man.id).칸.B2, sheet(sl, res.id).칸.B2, E.evalLogic(sl), sl.edges, sl.nodes.map(n => [n.kind, n.stage])," + " sl.workbook.시트.map(s => s.이름)]);" + ) + qty, total_in, ev, edges, kinds, names = got + assert qty == {"식": "품!B2"} + assert total_in == {"식": "'보통 인부'!B5"} + assert ev["result"] == {"노무비": 50000, "재료비": None, "경비": None, "계": 50000} + assert ev["pills"]["n2"]["금액"] == 50000 and ev["pillsIn"]["n2"]["수량"] == 0.5 + assert ev["errors"] == [] + assert edges == [ + {"id": "e1", "from": {"node": "n1", "pill": "품"}, "to": {"node": "n2", "pill": "수량"}}, + { + "id": "e2", + "from": {"node": "n2", "pill": "노무비"}, + "to": {"node": "n3", "pill": "노무비"}, + }, + ] + assert kinds == [["input", 0], ["item", 3], ["result", 4]] + assert names == ["품", "보통 인부", "결과"] + + +def test_계산_두_항목_더함_값바뀜() -> None: + got = _run( + TWO + "const a = E.evalLogic(sl);\n" + "const sl2 = E.applyOutside(sl, [{ node: rate.id, cell: 'B2', value: 1.5 }]);\n" + "out([a.result, a.pills[calc.id], sheet(sl, res.id).칸.B2, E.evalLogic(sl2).result]);" + ) + a, c, cell, b = got + # 석공 0.2×1.3×200000 = 52000 · 보통인부 0.2×100000 = 20000 + assert a == {"노무비": 72000, "재료비": None, "경비": None, "계": 72000} + assert c == {"결과": 0.26} + assert cell == {"식": "석공!B5+보통인부!B5"} + assert b["계"] == 0.2 * 1.5 * 200000 + 20000 + + +def test_선_끊기_다시_잇기() -> None: + got = _run( + TWO + "const cut = sl.edges.find(e => e.from.node === stone.id).id;\n" + "const a = M.disconnect(sl, cut);\n" + "const b = M.disconnect(M.disconnect(sl, cut), sl.edges.find(e => e.from.node === man.id && e.to.node === res.id).id);\n" + "const c = link(a, stone, '노무비', res, '경비');\n" + "out([sheet(a, res.id).칸.B2, E.evalLogic(a).result, sheet(b, res.id).칸.B2 ?? null, E.checkLogic(b)," + " E.evalLogic(c).result, a.edges.length]);" + ) + cell_a, ra, cell_b, check_b, rc, n_edges = got + assert cell_a == {"식": "보통인부!B5"} and ra["계"] == 20000 + assert cell_b is None + assert "결과 상자에 이어진 선이 없음" in check_b + assert not any("입력 알약" in x for x in check_b) + assert rc == {"노무비": 20000, "재료비": None, "경비": 52000, "계": 72000} + assert n_edges == 5 + + +def test_순환_같은상자_두번_없는알약_막음() -> None: + got = _run( + TWO + + "let r2 = M.addNode(sl, 'calc', '다른 계산'); const sl3 = r2.sl; const other = r2.node;\n" + "const s4 = link(sl3, calc, '결과', other, '값1');\n" + "const tries = [\n" + " M.connect(s4, { node: other.id, pill: '결과' }, { node: calc.id, pill: '값1' }).why,\n" + " M.connect(s4, { node: calc.id, pill: '결과' }, { node: calc.id, pill: '값2' }).why,\n" + " M.connect(s4, { node: calc.id, pill: '결과' }, { node: other.id, pill: '값1' }).why,\n" + " M.connect(s4, { node: calc.id, pill: '없음' }, { node: other.id, pill: '값2' }).why,\n" + " M.connect(s4, { node: res.id, pill: '계' }, { node: pum.id, pill: '품' }).why,\n" + "];\n" + "const same = M.connect(s4, { node: other.id, pill: '결과' }, { node: calc.id, pill: '값1' }).sl === s4;\n" + "out([tries, same]);" + ) + tries, same = got + assert tries == [ + "이으면 돌고 도는 선이 됨", + "같은 상자끼리는 못 이음", + "이미 이어져 있음", + "알약이 없음", + "알약이 없음", + ] + assert same is True + + +def test_엔진_순환은_오류로_알림() -> None: + got = _run(TWO + "sheet(sl, calc.id).칸.B3 = { 식: 'B4' };\nout(E.evalLogic(sl).errors);") + assert got and all("돌고 도는 참조" in e for e in got) + assert got[0].startswith("「할증 품」 ") + + +def test_바깥값_칸목록과_표_덩어리() -> None: + got = _run( + ONE + + "r = M.addNode(sl, 'table', '메쌓기 표'); sl = r.sl; const tb = r.node; tb.ref = 'QF000421';\n" + "sl.nodes.find(n => n.id === tb.id).ref = 'QF000421';\n" + "sl = M.setPill(sl, tb.id, 'E1', 'out', '표', { outside: true });\n" + "r = M.addNode(sl, 'logic', '작업량'); sl = r.sl; const lg = r.node;\n" + "sl.nodes.find(n => n.id === lg.id).ref = 'GF000114';\n" + "const cells = E.outsideCells(sl);\n" + "sl = E.applyOutside(sl, [{ node: tb.id, cell: 'E1', value: [['뒷길이', '석공'], [25, 0.15], [30, null]] }," + " { node: lg.id, cell: 'B2', value: 12.5 }]);\n" + "const s = sheet(sl, tb.id).칸;\n" + "out([cells, s.E1, s.F2, s.E3, s.F3 ?? null, E.evalLogic(sl).pills[lg.id], E.checkLogic(sl)]);" + ) + cells, e1, f2, e3, f3, lg, check = got + assert cells == [ + {"node": "n2", "cell": "B3", "kind": "price", "ref": "LB000002"}, + {"node": "n4", "cell": "E1", "kind": "table", "ref": "QF000421"}, + {"node": "n5", "cell": "B2", "kind": "logic", "ref": "GF000114"}, + ] + assert (e1, f2, e3, f3) == ({"값": "뒷길이"}, {"값": 0.15}, {"값": 30}, None) + assert lg == {"결과": 12.5} + assert "「메쌓기 표」의 입력 알약 「조건」이 비어 있음" in check + + +def test_알약_정하기_이름바꾸면_선이_따라감() -> None: + got = _run( + ONE + "let s2 = M.setPill(sl, pum.id, 'B2', 'out', '품셈 품');\n" + "const e = s2.edges[0].from;\n" + "s2 = M.setPill(s2, pum.id, 'D2', 'out', '품셈 품');\n" + "const moved = sheet(s2, man.id).칸.B2;\n" + "const s3 = M.removePill(s2, pum.id, 'out', '품셈 품');\n" + "const s4 = M.setPill(sl, man.id, 'C9', 'in', '할증');\n" + "out([e, moved, s3.edges.length, sheet(s3, man.id).칸.B2 ?? null, s4.nodes[1].pillsIn.map(p => [p.name, p.cell])]);" + ) + e, moved, n_edges, cleared, ins = got + assert e == {"node": "n1", "pill": "품셈 품"} + assert moved == {"식": "품!D2"} + assert n_edges == 1 and cleared is None + assert ins == [["수량", "B2"], ["단가", "B3"], ["할증", "C9"]] + + +def test_상자_지우기() -> None: + got = _run( + TWO + "const s2 = M.removeNode(sl, stone.id);\n" + "out([s2.nodes.map(n => n.name), s2.workbook.시트.map(s => s.이름), s2.edges.length," + " sheet(s2, res.id).칸.B2, E.evalLogic(s2).result.계, sl.nodes.length]);" + ) + names, sheets, n_edges, cell, total, before = got + assert names == ["품", "할증률", "할증 품", "보통인부", "결과"] and sheets == names + assert n_edges == 4 and cell == {"식": "보통인부!B5"} and total == 20000 + assert before == 6 # 받은 것은 안 바뀜 + + +def test_같은_이름_상자와_새_번호() -> None: + got = _run( + "let sl = M.newLogic(); let r = M.addNode(sl, 'input', '높이'); sl = r.sl;\n" + "r = M.addNode(sl, 'input', '높이'); sl = r.sl;\n" + "sl = M.removeNode(sl, 'n1'); r = M.addNode(sl, 'const', '높이'); sl = r.sl;\n" + "out([sl.nodes.map(n => [n.id, n.name, n.sheetId]), E.checkLogic(sl)]);" + ) + nodes, check = got + assert nodes == [["n2", "높이 (2)", "s2"], ["n3", "높이", "s3"]] + assert check == ["결과 상자가 없음"] + + +def test_화면_글에_금지_낱말_없음() -> None: + for name in ("M01_SL_Model.ts", "M01_SL_Eval.ts"): + src = (SL / 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)