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:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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 = 값 없음"
|
||||
|
||||
Reference in New Issue
Block a user