feat(M02-흐름도): 식 보기 FormulaView · 엑셀 조건 만들기 spreadsheet_condition(우클릭 · conditions 줄 때만) + 시험
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
This commit is contained in:
@@ -22,6 +22,7 @@ import { createCalcEngine } from "./spreadsheet_graph";
|
||||
import { createGrid } from "./spreadsheet_grid";
|
||||
import { createHistory } from "./spreadsheet_history";
|
||||
import { attachKeys } from "./spreadsheet_keys";
|
||||
import type { ConditionOptions } from "./spreadsheet_condition";
|
||||
import type { LinkOptions, Links } from "./spreadsheet_links";
|
||||
import { attachMenu } from "./spreadsheet_menu";
|
||||
import { attachMouse } from "./spreadsheet_mouse";
|
||||
@@ -62,6 +63,8 @@ export interface SpreadsheetOptions {
|
||||
/** 미리보기 수정(카드 안 작은 표) — 도구 모음 · 오른쪽 클릭 메뉴 없이 수식 입력 줄 · 칸 고침 · 단축키 ·
|
||||
* 알약 막대만. 다른 화면 모양은 그대로 */
|
||||
preview?: boolean;
|
||||
/** 칸 우클릭 「조건 만들기」 — 이것을 줄 때만 붙음(늦게 불러옴) · 안 주면 화면 그대로 */
|
||||
conditions?: ConditionOptions;
|
||||
}
|
||||
|
||||
export interface SpreadsheetHandle {
|
||||
@@ -165,6 +168,10 @@ export function createSpreadsheet(
|
||||
grid.render();
|
||||
select(ctx.selection);
|
||||
loadLinks();
|
||||
if (opts.conditions && !readOnly)
|
||||
void import("./spreadsheet_condition").then((m) => {
|
||||
if (root.isConnected) others.push(m.attachCondition(ctx, opts.conditions!));
|
||||
});
|
||||
|
||||
function notify(message: string): void {
|
||||
toast.textContent = message;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
.ss-cond-menu {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.ss-cond {
|
||||
min-width: min(46rem, 96vw);
|
||||
}
|
||||
|
||||
.ss-cond-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-4) 0;
|
||||
}
|
||||
|
||||
.ss-cond-label {
|
||||
min-width: 5em;
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.ss-cond-pick {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.ss-cond-cell,
|
||||
.ss-cond-value {
|
||||
width: 7em;
|
||||
}
|
||||
|
||||
.ss-cond .ui-input.is-bad {
|
||||
border-color: var(--color-danger);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.ss-cond-note {
|
||||
margin: 0 0 var(--spacing-8);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-left: 4px solid var(--color-warning);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.ss-cond-formula {
|
||||
flex: 1 1 20em;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ss-cond-foot {
|
||||
margin-top: var(--spacing-8);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_condition.ts
|
||||
* 엑셀 「조건 만들기」 — 칸 우클릭 → 줄 판: 만약 [칸·알약] [비교] [값] → [칸·알약·값] · 아니면 만약 … · 아니면 → 고른 칸에 IF 식(중첩 IF)
|
||||
* 엑셀로 쓴 IF 도 같은 줄로 다시 엶(`formulaToDraft`) · 못 바꾸는 식은 「식 그대로 고침」 안내
|
||||
* 부르는 쪽이 `conditions` 선택을 줄 때만 붙음(`attachCondition` · 늦게 불러옴) → 그 밖 화면은 그대로
|
||||
* 앞 절반(DraftToFormula · formulaToDraft)은 DOM 을 모름 — 시험이 이 구역만 떼어 Node 로 돌림
|
||||
* 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
import { toA1 } from "./spreadsheet_address";
|
||||
import {
|
||||
COND_OPS,
|
||||
CELL,
|
||||
draftToFormula,
|
||||
formulaToDraft,
|
||||
type CondBranch,
|
||||
type CondDraft,
|
||||
type CondOp,
|
||||
type CondOperand,
|
||||
} from "./spreadsheet_condition_text";
|
||||
import "./spreadsheet_condition.css";
|
||||
|
||||
/** 부르는 쪽이 주는 것 — valuesFor(a1) = 그 칸에 올 수 있는 값 목록(없으면 null → 값 칸) · pills = 이름 붙은 칸 */
|
||||
export interface ConditionOptions {
|
||||
valuesFor(a1: string): (string | number)[] | null;
|
||||
pills?(): { name: string; cell: string }[];
|
||||
}
|
||||
|
||||
export { COND_OPS, draftToFormula, formulaToDraft };
|
||||
export type { CondBranch, CondDraft, CondOp, CondOperand };
|
||||
|
||||
/* ── 글 ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const TEXT = {
|
||||
Menu: ["조건 만들기", "Make a condition"],
|
||||
Title: ["조건 만들기", "Make a condition"],
|
||||
If: ["만약", "If"],
|
||||
ElseIf: ["아니면 만약", "Else if"],
|
||||
Then: ["→", "→"],
|
||||
Otherwise: ["아니면 →", "Otherwise →"],
|
||||
AddElseIf: ["+ 아니면 만약", "+ Else if"],
|
||||
Remove: ["지움", "Remove"],
|
||||
Make: ["만들기", "Make"],
|
||||
Cancel: ["취소", "Cancel"],
|
||||
Close: ["닫기", "Close"],
|
||||
CellPick: ["칸 직접", "A cell"],
|
||||
ValuePick: ["값", "A value"],
|
||||
Cell: ["칸 주소", "Cell address"],
|
||||
Value: ["값", "Value"],
|
||||
BadCell: ["칸 주소가 아님 — 예: B2", "Not a cell address — e.g. B2"],
|
||||
Target: ["식이 들어갈 칸", "Formula goes into"],
|
||||
AsIs: [
|
||||
"이 식은 조건 줄로 바꿀 수 없음 — 식 그대로 고침",
|
||||
"This formula can't be shown as lines — edit the formula as it is",
|
||||
],
|
||||
} as const;
|
||||
|
||||
const tc = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||||
|
||||
/* ── 줄 판 ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
const OWN = "\u0001cell";
|
||||
const OWN_VALUE = "\u0001value";
|
||||
|
||||
function select(
|
||||
options: { value: string; text: string }[],
|
||||
value: string,
|
||||
onPick: (v: string) => void,
|
||||
aria: string,
|
||||
): HTMLSelectElement {
|
||||
const s = el("select", { className: "ui-select ui-select--sm", attrs: { "aria-label": aria } });
|
||||
for (const o of options) s.append(el("option", { attrs: { value: o.value }, text: o.text }));
|
||||
s.value = value;
|
||||
s.addEventListener("change", () => onPick(s.value));
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 칸 고르기 — 이름 붙은 칸(알약) 아니면 칸 주소 직접 */
|
||||
function cellPick(
|
||||
opts: ConditionOptions,
|
||||
value: string,
|
||||
onChange: (cell: string) => void,
|
||||
aria: string,
|
||||
): HTMLElement {
|
||||
const pills = opts.pills?.() ?? [];
|
||||
const hit = pills.find((p) => p.cell === value);
|
||||
const options = [
|
||||
...pills.map((p) => ({ value: p.cell, text: `${p.name} (${p.cell})` })),
|
||||
{ value: OWN, text: tc("CellPick") },
|
||||
];
|
||||
const box = el("span", { className: "ss-cond-pick" });
|
||||
const sel = select(options, hit ? value : OWN, (v) => v !== OWN && onChange(v), aria);
|
||||
box.append(sel);
|
||||
if (!hit) {
|
||||
const input = el("input", {
|
||||
className: "ui-input ui-input--sm ss-cond-cell",
|
||||
attrs: { type: "text", "aria-label": tc("Cell"), placeholder: "B2" },
|
||||
});
|
||||
input.value = value;
|
||||
input.addEventListener("change", () => {
|
||||
const m = CELL.exec(input.value.trim().toUpperCase());
|
||||
input.classList.toggle("is-bad", !m);
|
||||
input.title = m ? "" : tc("BadCell");
|
||||
if (m) onChange(`${m[1]}${m[2]}`);
|
||||
});
|
||||
box.append(input);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 값 쪽 고르기 — 칸 · 알약 · 값(목록이 있으면 고르기) */
|
||||
function operandPick(
|
||||
opts: ConditionOptions,
|
||||
o: CondOperand,
|
||||
hint: string | null,
|
||||
onChange: (o: CondOperand) => void,
|
||||
aria: string,
|
||||
): HTMLElement {
|
||||
const box = el("span", { className: "ss-cond-pick" });
|
||||
const pills = opts.pills?.() ?? [];
|
||||
const isPill = o.kind === "cell" && pills.some((p) => p.cell === o.cell);
|
||||
const options = [
|
||||
...pills.map((p) => ({ value: p.cell, text: `${p.name} (${p.cell})` })),
|
||||
{ value: OWN, text: tc("CellPick") },
|
||||
{ value: OWN_VALUE, text: tc("ValuePick") },
|
||||
];
|
||||
const mode = o.kind === "value" ? OWN_VALUE : isPill ? o.cell : OWN;
|
||||
box.append(
|
||||
select(
|
||||
options,
|
||||
mode,
|
||||
(v) => {
|
||||
if (v === OWN_VALUE) onChange({ kind: "value", value: 0 });
|
||||
else if (v === OWN) onChange({ kind: "cell", cell: "B2" });
|
||||
else onChange({ kind: "cell", cell: v });
|
||||
},
|
||||
aria,
|
||||
),
|
||||
);
|
||||
if (o.kind === "cell" && !isPill) {
|
||||
const input = el("input", {
|
||||
className: "ui-input ui-input--sm ss-cond-cell",
|
||||
attrs: { type: "text", "aria-label": tc("Cell") },
|
||||
});
|
||||
input.value = o.cell;
|
||||
input.addEventListener("change", () => {
|
||||
const m = CELL.exec(input.value.trim().toUpperCase());
|
||||
input.classList.toggle("is-bad", !m);
|
||||
if (m) onChange({ kind: "cell", cell: `${m[1]}${m[2]}` });
|
||||
});
|
||||
box.append(input);
|
||||
}
|
||||
if (o.kind === "value") {
|
||||
const list = hint ? opts.valuesFor(hint) : null;
|
||||
if (list && list.length) {
|
||||
const key = (v: string | number): string => `${typeof v}:${v}`;
|
||||
box.append(
|
||||
select(
|
||||
list.map((v) => ({ value: key(v), text: String(v) })),
|
||||
key(o.value),
|
||||
(k) => {
|
||||
const hitv = list.find((v) => key(v) === k);
|
||||
if (hitv !== undefined) onChange({ kind: "value", value: hitv });
|
||||
},
|
||||
tc("Value"),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const input = el("input", {
|
||||
className: "ui-input ui-input--sm ss-cond-value",
|
||||
attrs: { type: "text", "aria-label": tc("Value") },
|
||||
});
|
||||
input.value = String(o.value);
|
||||
input.addEventListener("change", () => {
|
||||
const t = input.value.trim();
|
||||
onChange({ kind: "value", value: t !== "" && Number.isFinite(Number(t)) ? Number(t) : t });
|
||||
});
|
||||
box.append(input);
|
||||
}
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
function openJob(
|
||||
ctx: SpreadsheetContext,
|
||||
opts: ConditionOptions,
|
||||
a1: string,
|
||||
draft: CondDraft | null,
|
||||
existing: string | null,
|
||||
): void {
|
||||
openModal({
|
||||
title: `${tc("Title")} — ${a1}`,
|
||||
closeLabel: tc("Close"),
|
||||
dialogClass: "ss-cond",
|
||||
mount: (body, close) => {
|
||||
let d: CondDraft = draft ?? {
|
||||
branches: [
|
||||
{
|
||||
left: "B1",
|
||||
op: "=",
|
||||
right: { kind: "value", value: "" },
|
||||
then: { kind: "value", value: 0 },
|
||||
},
|
||||
],
|
||||
otherwise: { kind: "value", value: 0 },
|
||||
};
|
||||
if (existing !== null && !draft) {
|
||||
body.append(
|
||||
el("p", { className: "ss-cond-note", text: tc("AsIs") }),
|
||||
el("code", { className: "ss-cond-formula", text: `=${existing}` }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const draw = (): void => {
|
||||
const set = (next: CondDraft): void => ((d = next), draw());
|
||||
const rows = d.branches.map((b, i) => {
|
||||
const edit = (patch: Partial<CondBranch>): void =>
|
||||
set({ ...d, branches: d.branches.map((x, j) => (j === i ? { ...x, ...patch } : x)) });
|
||||
return el("div", {
|
||||
className: "ss-cond-row",
|
||||
children: [
|
||||
el("span", { className: "ss-cond-label", text: tc(i ? "ElseIf" : "If") }),
|
||||
cellPick(opts, b.left, (cell) => edit({ left: cell }), tc("If")),
|
||||
select(
|
||||
COND_OPS.map((o) => ({ value: o, text: o })),
|
||||
b.op,
|
||||
(v) => edit({ op: v as CondOp }),
|
||||
"op",
|
||||
),
|
||||
operandPick(opts, b.right, b.left, (right) => edit({ right }), tc("Value")),
|
||||
tc("Then"),
|
||||
operandPick(opts, b.then, null, (then) => edit({ then }), tc("Then")),
|
||||
d.branches.length > 1
|
||||
? createButton({
|
||||
label: tc("Remove"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => set({ ...d, branches: d.branches.filter((_, j) => j !== i) }),
|
||||
})
|
||||
: "",
|
||||
],
|
||||
});
|
||||
});
|
||||
const last = d.branches[d.branches.length - 1];
|
||||
body.replaceChildren(
|
||||
...rows,
|
||||
el("div", {
|
||||
className: "ss-cond-row",
|
||||
children: [
|
||||
createButton({
|
||||
label: tc("AddElseIf"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
set({
|
||||
...d,
|
||||
branches: [
|
||||
...d.branches,
|
||||
{
|
||||
left: last.left,
|
||||
op: "=",
|
||||
right: { kind: "value", value: "" },
|
||||
then: { kind: "value", value: 0 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "ss-cond-row",
|
||||
children: [
|
||||
el("span", { className: "ss-cond-label", text: tc("Otherwise") }),
|
||||
operandPick(
|
||||
opts,
|
||||
d.otherwise,
|
||||
null,
|
||||
(otherwise) => set({ ...d, otherwise }),
|
||||
tc("Otherwise"),
|
||||
),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "ss-cond-row ss-cond-foot",
|
||||
children: [
|
||||
el("code", {
|
||||
className: "ss-cond-formula",
|
||||
text: `${tc("Target")} ${a1}: =${draftToFormula(d)}`,
|
||||
}),
|
||||
createButton({
|
||||
label: tc("Make"),
|
||||
variant: "filled",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
const sheet = ctx.sheet();
|
||||
const { 값: _v, ...keep } = sheet.칸[a1] ?? {};
|
||||
ctx.dispatch({
|
||||
종류: "칸",
|
||||
시트: sheet.id,
|
||||
칸: { [a1]: { ...keep, 식: draftToFormula(d) } },
|
||||
});
|
||||
close();
|
||||
},
|
||||
}),
|
||||
createButton({ label: tc("Cancel"), variant: "ghost", size: "sm", onClick: close }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
};
|
||||
draw();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 칸 우클릭에 「조건 만들기」 한 줄 — conditions 를 준 편집기에만 붙음 */
|
||||
export function attachCondition(ctx: SpreadsheetContext, opts: ConditionOptions): PartHandle {
|
||||
let menu: HTMLElement | null = null;
|
||||
const close = (): void => {
|
||||
menu?.remove();
|
||||
menu = null;
|
||||
document.removeEventListener("pointerdown", away, true);
|
||||
};
|
||||
const away = (e: Event): void => {
|
||||
if (menu && !menu.contains(e.target as Node)) close();
|
||||
};
|
||||
const onContext = (e: MouseEvent): void => {
|
||||
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
|
||||
if (!hit || hit.kind !== "cell" || ctx.readOnly) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
close();
|
||||
const a1 = toA1(hit.r, hit.c);
|
||||
const sheet = ctx.sheet();
|
||||
const cell = sheet.칸[a1];
|
||||
menu = el("div", { className: "ss-cond-menu" });
|
||||
menu.style.left = `${e.clientX}px`;
|
||||
menu.style.top = `${e.clientY}px`;
|
||||
menu.append(
|
||||
createButton({
|
||||
label: tc("Menu"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
close();
|
||||
const existing = cell?.식 ?? null;
|
||||
const draft = existing !== null ? formulaToDraft(existing) : null;
|
||||
openJob(ctx, opts, a1, draft, existing !== null && !draft ? existing : null);
|
||||
},
|
||||
}),
|
||||
);
|
||||
document.body.append(menu);
|
||||
document.addEventListener("pointerdown", away, true);
|
||||
};
|
||||
ctx.root.addEventListener("contextmenu", onContext, true);
|
||||
return {
|
||||
root: null,
|
||||
refresh() {},
|
||||
destroy() {
|
||||
ctx.root.removeEventListener("contextmenu", onContext, true);
|
||||
close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_condition_text.ts
|
||||
* 「조건 만들기」 의 글 쪽(DOM 없음) — 줄들 ↔ IF 식 글 · 바깥 쉼표 가름 · 시험이 이 파일만 떼어 Node 로 돌림
|
||||
* ========================================================================== */
|
||||
|
||||
export type CondOp = "=" | "≠" | "≥" | "≤" | ">" | "<";
|
||||
export type CondOperand =
|
||||
{ kind: "cell"; cell: string } | { kind: "value"; value: number | string };
|
||||
export interface CondBranch {
|
||||
/** 비교하는 칸 */
|
||||
left: string;
|
||||
op: CondOp;
|
||||
right: CondOperand;
|
||||
then: CondOperand;
|
||||
}
|
||||
export interface CondDraft {
|
||||
branches: CondBranch[];
|
||||
otherwise: CondOperand;
|
||||
}
|
||||
|
||||
export const XL_OP: Record<CondOp, string> = {
|
||||
"=": "=",
|
||||
"≠": "<>",
|
||||
"≥": ">=",
|
||||
"≤": "<=",
|
||||
">": ">",
|
||||
"<": "<",
|
||||
};
|
||||
export const FROM_XL: Record<string, CondOp> = {
|
||||
"=": "=",
|
||||
"<>": "≠",
|
||||
">=": "≥",
|
||||
"<=": "≤",
|
||||
">": ">",
|
||||
"<": "<",
|
||||
};
|
||||
export const COND_OPS: CondOp[] = ["=", "≠", "≥", "≤", ">", "<"];
|
||||
|
||||
export const CELL = /^\$?([A-Z]{1,3})\$?(\d+)$/;
|
||||
|
||||
/* ── 글 ↔ 줄 (DOM 없음) ───────────────────────────────────────────────── */
|
||||
|
||||
export const operandText = (o: CondOperand): string =>
|
||||
o.kind === "cell"
|
||||
? o.cell
|
||||
: typeof o.value === "number"
|
||||
? String(o.value)
|
||||
: `"${o.value.replace(/"/g, '""')}"`;
|
||||
|
||||
/** 줄들 → 식 글(앞 = 뺌) — 아니면 만약 은 안쪽 IF */
|
||||
export function draftToFormula(d: CondDraft): string {
|
||||
return d.branches.reduceRight(
|
||||
(rest, b) =>
|
||||
`IF(${b.left}${XL_OP[b.op]}${operandText(b.right)},${operandText(b.then)},${rest})`,
|
||||
operandText(d.otherwise),
|
||||
);
|
||||
}
|
||||
|
||||
/** 바깥 쉼표로 가름 — 따옴표 · 괄호 안은 건너뜀 */
|
||||
export function splitArgs(s: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let quote = false;
|
||||
let from = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (ch === '"') quote = !quote;
|
||||
else if (quote) continue;
|
||||
else if (ch === "(") depth++;
|
||||
else if (ch === ")") depth--;
|
||||
else if (ch === "," && depth === 0) {
|
||||
out.push(s.slice(from, i));
|
||||
from = i + 1;
|
||||
}
|
||||
}
|
||||
out.push(s.slice(from));
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseOperand(raw: string): CondOperand | null {
|
||||
const t = raw.trim();
|
||||
const cell = CELL.exec(t);
|
||||
if (cell) return { kind: "cell", cell: `${cell[1]}${cell[2]}` };
|
||||
if (/^-?\d+(\.\d+)?$/.test(t)) return { kind: "value", value: Number(t) };
|
||||
const q = /^"((?:[^"]|"")*)"$/.exec(t);
|
||||
return q ? { kind: "value", value: q[1].replace(/""/g, '"') } : null;
|
||||
}
|
||||
|
||||
/** 바깥 괄호 하나로 감싼 IF( … ) 의 안쪽 글 — 아니면 null */
|
||||
export function ifBody(f: string): string | null {
|
||||
const t = f.trim();
|
||||
if (!/^IF\(/i.test(t) || !t.endsWith(")")) return null;
|
||||
let depth = 0;
|
||||
let quote = false;
|
||||
for (let i = 2; i < t.length; i++) {
|
||||
if (t[i] === '"') quote = !quote;
|
||||
else if (quote) continue;
|
||||
else if (t[i] === "(") depth++;
|
||||
else if (t[i] === ")" && --depth === 0 && i !== t.length - 1) return null;
|
||||
}
|
||||
return t.slice(3, -1);
|
||||
}
|
||||
|
||||
/** 식 글(앞 = 뺌) → 줄들 — 이 판이 만드는 꼴(칸 비교 · 단순 값 · 아니면 쪽 중첩)만 읽고 나머지는 null */
|
||||
export function formulaToDraft(formula: string): CondDraft | null {
|
||||
const body = ifBody(formula);
|
||||
if (body === null) return null;
|
||||
const args = splitArgs(body);
|
||||
if (args.length !== 3) return null;
|
||||
const m = /^\s*(.+?)\s*(>=|<=|<>|=|>|<)\s*(.+?)\s*$/.exec(args[0]);
|
||||
if (!m) return null;
|
||||
const left = parseOperand(m[1]);
|
||||
const right = parseOperand(m[3]);
|
||||
const then = parseOperand(args[1]);
|
||||
if (!left || left.kind !== "cell" || !right || !then) return null;
|
||||
const branch: CondBranch = { left: left.cell, op: FROM_XL[m[2]], right, then };
|
||||
const plain = parseOperand(args[2]);
|
||||
if (plain) return { branches: [branch], otherwise: plain };
|
||||
const rest = formulaToDraft(args[2]);
|
||||
return rest ? { branches: [branch, ...rest.branches], otherwise: rest.otherwise } : null;
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_FormulaView.ts
|
||||
* 함수 상자 「식 보기」 — 결과 칸과 그 칸이 기대는 중간 칸의 식을 칸 주소 → 연결 알약 이름으로 · ×, ÷ · 값 들어간 식 · = 결과 ·
|
||||
* IF 는 만약 / 아니면 줄 + 지금 타는 갈래(now) · 앞 절반은 DOM 을 모름(시험이 Node 로 돌림) · 상세 내용 함수 판과 왼쪽 텍스트 수식이 함께 씀
|
||||
* 모양은 `docs/raw/M01_쉬운단가/흐름도_적용_정함.md` 약속 8 · 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { parseA1 } from "../../A00_Common/spreadsheet/spreadsheet_address";
|
||||
import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc";
|
||||
import { ifBody, splitArgs } from "../../A00_Common/spreadsheet/spreadsheet_condition_text";
|
||||
import type { CalcValue } from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import type { CfBox } from "./M02_DF_Model";
|
||||
|
||||
export type CellScalar = number | string | boolean | null;
|
||||
|
||||
export interface FormulaBranch {
|
||||
when: string;
|
||||
then: string;
|
||||
/** 지금 타는 갈래 */
|
||||
now: boolean;
|
||||
}
|
||||
|
||||
export interface FormulaLine {
|
||||
/** 이 식이 든 칸의 이름(연결 알약 이름 · 없으면 칸 주소) */
|
||||
name: string;
|
||||
/** 이름으로 쓴 식 — 「석공량 × (1 + 증가율 ÷ 100)」 */
|
||||
named: string;
|
||||
/** 값 넣은 식 — 「0.15 × (1 + 4 ÷ 100) = 0.156」 */
|
||||
valued: string;
|
||||
/** IF 면 만약 / 아니면 줄 */
|
||||
branches?: FormulaBranch[];
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
Title: ["식 보기", "Formulas"],
|
||||
Empty: ["식이 든 결과 칸이 아직 없음", "No result cell with a formula yet"],
|
||||
Else: ["아니면", "Otherwise"],
|
||||
If: ["만약", "If"],
|
||||
Now: ["지금", "now"],
|
||||
} as const;
|
||||
|
||||
const tv = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||||
|
||||
/* ── 칸 · 이름 ──────────────────────────────────────────────────────────── */
|
||||
|
||||
const REF = /(?<![A-Za-z0-9_.'!])\$?([A-Z]{1,3})\$?(\d+)(?![A-Za-z0-9_(])/g;
|
||||
|
||||
/** 따옴표 안 글은 그대로 두고 바깥 글만 바꿈 */
|
||||
function outside(text: string, fn: (part: string) => string): string {
|
||||
return text
|
||||
.split(/("(?:[^"]|"")*")/)
|
||||
.map((p, i) => (i % 2 ? p : fn(p)))
|
||||
.join("");
|
||||
}
|
||||
|
||||
const upper = (a1: string): string => a1.toUpperCase().replace(/\$/g, "");
|
||||
|
||||
/** 칸 주소 → 이름 — 받는 알약 · 변수 · 결과 칸 이름(연결 없는 칸은 주소 그대로) */
|
||||
export function cellNames(box: CfBox): Record<string, string> {
|
||||
const f = box.func;
|
||||
const out: Record<string, string> = {};
|
||||
if (!f) return out;
|
||||
for (const i of f.inputs) if (i.cell) out[upper(i.cell)] = i.port;
|
||||
for (const v of f.variables) if (v.cell) out[upper(v.cell)] = v.name;
|
||||
if (f.output.cell) out[upper(f.output.cell)] = f.output.name;
|
||||
return out;
|
||||
}
|
||||
|
||||
const refsIn = (formula: string): string[] => {
|
||||
const found: string[] = [];
|
||||
outside(formula, (p) => p.replace(REF, (m, c: string, r: string) => (found.push(`${c}${r}`), m)));
|
||||
return found;
|
||||
};
|
||||
|
||||
const OPS: [RegExp, string][] = [
|
||||
[/\*/g, " × "],
|
||||
[/\//g, " ÷ "],
|
||||
[/<>/g, " ≠ "],
|
||||
[/>=/g, " ≥ "],
|
||||
[/<=/g, " ≤ "],
|
||||
[/(?<![<>≠≥≤])=/g, " = "],
|
||||
[/\+/g, " + "],
|
||||
[/(?<=[\w가-힣)])-(?=[\w가-힣(])/g, " − "],
|
||||
];
|
||||
|
||||
/** 보기 좋게 — × ÷ ≠ ≥ ≤ 로 */
|
||||
function pretty(text: string): string {
|
||||
return outside(text, (p) => OPS.reduce((s, [re, to]) => s.replace(re, to), p))
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
const fmt = (n: number): string =>
|
||||
Number(n.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
|
||||
function showScalar(v: CellScalar | undefined): string {
|
||||
if (v === undefined || v === null) return "0";
|
||||
if (typeof v === "number") return fmt(v);
|
||||
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
||||
return `"${v}"`;
|
||||
}
|
||||
|
||||
/** 이름으로 쓴 식 */
|
||||
export function namedText(formula: string, names: Record<string, string>): string {
|
||||
return pretty(
|
||||
outside(formula, (p) =>
|
||||
p.replace(REF, (_m, c: string, r: string) => names[`${c}${r}`] ?? `${c}${r}`),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** 값 넣은 식(값이 문자면 따옴표) */
|
||||
export function valuedText(formula: string, values: Record<string, CellScalar>): string {
|
||||
return pretty(
|
||||
outside(formula, (p) =>
|
||||
p.replace(REF, (_m, c: string, r: string) => showScalar(values[`${c}${r}`])),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 갈래 ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
const num = (v: CellScalar | undefined): number | null => {
|
||||
if (typeof v === "number") return v;
|
||||
if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return Number(v);
|
||||
return null;
|
||||
};
|
||||
|
||||
/** 조건 한 줄 `왼 비교 오른` 의 참거짓 — 못 읽으면 null */
|
||||
function holds(cond: string, values: Record<string, CellScalar>): boolean | null {
|
||||
const m = /^\s*(.+?)\s*(>=|<=|<>|=|>|<)\s*(.+?)\s*$/.exec(cond);
|
||||
if (!m) return null;
|
||||
const read = (t: string): CellScalar | undefined => {
|
||||
const x = t.trim();
|
||||
const cell = /^\$?([A-Z]{1,3})\$?(\d+)$/.exec(x);
|
||||
if (cell) return values[`${cell[1]}${cell[2]}`];
|
||||
const q = /^"((?:[^"]|"")*)"$/.exec(x);
|
||||
if (q) return q[1].replace(/""/g, '"');
|
||||
return Number.isFinite(Number(x)) ? Number(x) : undefined;
|
||||
};
|
||||
const a = read(m[1]);
|
||||
const b = read(m[3]);
|
||||
if (a === undefined || b === undefined) return null;
|
||||
const [l, r] = num(a) !== null && num(b) !== null ? [num(a)!, num(b)!] : [String(a), String(b)];
|
||||
switch (m[2]) {
|
||||
case "=":
|
||||
return l === r;
|
||||
case "<>":
|
||||
return l !== r;
|
||||
case ">=":
|
||||
return l >= r;
|
||||
case "<=":
|
||||
return l <= r;
|
||||
case ">":
|
||||
return l > r;
|
||||
default:
|
||||
return l < r;
|
||||
}
|
||||
}
|
||||
|
||||
/** IF 사슬 → 갈래들 — IF 가 아니면 undefined */
|
||||
function branchesOf(
|
||||
formula: string,
|
||||
names: Record<string, string>,
|
||||
values: Record<string, CellScalar>,
|
||||
): FormulaBranch[] | undefined {
|
||||
const out: FormulaBranch[] = [];
|
||||
let rest = formula;
|
||||
let taken = false;
|
||||
for (;;) {
|
||||
const body = ifBody(rest);
|
||||
if (body === null) break;
|
||||
const a = splitArgs(body);
|
||||
if (a.length !== 3) return undefined;
|
||||
const ok = holds(a[0], values) === true && !taken;
|
||||
if (ok) taken = true;
|
||||
out.push({ when: namedText(a[0], names), then: namedText(a[1], names), now: ok });
|
||||
rest = a[2];
|
||||
}
|
||||
if (!out.length) return undefined;
|
||||
out.push({ when: tv("Else"), then: namedText(rest, names), now: !taken });
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── 줄 ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 식 보기 줄들 — 결과 칸이 기대는 칸의 식이 먼저 · 결과 칸이 마지막 */
|
||||
export function formulaLines(box: CfBox, values: Record<string, CellScalar>): FormulaLine[] {
|
||||
const f = box.func;
|
||||
if (!f?.output.cell) return [];
|
||||
const names = cellNames(box);
|
||||
const cells = f.sheet.칸;
|
||||
const order: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const visit = (a1: string): void => {
|
||||
const key = upper(a1);
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
const formula = cells[key]?.식;
|
||||
if (formula === undefined) return;
|
||||
for (const r of refsIn(formula)) visit(r);
|
||||
order.push(key);
|
||||
};
|
||||
visit(f.output.cell);
|
||||
return order.map((key) => {
|
||||
const formula = cells[key].식 as string;
|
||||
const result = showScalar(values[key]);
|
||||
const branches = branchesOf(formula, names, values);
|
||||
return {
|
||||
name: names[key] ?? key,
|
||||
named: namedText(formula, names),
|
||||
valued: `${valuedText(formula, values)} = ${result}`,
|
||||
...(branches ? { branches } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 시트 칸 값 — 받는 알약 칸에 들어온 값(arrived) · 변수 예시값을 넣고 엔진으로 셈 */
|
||||
export function cellValuesOf(
|
||||
box: CfBox,
|
||||
arrived: Record<string, unknown> | undefined,
|
||||
): Record<string, CellScalar> {
|
||||
const f = box.func;
|
||||
if (!f) return {};
|
||||
const sheet = structuredClone(f.sheet);
|
||||
const scalar = (v: unknown): number | string | boolean | undefined =>
|
||||
typeof v === "number" || typeof v === "string" || typeof v === "boolean" ? v : undefined;
|
||||
const put = (cell: string, v: number | string | boolean | undefined): void => {
|
||||
if (!parseA1(cell) || v === undefined) return;
|
||||
sheet.칸[upper(cell)] = { 값: v };
|
||||
};
|
||||
for (const i of f.inputs) if (i.cell) put(i.cell, scalar(arrived?.[i.port]));
|
||||
for (const v of f.variables) if (v.cell && v.example !== null) put(v.cell, v.example);
|
||||
const calc = recalcWorkbook({ 종류: "통합문서", 판: 1, 열: "DF", 서식: [{}], 시트: [sheet] });
|
||||
const out: Record<string, CellScalar> = {};
|
||||
for (const [key, cell] of Object.entries(sheet.칸)) {
|
||||
const c: CalcValue | undefined = calc[sheet.id]?.[key];
|
||||
out[key] = cell.식 === undefined ? (cell.값 ?? null) : c ? fromCalc(c) : null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function fromCalc(v: CalcValue): CellScalar {
|
||||
if ("수" in v) return Number(v.수);
|
||||
if ("글" in v) return v.글;
|
||||
if ("참" in v) return v.참;
|
||||
return v.오류;
|
||||
}
|
||||
|
||||
/* ── 그리기 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 식 보기 그리기 — 줄마다 이름 식 · 값 식 · 갈래(지금 갈래는 ▶ 와 「지금」 글) */
|
||||
export function renderFormulaView(host: HTMLElement, lines: FormulaLine[]): void {
|
||||
host.classList.add("m02-df-formula");
|
||||
host.replaceChildren(
|
||||
el("strong", { className: "m02-df-formula__title", text: tv("Title") }),
|
||||
...(lines.length
|
||||
? lines.map((l) =>
|
||||
el("div", {
|
||||
className: "m02-df-formula__line",
|
||||
children: [
|
||||
el("div", { children: [el("b", { text: `${l.name} = ` }), l.named] }),
|
||||
el("div", { className: "m02-df-formula__valued", text: l.valued }),
|
||||
...(l.branches
|
||||
? [
|
||||
el("ul", {
|
||||
className: "m02-df-formula__branches",
|
||||
children: l.branches.map((b, i) =>
|
||||
el("li", {
|
||||
className: b.now ? "is-now" : "",
|
||||
text: `${b.now ? "▶ " : ""}${i < l.branches!.length - 1 ? `${tv("If")} ${b.when} → ` : `${b.when} → `}${b.then}${b.now ? ` (${tv("Now")})` : ""}`,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
)
|
||||
: [el("p", { className: "m02-df-formula__empty", text: tv("Empty") })]),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
"""단가산출 흐름도 「식 보기」(`M02_DF_FormulaView.ts`) · 엑셀 「조건 만들기」 글 쪽(`spreadsheet_condition_text.ts`).
|
||||
|
||||
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS(엔진 포함)는 이어 트랜스파일 · 화면 부품(@ui 의 elements · locale · modal)은 빈 흉내 · 엔진이 쓰는 @ui/sheet 는 그대로.
|
||||
견본 함수 상자는 이 시험 안에 둠.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
VIEW = ROOT / "M02_MasterTemplete" / "DataFlow" / "M02_DF_FormulaView.ts"
|
||||
TEXT = ROOT / "A00_Common" / "spreadsheet" / "spreadsheet_condition_text.ts"
|
||||
|
||||
RUNNER = r"""
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const [root, viewFile, textFile] = process.argv.slice(1);
|
||||
const ts = require(path.join(root, "config", "node_modules", "typescript"));
|
||||
const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? 0 : () => undefined) });
|
||||
const cache = {};
|
||||
function load(f) {
|
||||
if (cache[f]) return cache[f].exports;
|
||||
const js = ts.transpileModule(fs.readFileSync(f, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = (cache[f] = { exports: {} });
|
||||
const req = (id) => {
|
||||
if (id.startsWith(".") || id.startsWith("@ui/sheet/")) {
|
||||
const p = id.startsWith("@ui/sheet/")
|
||||
? path.join(root, "ui_template", "sheet", id.slice("@ui/sheet/".length))
|
||||
: path.resolve(path.dirname(f), id);
|
||||
return fs.existsSync(p + ".ts") ? load(p + ".ts") : stub;
|
||||
}
|
||||
return stub;
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, req);
|
||||
return box.exports;
|
||||
}
|
||||
const v = load(viewFile);
|
||||
const t = load(textFile);
|
||||
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const out = jobs.map((job) => {
|
||||
if (job.op === "lines") {
|
||||
const values = v.cellValuesOf(job.box, job.arrived);
|
||||
return { values, lines: v.formulaLines(job.box, values) };
|
||||
}
|
||||
if (job.op === "toFormula") return t.draftToFormula(job.draft);
|
||||
if (job.op === "toDraft") return t.formulaToDraft(job.formula);
|
||||
return null;
|
||||
});
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def run(jobs: list[dict]) -> list:
|
||||
if shutil.which("node") is None:
|
||||
pytest.skip("node 가 없음")
|
||||
done = subprocess.run(
|
||||
["node", "-e", RUNNER, str(ROOT), str(VIEW), str(TEXT)],
|
||||
input=json.dumps(jobs, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
def func_box(cells: dict, inputs: list, variables: list, out_cell: str, out_name: str) -> dict:
|
||||
return {
|
||||
"id": "f1",
|
||||
"kind": "function",
|
||||
"name": "석공 품",
|
||||
"color": "",
|
||||
"unit": "인",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"func": {
|
||||
"sheet": {"id": "s1", "이름": "석공 품", "칸": cells},
|
||||
"inputs": inputs,
|
||||
"variables": variables,
|
||||
"output": {"cell": out_cell, "name": out_name, "unit": "인"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def var(name: str, cell: str, example) -> dict:
|
||||
return {"name": name, "type": "number", "unit": "-", "example": example, "cell": cell}
|
||||
|
||||
|
||||
# 석공량(받는 알약 B1) · 높이(변수 B2) → B3 = 석공량 × (1 + 높이 ÷ 100)
|
||||
PLAIN = func_box(
|
||||
{
|
||||
"A1": {"값": "석공량"},
|
||||
"B3": {"식": "B1*(1+B2/100)"},
|
||||
},
|
||||
[{"port": "석공량", "cell": "B1"}],
|
||||
[var("높이", "B2", 4)],
|
||||
"B3",
|
||||
"석공 인",
|
||||
)
|
||||
|
||||
|
||||
def test_이름_식과_값_식():
|
||||
(got,) = run([{"op": "lines", "box": PLAIN, "arrived": {"석공량": 0.15}}])
|
||||
assert got["values"]["B3"] == pytest.approx(0.156)
|
||||
(line,) = got["lines"]
|
||||
assert line["name"] == "석공 인"
|
||||
assert line["named"] == "석공량 × (1 + 높이 ÷ 100)"
|
||||
assert line["valued"] == "0.15 × (1 + 4 ÷ 100) = 0.156"
|
||||
assert "branches" not in line
|
||||
|
||||
|
||||
def test_중간_칸이_먼저_결과_칸이_마지막():
|
||||
box = func_box(
|
||||
{"B3": {"식": "B1*B2"}, "B4": {"식": "B3+1"}},
|
||||
[{"port": "석공량", "cell": "B1"}],
|
||||
[var("높이", "B2", 4)],
|
||||
"B4",
|
||||
"합",
|
||||
)
|
||||
(got,) = run([{"op": "lines", "box": box, "arrived": {"석공량": 2}}])
|
||||
assert [x["name"] for x in got["lines"]] == ["B3", "합"] # 연결 없는 칸은 주소 그대로
|
||||
assert got["lines"][1]["named"] == "B3 + 1"
|
||||
assert got["lines"][1]["valued"] == "8 + 1 = 9"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("ground", "now"),
|
||||
[("토사", [True, False]), ("암", [False, True])],
|
||||
)
|
||||
def test_IF_줄과_지금_갈래(ground, now):
|
||||
box = func_box(
|
||||
{"B3": {"식": 'IF(B4="토사",B1*2,B1*3)'}},
|
||||
[{"port": "석공량", "cell": "B1"}],
|
||||
[{"name": "지반", "type": "choice", "unit": "-", "example": ground, "cell": "B4"}],
|
||||
"B3",
|
||||
"결과",
|
||||
)
|
||||
(got,) = run([{"op": "lines", "box": box, "arrived": {"석공량": 10}}])
|
||||
(line,) = got["lines"]
|
||||
assert [b["now"] for b in line["branches"]] == now
|
||||
assert line["branches"][0]["when"] == '지반 = "토사"'
|
||||
assert line["branches"][0]["then"] == "석공량 × 2"
|
||||
assert line["branches"][1] == {"when": "아니면", "then": "석공량 × 3", "now": now[1]}
|
||||
assert line["valued"].endswith("= 20" if ground == "토사" else "= 30")
|
||||
|
||||
|
||||
def test_조건_만들기_식과_다시_줄():
|
||||
draft = {
|
||||
"branches": [
|
||||
{
|
||||
"left": "B1",
|
||||
"op": "=",
|
||||
"right": {"kind": "value", "value": "토사"},
|
||||
"then": {"kind": "cell", "cell": "C1"},
|
||||
},
|
||||
{
|
||||
"left": "B1",
|
||||
"op": "≥",
|
||||
"right": {"kind": "value", "value": 3},
|
||||
"then": {"kind": "value", "value": 0.5},
|
||||
},
|
||||
],
|
||||
"otherwise": {"kind": "value", "value": 0},
|
||||
}
|
||||
(formula,) = run([{"op": "toFormula", "draft": draft}])
|
||||
assert formula == 'IF(B1="토사",C1,IF(B1>=3,0.5,0))'
|
||||
(back,) = run([{"op": "toDraft", "formula": formula}])
|
||||
assert back == draft
|
||||
|
||||
|
||||
@pytest.mark.parametrize("formula", ["SUM(B1:B2)", "IF(B1+1>2,1,2)", "IF(B1>2,B2*2,0)"])
|
||||
def test_못_바꾸는_식은_null(formula):
|
||||
(back,) = run([{"op": "toDraft", "formula": formula}])
|
||||
assert back is None
|
||||
Reference in New Issue
Block a user