From 736dac4eb5a477875c380f8e69fcf1ca207be1e7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 20:14:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(spreadsheet):=20=EC=82=B0=EC=B6=9C?= =?UTF-8?q?=EA=B7=BC=EA=B1=B0=20=EC=8A=A4=ED=94=84=EB=A0=88=EB=93=9C?= =?UTF-8?q?=EC=8B=9C=ED=8A=B8=20A=20=EC=97=94=EC=A7=84=20=ED=95=B5?= =?UTF-8?q?=EC=8B=AC=20=E2=80=94=20=EC=A3=BC=EC=86=8C=20=C2=B7=20=ED=8C=8C?= =?UTF-8?q?=EC=84=9C=20=C2=B7=20=ED=92=80=EC=9D=B4=20=C2=B7=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=20=EC=9E=AC=EA=B3=84=EC=82=B0=20=C2=B7=20=EC=B0=B8?= =?UTF-8?q?=EC=A1=B0=20=EC=98=AE=EA=B9=80=20=C2=B7=20=EB=AA=85=EB=A0=B9=20?= =?UTF-8?q?=C2=B7=20=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - address · parser — A1 ↔ 행열 · 낱말(편집기 색칠용 자리) · 엑셀 우선순위 나무(-2^2=4 · % · & · 비교) · 나무 → 글 - eval — 분수 풀이 · 오류 값 전파(왼쪽 먼저) · 형 바꾸기(빈 칸 · 수 글 · 참거짓) · 일반 형식 글 15 자리 · 무리수 15 자리 - graph — 의존 그래프 · 고친 칸에 딸린 칸만 재계산 · 순환 = 고리 칸마다 #CYCLE! + 까닭 - refshift — 행열 넣기 · 지우기 · 잘라 붙이기 · 시트 이름 · 시트 지우기 참조 옮김(지운 칸 #REF!) · 복사 상대 옮김 · 구글 R1C1 → A1 - commands · history · recalc — 명령마다 되돌림 짝 · 병합 규칙 · 서식 한 벌 표 · 묶음 원자 · Node 한 벌 풀이 - 시험 resources/tester/spreadsheet/test_spreadsheet_engine.py — 엑셀 규칙표 9 묶음 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF --- A00_Common/spreadsheet/spreadsheet_address.ts | 106 ++- .../spreadsheet/spreadsheet_commands.ts | 611 +++++++++++++++- A00_Common/spreadsheet/spreadsheet_eval.ts | 271 ++++++- A00_Common/spreadsheet/spreadsheet_graph.ts | 315 ++++++++- A00_Common/spreadsheet/spreadsheet_history.ts | 38 +- A00_Common/spreadsheet/spreadsheet_parser.ts | 310 +++++++- A00_Common/spreadsheet/spreadsheet_recalc.ts | 10 +- .../spreadsheet/spreadsheet_refshift.ts | 180 ++++- .../spreadsheet/helper_spreadsheet_engine.cjs | 665 ++++++++++++++++++ .../spreadsheet/test_spreadsheet_engine.py | 35 + 10 files changed, 2430 insertions(+), 111 deletions(-) create mode 100644 resources/tester/spreadsheet/helper_spreadsheet_engine.cjs create mode 100644 resources/tester/spreadsheet/test_spreadsheet_engine.py diff --git a/A00_Common/spreadsheet/spreadsheet_address.ts b/A00_Common/spreadsheet/spreadsheet_address.ts index fbe436f5..8e8c38bd 100644 --- a/A00_Common/spreadsheet/spreadsheet_address.ts +++ b/A00_Common/spreadsheet/spreadsheet_address.ts @@ -1,55 +1,115 @@ /* ============================================================================= * spreadsheet_address.ts (주인 A) * 주소 — A1 ↔ 행열(0 부터) · 범위 글 · 열 글자 · 시트 이름 따옴표 · Map 열쇠. - * 0 계약 머리 — 몸은 A 가 채움. 계약은 `spreadsheet_types.ts`. + * 계약은 `spreadsheet_types.ts`. * ========================================================================== */ +import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types"; import type { CellAddress, CellRange } from "./spreadsheet_types"; -const todo = (): never => { - throw new Error("spreadsheet_address: 아직 없음(A)"); -}; - /** 0 → `A` · 27 → `AB` */ -export function colName(_c: number): string { - return todo(); +export function colName(c: number): string { + let s = ""; + for (let n = c + 1; n > 0; n = Math.floor((n - 1) / 26)) { + s = String.fromCharCode(65 + ((n - 1) % 26)) + s; + } + return s; } /** `AB` → 27 · 못 읽으면 -1 (대소문자 가리지 않음) */ -export function colIndex(_letters: string): number { - return todo(); +export function colIndex(letters: string): number { + if (!/^[A-Za-z]{1,3}$/.test(letters)) return -1; + let n = 0; + for (const ch of letters.toUpperCase()) n = n * 26 + ch.charCodeAt(0) - 64; + return n <= MAX_COLS ? n - 1 : -1; +} + +/** 행 글(1 부터) → 0 부터 · 못 읽으면 -1 */ +export function rowIndex(digits: string): number { + const n = /^\d{1,7}$/.test(digits) ? Number(digits) : 0; + return n >= 1 && n <= MAX_ROWS ? n - 1 : -1; } /** (30, 4) → `E31` */ -export function toA1(_r: number, _c: number): string { - return todo(); +export function toA1(r: number, c: number): string { + return colName(c) + (r + 1); } /** `E31` · `$E$31` → {r:30, c:4} · 못 읽으면 null */ -export function parseA1(_text: string): CellAddress | null { - return todo(); +export function parseA1(text: string): CellAddress | null { + const m = /^\$?([A-Za-z]{1,3})\$?(\d+)$/.exec(text.trim()); + if (!m) return null; + const c = colIndex(m[1]); + const r = rowIndex(m[2]); + return c < 0 || r < 0 ? null : { r, c }; } /** 칸 하나면 `E31` · 아니면 `C3:AD3` · 행 전체 `3:3` · 열 전체 `A:A` */ -export function rangeToA1(_range: CellRange): string { - return todo(); +export function rangeToA1(range: CellRange): string { + const { r0, c0, r1, c1 } = range; + if (c0 === 0 && c1 === MAX_COLS - 1) return `${r0 + 1}:${r1 + 1}`; + if (r0 === 0 && r1 === MAX_ROWS - 1) return `${colName(c0)}:${colName(c1)}`; + const a = toA1(r0, c0); + return r0 === r1 && c0 === c1 ? a : `${a}:${toA1(r1, c1)}`; +} + +/** 닫힌 범위로 세움(뒤집힌 것 바로) */ +export function normRange(r0: number, c0: number, r1: number, c1: number): CellRange { + return { + r0: Math.min(r0, r1), + c0: Math.min(c0, c1), + r1: Math.max(r0, r1), + c1: Math.max(c0, c1), + }; } /** `C3:AD3` · `E31` · `A:A` · `3:5` → 범위(뒤집힌 글은 바로 세움) · 못 읽으면 null */ -export function parseRange(_text: string): CellRange | null { - return todo(); +export function parseRange(text: string): CellRange | null { + const parts = text.trim().split(":"); + if (parts.length > 2) return null; + const [a, b = a] = parts; + const p = parseA1(a); + const q = parseA1(b); + if (p && q) return normRange(p.r, p.c, q.r, q.c); + if (parts.length !== 2) return null; + const strip = (s: string) => s.replace(/^\$/, ""); + const ca = colIndex(strip(a)); + const cb = colIndex(strip(b)); + if (ca >= 0 && cb >= 0) return normRange(0, ca, MAX_ROWS - 1, cb); + const ra = rowIndex(strip(a)); + const rb = rowIndex(strip(b)); + if (ra >= 0 && rb >= 0) return normRange(ra, 0, rb, MAX_COLS - 1); + return null; +} + +export function inRange(range: CellRange, r: number, c: number): boolean { + return r >= range.r0 && r <= range.r1 && c >= range.c0 && c <= range.c1; +} + +export function rangesOverlap(a: CellRange, b: CellRange): boolean { + return a.r0 <= b.r1 && b.r0 <= a.r1 && a.c0 <= b.c1 && b.c0 <= a.c1; +} + +/** `a` 가 `b` 안에 통째로 듦 */ +export function rangeInside(a: CellRange, b: CellRange): boolean { + return a.r0 >= b.r0 && a.r1 <= b.r1 && a.c0 >= b.c0 && a.c1 <= b.c1; } /** 식에 넣을 시트 이름 — 글자 · 숫자 · _ 밖이 있으면 `'…'`(안의 `'` 는 `''`) */ -export function quoteSheet(_name: string): string { - return todo(); +export function quoteSheet(name: string): string { + const plain = + /^[\p{L}_][\p{L}\p{N}_]*$/u.test(name) && + !/^[A-Za-z]{1,3}\d+$/.test(name) && // 칸 주소처럼 보이는 이름 + !/^(TRUE|FALSE)$/i.test(name) && + !/^R\d*C\d*$/i.test(name); // R1C1 처럼 보이는 이름 + return plain ? name : `'${name.replace(/'/g, "''")}'`; } /** Map 열쇠 — r × MAX_COLS + c */ -export function cellId(_r: number, _c: number): number { - return todo(); +export function cellId(r: number, c: number): number { + return r * MAX_COLS + c; } -export function fromCellId(_id: number): CellAddress { - return todo(); +export function fromCellId(id: number): CellAddress { + return { r: Math.floor(id / MAX_COLS), c: id % MAX_COLS }; } diff --git a/A00_Common/spreadsheet/spreadsheet_commands.ts b/A00_Common/spreadsheet/spreadsheet_commands.ts index a646b15d..1d3606b4 100644 --- a/A00_Common/spreadsheet/spreadsheet_commands.ts +++ b/A00_Common/spreadsheet/spreadsheet_commands.ts @@ -4,32 +4,615 @@ * 병합 규칙(엑셀): 병합 일부를 가르는 칸 쓰기 · 옮기기는 막음(오류 던짐 → 화면이 알림) · * 병합 안에 행열을 넣으면 병합이 늘고 지우면 줆 · 병합하면 왼위 칸 값만 남김. * 서식 표는 같은 서식을 한 번만 둠(칸은 번호) · 안 쓰는 서식 정리는 저장 전에(`compactStyles`). - * 0 계약 머리 — 몸은 A 가 채움. + * 행열 · 옮기기 · 시트 지우기는 바뀐 시트를 통째로 되돌림(`시트통째`) — 다른 시트 식 글까지 한 번에. * ========================================================================== */ -import type { Command, CommandEffect, Sheet, Workbook } from "./spreadsheet_types"; - -const todo = (): never => { - throw new Error("spreadsheet_commands: 아직 없음(A)"); -}; +import { + colName, + inRange, + normRange, + parseA1, + parseRange, + rangeInside, + rangesOverlap, + rangeToA1, + toA1, +} from "./spreadsheet_address"; +import { shiftForCommand, shiftSpan } from "./spreadsheet_refshift"; +import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types"; +import type { + Cell, + CellRange, + CellStyle, + Command, + CommandEffect, + Sheet, + SheetCellAddress, + Workbook, +} from "./spreadsheet_types"; /** 막힌 명령(병합 가름 · 마지막 시트 지우기 · 같은 시트 이름 …) — 화면이 `message` 를 알림 */ export class CommandError extends Error {} -export function applyCommand(_book: Workbook, _command: Command): CommandEffect { - return todo(); +type Of = Extract; + +const clone = (x: T): T => structuredClone(x); + +function sheetOf(book: Workbook, id: string): Sheet { + const s = book.시트.find((x) => x.id === id); + if (!s) throw new CommandError(`없는 시트: ${id}`); + return s; +} + +const mergesOf = (s: Sheet): CellRange[] => + (s.병합 ?? []).map((t) => parseRange(t)).filter((r): r is CellRange => r !== null); + +function setMerges(s: Sheet, list: CellRange[]) { + if (list.length) s.병합 = list.map(rangeToA1); + else delete s.병합; +} + +/** 칸 열쇠 중 범위 안 것 */ +function cellsIn(s: Sheet, ranges: CellRange[]): [string, number, number][] { + const out: [string, number, number][] = []; + for (const a1 of Object.keys(s.칸)) { + const at = parseA1(a1); + if (at && ranges.some((rg) => inRange(rg, at.r, at.c))) out.push([a1, at.r, at.c]); + } + return out; +} + +const isFull = (rg: CellRange, rows: boolean) => + rows ? rg.c0 === 0 && rg.c1 === MAX_COLS - 1 : rg.r0 === 0 && rg.r1 === MAX_ROWS - 1; + +function sortKeys(v: unknown): unknown { + if (!v || typeof v !== "object" || Array.isArray(v)) return v; + const o = v as Record; + return Object.fromEntries( + Object.keys(o) + .sort() + .map((k) => [k, sortKeys(o[k])]), + ); +} +const styleKey = (s: CellStyle) => JSON.stringify(sortKeys(s)); + +function internStyle(book: Workbook, style: CellStyle): number { + const key = styleKey(style); + const i = book.서식.findIndex((s) => styleKey(s) === key); + if (i >= 0) return i; + book.서식.push(style); + return book.서식.length - 1; +} + +/** 칸 자리에서 물려받는 서식(칸 서식 없을 때) — 행 · 열 · 0 */ +const inherited = (s: Sheet, r: number, c: number) => + s.행?.[r + 1]?.서식 ?? s.열?.[colName(c)]?.서식 ?? 0; + +const addr = (시트: string, r: number, c: number): SheetCellAddress => ({ 시트, r, c }); + +/** 바뀐 시트만 통째 되돌림 짝 */ +function restoreChanged(book: Workbook, old: Sheet[]): Command[] { + const out: Command[] = []; + for (const o of old) { + const now = book.시트.find((x) => x.id === o.id); + if (now && JSON.stringify(now) !== JSON.stringify(o)) out.push({ 종류: "시트통째", 시트: o }); + } + return out; +} + +/** 모든 식 글을 명령에 맞춰 고침 — 시트 이름을 바꾸기 전에 부름 */ +function rewriteAll(book: Workbook, command: Command) { + for (const s of book.시트) + for (const cell of Object.values(s.칸)) + if (cell.식 !== undefined) cell.식 = shiftForCommand(cell.식, s.id, command, book); +} + +function checkSheetName(book: Workbook, name: string, self?: string) { + if (!name.trim() || name.length > 31) throw new CommandError("시트 이름은 1~31 글자"); + if (/[[\]:*?/\\]/.test(name) || name.startsWith("'") || name.endsWith("'")) + throw new CommandError("시트 이름에 못 쓰는 글자: [ ] : * ? / \\ 와 앞뒤 '"); + if (book.시트.some((s) => s.id !== self && s.이름.toLowerCase() === name.toLowerCase())) + throw new CommandError(`같은 이름 시트가 있음: ${name}`); +} + +// ── 칸 ──────────────────────────────────────────────────────────────────── + +function setCells(book: Workbook, cmd: Of<"칸">): CommandEffect { + const s = sheetOf(book, cmd.시트); + const merges = mergesOf(s); + const plan: [string, number, number, Cell | null][] = []; + for (const [key, cell] of Object.entries(cmd.칸)) { + const at = parseA1(key); + if (!at) throw new CommandError(`칸 주소 틀림: ${key}`); + if (cell && (cell.값 !== undefined || cell.식 !== undefined)) + if (merges.some((m) => inRange(m, at.r, at.c) && (m.r0 !== at.r || m.c0 !== at.c))) + throw new CommandError("병합한 칸은 왼위 칸에만 값을 넣음"); + plan.push([toA1(at.r, at.c), at.r, at.c, cell]); + } + const prev: Record = {}; + for (const [a1, , , cell] of plan) { + prev[a1] = s.칸[a1] ? clone(s.칸[a1]) : null; + if (cell) s.칸[a1] = clone(cell); + else delete s.칸[a1]; + } + return { + undo: { 종류: "칸", 시트: s.id, 칸: prev }, + cells: plan.map(([, r, c]) => addr(s.id, r, c)), + rebuild: false, + }; +} + +function clearContents(book: Workbook, cmd: Of<"내용지움">): CommandEffect { + const s = sheetOf(book, cmd.시트); + const prev: Record = {}; + const cells: SheetCellAddress[] = []; + for (const [a1, r, c] of cellsIn(s, cmd.범위)) { + const cell = s.칸[a1]; + if (cell.값 === undefined && cell.식 === undefined) continue; + prev[a1] = clone(cell); + if (cell.서식 === undefined) delete s.칸[a1]; + else s.칸[a1] = { 서식: cell.서식 }; + cells.push(addr(s.id, r, c)); + } + return { undo: { 종류: "칸", 시트: s.id, 칸: prev }, cells, rebuild: false }; +} + +// ── 서식 ──────────────────────────────────────────────────────────────────── + +/** 범위 서식 바꿈 — `change(지금 번호) → 새 번호`. 행 · 열 전체는 행 · 열 서식 + 든 칸. */ +function restyle( + book: Workbook, + sheet: string, + ranges: CellRange[], + change: (i: number) => number, +): CommandEffect { + const s = sheetOf(book, sheet); + const before = clone(s); + const memo = new Map(); + const f = (i: number) => { + if (!memo.has(i)) memo.set(i, change(i)); + return memo.get(i)!; + }; + const prev: Record = {}; + const cells: SheetCellAddress[] = []; + let whole = false; + const setInfo = (axis: "행" | "열", key: string) => { + const table = (s[axis] ??= {}); + const info = (table[key] ??= {}); + const idx = f(info.서식 ?? 0); + if (idx) info.서식 = idx; + else delete info.서식; + if (!Object.keys(info).length) delete table[key]; + }; + const touch = (r: number, c: number) => { + const a1 = toA1(r, c); + if (a1 in prev) return; + const cell = s.칸[a1]; + prev[a1] = cell ? clone(cell) : null; + const next: Cell = { ...cell }; + const idx = f(cell?.서식 ?? inherited(s, r, c)); + if (idx === inherited(s, r, c)) delete next.서식; + else next.서식 = idx; + if (Object.keys(next).length) s.칸[a1] = next; + else delete s.칸[a1]; + cells.push(addr(s.id, r, c)); + }; + for (const rg of ranges) { + const fullRows = isFull(rg, true); + const fullCols = isFull(rg, false); + if (fullRows && fullCols) { + // ponytail: 시트 통째 서식은 든 칸 · 있는 행 · 열 서식만 — 시트 기본 서식 칸이 계약에 없음 + whole = true; + for (const k of Object.keys(s.행 ?? {})) setInfo("행", k); + for (const k of Object.keys(s.열 ?? {})) setInfo("열", k); + } else if (fullRows || fullCols) { + whole = true; + for (let i = fullRows ? rg.r0 : rg.c0; i <= (fullRows ? rg.r1 : rg.c1); i++) + setInfo(fullRows ? "행" : "열", fullRows ? String(i + 1) : colName(i)); + } else { + if ((rg.r1 - rg.r0 + 1) * (rg.c1 - rg.c0 + 1) > 1_000_000) + throw new CommandError("범위가 너무 큼"); + for (let r = rg.r0; r <= rg.r1; r++) for (let c = rg.c0; c <= rg.c1; c++) touch(r, c); + continue; + } + for (const [, r, c] of cellsIn(s, [rg])) touch(r, c); + } + if (s.행 && !Object.keys(s.행).length) delete s.행; + if (s.열 && !Object.keys(s.열).length) delete s.열; + return whole + ? { undo: { 종류: "시트통째", 시트: before }, cells, rebuild: true } + : { undo: { 종류: "칸", 시트: s.id, 칸: prev }, cells, rebuild: false }; +} + +function patchStyle(book: Workbook, cmd: Of<"서식">): CommandEffect { + return restyle(book, cmd.시트, cmd.범위, (i) => { + const next: Record = { ...book.서식[i] }; + for (const [k, v] of Object.entries(cmd.바꿀)) { + if (v === null) delete next[k]; + else if (v !== undefined) next[k] = clone(v); + } + return internStyle(book, next as CellStyle); + }); +} + +// ── 행열 넣기 · 지우기 ────────────────────────────────────────────────────── + +function insertDelete( + book: Workbook, + cmd: Of<"행넣기" | "행지우기" | "열넣기" | "열지우기">, +): CommandEffect { + const s = sheetOf(book, cmd.시트); + const rows = cmd.종류.startsWith("행"); + const insert = cmd.종류.endsWith("넣기"); + const max = rows ? MAX_ROWS : MAX_COLS; + const { at, 수: n } = cmd; + if (n < 1 || at < 0 || at >= max || (!insert && at + n > max)) + throw new CommandError("넣기 · 지우기 자리가 틀림"); + const pos = (a1: string) => { + const p = parseA1(a1)!; + return rows ? p.r : p.c; + }; + if (insert && Object.keys(s.칸).some((a1) => pos(a1) + n >= max)) + throw new CommandError("격자 끝 칸이 밀려 나감 — 끝 쪽 칸을 먼저 지움"); + const old = book.시트.map(clone); + rewriteAll(book, cmd); + const span = (i: number) => shiftSpan(i, i, insert, at, n, max)?.[0]; + + const cells: Record = {}; + for (const [a1, cell] of Object.entries(s.칸)) { + const p = parseA1(a1)!; + const i = span(rows ? p.r : p.c); + if (i !== undefined) cells[rows ? toA1(i, p.c) : toA1(p.r, i)] = cell; + } + s.칸 = cells; + + const merges: CellRange[] = []; + for (const m of mergesOf(s)) { + const sp = rows + ? shiftSpan(m.r0, m.r1, insert, at, n, max) + : shiftSpan(m.c0, m.c1, insert, at, n, max); + if (!sp) continue; + const next = rows ? { ...m, r0: sp[0], r1: sp[1] } : { ...m, c0: sp[0], c1: sp[1] }; + if (next.r0 !== next.r1 || next.c0 !== next.c1) merges.push(next); + } + setMerges(s, merges); + + const axis = rows ? "행" : "열"; + const table = s[axis]; + if (table) { + const next: Record = {}; + for (const [k, v] of Object.entries(table)) { + const i = span(rows ? Number(k) - 1 : parseA1(`${k}1`)!.c); + if (i !== undefined) next[rows ? String(i + 1) : colName(i)] = v; + } + if (Object.keys(next).length) s[axis] = next; + else delete s[axis]; + } + + const key = rows ? "행" : "열"; + const frozen = s.틀고정?.[key]; + if (frozen && at < frozen) + s.틀고정![key] = insert ? frozen + n : frozen - (Math.min(frozen, at + n) - at); + + return { undo: { 종류: "묶음", 명령: restoreChanged(book, old) }, cells: [], rebuild: true }; +} + +// ── 옮기기 (잘라 붙이기) ───────────────────────────────────────────────────── + +function move(book: Workbook, cmd: Of<"옮기기">): CommandEffect { + const s = sheetOf(book, cmd.시트); + const t = sheetOf(book, cmd.대상시트); + const src = normRange(cmd.범위.r0, cmd.범위.c0, cmd.범위.r1, cmd.범위.c1); + const dr = cmd.r - src.r0; + const dc = cmd.c - src.c0; + const dst: CellRange = { r0: cmd.r, c0: cmd.c, r1: src.r1 + dr, c1: src.c1 + dc }; + if (dst.r0 < 0 || dst.c0 < 0 || dst.r1 >= MAX_ROWS || dst.c1 >= MAX_COLS) + throw new CommandError("옮길 자리가 격자 밖"); + const cuts = (list: CellRange[], rg: CellRange) => + list.some((m) => rangesOverlap(m, rg) && !rangeInside(m, rg)); + if (cuts(mergesOf(s), src) || cuts(mergesOf(t), dst)) + throw new CommandError("병합 일부를 가르는 옮기기는 못 함"); + if (s === t && dr === 0 && dc === 0) + return { undo: { 종류: "묶음", 명령: [] }, cells: [], rebuild: false }; + + const old = book.시트.map(clone); + const moved: [number, number, Cell][] = []; + for (const [a1, r, c] of cellsIn(s, [src])) { + const cell = s.칸[a1]; + if (cell.식 !== undefined) cell.식 = shiftForCommand(cell.식, s.id, cmd, book, t.id); + moved.push([r + dr, c + dc, cell]); + delete s.칸[a1]; + } + rewriteAll(book, cmd); + for (const [a1] of cellsIn(t, [dst])) delete t.칸[a1]; + for (const [r, c, cell] of moved) t.칸[toA1(r, c)] = cell; + + const sm = mergesOf(s); + const carried = sm.filter((m) => rangeInside(m, src)); + setMerges( + s, + sm.filter((m) => !rangeInside(m, src)), + ); + setMerges(t, [ + ...mergesOf(t).filter((m) => !rangeInside(m, dst)), + ...carried.map((m) => ({ r0: m.r0 + dr, c0: m.c0 + dc, r1: m.r1 + dr, c1: m.c1 + dc })), + ]); + return { undo: { 종류: "묶음", 명령: restoreChanged(book, old) }, cells: [], rebuild: true }; +} + +// ── 병합 ──────────────────────────────────────────────────────────────────── + +function merge(book: Workbook, cmd: Of<"병합">): CommandEffect { + const s = sheetOf(book, cmd.시트); + const rg = normRange(cmd.범위.r0, cmd.범위.c0, cmd.범위.r1, cmd.범위.c1); + if (rg.r0 === rg.r1 && rg.c0 === rg.c1) + return { undo: { 종류: "묶음", 명령: [] }, cells: [], rebuild: false }; + const list = mergesOf(s); + if (list.some((m) => rangesOverlap(m, rg) && !rangeInside(m, rg))) + throw new CommandError("다른 병합과 걸침"); + const absorbed = list.filter((m) => rangeInside(m, rg)); + const prev: Record = {}; + const cells: SheetCellAddress[] = []; + for (const [a1, r, c] of cellsIn(s, [rg])) { + const cell = s.칸[a1]; + if ((r === rg.r0 && c === rg.c0) || (cell.값 === undefined && cell.식 === undefined)) continue; + prev[a1] = clone(cell); + if (cell.서식 === undefined) delete s.칸[a1]; + else s.칸[a1] = { 서식: cell.서식 }; + cells.push(addr(s.id, r, c)); + } + setMerges(s, [...list.filter((m) => !rangeInside(m, rg)), rg]); + const undo: Command[] = [ + { 종류: "병합풀기", 시트: s.id, 범위: rg }, + ...absorbed.map((m): Command => ({ 종류: "병합", 시트: s.id, 범위: m })), + { 종류: "칸", 시트: s.id, 칸: prev }, + ]; + return { undo: { 종류: "묶음", 명령: undo }, cells, rebuild: false }; +} + +function unmerge(book: Workbook, cmd: Of<"병합풀기">): CommandEffect { + const s = sheetOf(book, cmd.시트); + const list = mergesOf(s); + const gone = list.filter((m) => rangesOverlap(m, cmd.범위)); + setMerges( + s, + list.filter((m) => !rangesOverlap(m, cmd.범위)), + ); + const undo = gone.map((m): Command => ({ 종류: "병합", 시트: s.id, 범위: m })); + return { undo: { 종류: "묶음", 명령: undo }, cells: [], rebuild: false }; +} + +// ── 행 · 열 모양 ───────────────────────────────────────────────────────────── + +/** 행 · 열 정보 한 속성 바꿈 → 원래 값끼리 묶은 되돌림 짝 */ +function setAxis( + s: Sheet, + axis: "행" | "열", + idx: number[], + prop: "폭" | "높이" | "숨김", + value: V | null, + undoOf: (idx: number[], prev: V | null) => Command, +): Command { + const table = ((s as unknown as Record>>)[axis] ??= + {}); + const groups = new Map(); + for (const i of idx) { + const k = axis === "열" ? colName(i) : String(i + 1); + const prev = table[k]?.[prop]; + const g = JSON.stringify(prev ?? null); + groups.set(g, [...(groups.get(g) ?? []), i]); + const info = (table[k] ??= {}); + if (value === null || value === false) delete info[prop]; + else info[prop] = value; + if (!Object.keys(info).length) delete table[k]; + } + if (!Object.keys(table).length) delete s[axis]; + return { 종류: "묶음", 명령: [...groups].map(([g, list]) => undoOf(list, JSON.parse(g))) }; +} + +// ── 시트 ──────────────────────────────────────────────────────────────────── + +function addSheet(book: Workbook, cmd: Of<"시트더하기">): CommandEffect { + if (book.시트.some((s) => s.id === cmd.시트.id)) + throw new CommandError(`같은 id 시트가 있음: ${cmd.시트.id}`); + checkSheetName(book, cmd.시트.이름); + book.시트.splice(Math.max(0, Math.min(cmd.자리, book.시트.length)), 0, clone(cmd.시트)); + return { undo: { 종류: "시트지우기", 시트: cmd.시트.id }, cells: [], rebuild: true }; +} + +function removeSheet(book: Workbook, cmd: Of<"시트지우기">): CommandEffect { + const s = sheetOf(book, cmd.시트); + if (book.시트.length === 1) throw new CommandError("마지막 시트는 못 지움"); + const at = book.시트.indexOf(s); + const old = book.시트.map(clone); + rewriteAll(book, cmd); + book.시트.splice(at, 1); + if (book.활성 === s.id) book.활성 = book.시트[Math.min(at, book.시트.length - 1)].id; + const undo: Command[] = [ + { 종류: "시트더하기", 시트: old[at], 자리: at }, + ...restoreChanged(book, old), + ]; + return { undo: { 종류: "묶음", 명령: undo }, cells: [], rebuild: true }; +} + +function renameSheet(book: Workbook, cmd: Of<"시트이름">): CommandEffect { + const s = sheetOf(book, cmd.시트); + checkSheetName(book, cmd.이름, s.id); + const prev = s.이름; + rewriteAll(book, cmd); + s.이름 = cmd.이름; + return { undo: { 종류: "시트이름", 시트: s.id, 이름: prev }, cells: [], rebuild: true }; +} + +function moveSheet(book: Workbook, cmd: Of<"시트옮기기">): CommandEffect { + const s = sheetOf(book, cmd.시트); + const from = book.시트.indexOf(s); + book.시트.splice(from, 1); + book.시트.splice(Math.max(0, Math.min(cmd.자리, book.시트.length)), 0, s); + return { undo: { 종류: "시트옮기기", 시트: s.id, 자리: from }, cells: [], rebuild: false }; +} + +function replaceSheet(book: Workbook, cmd: Of<"시트통째">): CommandEffect { + const at = book.시트.findIndex((x) => x.id === cmd.시트.id); + if (at < 0) throw new CommandError(`없는 시트: ${cmd.시트.id}`); + const prev = book.시트[at]; + book.시트[at] = clone(cmd.시트); + const undo: Of<"시트통째"> = { 종류: "시트통째", 시트: prev }; + if (cmd.서식) { + undo.서식 = book.서식; + book.서식 = clone(cmd.서식); + } + return { undo, cells: [], rebuild: true }; +} + +function batch(book: Workbook, cmd: Of<"묶음">): CommandEffect { + const done: CommandEffect[] = []; + try { + for (const c of cmd.명령) done.push(applyCommand(book, c)); + } catch (e) { + for (const d of done.reverse()) applyCommand(book, d.undo); + throw e; + } + return { + undo: { 종류: "묶음", 명령: done.map((d) => d.undo).reverse() }, + cells: done.flatMap((d) => d.cells), + rebuild: done.some((d) => d.rebuild), + }; +} + +// ── 들머리 ────────────────────────────────────────────────────────────────── + +export function applyCommand(book: Workbook, command: Command): CommandEffect { + const plain = (undo: Command): CommandEffect => ({ undo, cells: [], rebuild: false }); + switch (command.종류) { + case "칸": + return setCells(book, command); + case "내용지움": + return clearContents(book, command); + case "서식": + return patchStyle(book, command); + case "서식지움": + return restyle(book, command.시트, command.범위, () => 0); + case "행넣기": + case "행지우기": + case "열넣기": + case "열지우기": + return insertDelete(book, command); + case "옮기기": + return move(book, command); + case "병합": + return merge(book, command); + case "병합풀기": + return unmerge(book, command); + case "열폭": { + const s = sheetOf(book, command.시트); + return plain( + setAxis(s, "열", command.열, "폭", command.폭, (열, 폭) => ({ + 종류: "열폭", + 시트: s.id, + 열, + 폭, + })), + ); + } + case "행높이": { + const s = sheetOf(book, command.시트); + return plain( + setAxis(s, "행", command.행, "높이", command.높이, (행, 높이) => ({ + 종류: "행높이", + 시트: s.id, + 행, + 높이, + })), + ); + } + case "숨김": { + const s = sheetOf(book, command.시트); + const { 축 } = command; + return plain( + setAxis(s, 축, command.번호, "숨김", command.숨김, (번호, prev) => ({ + 종류: "숨김", + 시트: s.id, + 축, + 번호, + 숨김: prev ?? false, + })), + ); + } + case "틀고정": { + const s = sheetOf(book, command.시트); + const prev = { 행: s.틀고정?.행 ?? 0, 열: s.틀고정?.열 ?? 0 }; + const next: Sheet["틀고정"] = {}; + if (command.행 > 0) next.행 = command.행; + if (command.열 > 0) next.열 = command.열; + if (Object.keys(next).length) s.틀고정 = next; + else delete s.틀고정; + return plain({ 종류: "틀고정", 시트: s.id, ...prev }); + } + case "눈금선": { + const s = sheetOf(book, command.시트); + const prev = s.보기?.눈금선 ?? true; + if (command.보임) { + delete s.보기?.눈금선; + if (s.보기 && !Object.keys(s.보기).length) delete s.보기; + } else (s.보기 ??= {}).눈금선 = false; + return plain({ 종류: "눈금선", 시트: s.id, 보임: prev }); + } + case "시트더하기": + return addSheet(book, command); + case "시트지우기": + return removeSheet(book, command); + case "시트이름": + return renameSheet(book, command); + case "시트옮기기": + return moveSheet(book, command); + case "시트통째": + return replaceSheet(book, command); + case "묶음": + return batch(book, command); + } } /** 빈 통합문서 — 시트 하나(`s1` · 이름은 부른 쪽) · 서식 [{}] */ -export function emptyWorkbook(_column: string, _sheetName: string): Workbook { - return todo(); +export function emptyWorkbook(column: string, sheetName: string): Workbook { + return { + 종류: "통합문서", + 판: 1, + 열: column, + 서식: [{}], + 시트: [emptySheet("s1", sheetName)], + 활성: "s1", + }; } -export function emptySheet(_id: string, _name: string): Sheet { - return todo(); +export function emptySheet(id: string, name: string): Sheet { + return { id, 이름: name, 칸: {} }; } /** 안 쓰는 서식을 빼고 번호를 다시 매김 — 저장 직전(되돌리기 짝은 안 만듦) */ -export function compactStyles(_book: Workbook): void { - return todo(); +export function compactStyles(book: Workbook): void { + const next: CellStyle[] = []; + const byKey = new Map(); + const remap = new Map(); + const map = (i: number) => { + let j = remap.get(i); + if (j === undefined) { + const style = book.서식[i] ?? {}; + const k = styleKey(style); + j = byKey.get(k); + if (j === undefined) { + j = next.push(style) - 1; + byKey.set(k, j); + } + remap.set(i, j); + } + return j; + }; + map(0); + for (const s of book.시트) { + for (const cell of Object.values(s.칸)) if (cell.서식 !== undefined) cell.서식 = map(cell.서식); + for (const info of [...Object.values(s.행 ?? {}), ...Object.values(s.열 ?? {})]) + if (info.서식 !== undefined) info.서식 = map(info.서식); + } + book.서식 = next; } diff --git a/A00_Common/spreadsheet/spreadsheet_eval.ts b/A00_Common/spreadsheet/spreadsheet_eval.ts index c1b62a31..7b48adfd 100644 --- a/A00_Common/spreadsheet/spreadsheet_eval.ts +++ b/A00_Common/spreadsheet/spreadsheet_eval.ts @@ -3,9 +3,24 @@ * 나무 풀기 · 오류 값 전파 · 형 바꾸기(엑셀 규칙). B 의 함수들이 형 바꾸기 도우미를 씀. * 빈 칸 = 수에서 0 · 글에서 "" · `"12"+1 = 13` · `TRUE+1 = 2` · 글이 수로 안 읽히면 `#VALUE!`. * 무리수(SQRT · 삼각 · PI · 소수 거듭제곱)는 double → 유효 15 자리 십진 → 분수(B · A 같이 `fromDouble`). - * 0 계약 머리 — 몸은 A 가 채움. + * 오류 전파 = 왼쪽부터 처음 만난 오류. 비교 = 수 < 글 < 참거짓 · 글은 대소문자 가리지 않음. * ========================================================================== */ +import { + add, + cmp, + div, + frac, + fracToString, + mul, + parseDecimal, + roundAt, + sub, + toFrac, + ZERO, +} from "@ui/sheet/ui_template_sheet_frac"; +import { normRange } from "./spreadsheet_address"; +import { getFunction } from "./spreadsheet_functions"; import type { CalcValue, ErrorCode, @@ -14,45 +29,259 @@ import type { EvalResult, FormulaNode, Frac, + RangeValue, Scalar, } from "./spreadsheet_types"; -const todo = (): never => { - throw new Error("spreadsheet_eval: 아직 없음(A)"); -}; - -export function evaluate(_node: FormulaNode, _ctx: EvalContext): EvalResult { - return todo(); +export function err(code: ErrorCode, why?: string): ErrorValue { + return why === undefined ? { error: code } : { error: code, why }; } -export function err(_code: ErrorCode, _why?: string): ErrorValue { - return todo(); +export function isError(value: unknown): value is ErrorValue { + return typeof value === "object" && value !== null && "error" in value; } -export function isError(_value: unknown): _value is ErrorValue { - return todo(); +export function isFrac(value: unknown): value is Frac { + return typeof value === "object" && value !== null && "n" in value && "d" in value; +} + +export function isRange(value: unknown): value is RangeValue { + return typeof value === "object" && value !== null && (value as RangeValue).kind === "range"; +} + +const ONE = frac(1n); +const HUNDRED = frac(100n); +const BIG_D = 10n ** 40n; + +/** 분모가 너무 커지면(나눗셈 사슬) 30 자리 십진으로 굳힘 */ +export function tame(x: Frac): Frac { + return x.d > BIG_D ? parseDecimal(fracToString(x)) : x; +} + +/** 범위 → 칸 하나(1×1 일 때만) · 아니면 `#VALUE!` */ +function single(value: EvalResult): Scalar { + if (!isRange(value)) return value; + return value.rows === 1 && value.cols === 1 + ? value.at(0, 0) + : err("#VALUE!", "범위를 값 하나로 못 씀"); } /** 수로 — 빈 칸 0 · 참거짓 1/0 · 수 글 · 아니면 `#VALUE!` · 범위는 칸 하나일 때만 */ -export function asNumber(_value: EvalResult): Frac | ErrorValue { - return todo(); +export function asNumber(value: EvalResult): Frac | ErrorValue { + const v = single(value); + if (v === null) return ZERO; + if (typeof v === "boolean") return v ? ONE : ZERO; + if (typeof v === "string") { + const t = v.trim(); + const pct = t.endsWith("%"); + try { + const n = parseDecimal(pct ? t.slice(0, -1) : t); + return pct ? div(n, HUNDRED) : n; + } catch { + return err("#VALUE!", `수로 못 읽는 글: "${v}"`); + } + } + return v; +} + +/** 일반 형식 글 — 유효 15 자리 · 1e15 넘거나 1e-9 밑이면 `1.5E+18` 꼴(엑셀 `&` 와 같게) */ +export function numberText(x: Frac): string { + if (x.n === 0n) return "0"; + const d = parseFloat(fracToString(x)); + const e = Math.floor(Math.log10(Math.abs(d))); + if (e >= 15 || e < -9) { + const [m, ex] = d.toExponential(14).split("e"); + const mant = m.replace(/\.?0+$/, ""); + const n = Number(ex); + return `${mant}E${n < 0 ? "-" : "+"}${String(Math.abs(n)).padStart(2, "0")}`; + } + return fracToString(roundAt(x, 14 - e, "round")); } /** 글로 — 수는 일반 형식 글(엑셀 `&` 와 같게) · 참거짓 `TRUE`/`FALSE` */ -export function asText(_value: EvalResult): string | ErrorValue { - return todo(); +export function asText(value: EvalResult): string | ErrorValue { + const v = single(value); + if (v === null) return ""; + if (typeof v === "string" || isError(v)) return v; + if (typeof v === "boolean") return v ? "TRUE" : "FALSE"; + return numberText(v); } -export function asBool(_value: EvalResult): boolean | ErrorValue { - return todo(); +export function asBool(value: EvalResult): boolean | ErrorValue { + const v = single(value); + if (v === null) return false; + if (typeof v === "boolean" || isError(v)) return v; + if (typeof v === "string") { + const t = v.toUpperCase(); + return t === "TRUE" + ? true + : t === "FALSE" + ? false + : err("#VALUE!", `참거짓으로 못 읽는 글: "${v}"`); + } + return v.n !== 0n; } /** double → 유효 15 자리 십진 → 분수 · NaN · 무한은 `#NUM!` */ -export function fromDouble(_x: number): Frac | ErrorValue { - return todo(); +export function fromDouble(x: number): Frac | ErrorValue { + if (!Number.isFinite(x)) return err("#NUM!", "수가 너무 크거나 뜻이 없음"); + return x === 0 ? ZERO : parseDecimal(x.toPrecision(15)); } +export const toDouble = (x: Frac): number => parseFloat(fracToString(x)); + /** 저장 모양 — 수는 십진 글(30 자리까지) */ -export function toCalcValue(_value: Scalar): CalcValue { - return todo(); +export function toCalcValue(value: Scalar): CalcValue { + if (value === null) return { 수: "0" }; + if (typeof value === "string") return { 글: value }; + if (typeof value === "boolean") return { 참: value }; + if (isError(value)) + return value.why ? { 오류: value.error, 까닭: value.why } : { 오류: value.error }; + return { 수: fracToString(value) }; +} + +// ── 연산 ──────────────────────────────────────────────────────────────────── + +function power(a: Frac, b: Frac): Frac | ErrorValue { + if (a.n === 0n) { + if (b.n === 0n) return err("#NUM!", "0^0"); + return b.n < 0n ? err("#DIV/0!", "0 의 음수 거듭제곱") : ZERO; + } + if (b.d === 1n) { + const e = b.n < 0n ? -b.n : b.n; + const bits = BigInt(a.n.toString(2).length + a.d.toString(2).length); + // ponytail: 4,000 비트 넘는 정확 거듭제곱은 double 로 — 실무 식엔 없음 + if (bits * e <= 4000n) { + const r = frac(a.n ** e, a.d ** e); + return tame(b.n < 0n ? div(ONE, r) : r); + } + } else if (a.n < 0n) return err("#NUM!", "음수의 소수 거듭제곱"); + return fromDouble(Math.pow(toDouble(a), toDouble(b))); +} + +function arith(op: string, a: Frac, b: Frac): Frac | ErrorValue { + switch (op) { + case "+": + return tame(add(a, b)); + case "-": + return tame(sub(a, b)); + case "*": + return tame(mul(a, b)); + case "/": + return b.n === 0n ? err("#DIV/0!", "0 으로 나눔") : tame(div(a, b)); + default: + return power(a, b); + } +} + +const RANK = (v: Scalar) => (isFrac(v) ? 0 : typeof v === "string" ? 1 : 2); + +/** 엑셀 비교 — 빈 칸은 맞은편 형의 빈 값(0 · "" · FALSE) · 형이 다르면 수 < 글 < 참거짓 */ +export function compareValues(a: Scalar, b: Scalar): number { + const blank = (other: Scalar): Scalar => + isFrac(other) + ? ZERO + : typeof other === "string" + ? "" + : typeof other === "boolean" + ? false + : ZERO; + if (a === null) a = blank(b); + if (b === null) b = blank(a); + const ra = RANK(a); + const rb = RANK(b); + if (ra !== rb) return ra < rb ? -1 : 1; + if (isFrac(a)) return cmp(a, b as Frac); + if (typeof a === "string") { + const x = a.toLowerCase(); + const y = (b as string).toLowerCase(); + return x === y ? 0 : x < y ? -1 : 1; + } + return a === b ? 0 : a ? 1 : -1; +} + +function compareOp(op: string, a: Scalar, b: Scalar): boolean { + const c = compareValues(a, b); + return op === "=" + ? c === 0 + : op === "<>" + ? c !== 0 + : op === "<" + ? c < 0 + : op === "<=" + ? c <= 0 + : op === ">" + ? c > 0 + : c >= 0; +} + +// ── 풀기 ──────────────────────────────────────────────────────────────────── + +export function evaluate(node: FormulaNode, ctx: EvalContext): EvalResult { + switch (node.type) { + case "number": + return node.value; + case "string": + return node.value; + case "bool": + return node.value; + case "error": + return err(node.code); + case "name": + return err("#NAME?", `모르는 이름: ${node.name}`); + case "cell": + case "range": { + const sheet = node.sheet === null ? ctx.sheet : ctx.sheetId(node.sheet); + if (sheet === null) return err("#REF!", `없는 시트: ${node.sheet}`); + if (node.type === "cell") return ctx.cell(sheet, node.ref.r, node.ref.c); + return ctx.range(sheet, normRange(node.from.r, node.from.c, node.to.r, node.to.c)); + } + case "unary": { + const v = evaluate(node.arg, ctx); + if (node.op === "+") return single(v); + const n = asNumber(v); + if (isError(n)) return n; + return node.op === "-" ? frac(-n.n, n.d) : div(n, HUNDRED); + } + case "binary": { + const left = evaluate(node.left, ctx); + const right = evaluate(node.right, ctx); + if (node.op === "&") { + const a = asText(left); + if (isError(a)) return a; + const b = asText(right); + return isError(b) ? b : a + b; + } + if (PLAIN_OPS.has(node.op)) { + const a = asNumber(left); + if (isError(a)) return a; + const b = asNumber(right); + return isError(b) ? b : arith(node.op, a, b); + } + const a = single(left); + if (isError(a)) return a; + const b = single(right); + return isError(b) ? b : compareOp(node.op, a, b); + } + case "call": { + const fn = getFunction(node.name); + if (!fn) return err("#NAME?", `모르는 함수: ${node.name}`); + const n = node.args.length; + if (n < fn.min || (fn.max !== undefined && n > fn.max)) { + const want = fn.max === fn.min ? `${fn.min}` : `${fn.min}~${fn.max ?? ""}`; + return err("#VALUE!", `${node.name} 인자 수 ${n} (받는 수 ${want})`); + } + return fn.call( + node.args.map((a) => () => evaluate(a, ctx)), + ctx, + ); + } + } +} + +const PLAIN_OPS = new Set(["+", "-", "*", "/", "^"]); + +/** 수로 온 입력 값 → 풀이 값 */ +export function inputValue(v: number | string | boolean): Scalar { + return typeof v === "number" ? toFrac(v) : v; } diff --git a/A00_Common/spreadsheet/spreadsheet_graph.ts b/A00_Common/spreadsheet/spreadsheet_graph.ts index 76882474..92ed4dc7 100644 --- a/A00_Common/spreadsheet/spreadsheet_graph.ts +++ b/A00_Common/spreadsheet/spreadsheet_graph.ts @@ -1,17 +1,316 @@ /* ============================================================================= * spreadsheet_graph.ts (주인 A) * 계산 엔진 — 식마다 가리키는 칸 · 범위로 의존 그래프 · 바뀐 칸에 딸린 칸만 차례로 다시 풂. - * 순환은 고리의 칸마다 `#CYCLE!`(까닭 = 고리 칸 목록) · 반복 계산 안 함 · 나머지 칸은 계속 풂. - * 식 나무는 식 글마다 캐시. 0 계약 머리 — 몸은 A 가 채움. + * 순환은 고리의 칸마다 `#CYCLE!`(까닭 = 고리 칸 목록) · 반복 계산 안 함 · 나머지 칸은 계속 풂 + * (고리를 읽는 칸은 오류 전파로 `#CYCLE!`). 식 나무는 식 글마다 캐시. + * 풀이는 필요할 때 끌어 풂(앞 칸이 안 풀렸으면 먼저) — 차례는 저절로 위상 순서. * ========================================================================== */ -import type { CalcEngine, Workbook } from "./spreadsheet_types"; +import { FormulaError, ZERO } from "@ui/sheet/ui_template_sheet_frac"; +import { cellId, fromCellId, inRange, parseA1, toA1 } from "./spreadsheet_address"; +import { err, evaluate, inputValue, isRange, toCalcValue } from "./spreadsheet_eval"; +import { parseFormula } from "./spreadsheet_parser"; +import type { + CalcEngine, + CalcValues, + Cell, + CellRange, + EvalContext, + EvalResult, + FormulaNode, + ParseResult, + RangeValue, + Scalar, + SheetCellAddress, + Workbook, +} from "./spreadsheet_types"; -const todo = (): never => { - throw new Error("spreadsheet_graph: 아직 없음(A)"); -}; +interface SheetIndex { + id: string; + cells: Map; + /** 값 든 칸의 가장 먼 행 · 열 — 행 · 열 전체 범위를 여기까지만 읽음 */ + maxR: number; + maxC: number; +} + +interface Dep { + sheet: string; + range: CellRange; +} + +const DONE = 2; +const ACTIVE = 1; + +const keyOf = (sheet: string, id: number) => `${sheet}!${id}`; + +function splitKey(key: string): SheetCellAddress { + const i = key.lastIndexOf("!"); + return { 시트: key.slice(0, i), ...fromCellId(Number(key.slice(i + 1))) }; +} + +/** 나무 안 칸 · 범위 참조(시트 이름 → id · 없는 시트는 뺌) */ +function collectRefs( + node: FormulaNode, + host: string, + sheetId: (n: string) => string | null, + out: Dep[], +) { + switch (node.type) { + case "cell": + case "range": { + const sheet = node.sheet === null ? host : sheetId(node.sheet); + if (sheet === null) return; + const [a, b] = node.type === "cell" ? [node.ref, node.ref] : [node.from, node.to]; + out.push({ + sheet, + range: { + r0: Math.min(a.r, b.r), + c0: Math.min(a.c, b.c), + r1: Math.max(a.r, b.r), + c1: Math.max(a.c, b.c), + }, + }); + return; + } + case "unary": + return collectRefs(node.arg, host, sheetId, out); + case "binary": + collectRefs(node.left, host, sheetId, out); + return collectRefs(node.right, host, sheetId, out); + case "call": + for (const a of node.args) collectRefs(a, host, sheetId, out); + } +} /** 문서를 참조로 쥠 — 만들 때 전부 풂. */ -export function createCalcEngine(_book: Workbook): CalcEngine { - return todo(); +export function createCalcEngine(book: Workbook): CalcEngine { + const parsed = new Map(); + let sheets = new Map(); + let names = new Map(); + /** 식 칸 → 가리키는 칸 · 범위 */ + let precs = new Map(); + /** 칸 하나 → 그 칸을 가리키는 식 칸 */ + let cellDeps = new Map>(); + /** 시트 → 범위를 가리키는 식 칸 */ + let rangeDeps = new Map>(); + let vals = new Map(); + let state = new Map(); + const stack: string[] = []; + const cycle = new Map(); + + const sheetId = (name: string) => names.get(name.toLowerCase()) ?? null; + + const parse = (text: string) => { + let p = parsed.get(text); + if (!p) parsed.set(text, (p = parseFormula(text))); + return p; + }; + + function unlink(key: string) { + for (const d of precs.get(key) ?? []) { + if (d.range.r0 === d.range.r1 && d.range.c0 === d.range.c1) + cellDeps.get(keyOf(d.sheet, cellId(d.range.r0, d.range.c0)))?.delete(key); + else rangeDeps.get(d.sheet)?.delete(key); + } + precs.delete(key); + } + + /** 칸 하나를 색인에 올림(식이면 가리키는 곳 잇기) */ + function index(sheet: SheetIndex, id: number, cell: Cell | undefined) { + const key = keyOf(sheet.id, id); + unlink(key); + vals.delete(key); + state.delete(key); + cycle.delete(key); + if (!cell || (cell.식 === undefined && cell.값 === undefined)) { + sheet.cells.delete(id); + return; + } + sheet.cells.set(id, cell); + const { r, c } = fromCellId(id); + sheet.maxR = Math.max(sheet.maxR, r); + sheet.maxC = Math.max(sheet.maxC, c); + if (cell.식 === undefined) { + vals.set(key, inputValue(cell.값!)); + return; + } + const p = parse(cell.식); + const deps: Dep[] = []; + if (p.ok) collectRefs(p.node, sheet.id, sheetId, deps); + precs.set(key, deps); + for (const d of deps) { + if (d.range.r0 === d.range.r1 && d.range.c0 === d.range.c1) { + const k = keyOf(d.sheet, cellId(d.range.r0, d.range.c0)); + let set = cellDeps.get(k); + if (!set) cellDeps.set(k, (set = new Set())); + set.add(key); + } else { + let m = rangeDeps.get(d.sheet); + if (!m) rangeDeps.set(d.sheet, (m = new Map())); + let list = m.get(key); + if (!list) m.set(key, (list = [])); + list.push(d.range); + } + } + } + + function rebuild(next: Workbook) { + book = next; + sheets = new Map(); + names = new Map(); + precs = new Map(); + cellDeps = new Map(); + rangeDeps = new Map(); + vals = new Map(); + state = new Map(); + cycle.clear(); + for (const s of book.시트) { + sheets.set(s.id, { id: s.id, cells: new Map(), maxR: 0, maxC: 0 }); + names.set(s.이름.toLowerCase(), s.id); + } + for (const s of book.시트) { + const idx = sheets.get(s.id)!; + for (const [a1, cell] of Object.entries(s.칸)) { + const at = parseA1(a1); + if (at) index(idx, cellId(at.r, at.c), cell); + } + } + for (const key of precs.keys()) compute(key); + } + + function ctxFor(sheet: string, r: number, c: number): EvalContext { + return { sheet, r, c, cell: read, range, sheetId }; + } + + function read(sheet: string, r: number, c: number): Scalar { + const key = keyOf(sheet, cellId(r, c)); + if (precs.has(key)) return compute(key); + return vals.get(key) ?? null; + } + + function range(sheet: string, rg: CellRange): RangeValue { + const s = sheets.get(sheet); + // ponytail: 행 · 열 전체 범위는 값 든 끝까지만 — INDEX(A:A, 끝 너머)는 #REF! 로 갈림 + const r1 = Math.min(rg.r1, Math.max(rg.r0, s?.maxR ?? 0)); + const c1 = Math.min(rg.c1, Math.max(rg.c0, s?.maxC ?? 0)); + return { + kind: "range", + rows: r1 - rg.r0 + 1, + cols: c1 - rg.c0 + 1, + at: (i, j) => read(sheet, rg.r0 + i, rg.c0 + j), + }; + } + + /** 식 칸 하나 풂 — 풀이 중인 칸을 다시 만나면 그 사이가 고리 */ + function compute(key: string): Scalar { + const st = state.get(key); + if (st === DONE) return vals.get(key) ?? null; + if (st === ACTIVE) { + const ring = stack.slice(stack.indexOf(key)); + const why = `순환 참조: ${ring.map(label).join(" → ")} → ${label(key)}`; + for (const k of ring) if (!cycle.has(k)) cycle.set(k, why); + return err("#CYCLE!", why); + } + state.set(key, ACTIVE); + stack.push(key); + const { 시트, r, c } = splitKey(key); + const cell = sheets.get(시트)!.cells.get(cellId(r, c))!; + let v: Scalar; + try { + // ponytail: 끌어 풀기는 재귀 — 수천 칸 사슬이면 스택이 넘침, 그때 위상 순서 반복으로 + v = run(cell.식!, ctxFor(시트, r, c)); + } catch (e) { + state.delete(key); + throw e; + } finally { + stack.pop(); + } + const why = cycle.get(key); + if (why) v = err("#CYCLE!", why); + vals.set(key, v); + state.set(key, DONE); + return v; + } + + function label(key: string) { + const { 시트, r, c } = splitKey(key); + const s = book.시트.find((x) => x.id === 시트); + return book.시트.length > 1 && s ? `${s.이름}!${toA1(r, c)}` : toA1(r, c); + } + + function run(text: string, ctx: EvalContext): Scalar { + const p = parse(text); + if (!p.ok) return err("#NAME?", `식을 못 읽음: ${p.message} (${p.at + 1}번째 글자)`); + let v: EvalResult; + try { + v = evaluate(p.node, ctx); + } catch (e) { + if (e instanceof FormulaError && e.message.startsWith("0 으로")) + return err("#DIV/0!", e.message); + return err("#VALUE!", e instanceof Error ? e.message : String(e)); + } + // ponytail: 범위를 칸에 넣으면 한 칸만 — 엑셀 암묵 교차(식 든 행 · 열과 겹친 칸)는 안 함 + if (isRange(v)) + v = v.rows === 1 && v.cols === 1 ? v.at(0, 0) : err("#VALUE!", "범위를 칸 하나에 못 넣음"); + return v === null ? ZERO : v; + } + + function dependents(key: string, out: Set) { + for (const k of cellDeps.get(key) ?? []) out.add(k); + const { 시트, r, c } = splitKey(key); + for (const [k, list] of rangeDeps.get(시트) ?? []) + if (list.some((rg) => inRange(rg, r, c))) out.add(k); + } + + rebuild(book); + + return { + update(changed) { + const dirty = new Set(); + for (const a of changed) { + const s = sheets.get(a.시트); + const sheet = book.시트.find((x) => x.id === a.시트); + if (!s || !sheet) continue; + const id = cellId(a.r, a.c); + index(s, id, sheet.칸[toA1(a.r, a.c)]); + dirty.add(keyOf(a.시트, id)); + } + // 딸린 칸을 끝까지 모음(고리였던 칸도 다시) + const queue = [...dirty]; + while (queue.length) { + const next = new Set(); + dependents(queue.pop()!, next); + for (const k of next) + if (!dirty.has(k)) { + dirty.add(k); + queue.push(k); + } + } + for (const k of dirty) + if (precs.has(k)) { + state.delete(k); + cycle.delete(k); + } + for (const k of dirty) if (precs.has(k)) compute(k); + return [...dirty].map(splitKey); + }, + rebuild, + value(sheet, r, c) { + return read(sheet, r, c); + }, + precedents(sheet, r, c) { + return (precs.get(keyOf(sheet, cellId(r, c))) ?? []).map((d) => ({ + 시트: d.sheet, + 범위: d.range, + })); + }, + snapshot() { + const out: CalcValues = {}; + for (const key of precs.keys()) { + const { 시트, r, c } = splitKey(key); + (out[시트] ??= {})[toA1(r, c)] = toCalcValue(compute(key)); + } + return out; + }, + }; } diff --git a/A00_Common/spreadsheet/spreadsheet_history.ts b/A00_Common/spreadsheet/spreadsheet_history.ts index ebf7bce5..e2922f82 100644 --- a/A00_Common/spreadsheet/spreadsheet_history.ts +++ b/A00_Common/spreadsheet/spreadsheet_history.ts @@ -1,16 +1,38 @@ /* ============================================================================= * spreadsheet_history.ts (주인 A) * 되돌리기 · 다시 — 명령과 되돌림 짝을 쌓음(메모리만 · 캐시에 안 넣음). - * 0 계약 머리 — 몸은 A 가 채움. + * undo() 는 되돌림 짝을 · redo() 는 원래 명령을 돌려줌 — 적용은 부른 쪽(`applyCommand`). * ========================================================================== */ -import type { History } from "./spreadsheet_types"; - -const todo = (): never => { - throw new Error("spreadsheet_history: 아직 없음(A)"); -}; +import type { Command, History } from "./spreadsheet_types"; /** `limit` = 쌓을 수(넘으면 오래된 것부터 버림) */ -export function createHistory(_limit?: number): History { - return todo(); +export function createHistory(limit = 100): History { + let done: [Command, Command][] = []; + let undone: [Command, Command][] = []; + return { + push(command, undo) { + done.push([command, undo]); + if (done.length > limit) done.shift(); + undone = []; + }, + undo() { + const pair = done.pop(); + if (!pair) return null; + undone.push(pair); + return pair[1]; + }, + redo() { + const pair = undone.pop(); + if (!pair) return null; + done.push(pair); + return pair[0]; + }, + canUndo: () => done.length > 0, + canRedo: () => undone.length > 0, + clear() { + done = []; + undone = []; + }, + }; } diff --git a/A00_Common/spreadsheet/spreadsheet_parser.ts b/A00_Common/spreadsheet/spreadsheet_parser.ts index ab069c7d..7279f7b0 100644 --- a/A00_Common/spreadsheet/spreadsheet_parser.ts +++ b/A00_Common/spreadsheet/spreadsheet_parser.ts @@ -4,26 +4,310 @@ * 우선순위는 엑셀과 같음: 범위 `:` > 부호 `-` `+` > `%` > `^` > `* /` > `+ -` > `&` > 비교 * (부호가 `^` 보다 먼저 — `-2^2 = 4`). 같은 단은 왼쪽부터(`^` 도 왼쪽부터 · 엑셀과 같음). * 글 `"…"` 안 `""` = 따옴표 하나 · 시트 `'이름'!A1` · `이름!A1` · 오류 값 글자 · TRUE/FALSE. - * 0 계약 머리 — 몸은 A 가 채움. + * 참조 낱말 풀기(`parseRef` · `renderRef`)는 참조 옮김(`spreadsheet_refshift.ts`)이 같이 씀. * ========================================================================== */ -import type { FormulaNode, ParseResult, Token } from "./spreadsheet_types"; +import { parseDecimal, ZERO } from "@ui/sheet/ui_template_sheet_frac"; +import { colIndex, colName, quoteSheet, rowIndex } from "./spreadsheet_address"; +import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types"; +import type { ErrorCode, FormulaNode, ParseResult, RefCell, Token } from "./spreadsheet_types"; -const todo = (): never => { - throw new Error("spreadsheet_parser: 아직 없음(A)"); -}; +// ── 참조 낱말 ──────────────────────────────────────────────────────────────── -/** 앞 `=` 뺀 식 글 → 낱말(빈칸 낱말 포함 · 끝까지 · 못 읽는 글은 `unknown`) — 편집기 색칠 · 가리키기용 */ -export function tokenize(_formula: string): Token[] { - return todo(); +const IDC = "[\\p{L}\\p{N}_.]"; +const END = `(?!${IDC}|\\()`; +const CELL = "(\\$?)([A-Za-z]{1,3})(\\$?)(\\d+)"; +const RE_PREFIX = /^(?:'((?:[^']|'')+)'|([\p{L}\p{N}_.]+))!/u; +const RE_AREA = new RegExp(`^${CELL}(?::${CELL})?${END}`, "u"); +const RE_COLS = new RegExp(`^(\\$?)([A-Za-z]{1,3}):(\\$?)([A-Za-z]{1,3})${END}`, "u"); +const RE_ROWS = new RegExp(`^(\\$?)(\\d+):(\\$?)(\\d+)${END}`, "u"); +const RE_ERROR = /^#(NULL!|DIV\/0!|VALUE!|REF!|NAME\?|NUM!|N\/A|CYCLE!)/i; +const RE_IDENT = /^[\p{L}_\\][\p{L}\p{N}_.]*/u; + +/** 참조 낱말 하나 — `kind` cell = 칸 · area = `A1:B2` · cols = `A:C` · rows = `3:5`. */ +export interface RefParts { + /** 적힌 시트 머리 글 그대로(`'돌-골막이'!`) · 없으면 "" */ + prefix: string; + sheet: string | null; + kind: "cell" | "area" | "cols" | "rows"; + a: RefCell; + b: RefCell; } +/** 참조 글 → 조각 · 참조가 아니면 null(주소가 격자 밖이어도 null → 이름) */ +export function parseRef(text: string): RefParts | null { + const pm = RE_PREFIX.exec(text); + const prefix = pm ? pm[0] : ""; + const sheet = pm ? (pm[1] !== undefined ? pm[1].replace(/''/g, "'") : pm[2]) : null; + const body = text.slice(prefix.length); + const pt = (sr: string, col: number, sc: string, row: number): RefCell => ({ + r: row, + c: col, + absR: sc === "$", + absC: sr === "$", + }); + let m = RE_AREA.exec(body); + if (m && m[0].length === body.length) { + const a = pt(m[1], colIndex(m[2]), m[3], rowIndex(m[4])); + const b = m[5] === undefined ? { ...a } : pt(m[5], colIndex(m[6]), m[7], rowIndex(m[8])); + if (a.r < 0 || a.c < 0 || b.r < 0 || b.c < 0) return null; + return { prefix, sheet, kind: m[5] === undefined ? "cell" : "area", a, b }; + } + m = RE_COLS.exec(body); + if (m && m[0].length === body.length) { + const c0 = colIndex(m[2]); + const c1 = colIndex(m[4]); + if (c0 < 0 || c1 < 0) return null; + return { + prefix, + sheet, + kind: "cols", + a: pt(m[1], c0, "", 0), + b: pt(m[3], c1, "", MAX_ROWS - 1), + }; + } + m = RE_ROWS.exec(body); + if (m && m[0].length === body.length) { + const r0 = rowIndex(m[2]); + const r1 = rowIndex(m[4]); + if (r0 < 0 || r1 < 0) return null; + return { + prefix, + sheet, + kind: "rows", + a: pt("", 0, m[1], r0), + b: pt("", MAX_COLS - 1, m[3], r1), + }; + } + return null; +} + +const cellText = (p: RefCell): string => + `${p.absC ? "$" : ""}${colName(p.c)}${p.absR ? "$" : ""}${p.r + 1}`; + +/** 조각 → 참조 글(시트 머리는 `prefix` 그대로) */ +export function renderRef(p: RefParts): string { + if (p.kind === "cols") + return `${p.prefix}${p.a.absC ? "$" : ""}${colName(p.a.c)}:${p.b.absC ? "$" : ""}${colName(p.b.c)}`; + if (p.kind === "rows") + return `${p.prefix}${p.a.absR ? "$" : ""}${p.a.r + 1}:${p.b.absR ? "$" : ""}${p.b.r + 1}`; + return p.prefix + cellText(p.a) + (p.kind === "area" ? `:${cellText(p.b)}` : ""); +} + +// ── 낱말 나누기 ─────────────────────────────────────────────────────────────── + +/** 앞 `=` 뺀 식 글 → 낱말(빈칸 낱말 포함 · 끝까지 · 못 읽는 글은 `unknown`) — 편집기 색칠 · 가리키기용. + * 이름(정의 안 된 글자 묶음)도 `unknown` — 파서가 이름 나무로 읽음. */ +export function tokenize(formula: string): Token[] { + const out: Token[] = []; + let i = 0; + let ref: string | null; + const push = (kind: Token["kind"], len: number) => { + out.push({ kind, text: formula.slice(i, i + len), start: i, end: i + len }); + i += len; + }; + while (i < formula.length) { + const rest = formula.slice(i); + const ch = rest[0]; + let m: RegExpExecArray | null; + if ((m = /^\s+/.exec(rest))) push("space", m[0].length); + else if (ch === '"') { + const s = /^"(?:[^"]|"")*"?/.exec(rest)!; + push("string", s[0].length); + } else if ((m = /^([\p{L}_][\p{L}\p{N}_.]*)\(/u.exec(rest)) && !/^(TRUE|FALSE)$/i.test(m[1])) + push("func", m[1].length); + else if ((ref = refAt(rest))) push(/#REF!$/i.test(ref) ? "error" : "ref", ref.length); + else if ((m = /^(TRUE|FALSE)(?![\p{L}\p{N}_.(])/iu.exec(rest))) push("bool", m[0].length); + else if ((m = RE_ERROR.exec(rest))) push("error", m[0].length); + else if ((m = /^(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/.exec(rest))) push("number", m[0].length); + else if ((m = RE_IDENT.exec(rest))) push("unknown", m[0].length); + else if ((m = /^(<>|<=|>=|[-+*/^&=<>%:])/.exec(rest))) push("op", m[0].length); + else if (ch === "(" || ch === ")") push("paren", 1); + else if (ch === ",") push("comma", 1); + else push("unknown", 1); + } + return out; +} + +/** 머리 이 자리에서 시작하는 참조(또는 `시트!#REF!`) 글 */ +function refAt(rest: string): string | null { + const head = RE_PREFIX.exec(rest)?.[0] ?? ""; + const body = rest.slice(head.length); + if (head && /^#REF!/i.test(body)) return head + body.slice(0, 5); + for (const re of [RE_AREA, RE_COLS, RE_ROWS]) { + const m = re.exec(body); + if (m && parseRef(head + m[0])) return head + m[0]; + } + return null; +} + +// ── 나무 ──────────────────────────────────────────────────────────────────── + +class Fail { + constructor( + readonly message: string, + readonly at: number, + ) {} +} + +const COMPARE = ["=", "<>", "<", "<=", ">", ">="]; + /** 앞 `=` 뺀 식 글 → 나무. 앞에 `=` 가 붙어 와도 됨. 함수 이름은 대문자로. */ -export function parseFormula(_formula: string): ParseResult { - return todo(); +export function parseFormula(formula: string): ParseResult { + const text = formula.startsWith("=") ? formula.slice(1) : formula; + const toks = tokenize(text).filter((t) => t.kind !== "space"); + let p = 0; + const peek = (): Token | undefined => toks[p]; + const isOp = (...ops: string[]) => { + const t = toks[p]; + return !!t && t.kind === "op" && ops.includes(t.text); + }; + const where = () => (toks[p] ? toks[p].start : text.length); + + const binary = (ops: string[], next: () => FormulaNode) => (): FormulaNode => { + let left = next(); + while (isOp(...ops)) { + const op = toks[p++].text as "+"; + left = { type: "binary", op, left, right: next() }; + } + return left; + }; + const primary = (): FormulaNode => { + const t = peek(); + if (!t) throw new Fail("식이 끝남", text.length); + p++; + switch (t.kind) { + case "number": + return { type: "number", value: parseDecimal(t.text), text: t.text }; + case "string": + if (t.text.length < 2 || !t.text.endsWith('"')) throw new Fail("따옴표가 안 닫힘", t.start); + return { type: "string", value: t.text.slice(1, -1).replace(/""/g, '"') }; + case "bool": + return { type: "bool", value: t.text.toUpperCase() === "TRUE" }; + case "error": + return { + type: "error", + code: t.text.slice(t.text.lastIndexOf("#")).toUpperCase() as ErrorCode, + }; + case "ref": { + const r = parseRef(t.text)!; + return r.kind === "cell" + ? { type: "cell", sheet: r.sheet, ref: r.a } + : { type: "range", sheet: r.sheet, from: r.a, to: r.b }; + } + case "func": { + const name = t.text.toUpperCase().replace(/^_XLFN\./, ""); + p++; // `(` + const args: FormulaNode[] = []; + if (peek()?.text === ")") { + p++; + return { type: "call", name, args }; + } + for (;;) { + const k = peek(); + // 빈 인자(`IF(A1,,1)`) = 0 · 글은 비움 + if (k && (k.kind === "comma" || k.text === ")")) + args.push({ type: "number", value: ZERO, text: "" }); + else args.push(compare()); + const s = toks[p++]; + if (s?.kind === "comma") continue; + if (s?.text === ")") return { type: "call", name, args }; + throw new Fail("함수 괄호가 안 닫힘", s ? s.start : text.length); + } + } + case "paren": { + if (t.text !== "(") break; + const inner = compare(); + if (peek()?.text !== ")") throw new Fail("괄호가 안 닫힘", where()); + p++; + return inner; + } + case "unknown": + if (RE_IDENT.exec(t.text)?.[0] === t.text) return { type: "name", name: t.text }; + } + throw new Fail(`읽지 못한 글: ${t.text}`, t.start); + }; + const unary = (): FormulaNode => { + if (isOp("-", "+")) { + const op = toks[p++].text as "-"; + return { type: "unary", op, arg: unary() }; + } + return primary(); + }; + const percent = (): FormulaNode => { + let node = unary(); + while (isOp("%")) { + p++; + node = { type: "unary", op: "%", arg: node }; + } + return node; + }; + const power = binary(["^"], percent); + const mul = binary(["*", "/"], power); + const add = binary(["+", "-"], mul); + const concat = binary(["&"], add); + const compare = binary(COMPARE, concat); + + try { + if (!toks.length) throw new Fail("빈 식", 0); + const node = compare(); + if (p < toks.length) throw new Fail(`남은 글: ${toks[p].text}`, toks[p].start); + return { ok: true, node }; + } catch (e) { + if (e instanceof Fail) return { ok: false, message: e.message, at: e.at }; + throw e; + } } -/** 나무 → 앞 `=` 뺀 식 글(참조 옮김 뒤 다시 적기) — 수는 원래 글(`text`) 그대로 */ -export function formulaToText(_node: FormulaNode): string { - return todo(); +// ── 나무 → 글 ───────────────────────────────────────────────────────────────── + +const PREC: Record = { "&": 2, "+": 3, "-": 3, "*": 4, "/": 4, "^": 5 }; +for (const op of COMPARE) PREC[op] = 1; + +function prec(node: FormulaNode): number { + if (node.type === "binary") return PREC[node.op]; + if (node.type === "unary") return node.op === "%" ? 6 : 7; + return 8; +} + +const sheetHead = (sheet: string | null) => (sheet === null ? "" : `${quoteSheet(sheet)}!`); + +/** 나무 → 앞 `=` 뺀 식 글(참조 옮김 뒤 다시 적기) — 수는 원래 글(`text`) 그대로 · 괄호는 필요한 곳만 */ +export function formulaToText(node: FormulaNode): string { + const wrap = (child: FormulaNode, need: boolean) => + need ? `(${formulaToText(child)})` : formulaToText(child); + switch (node.type) { + case "number": + return node.text; + case "string": + return `"${node.value.replace(/"/g, '""')}"`; + case "bool": + return node.value ? "TRUE" : "FALSE"; + case "error": + return node.code; + case "name": + return node.name; + case "cell": + return sheetHead(node.sheet) + cellText(node.ref); + case "range": { + const { from, to } = node; + const kind = + from.r === 0 && to.r === MAX_ROWS - 1 + ? "cols" + : from.c === 0 && to.c === MAX_COLS - 1 + ? "rows" + : "area"; + return renderRef({ prefix: sheetHead(node.sheet), sheet: node.sheet, kind, a: from, b: to }); + } + case "unary": + return node.op === "%" + ? `${wrap(node.arg, prec(node.arg) < 6)}%` + : `${node.op}${wrap(node.arg, prec(node.arg) < 7)}`; + case "binary": { + const p = PREC[node.op]; + return `${wrap(node.left, prec(node.left) < p)}${node.op}${wrap(node.right, prec(node.right) <= p)}`; + } + case "call": + return `${node.name}(${node.args.map(formulaToText).join(",")})`; + } } diff --git a/A00_Common/spreadsheet/spreadsheet_recalc.ts b/A00_Common/spreadsheet/spreadsheet_recalc.ts index 7fa012da..30383ce2 100644 --- a/A00_Common/spreadsheet/spreadsheet_recalc.ts +++ b/A00_Common/spreadsheet/spreadsheet_recalc.ts @@ -2,15 +2,11 @@ * spreadsheet_recalc.ts (주인 A) * 통합문서 한 벌 풀이 — 서버 Node 진입(`common_util_spreadsheet_node.ts`) · 시험이 부름. * 화면과 같은 엔진(`createCalcEngine`)을 한 번 돌려 `snapshot()` — 계산이 두 벌이 되지 않게. - * 0 계약 머리 — 몸은 A 가 채움. * ========================================================================== */ +import { createCalcEngine } from "./spreadsheet_graph"; import type { CalcValues, Workbook } from "./spreadsheet_types"; -const todo = (): never => { - throw new Error("spreadsheet_recalc: 아직 없음(A)"); -}; - -export function recalcWorkbook(_book: Workbook): CalcValues { - return todo(); +export function recalcWorkbook(book: Workbook): CalcValues { + return createCalcEngine(book).snapshot(); } diff --git a/A00_Common/spreadsheet/spreadsheet_refshift.ts b/A00_Common/spreadsheet/spreadsheet_refshift.ts index 75d87001..fe28e699 100644 --- a/A00_Common/spreadsheet/spreadsheet_refshift.ts +++ b/A00_Common/spreadsheet/spreadsheet_refshift.ts @@ -1,33 +1,179 @@ /* ============================================================================= * spreadsheet_refshift.ts (주인 A) - * 참조 옮김 — 행열 넣기 · 지우기 · 잘라 붙이기 · 시트 이름 바꾸기 때 식 글을 고쳐 적음(엑셀 규칙) · - * 복사 · 채우기 때 상대 참조만 옮김 · 구글 R1C1 식 → A1. + * 참조 옮김 — 행열 넣기 · 지우기 · 잘라 붙이기 · 시트 이름 바꾸기 · 시트 지우기 때 식 글을 고쳐 적음 + * (엑셀 규칙) · 복사 · 채우기 때 상대 참조만 옮김 · 구글 R1C1 식 → A1. * 지운 칸 · 격자 밖으로 나간 참조는 `#REF!` · 범위는 남은 만큼 줄어듦(끝까지 지우면 `#REF!`). - * 0 계약 머리 — 몸은 A 가 채움. + * 낱말 단위로 참조만 바꿔 끼움 — 빈칸 · 괄호 · 수 글은 적힌 그대로 둠. + * + * 엑셀 규칙표: + * 넣기 — 넣는 자리 이후를 가리키면 밀림 · 범위가 넣는 자리를 품으면 늘어남(첫 행 앞 넣기는 통째 밀림) + * 지우기 — 지운 칸 하나 = `#REF!` · 범위는 남은 만큼 · 전부 지우면 `#REF!` · `$` 도 똑같이 옮김 + * 옮기기 — 옮긴 덩어리 안을 가리키던 참조(범위는 통째 들 때만)는 따라감 · 덮어쓴 자리를 가리키면 `#REF!` + * 복사 — 상대만 (dr, dc) · 행 · 열 전체 범위는 그 축을 안 옮김 * ========================================================================== */ -import type { CellAddress, Command, Workbook } from "./spreadsheet_types"; +import { quoteSheet, rangeInside } from "./spreadsheet_address"; +import { parseRef, renderRef, tokenize } from "./spreadsheet_parser"; +import type { RefParts } from "./spreadsheet_parser"; +import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types"; +import type { CellAddress, CellRange, Command, Workbook } from "./spreadsheet_types"; -const todo = (): never => { - throw new Error("spreadsheet_refshift: 아직 없음(A)"); -}; +const REF_ERR = "#REF!"; -/** 행열 · 옮기기 · 시트이름 명령에 맞춰 식 글 고침(식이 든 시트 id = `host`) — 안 바뀌면 같은 글 */ +/** 참조 낱말만 `fn` 으로 바꿔 끼움(null = `#REF!`) */ +function mapRefs(formula: string, fn: (p: RefParts) => RefParts | string | null): string { + let out = ""; + for (const t of tokenize(formula)) { + const p = t.kind === "ref" ? parseRef(t.text) : null; + if (!p) { + out += t.text; + continue; + } + const q = fn(p); + out += q === null ? REF_ERR : typeof q === "string" ? q : renderRef(q); + } + return out; +} + +/** 넣기 · 지우기 한 축 — [lo, hi] → 새 [lo, hi] · 다 지워지면 null */ +export function shiftSpan( + lo: number, + hi: number, + insert: boolean, + at: number, + n: number, + max: number, +): [number, number] | null { + if (insert) { + if (lo >= at) [lo, hi] = [lo + n, hi + n]; + else if (hi >= at) hi += n; + if (lo >= max) return null; + return [lo, Math.min(hi, max - 1)]; + } + const end = at + n - 1; + if (lo >= at && hi <= end) return null; + return [lo < at ? lo : lo > end ? lo - n : at, hi < at ? hi : hi > end ? hi - n : at - 1]; +} + +const sheetIdOf = (book: Workbook, name: string) => + book.시트.find((s) => s.이름.toLowerCase() === name.toLowerCase())?.id ?? null; + +const box = (p: RefParts): CellRange => ({ + r0: Math.min(p.a.r, p.b.r), + c0: Math.min(p.a.c, p.b.c), + r1: Math.max(p.a.r, p.b.r), + c1: Math.max(p.a.c, p.b.c), +}); + +/** 행열 · 옮기기 · 시트이름 · 시트지우기 명령에 맞춰 식 글 고침(식이 든 시트 id = `host`) — 안 바뀌면 같은 글. + * `book` 은 명령 적용 **전** 문서(시트 이름으로 id 를 찾음). `newHost` = 옮기기로 식 칸 자체가 간 시트. */ export function shiftForCommand( - _formula: string, - _host: string, - _command: Command, - _book: Workbook, + formula: string, + host: string, + command: Command, + book: Workbook, + newHost: string = host, ): string { - return todo(); + if (command.종류 === "묶음") + return command.명령.reduce((f, c) => shiftForCommand(f, host, c, book, newHost), formula); + const target = (p: RefParts) => (p.sheet === null ? host : sheetIdOf(book, p.sheet)); + const nameOf = (id: string) => book.시트.find((s) => s.id === id)?.이름 ?? ""; + + switch (command.종류) { + case "행넣기": + case "행지우기": + case "열넣기": + case "열지우기": { + const rows = command.종류.startsWith("행"); + const insert = command.종류.endsWith("넣기"); + return mapRefs(formula, (p) => { + if (target(p) !== command.시트) return p; + if (rows ? p.kind === "cols" : p.kind === "rows") return p; // 다른 축 전체 범위 + const [lo, hi] = rows + ? [p.a.r, p.b.r].sort((x, y) => x - y) + : [p.a.c, p.b.c].sort((x, y) => x - y); + const span = shiftSpan(lo, hi, insert, command.at, command.수, rows ? MAX_ROWS : MAX_COLS); + if (!span) return null; + const q = structuredClone(p); + // 적힌 차례(뒤집힌 범위)는 바로 세움 — 엑셀도 다시 적을 때 바로 세움 + if (rows) [q.a.r, q.b.r] = span; + else [q.a.c, q.b.c] = span; + return q; + }); + } + case "옮기기": { + const src = command.범위; + const dr = command.r - src.r0; + const dc = command.c - src.c0; + const dst: CellRange = { r0: command.r, c0: command.c, r1: src.r1 + dr, c1: src.c1 + dc }; + return mapRefs(formula, (p) => { + const t = target(p); + if (t === null) return p; + const b = box(p); + let to = t; + const q = structuredClone(p); + if (t === command.시트 && p.kind !== "cols" && p.kind !== "rows" && rangeInside(b, src)) { + to = command.대상시트; + for (const pt of [q.a, q.b]) { + pt.r += dr; + pt.c += dc; + } + } else if (t === command.대상시트 && rangeInside(b, dst)) return null; + if (to === t && newHost === host) return q; + q.prefix = to === newHost ? "" : `${quoteSheet(nameOf(to))}!`; + return q; + }); + } + case "시트이름": + return mapRefs(formula, (p) => + p.sheet !== null && target(p) === command.시트 + ? { ...p, prefix: `${quoteSheet(command.이름)}!` } + : p, + ); + case "시트지우기": + return mapRefs(formula, (p) => (p.sheet !== null && target(p) === command.시트 ? null : p)); + default: + return formula; + } } /** 복사 · 채우기 — 상대 참조만 (dr, dc) 옮김 · 격자 밖은 `#REF!` */ -export function moveFormula(_formula: string, _dr: number, _dc: number): string { - return todo(); +export function moveFormula(formula: string, dr: number, dc: number): string { + return mapRefs(formula, (p) => { + const q = structuredClone(p); + for (const pt of [q.a, q.b]) { + if (!pt.absR && p.kind !== "cols") pt.r += dr; + if (!pt.absC && p.kind !== "rows") pt.c += dc; + if (pt.r < 0 || pt.r >= MAX_ROWS || pt.c < 0 || pt.c >= MAX_COLS) return null; + } + return q; + }); } /** 구글 `data-sheets-formula`(`=2*R[0]C[-1]`) → 붙일 자리 기준 A1 식(앞 `=` 뺌) */ -export function r1c1ToA1(_formula: string, _at: CellAddress): string { - return todo(); +export function r1c1ToA1(formula: string, at: CellAddress): string { + const text = formula.startsWith("=") ? formula.slice(1) : formula; + const axis = (part: string | undefined, base: number): [number, boolean] => + part === undefined || part === "" + ? [base, false] + : part.startsWith("[") + ? [base + Number(part.slice(1, -1)), false] + : [Number(part) - 1, true]; + // 글 · 따옴표 시트 이름은 건너뜀 · 이름 한가운데(`ROUND`)는 안 건드림 + return text.replace( + /("(?:[^"]|"")*"|'(?:[^']|'')*')|(? { + if (quoted) return whole; + const [r, absR] = axis(rp, at.r); + const [c, absC] = axis(cp, at.c); + if (r < 0 || r >= MAX_ROWS || c < 0 || c >= MAX_COLS) return REF_ERR; + return renderRef({ + prefix: "", + sheet: null, + kind: "cell", + a: { r, c, absR, absC }, + b: { r, c, absR, absC }, + }); + }, + ); } diff --git a/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs b/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs new file mode 100644 index 00000000..afa240df --- /dev/null +++ b/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs @@ -0,0 +1,665 @@ +/* 스프레드시트 엔진 A 시험 헬퍼 — `A00_Common/spreadsheet/*.ts` 를 그 자리에서 트랜스파일해 Node 로 돌림. + * 묶음마다 틀린 것 목록을 JSON 으로 냄 → `test_spreadsheet_engine.py` 가 묶음마다 빈 목록인지 봄. + * 함수 표(B 몫)는 가짜 넷(SUM · IF · IFERROR · ROUNDDOWN)으로 갈아 끼움 — A 의 부르는 길만 잼. + * 확장자 `.cjs` = 루트 package.json `"type": "module"` 때문. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); +const DIR = path.join(ROOT, "A00_Common", "spreadsheet"); + +const cache = new Map(); +let fakeFunctions = null; +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 === "./spreadsheet_functions") return fakeFunctions; + 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}.ts`)); + return require(r); + }; + new Function("exports", "module", "require", js)(mod.exports, mod, req); + return mod.exports; +} +const load = (name) => loadTs(path.join(DIR, `${name}.ts`)); + +fakeFunctions = { + getFunction: (name) => FAKE[name], + functionNames: () => Object.keys(FAKE), +}; +const F = loadTs(path.join(ROOT, "ui_template", "sheet", "ui_template_sheet_frac.ts")); +const E = load("spreadsheet_eval"); +const FAKE = { + SUM: { + min: 1, + call(args) { + let total = F.ZERO; + for (const a of args) { + const v = a(); + if (E.isError(v)) return v; + if (v && v.kind === "range") { + for (let i = 0; i < v.rows; i++) + for (let j = 0; j < v.cols; j++) { + const x = v.at(i, j); + if (E.isError(x)) return x; + if (E.isFrac(x)) total = F.add(total, x); + } + } else { + const n = E.asNumber(v); + if (E.isError(n)) return n; + total = F.add(total, n); + } + } + return total; + }, + }, + IF: { + min: 2, + max: 3, + call(args) { + const c = E.asBool(args[0]()); + if (E.isError(c)) return c; + return c ? args[1]() : args[2] ? args[2]() : false; + }, + }, + IFERROR: { + min: 2, + max: 2, + call(args) { + const v = args[0](); + return E.isError(v) ? args[1]() : v; + }, + }, + ROUNDDOWN: { + min: 2, + max: 2, + call(args) { + const x = E.asNumber(args[0]()); + if (E.isError(x)) return x; + const d = E.asNumber(args[1]()); + if (E.isError(d)) return d; + return F.roundAt(x, Number(F.fracToString(d)), "trunc"); + }, + }, +}; + +const A = load("spreadsheet_address"); +const P = load("spreadsheet_parser"); +const G = load("spreadsheet_graph"); +const R = load("spreadsheet_refshift"); +const C = load("spreadsheet_commands"); +const H = load("spreadsheet_history"); +const RC = load("spreadsheet_recalc"); + +const results = {}; +let group = ""; +const fails = () => (results[group] ??= []); +const eq = (label, got, want) => { + const g = JSON.stringify(got); + const w = JSON.stringify(want); + if (g !== w) fails().push(`${label}: got ${g} want ${w}`); +}; +const section = (name, fn) => { + group = name; + results[name] = []; + try { + fn(); + } catch (e) { + fails().push(`예외: ${e && e.stack ? e.stack : e}`); + } +}; + +/** 값 보기 — 수는 십진 글 · 오류는 코드 */ +function show(v) { + if (v === null) return null; + if (E.isError(v)) return v.error; + if (E.isFrac(v)) return F.fracToString(v); + return v; +} + +/** 시트 둘(s1 「본」 · s2 「치수」) · 칸 표 → 통합문서 */ +function book(cells1, cells2 = {}) { + const wrap = (cells) => + Object.fromEntries( + Object.entries(cells).map(([k, v]) => + typeof v === "string" && v.startsWith("=") ? [k, { 식: v.slice(1) }] : [k, { 값: v }], + ), + ); + return { + 종류: "통합문서", + 판: 1, + 열: "t", + 서식: [{}], + 시트: [ + { id: "s1", 이름: "본", 칸: wrap(cells1) }, + { id: "s2", 이름: "치수", 칸: wrap(cells2) }, + ], + }; +} + +/** 식 하나 풀어 보기 */ +function calc(formula, cells = {}) { + const b = book({ ...cells, Z99: "=" + formula }); + const e = G.createCalcEngine(b); + const p = A.parseA1("Z99"); + return show(e.value("s1", p.r, p.c)); +} + +const at = (a1) => A.parseA1(a1); +const val = (engine, sheet, a1) => show(engine.value(sheet, at(a1).r, at(a1).c)); + +// ── 1. 주소 ───────────────────────────────────────────────────────────────── +section("address", () => { + eq("colName", [0, 25, 26, 27, 701, 702, 16383].map(A.colName), ["A", "Z", "AA", "AB", "ZZ", "AAA", "XFD"]); + eq("colIndex", ["A", "ab", "XFD", "XFE", "", "A1"].map(A.colIndex), [0, 27, 16383, -1, -1, -1]); + eq("toA1", A.toA1(30, 4), "E31"); + eq("parseA1", [A.parseA1("$E$31"), A.parseA1("e31"), A.parseA1("A0"), A.parseA1("XFE1")], [ + { r: 30, c: 4 }, + { r: 30, c: 4 }, + null, + null, + ]); + eq("parseRange 뒤집힘", A.parseRange("B5:A1"), { r0: 0, c0: 0, r1: 4, c1: 1 }); + eq("rangeToA1", [ + A.rangeToA1({ r0: 2, c0: 2, r1: 2, c1: 29 }), + A.rangeToA1({ r0: 30, c0: 4, r1: 30, c1: 4 }), + A.rangeToA1(A.parseRange("A:C")), + A.rangeToA1(A.parseRange("3:5")), + A.rangeToA1(A.parseRange("$B$2:$C$3")), + ], ["C3:AD3", "E31", "A:C", "3:5", "B2:C3"]); + eq("quoteSheet", ["치수", "돌-골막이치수", "암거수량집계표(2×2)", "a'b", "A1", "Sheet 1", "TRUE", "_x"].map(A.quoteSheet), [ + "치수", + "'돌-골막이치수'", + "'암거수량집계표(2×2)'", + "'a''b'", + "'A1'", + "'Sheet 1'", + "'TRUE'", + "_x", + ]); + eq("cellId 왕복", A.fromCellId(A.cellId(1048575, 16383)), { r: 1048575, c: 16383 }); +}); + +// ── 2. 파서 ───────────────────────────────────────────────────────────────── +section("parser", () => { + const kinds = (f) => P.tokenize(f).map((t) => `${t.kind}:${t.text}`); + eq("낱말", kinds("'돌-골막이'!D14+SUM(A1:B2, $C$3)&\"a\"\"b\""), [ + "ref:'돌-골막이'!D14", + "op:+", + "func:SUM", + "paren:(", + "ref:A1:B2", + "comma:,", + "space: ", + "ref:$C$3", + "paren:)", + "op:&", + 'string:"a""b"', + ]); + eq("낱말 자리", P.tokenize("A1 + 2").map((t) => [t.start, t.end]), [[0, 2], [2, 3], [3, 4], [4, 5], [5, 6]]); + eq("낱말 행열 · 오류 · 참거짓", kinds("A:A+3:5+#N/A+치수!#REF!+true+LOG10(1)"), [ + "ref:A:A", "op:+", "ref:3:5", "op:+", "error:#N/A", "op:+", "error:치수!#REF!", "op:+", "bool:true", "op:+", + "func:LOG10", "paren:(", "number:1", "paren:)", + ]); + // 나무 → 글 왕복(괄호는 필요한 곳만) + const round = [ + ["ROUNDDOWN((G31+J31/2)*P31,2)", "ROUNDDOWN((G31+J31/2)*P31,2)"], + ["=-2^2", "-2^2"], + ["-(2^2)", "-(2^2)"], + ["(1+2)*3", "(1+2)*3"], + ["1-(2-3)", "1-(2-3)"], + ["(1-2)-3", "1-2-3"], + ["2^(3^2)", "2^(3^2)"], + ['"a"&(1+2)', '"a"&1+2'], + ["(A1&B1)=C1", "A1&B1=C1"], + ["50%^2", "50%^2"], + ["'돌-골막이치수'!D14*$A$1", "'돌-골막이치수'!D14*$A$1"], + ["치수!B1:C2", "치수!B1:C2"], + ["SUM($A:$A,3:3)", "SUM($A:$A,3:3)"], + ["IF(A1,,1)", "IF(A1,,1)"], + ["sum()", "SUM()"], + ['"say ""hi"""', '"say ""hi"""'], + ["#REF!+1", "#REF!+1"], + ["TRUE<>false", "TRUE<>FALSE"], + ["1.50E+3", "1.50E+3"], + ["_xlfn.CONCAT(1)", "CONCAT(1)"], + ["미정의이름*2", "미정의이름*2"], + ]; + for (const [src, want] of round) { + const p = P.parseFormula(src); + eq(`왕복 ${src}`, p.ok ? P.formulaToText(p.node) : `실패 ${p.message}`, want); + } + const bad = ["1+", "(1", "SUM(1", '"abc', "1 2", ")", "", "A1:", "1+*2"]; + for (const src of bad) eq(`실패 ${src}`, P.parseFormula(src).ok, false); + const f = P.parseFormula("1+SUM(2"); + eq("실패 자리", [f.ok, typeof f.at === "number", typeof f.message], [false, true, "string"]); + eq("격자 밖 주소는 이름", P.parseFormula("XFE1").node, { type: "name", name: "XFE1" }); +}); + +// ── 3. 우선순위 · 형 바꾸기 ───────────────────────────────────────────────── +section("precedence", () => { + const cases = [ + ["-2^2", "4"], + ["0-2^2", "-4"], + ["2^3^2", "64"], + ["2+3*4", "14"], + ["(2+3)*4", "20"], + ["10%*2", "0.2"], + ["-10%", "-0.1"], + ["2^50%", "1.4142135623731"], + ['"a"&1+2', "a3"], + ['1+2&""', "3"], + ["1&2=12", false], + ['1&2="12"', true], + ["3>2=TRUE", true], + ['"abc"="ABC"', true], + ['1<"a"', true], + ['"a""a"', true], + ["0.1+0.2=0.3", true], + ["1/3*3=1", true], + ["A9", "0"], + ["-A9", "0"], + ['A9=""', true], + ["A9=0", true], + ['"12"+1', "13"], + ["TRUE+1", "2"], + ['" 1,234 "+1', "1235"], + ['"50%"+0', "0.5"], + ["2^0.5", "1.4142135623731"], + ["2^-1", "0.5"], + ["(-2)^3", "-8"], + ['1/3&""', "0.333333333333333"], + ['2^60&""', "1.15292150460685E+18"], + ['(0.1+0.2)&""', "0.3"], + ['0.000000001&""', "0.000000001"], + ['TRUE&1', "TRUE1"], + ["+\"x\"", "x"], + ]; + for (const [f, want] of cases) eq(f, calc(f), want); +}); + +// ── 4. 오류 값 · 전파 ─────────────────────────────────────────────────────── +section("errors", () => { + const cases = [ + ["1/0", "#DIV/0!"], + ['"a"+1', "#VALUE!"], + ["#N/A+1/0", "#N/A"], + ["1/0&#N/A", "#DIV/0!"], + ["1/0=#N/A", "#DIV/0!"], + ["-#NUM!", "#NUM!"], + ["(-8)^(1/3)", "#NUM!"], + ["0^0", "#NUM!"], + ["0^-1", "#DIV/0!"], + ["없는시트!A1", "#REF!"], + ["#REF!", "#REF!"], + ["이름표*2", "#NAME?"], + ["FOO(1)", "#NAME?"], + ["IF(1)", "#VALUE!"], + ["1+", "#NAME?"], + ["IF(TRUE,1,1/0)", "1"], + ["IF(FALSE,1/0,2)", "2"], + ["IFERROR(1/0,5)", "5"], + ["IFERROR(A1,5)", "5"], + ["A1:B2", "#VALUE!"], + ["A1:A1", "#NULL!"], + ["B1*2", "#DIV/0!"], + ["SUM(B1:B2)", "#DIV/0!"], + ]; + for (const [f, want] of cases) eq(f, calc(f, { A1: "=#NULL!", B1: "=1/0" }), want); + const b = book({ A1: "=1+", B1: "=FOO(1)", C1: "=IF(1)" }); + const e = G.createCalcEngine(b); + const why = (a1) => e.value("s1", at(a1).r, at(a1).c).why || ""; + eq("까닭 파싱", why("A1").startsWith("식을 못 읽음"), true); + eq("까닭 함수", why("B1"), "모르는 함수: FOO"); + eq("까닭 인자 수", why("C1").includes("인자 수 1"), true); + eq("toCalcValue 오류", E.toCalcValue(e.value("s1", 0, 1)), { 오류: "#NAME?", 까닭: "모르는 함수: FOO" }); +}); + +// ── 5. 순환 ───────────────────────────────────────────────────────────────── +section("cycle", () => { + const b = book({ A1: "=B1+1", B1: "=A1+1", C1: "=A1*2", D1: 5, E1: "=E1", F1: "=SUM(F2:F3)", F3: "=F1", G1: "=D1*2" }); + const e = G.createCalcEngine(b); + const v = (a1) => val(e, "s1", a1); + eq("고리 칸", ["A1", "B1", "E1", "F1", "F3"].map(v), ["#CYCLE!", "#CYCLE!", "#CYCLE!", "#CYCLE!", "#CYCLE!"]); + eq("고리를 읽는 칸", v("C1"), "#CYCLE!"); + eq("나머지 칸", [v("D1"), v("G1")], ["5", "10"]); + const why = e.value("s1", 0, 0).why; + eq("까닭 = 고리 칸 목록", /A1/.test(why) && /B1/.test(why) && why.startsWith("순환 참조"), true); + // 고리를 끊으면 딸린 칸이 풀림 + C.applyCommand(b, { 종류: "칸", 시트: "s1", 칸: { B1: { 값: 3 } } }); + e.update([{ 시트: "s1", ...at("B1") }]); + eq("끊은 뒤", ["A1", "B1", "C1"].map(v), ["4", "3", "8"]); + // 다시 고리 + C.applyCommand(b, { 종류: "칸", 시트: "s1", 칸: { B1: { 식: "C1" } } }); + e.update([{ 시트: "s1", ...at("B1") }]); + eq("다시 고리", ["A1", "B1", "C1", "D1"].map(v), ["#CYCLE!", "#CYCLE!", "#CYCLE!", "5"]); +}); + +// ── 6. 딸린 칸만 다시 ─────────────────────────────────────────────────────── +section("graph", () => { + const b = book({ A1: 1, A2: 2, B1: "=A1*2", C1: "=B1+1", D1: "=7", E1: "=SUM(A1:A3)", F1: "=치수!B1*10" }, { B1: 0.9 }); + const e = G.createCalcEngine(b); + const v = (a1) => val(e, "s1", a1); + eq("처음", ["B1", "C1", "D1", "E1", "F1"].map(v), ["2", "3", "7", "3", "9"]); + b.시트[0].칸.A1 = { 값: 10 }; + const redo = e.update([{ 시트: "s1", ...at("A1") }]).map((a) => A.toA1(a.r, a.c)).sort(); + eq("다시 푼 칸", redo, ["A1", "B1", "C1", "E1"]); + eq("값", ["B1", "C1", "D1", "E1"].map(v), ["20", "21", "7", "12"]); + b.시트[0].칸.A3 = { 값: 5 }; + eq("범위 안 새 칸", e.update([{ 시트: "s1", ...at("A3") }]).map((a) => A.toA1(a.r, a.c)).sort(), ["A3", "E1"]); + eq("범위 값", v("E1"), "17"); + b.시트[1].칸.B1 = { 값: 2 }; + eq("다른 시트", e.update([{ 시트: "s2", ...at("B1") }]).map((a) => `${a.시트}!${A.toA1(a.r, a.c)}`).sort(), [ + "s1!F1", + "s2!B1", + ]); + eq("다른 시트 값", v("F1"), "20"); + eq("앞 칸", e.precedents("s1", at("E1").r, at("E1").c), [{ 시트: "s1", 범위: { r0: 0, c0: 0, r1: 2, c1: 0 } }]); + eq("snapshot 식 칸만", Object.keys(e.snapshot().s1).sort(), ["B1", "C1", "D1", "E1", "F1"]); + // 긴 사슬(끌어 풀기 재귀 깊이) + const chain = { A1: 1 }; + for (let i = 2; i <= 1500; i++) chain[`A${i}`] = `=A${i - 1}+1`; + eq("사슬 1500", val(G.createCalcEngine(book(chain)), "s1", "A1500"), "1500"); +}); + +// ── 7. 참조 옮김 엑셀 규칙표 ──────────────────────────────────────────────── +section("refshift", () => { + const b = book({}, {}); + const run = (label, cmd, rows, host = "s1") => { + for (const [src, want] of rows) eq(`${label} ${src}`, R.shiftForCommand(src, host, cmd, b), want); + }; + run("행넣기 3행에 1", { 종류: "행넣기", 시트: "s1", at: 2, 수: 1 }, [ + ["A1", "A1"], + ["A3", "A4"], + ["A2:A5", "A2:A6"], + ["A3:A5", "A4:A6"], + ["$A$3", "$A$4"], + ["A$3+$A3", "A$4+$A4"], + ["A:A", "A:A"], + ["3:5", "4:6"], + ["2:5", "2:6"], + ["치수!A3", "치수!A3"], + ["본!A3", "본!A4"], + ["( A3 + 1 )", "( A4 + 1 )"], + ['"A3"&A3', '"A3"&A4'], + ["A5:A2", "A2:A6"], + ]); + run("행넣기 다른 시트에서", { 종류: "행넣기", 시트: "s1", at: 2, 수: 1 }, [["본!A3+A3", "본!A4+A3"]], "s2"); + run("행지우기 3~4행", { 종류: "행지우기", 시트: "s1", at: 2, 수: 2 }, [ + ["A3", "#REF!"], + ["A4", "#REF!"], + ["A5", "A3"], + ["A2:A6", "A2:A4"], + ["A3:A4", "#REF!"], + ["A1:A3", "A1:A2"], + ["A4:A8", "A3:A6"], + ["SUM(A3,A5)", "SUM(#REF!,A3)"], + ["$B$9", "$B$7"], + ["3:3", "#REF!"], + ["B:B", "B:B"], + ]); + run("열넣기 B열에 2", { 종류: "열넣기", 시트: "s1", at: 1, 수: 2 }, [ + ["B1", "D1"], + ["A1:C1", "A1:E1"], + ["A:B", "A:D"], + ["3:3", "3:3"], + ["$B$1", "$D$1"], + ["A1", "A1"], + ]); + run("열지우기 B열", { 종류: "열지우기", 시트: "s1", at: 1, 수: 1 }, [ + ["B1", "#REF!"], + ["C1", "B1"], + ["A1:C1", "A1:B1"], + ["B:B", "#REF!"], + ]); + const cut = { 종류: "옮기기", 시트: "s1", 범위: { r0: 0, c0: 0, r1: 1, c1: 1 }, 대상시트: "s1", r: 4, c: 3 }; + run("옮기기 A1:B2 → D5", cut, [ + ["A1", "D5"], + ["$B$2", "$E$6"], + ["A1:B2", "D5:E6"], + ["A1:C3", "A1:C3"], + ["D5", "#REF!"], + ["D5:E6", "#REF!"], + ["C3", "C3"], + ["치수!A1", "치수!A1"], + ]); + const cutAway = { 종류: "옮기기", 시트: "s1", 범위: { r0: 0, c0: 0, r1: 0, c1: 0 }, 대상시트: "s2", r: 9, c: 0 }; + run("옮기기 다른 시트로", cutAway, [["A1+B1", "치수!A10+B1"]]); + run("옮기기 다른 시트로(그쪽 식)", cutAway, [["본!A1+A1", "A10+A1"]], "s2"); + eq("옮겨 간 식 자체", R.shiftForCommand("A1+B1", "s1", cutAway, b, "s2"), "A10+본!B1"); + run("시트이름 치수 → 치 수", { 종류: "시트이름", 시트: "s2", 이름: "치 수" }, [ + ["치수!B1", "'치 수'!B1"], + ["'치수'!B1:B2", "'치 수'!B1:B2"], + ["치수!B1+B1", "'치 수'!B1+B1"], + ]); + run("시트지우기", { 종류: "시트지우기", 시트: "s2" }, [ + ["치수!B1+1", "#REF!+1"], + ["B1", "B1"], + ]); + const mv = [ + [["A1+$B$2+B$3+$C4", 1, 1], "B2+$B$2+C$3+$C5"], + [["A1", -1, 0], "#REF!"], + [["A:A+3:3", 5, 1], "B:B+8:8"], + [["SUM(A1:B2)*2", 2, 0], "SUM(A3:B4)*2"], + [["치수!A1", 0, 1], "치수!B1"], + [["XFD1", 0, 1], "#REF!"], + ]; + for (const [[f, dr, dc], want] of mv) eq(`복사 ${f} (${dr},${dc})`, R.moveFormula(f, dr, dc), want); + const r1 = [ + [["=2*R[0]C[-1]", { r: 4, c: 3 }], "2*C5"], + [["=SUM(R[-2]C:R[-1]C)", { r: 4, c: 0 }], "SUM(A3:A4)"], + [["=R1C1", { r: 9, c: 9 }], "$A$1"], + [['="RC"&ROUND(RC[1],0)', { r: 0, c: 0 }], '"RC"&ROUND(B1,0)'], + [["='치수'!R[1]C", { r: 0, c: 0 }], "'치수'!A2"], + [["=R[-1]C", { r: 0, c: 0 }], "#REF!"], + ]; + for (const [[f, pos], want] of r1) eq(`R1C1 ${f}`, R.r1c1ToA1(f, pos), want); +}); + +// ── 8. 명령 · 되돌리기 ────────────────────────────────────────────────────── +const SAMPLE = JSON.parse(fs.readFileSync(path.join(DIR, "spreadsheet_sample.json"), "utf8")); +section("commands", () => { + const fresh = () => structuredClone(SAMPLE); + // 열쇠 차례 · 병합 차례는 뜻이 없음 — 바로 세워 견줌 + const canon = (v, k) => + Array.isArray(v) + ? (k === "병합" ? [...v].sort() : v).map((x) => canon(x)) + : v && typeof v === "object" + ? Object.fromEntries(Object.keys(v).sort().map((key) => [key, canon(v[key], key)])) + : v; + const sheetsJson = (b) => JSON.stringify(canon(b.시트)); + /** 적용 → 되돌림 → 처음과 같은지 · 다시 → 적용 뒤와 같은지 */ + const roundTrip = (label, cmd, check) => { + const b = fresh(); + const before = sheetsJson(b); + const fx = C.applyCommand(b, cmd); + const after = sheetsJson(b); + if (check) check(b, fx); + C.applyCommand(b, fx.undo); + eq(`${label} 되돌림`, sheetsJson(b) === before, true); + C.applyCommand(b, cmd); + eq(`${label} 다시`, sheetsJson(b) === after, true); + }; + const s1 = (b) => b.시트[0]; + roundTrip("칸", { 종류: "칸", 시트: "s1", 칸: { h4: { 값: 1 }, A1: null, Z9: { 식: "H4*2" } } }, (b, fx) => { + eq("칸 열쇠 바로 세움", [s1(b).칸.H4, s1(b).칸.A1, fx.cells.length, fx.rebuild], [{ 값: 1 }, undefined, 3, false]); + }); + roundTrip("내용지움", { 종류: "내용지움", 시트: "s1", 범위: [A.parseRange("A3:H4")] }, (b, fx) => { + eq("서식은 남음", [s1(b).칸.A3, s1(b).칸.D3, fx.cells.length], [{ 서식: 2 }, { 서식: 2 }, 10]); + }); + roundTrip("서식", { 종류: "서식", 시트: "s1", 범위: [A.parseRange("H4:H6")], 바꿀: { 굵게: true, 형식: null } }, (b) => { + const st = b.서식[s1(b).칸.H4.서식]; + eq("서식 덮기", [st.굵게, st.형식, s1(b).칸.H4.서식 === s1(b).칸.H5.서식], [true, undefined, true]); + }); + roundTrip("서식 열 전체", { 종류: "서식", 시트: "s1", 범위: [A.parseRange("B:B")], 바꿀: { 기울임: true } }, (b, fx) => { + eq("열 서식", [!!s1(b).열.B.서식, fx.rebuild, b.서식[s1(b).칸.B4.서식].기울임], [true, true, true]); + }); + roundTrip("서식지움", { 종류: "서식지움", 시트: "s1", 범위: [A.parseRange("A3:B4")] }, (b) => { + eq("서식지움", [s1(b).칸.A3, s1(b).칸.A4], [{ 값: "공종" }, { 값: "터파기" }]); + }); + roundTrip("행넣기", { 종류: "행넣기", 시트: "s1", at: 3, 수: 1 }, (b) => { + const c = s1(b).칸; + eq("행넣기 칸 · 식 · 병합", [c.H4, c.H5.식, c.H6.식, c.H7.식, c.J5.식, s1(b).병합], [ + undefined, + "ROUNDDOWN((J5+J6)/2*J7,2)", + "ROUNDDOWN(H5*0.3,2)", + "SUM(H5:H6)", + "치수!B1", + ["A1:H1", "C3:F3", "C5:F5", "C6:F6", "A7:G7"], + ]); + }); + roundTrip("행넣기 틀고정 위", { 종류: "행넣기", 시트: "s1", at: 0, 수: 2 }, (b) => { + eq("틀고정 · 행 높이 밀림", [s1(b).틀고정, s1(b).행], [{ 행: 5 }, { 3: { 높이: 20 } }]); + }); + roundTrip("행지우기", { 종류: "행지우기", 시트: "s1", at: 3, 수: 1 }, (b) => { + const c = s1(b).칸; + eq("행지우기", [c.H4.식, c.H5.식, s1(b).병합], ["ROUNDDOWN(#REF!*0.3,2)", "SUM(H4:H4)", ["A1:H1", "C3:F3", "C4:F4", "A5:G5"]]); + }); + roundTrip("열넣기 병합 안", { 종류: "열넣기", 시트: "s1", at: 2, 수: 2 }, (b) => { + eq("병합 늘어남 · 열 폭 밀림", [s1(b).병합[0], s1(b).열.E, s1(b).열.L, s1(b).칸.J4.식], ["A1:J1", { 폭: 6 }, { 숨김: true }, "ROUNDDOWN((L4+L5)/2*L6,2)"]); + }); + roundTrip("열지우기", { 종류: "열지우기", 시트: "s1", at: 2, 수: 4 }, (b) => { + eq("병합 줄고 없어짐", s1(b).병합, ["A1:D1", "A6:C6"]); + }); + roundTrip("옮기기", { 종류: "옮기기", 시트: "s1", 범위: A.parseRange("J4:J6"), 대상시트: "s2", r: 4, c: 0 }, (b) => { + const t = b.시트[1].칸; + eq("옮기기", [s1(b).칸.H4.식, t.A5.식, t.A6.값, s1(b).칸.J4], ["ROUNDDOWN((치수!A5+치수!A6)/2*치수!A7,2)", "B1", 0.3, undefined]); + }); + roundTrip("옮기기 병합째", { 종류: "옮기기", 시트: "s1", 범위: A.parseRange("A5:H6"), 대상시트: "s1", r: 19, c: 0 }, (b) => { + eq("병합 따라감", s1(b).병합, ["A1:H1", "C3:F3", "C4:F4", "C20:F20", "A21:G21"]); + }); + roundTrip("병합", { 종류: "병합", 시트: "s1", 범위: A.parseRange("A4:H5") }, (b, fx) => { + eq("왼위만 값", [s1(b).칸.A4.값, s1(b).칸.B4, s1(b).칸.H5, fx.cells.length], ["터파기", { 서식: 4 }, { 서식: 5 }, 9]); + eq("품은 병합 흡수", s1(b).병합, ["A1:H1", "C3:F3", "A6:G6", "A4:H5"]); + }); + roundTrip("병합풀기", { 종류: "병합풀기", 시트: "s1", 범위: A.parseRange("A1:D4") }, (b) => { + eq("병합풀기", s1(b).병합, ["C5:F5", "A6:G6"]); + }); + roundTrip("열폭", { 종류: "열폭", 시트: "s1", 열: [0, 1, 2], 폭: 9 }, (b) => eq("열폭", s1(b).열.A.폭, 9)); + roundTrip("열폭 기본으로", { 종류: "열폭", 시트: "s1", 열: [0, 2], 폭: null }, (b) => eq("열폭 지움", s1(b).열.A, undefined)); + roundTrip("행높이", { 종류: "행높이", 시트: "s1", 행: [0, 4], 높이: 30 }); + roundTrip("숨김", { 종류: "숨김", 시트: "s1", 축: "열", 번호: [8, 9], 숨김: true }); + roundTrip("보이기", { 종류: "숨김", 시트: "s1", 축: "열", 번호: [9], 숨김: false }, (b) => eq("보이기", s1(b).열.J, undefined)); + roundTrip("틀고정", { 종류: "틀고정", 시트: "s1", 행: 0, 열: 2 }, (b) => eq("틀고정", s1(b).틀고정, { 열: 2 })); + roundTrip("눈금선", { 종류: "눈금선", 시트: "s1", 보임: true }, (b) => eq("눈금선", s1(b).보기, undefined)); + roundTrip("시트더하기", { 종류: "시트더하기", 시트: C.emptySheet("s3", "새 시트"), 자리: 1 }, (b) => + eq("시트 차례", b.시트.map((s) => s.id), ["s1", "s3", "s2"]), + ); + roundTrip("시트지우기", { 종류: "시트지우기", 시트: "s2" }, (b) => eq("#REF!", s1(b).칸.J4.식, "#REF!")); + roundTrip("시트이름", { 종류: "시트이름", 시트: "s2", 이름: "치 수" }, (b) => eq("이름 따라감", s1(b).칸.J4.식, "'치 수'!B1")); + roundTrip("시트옮기기", { 종류: "시트옮기기", 시트: "s2", 자리: 0 }); + roundTrip("묶음", { + 종류: "묶음", + 명령: [ + { 종류: "칸", 시트: "s1", 칸: { K1: { 값: 1 } } }, + { 종류: "행넣기", 시트: "s1", at: 0, 수: 1 }, + { 종류: "병합", 시트: "s1", 범위: A.parseRange("K2:L2") }, + ], + }); + // 막힘 + const blocked = [ + ["병합 가운데 칸 쓰기", { 종류: "칸", 시트: "s1", 칸: { B1: { 값: 1 } } }], + ["병합 가르는 옮기기", { 종류: "옮기기", 시트: "s1", 범위: A.parseRange("A1:B1"), 대상시트: "s1", r: 10, c: 0 }], + ["병합 걸침", { 종류: "병합", 시트: "s1", 범위: A.parseRange("B1:B2") }], + ["같은 시트 이름", { 종류: "시트이름", 시트: "s1", 이름: "치수" }], + ["못 쓰는 글자", { 종류: "시트이름", 시트: "s1", 이름: "a/b" }], + ["격자 끝 밀림", { 종류: "열넣기", 시트: "s2", at: 0, 수: 16384 }], + ]; + for (const [label, cmd] of blocked) { + const b = fresh(); + const before = sheetsJson(b); + let msg = null; + try { + C.applyCommand(b, cmd); + } catch (e) { + msg = e instanceof C.CommandError ? "CommandError" : String(e); + } + eq(`막힘 ${label}`, [msg, sheetsJson(b) === before], ["CommandError", true]); + } + const one = C.emptyWorkbook("x", "시트1"); + let last = null; + try { + C.applyCommand(one, { 종류: "시트지우기", 시트: "s1" }); + } catch (e) { + last = e instanceof C.CommandError; + } + eq("마지막 시트", last, true); + // 묶음은 통째로 — 뒤가 막히면 앞도 되돌림 + const b = fresh(); + const before = sheetsJson(b); + try { + C.applyCommand(b, { + 종류: "묶음", + 명령: [ + { 종류: "칸", 시트: "s1", 칸: { K1: { 값: 1 } } }, + { 종류: "칸", 시트: "s1", 칸: { B1: { 값: 1 } } }, + ], + }); + } catch {} + eq("묶음 원자", sheetsJson(b) === before, true); + eq("emptyWorkbook", C.emptyWorkbook("h1", "시트1"), { + 종류: "통합문서", + 판: 1, + 열: "h1", + 서식: [{}], + 시트: [{ id: "s1", 이름: "시트1", 칸: {} }], + 활성: "s1", + }); + const cs = fresh(); + cs.서식.push({ 굵게: true }, { 크기: 12, 굵게: true }); + cs.시트[0].칸.K1 = { 값: 1, 서식: 8 }; + C.compactStyles(cs); + eq("compactStyles", [cs.서식.length, cs.시트[0].칸.K1.서식, cs.시트[0].칸.A1.서식], [7, 1, 1]); +}); + +// ── 9. 되돌리기 스택 · 엔진과 잇기 · 한 벌 풀이 ────────────────────────────── +section("history", () => { + const h = H.createHistory(2); + const c = (n) => ({ 종류: "칸", 시트: "s1", 칸: { A1: { 값: n } } }); + eq("빈", [h.canUndo(), h.canRedo(), h.undo(), h.redo()], [false, false, null, null]); + h.push(c(1), c(-1)); + h.push(c(2), c(-2)); + h.push(c(3), c(-3)); + eq("한도 넘어 버림", [h.undo(), h.undo(), h.undo()], [c(-3), c(-2), null]); + eq("다시", [h.canRedo(), h.redo(), h.redo(), h.redo()], [true, c(2), c(3), null]); + h.undo(); + h.push(c(4), c(-4)); + eq("새 명령은 다시 스택 비움", h.canRedo(), false); + h.clear(); + eq("clear", [h.canUndo(), h.canRedo()], [false, false]); + + // 명령 → 엔진 → Ctrl+Z · Ctrl+Y + const b = structuredClone(SAMPLE); + const e = G.createCalcEngine(b); + const hist = H.createHistory(); + const run = (cmd) => { + const fx = C.applyCommand(b, cmd); + if (fx.rebuild) e.rebuild(b); + else e.update(fx.cells); + return fx; + }; + const doCmd = (cmd) => hist.push(cmd, run(cmd).undo); + const v = (a1) => val(e, "s1", a1); + eq("견본 값", ["H4", "H5", "H6", "J4"].map(v), ["0.18", "0.05", "0.23", "0.9"]); + doCmd({ 종류: "칸", 시트: "s1", 칸: { J5: { 값: 0.5 } } }); + eq("고친 뒤", ["H4", "H5", "H6"].map(v), ["0.21", "0.06", "0.27"]); + doCmd({ 종류: "행지우기", 시트: "s1", at: 3, 수: 1 }); + eq("행 지운 뒤", [v("H4"), v("H5")], ["#REF!", "#REF!"]); + run(hist.undo()); + eq("Ctrl+Z", ["H4", "H5", "H6"].map(v), ["0.21", "0.06", "0.27"]); + run(hist.undo()); + eq("Ctrl+Z 둘", ["H4", "H5", "H6"].map(v), ["0.18", "0.05", "0.23"]); + run(hist.redo()); + eq("Ctrl+Y", v("H6"), "0.27"); + eq("recalcWorkbook = 견본 계산값", RC.recalcWorkbook(structuredClone(SAMPLE)), SAMPLE.계산값); +}); + +process.stdout.write(JSON.stringify(results)); diff --git a/resources/tester/spreadsheet/test_spreadsheet_engine.py b/resources/tester/spreadsheet/test_spreadsheet_engine.py new file mode 100644 index 00000000..b6e92882 --- /dev/null +++ b/resources/tester/spreadsheet/test_spreadsheet_engine.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +"""산출근거 스프레드시트 엔진 A — 주소 · 파서 · 우선순위 · 오류 전파 · 순환 · 딸린 칸 재계산 · +참조 옮김 엑셀 규칙표 · 명령 되돌림 짝 · 되돌리기 스택. + +Node 헬퍼(`helper_spreadsheet_engine.cjs`)가 TS 를 그 자리에서 트랜스파일해 돌리고 +묶음마다 틀린 것 목록을 냄 — 여기서는 묶음마다 빈 목록인지 봄. 규칙표는 헬퍼 안에 있음. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +GROUPS = ["address", "parser", "precedence", "errors", "cycle", "graph", "refshift", "commands", "history"] + + +@pytest.fixture(scope="module") +def results(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_spreadsheet_engine.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +@pytest.mark.parametrize("group", GROUPS) +def test_묶음(results, group): + assert group in results + assert results[group] == []