feat(spreadsheet): 산출근거 스프레드시트 A 엔진 핵심 — 주소 · 파서 · 풀이 · 의존 재계산 · 참조 옮김 · 명령 · 되돌리기
- 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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<K extends Command["종류"]> = Extract<Command, { 종류: K }>;
|
||||
|
||||
const clone = <T>(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<string, unknown>;
|
||||
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<string, Cell | null> = {};
|
||||
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<string, Cell | null> = {};
|
||||
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<number, number>();
|
||||
const f = (i: number) => {
|
||||
if (!memo.has(i)) memo.set(i, change(i));
|
||||
return memo.get(i)!;
|
||||
};
|
||||
const prev: Record<string, Cell | null> = {};
|
||||
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<string, unknown> = { ...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<string, Cell> = {};
|
||||
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<string, (typeof table)[string]> = {};
|
||||
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<string, Cell | null> = {};
|
||||
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<V>(
|
||||
s: Sheet,
|
||||
axis: "행" | "열",
|
||||
idx: number[],
|
||||
prop: "폭" | "높이" | "숨김",
|
||||
value: V | null,
|
||||
undoOf: (idx: number[], prev: V | null) => Command,
|
||||
): Command {
|
||||
const table = ((s as unknown as Record<string, Record<string, Record<string, unknown>>>)[axis] ??=
|
||||
{});
|
||||
const groups = new Map<string, number[]>();
|
||||
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<string, number>();
|
||||
const remap = new Map<number, number>();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<number, Cell>;
|
||||
/** 값 든 칸의 가장 먼 행 · 열 — 행 · 열 전체 범위를 여기까지만 읽음 */
|
||||
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<string, ParseResult>();
|
||||
let sheets = new Map<string, SheetIndex>();
|
||||
let names = new Map<string, string>();
|
||||
/** 식 칸 → 가리키는 칸 · 범위 */
|
||||
let precs = new Map<string, Dep[]>();
|
||||
/** 칸 하나 → 그 칸을 가리키는 식 칸 */
|
||||
let cellDeps = new Map<string, Set<string>>();
|
||||
/** 시트 → 범위를 가리키는 식 칸 */
|
||||
let rangeDeps = new Map<string, Map<string, CellRange[]>>();
|
||||
let vals = new Map<string, Scalar>();
|
||||
let state = new Map<string, number>();
|
||||
const stack: string[] = [];
|
||||
const cycle = new Map<string, string>();
|
||||
|
||||
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<string>) {
|
||||
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<string>();
|
||||
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<string>();
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 = [];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, number> = { "&": 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(",")})`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
/("(?:[^"]|"")*"|'(?:[^']|'')*')|(?<![\p{L}\p{N}_.$])R(\[-?\d+\]|\d+)?C(\[-?\d+\]|\d+)?(?![\p{L}\p{N}_.(])/gu,
|
||||
(whole, quoted: string | undefined, rp: string | undefined, cp: string | undefined) => {
|
||||
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 },
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"<TRUE', true],
|
||||
['"b">"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));
|
||||
@@ -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] == []
|
||||
Reference in New Issue
Block a user