fix(M02-흐름도): 함수 판 시트 · 식 보기가 선 규칙을 지나 들어온 값(arrived)을 받은 칸에 넣어 보임 - Paper · Entry 가 값 · 다른 로직을 넘김 · 오류 글은 값 없음

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
This commit is contained in:
2026-10-02 01:44:34 +09:00
co-authored by Claude Sonnet 5.5
parent 453ac49dd8
commit 9269e9da97
6 changed files with 146 additions and 61 deletions
+2 -1
View File
@@ -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 });
};
@@ -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";
@@ -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<string, unknown>;
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<string, string>,
):
| { 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 };
}
+61 -55
View File
@@ -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<string, unknown>;
/** 편집기가 마지막으로 낸 시트 글 — 같으면 다시 안 끼움 */
json: string;
cell: string;
@@ -72,15 +81,26 @@ const loadSheet = (): Promise<typeof import("../../A00_Common/spreadsheet/spread
const boxOf = (st: State): CfBox | undefined => 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<string, unknown>): 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<void> {
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<void> {
refreshSnap(st);
}
/** 이 상자 받는 알약에 선 규칙을 지나 들어온 값 — 셈이 막히면 빈 값 */
const arrivedOf = (lg: CfLogic, boxId: string, o: DetailOptions): Record<string, unknown> =>
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: "",
+2 -1
View File
@@ -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 });
};
@@ -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 = 값 없음"