diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts b/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts index 0cd240ba0..e2438aad8 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts @@ -476,7 +476,8 @@ function openDataFlow(): void { outHost.hidden = box?.kind !== "output"; inHost.hidden = box?.kind !== "input"; if (box?.kind === "input") M.input.renderInputPanel(inHost, lg, box.id, { change: edit }); - if (box?.kind === "function") M.func.renderFuncPanel(funcHost, lg, box.id, { change: edit }); + if (box?.kind === "function") + M.func.renderFuncPanel(funcHost, lg, box.id, { change: edit }, { values, logics }); if (box?.kind === "output") M.output.renderOutputPanel(outHost, lg, box.id, { change: edit }, { result }); }; diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts b/M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts index 8d9581eb4..b1d08283b 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts @@ -39,6 +39,7 @@ const TEXT = { Else: ["아니면", "Otherwise"], If: ["만약", "If"], Now: ["지금", "now"], + NoValue: ["값 없음", "no value"], } as const; const tv = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; @@ -95,7 +96,12 @@ function pretty(text: string): string { const fmt = (n: number): string => Number(n.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); +/** 오류 글(#N/A …)은 값이 없다는 뜻 — 따옴표째 보이지 않음 */ +const isError = (v: CellScalar | undefined): boolean => + typeof v === "string" && /^#[A-Z/0!?]+$/.test(v); + function showScalar(v: CellScalar | undefined): string { + if (isError(v)) return tv("NoValue"); if (v === undefined || v === null) return "0"; if (typeof v === "number") return fmt(v); if (typeof v === "boolean") return v ? "TRUE" : "FALSE"; diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FuncExtra.ts b/M02_MasterTemplete/DataFlow/M02_DF_FuncExtra.ts index 02a8c2cc0..287a19351 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_FuncExtra.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_FuncExtra.ts @@ -8,15 +8,17 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { showToast } from "@ui/ui_template_elements"; import type { ConditionOptions } from "../../A00_Common/spreadsheet/spreadsheet_condition"; import { renderCellTable } from "./M02_DF_CellTable"; -import { evalSafe, type DetailOptions } from "./M02_DF_Detail"; +import type { DetailOptions } from "./M02_DF_Detail"; import { cellValuesOf, formulaLines, renderFormulaView } from "./M02_DF_FormulaView"; import { TEXT } from "./M02_DF_FuncText"; -import { valueChoices, type CfFunc, type CfLogic } from "./M02_DF_Model"; +import { valueChoices, type CfFunc, type CfLogic, type CfVariable } from "./M02_DF_Model"; export interface ExtraContext { lg: CfLogic; boxId: string; detail: DetailOptions; + /** 선 규칙을 지나 들어온 값 */ + arrived: Record; readOnly: boolean; place(port: string, cell: string): void; setOutput(cell: string): void; @@ -31,8 +33,7 @@ const boxOf = (x: ExtraContext) => x.lg.boxes.find((b) => b.id === x.boxId); export function drawFormula(host: HTMLElement, x: ExtraContext): void { const box = boxOf(x); if (!box?.func) return; - const ev = evalSafe(x.lg, x.detail); - renderFormulaView(host, formulaLines(box, cellValuesOf(box, ev?.arrived?.[x.boxId]))); + renderFormulaView(host, formulaLines(box, cellValuesOf(box, x.arrived))); } /** 칸 연결 표 — 표에서 칸 바꾸기 · 풀기(입력/변수/결과 모두 같은 규칙: 이미 쓰는 칸 막음 · 결과 칸 하나) */ @@ -86,3 +87,59 @@ export function conditionsOf(get: () => ExtraContext): ConditionOptions { }, }; } + +/** 변수 만들기 폼 → 변수 · 틀리면 까닭(글 열쇠) — 같은 이름 · 이미 쓰는 칸 · 숫자 아님 · 범위 거꾸로 */ +export interface VarFormValues { + name: string; + type: CfVariable["type"]; + unit: string; + min: string; + max: string; + choices: string; + example: string; +} + +export function variableFrom( + form: VarFormValues, + f: CfFunc, + cell: string, + used: (f: CfFunc) => Map, +): + | { v: CfVariable; cell: string } + | { why: "NeedName" | "SameName" | "Busy" | "NotNumber" | "BadRange" } { + const name = form.name.trim(); + if (!name) return { why: "NeedName" }; + const mine = f.variables.find((v) => v.name === name); + if (!mine && [...f.inputs.map((i) => i.port), ...f.variables.map((v) => v.name)].includes(name)) + return { why: "SameName" }; + if (used({ ...f, variables: f.variables.filter((v) => v.name !== name) }).has(cell)) + return { why: "Busy" }; + const v: CfVariable = { + name, + type: form.type, + unit: form.unit.trim() || "-", + example: null, + cell, + }; + let example: number | string | null = form.example.trim() === "" ? null : form.example.trim(); + if (form.type === "number") { + if (example !== null) { + const n = Number(String(example).replace(/,/g, "")); + if (!Number.isFinite(n)) return { why: "NotNumber" }; + example = n; + } + const lo = form.min.trim() === "" ? null : Number(form.min); + const hi = form.max.trim() === "" ? null : Number(form.max); + if (lo !== null && hi !== null) { + if (!(lo < hi)) return { why: "BadRange" }; + v.range = [lo, hi]; + } + } else if (form.type === "choice") { + v.choices = form.choices + .split(",") + .map((x) => x.trim()) + .filter(Boolean); + } + v.example = example; + return { v, cell }; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts index 437d1d532..4c5814637 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts @@ -8,14 +8,21 @@ * ========================================================================== */ import { createButton, el, showToast } from "@ui/ui_template_elements"; +import type { Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types"; import type { CalcValues, SpreadsheetHandle, Workbook, } from "../../A00_Common/spreadsheet/spreadsheet"; import type { CfBox, CfFunc, CfLogic, CfRound, CfVariable } from "./M02_DF_Model"; -import type { DetailOptions } from "./M02_DF_Detail"; -import { conditionsOf, drawCells, drawFormula, type ExtraContext } from "./M02_DF_FuncExtra"; +import { evalSafe, type DetailOptions } from "./M02_DF_Detail"; +import { + conditionsOf, + drawCells, + drawFormula, + variableFrom, + type ExtraContext, +} from "./M02_DF_FuncExtra"; import { createRoundRow } from "./M02_DF_RoundRow"; import { createUnitPick, suggestPlaces } from "./M02_DF_UnitPick"; import { ERR_KEY, FRAMES, TEXT, tf } from "./M02_DF_FuncText"; @@ -44,6 +51,8 @@ interface State { handle: SpreadsheetHandle | null; /** 수식 칸 계산값 — 바뀐 뒤에만 새로 받음(받으면 격자가 다시 그려져, 표시 쪽에서 부르면 맴돎) */ snap: CalcValues; + /** 선 규칙을 지나 이 상자 받는 알약에 들어온 값 */ + arrived: Record; /** 편집기가 마지막으로 낸 시트 글 — 같으면 다시 안 끼움 */ json: string; cell: string; @@ -72,15 +81,26 @@ const loadSheet = (): Promise st.lg.boxes.find((b) => b.id === st.boxId); const funcOf = (st: State): CfFunc | undefined => boxOf(st)?.func; -const bookOf = (func: CfFunc): Workbook => ({ +const bookOf = (sheet: Sheet): Workbook => ({ 종류: "통합문서", 판: 1, 열: "cf", 서식: [{}], - 시트: [structuredClone(func.sheet)], - 활성: func.sheet.id, + 시트: [structuredClone(sheet)], + 활성: sheet.id, }); -const sheetJson = (func: CfFunc): string => JSON.stringify(func.sheet); + +/** 보이는 시트 — 받은 알약 칸에는 선 규칙을 지나 들어온 값(arrived)을 넣어 보임(저장하는 시트에는 안 넣음) */ +function withArrived(f: CfFunc, base: Sheet, arrived: Record): Sheet { + const sheet = structuredClone(base); + for (const i of f.inputs) { + const v = arrived[i.port]; + if (i.cell && (typeof v === "number" || typeof v === "string" || typeof v === "boolean")) + sheet.칸[i.cell.toUpperCase()] = { 값: v }; + } + return sheet; +} +const shownOf = (st: State): Sheet => withArrived(funcOf(st)!, funcOf(st)!.sheet, st.arrived); /* ------------------------------ 바꾸기 ------------------------------ */ @@ -119,54 +139,21 @@ function makeVariable(st: State): void { const form = st.form; const f = funcOf(st); if (!form || !f) return; - const name = form.name.trim(); - const fail = (key: keyof typeof TEXT): void => { - form.why = tf(key); - drawVars(st); - }; - if (!name) return fail("NeedName"); - const taken = [...f.inputs.map((i) => i.port), ...f.variables.map((v) => v.name)]; - const mine = f.variables.find((v) => v.name === name); - if (taken.includes(name) && !mine) return fail("SameName"); - const busy = usedCells({ ...f, variables: f.variables.filter((v) => v.name !== name) }); - if (busy.has(st.cell)) return fail("Busy"); - let example: number | string | null = form.example.trim() === "" ? null : form.example.trim(); - const v: CfVariable = { - name, - type: form.type, - unit: form.unit.trim() || "-", - example: null, - cell: st.cell, - }; - if (form.type === "number") { - if (example !== null) { - const n = Number(String(example).replace(/,/g, "")); - if (!Number.isFinite(n)) return fail("NotNumber"); - example = n; - } - const lo = form.min.trim() === "" ? null : Number(form.min); - const hi = form.max.trim() === "" ? null : Number(form.max); - if (lo !== null && hi !== null) { - if (!(lo < hi)) return fail("BadRange"); - v.range = [lo, hi]; - } - } else if (form.type === "choice") { - v.choices = form.choices - .split(",") - .map((x) => x.trim()) - .filter(Boolean); + const made = variableFrom(form, f, st.cell, usedCells); + if ("why" in made) { + form.why = tf(made.why); + return drawVars(st); } - v.example = example; - const cell = st.cell; + const { v, cell } = made; st.form = null; patchFunc(st, (x) => { const sheet = structuredClone(x.sheet); - if (example !== null) sheet.칸[cell] = { 값: example }; + if (v.example !== null) sheet.칸[cell] = { 값: v.example }; return { ...x, sheet, inputs: x.inputs.filter((i) => i.cell !== cell), - variables: [...x.variables.filter((o) => o.name !== name && o.cell !== cell), v], + variables: [...x.variables.filter((o) => o.name !== v.name && o.cell !== cell), v], }; }); } @@ -214,6 +201,7 @@ const extra = (st: State): ExtraContext => ({ lg: st.lg, boxId: st.boxId, detail: st.detail, + arrived: st.arrived, readOnly: st.readOnly, place: (port, cell) => placePort(st, port, cell), setOutput: (cell) => setOutput(st, cell), @@ -553,15 +541,26 @@ function onBook(st: State, book: Workbook): void { const f = funcOf(st); if (!f) return; const sheet = plain(structuredClone(book.시트[0])); + const shown = shownOf(st); const hit = f.inputs.find( - (i) => i.cell && JSON.stringify(f.sheet.칸[i.cell]) !== JSON.stringify(sheet.칸[i.cell]), + (i) => + i.cell && + JSON.stringify(shown.칸[i.cell.toUpperCase()]) !== + JSON.stringify(sheet.칸[i.cell.toUpperCase()]), ); if (hit) { showToast(tf("Locked"), "error"); - st.json = sheetJson(f); - return st.handle?.setDoc(bookOf(f)); + st.json = JSON.stringify(shown); + return st.handle?.setDoc(bookOf(shown)); } - st.json = JSON.stringify(sheet); + // 보이려고 넣은 들어온 값은 저장하는 시트에 남기지 않음 + for (const i of f.inputs) { + const key = i.cell.toUpperCase(); + if (!i.cell) continue; + if (f.sheet.칸[key]) sheet.칸[key] = f.sheet.칸[key]; + else delete sheet.칸[key]; + } + st.json = JSON.stringify(withArrived(f, sheet, st.arrived)); patchFunc(st, (x) => ({ ...x, sheet })); setTimeout(() => refreshSnap(st), 0); } @@ -578,8 +577,9 @@ async function mountSheet(st: State): Promise { const f = funcOf(st); if (!st.parts.sheet.isConnected || st.handle || !f) return; st.parts.sheet.replaceChildren(); - st.json = sheetJson(f); - st.handle = createSpreadsheet(st.parts.sheet, bookOf(f), { + const shown = shownOf(st); + st.json = JSON.stringify(shown); + st.handle = createSpreadsheet(st.parts.sheet, bookOf(shown), { preview: true, tabs: false, readOnly: st.readOnly, @@ -608,6 +608,10 @@ async function mountSheet(st: State): Promise { refreshSnap(st); } +/** 이 상자 받는 알약에 선 규칙을 지나 들어온 값 — 셈이 막히면 빈 값 */ +const arrivedOf = (lg: CfLogic, boxId: string, o: DetailOptions): Record => + evalSafe(lg, o)?.arrived?.[boxId] ?? {}; + /** 함수 상자 판 그리기 — 같은 상자면 편집기를 그대로 두고 알약 · 표시만 다시 */ export function renderFuncPanel( host: HTMLElement, @@ -622,10 +626,11 @@ export function renderFuncPanel( old.lg = lg; old.on = on; old.detail = opts; + old.arrived = arrivedOf(lg, boxId, opts); const f = funcOf(old); - if (old.handle && f && sheetJson(f) !== old.json) { - old.json = sheetJson(f); - old.handle.setDoc(bookOf(f)); + if (old.handle && f && JSON.stringify(shownOf(old)) !== old.json) { + old.json = JSON.stringify(shownOf(old)); + old.handle.setDoc(bookOf(shownOf(old))); setTimeout(() => refreshSnap(old), 0); return; } @@ -651,6 +656,7 @@ export function renderFuncPanel( on, readOnly, detail: opts, + arrived: arrivedOf(lg, boxId, opts), handle: null, snap: {}, json: "", diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts b/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts index 228f3debf..fd1a99b9e 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts @@ -282,7 +282,8 @@ export function createPaper(opts: PaperOpts): Paper { outHost.hidden = box?.kind !== "output"; inHost.hidden = box?.kind !== "input"; if (box?.kind === "input") renderInputPanel(inHost, lg, box.id, { change: edit }); - if (box?.kind === "function") renderFuncPanel(funcHost, lg, box.id, { change: edit }); + if (box?.kind === "function") + renderFuncPanel(funcHost, lg, box.id, { change: edit }, { values, logics }); if (box?.kind === "output") renderOutputPanel(outHost, lg, box.id, { change: edit }, { result }); }; diff --git a/resources/tester/test_m02_df_formula_view.py b/resources/tester/test_m02_df_formula_view.py index be3d768f0..e262efa20 100644 --- a/resources/tester/test_m02_df_formula_view.py +++ b/resources/tester/test_m02_df_formula_view.py @@ -193,3 +193,17 @@ def test_받는_칸은_선_규칙을_지난_값으로_그림(): assert got["lines"][0]["valued"] == "0.15 × (1 + 4 ÷ 100) = 0.156" (stale,) = run([{"op": "lines", "box": box, "arrived": {}}]) assert stale["lines"][0]["valued"].startswith("99 ×") # 들어온 값이 없을 때만 칸 값 + + +def test_오류_글은_따옴표_없이_값_없음(): + box = func_box( + {"B1": {"식": "NA()"}, "B3": {"식": "B1+1"}}, + [], + [], + "B3", + "합", + ) + (got,) = run([{"op": "lines", "box": box, "arrived": {}}]) + valued = [x["valued"] for x in got["lines"]] + assert all('"#' not in v for v in valued) + assert valued[-1] == "값 없음 + 1 = 값 없음"