From 279ef5fb4fea2a9195500e8e1e74e99e4781cfcc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 20:23:32 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(spreadsheet):=20A=20=EC=97=94=EC=A7=84?= =?UTF-8?q?=20=EB=BA=80=20=EC=85=8B=20=EC=B1=84=EC=9B=80=20=C2=B7=20?= =?UTF-8?q?=EB=A9=94=EB=AA=A8=20=EC=98=AE=EA=B9=80=20=E2=80=94=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=20=EB=8D=94=ED=95=A8(Sheet.=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=C2=B7=20Sheet.comments)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 계약 더함: Sheet.기본 { 서식 · 열폭 · 행높이 } — 모두 고르기 서식 · 폭 · 높이 · 칸 서식 물려받기 = 칸 → 행 → 열 → 시트 기본 → 0 - 계약 더함: Sheet.comments — A1 → 메모 글 · 행열 넣기 · 지우기 · 잘라 붙이기 때 칸과 같이 옮김(지운 칸 · 덮은 칸 메모는 지움) - 행 · 열 넣기 = 위(왼) 줄 칸 서식 · 높이 · 폭 따라감(숨김은 안 따라감 · 첫 줄 앞 넣기는 안 따라감) - 범위를 칸 하나 자리에 넣으면 엑셀 암묵 교차(한 열 범위 = 식 든 행 · 한 행 범위 = 식 든 열) - 풀이 차례를 반복 위상 순서로 — 2만 칸 사슬도 스택 안 넘침 - 시험 resources/tester/spreadsheet/test_spreadsheet_engine.py 규칙 더함 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF --- .../spreadsheet/spreadsheet_commands.ts | 88 +++++++++++++++++-- A00_Common/spreadsheet/spreadsheet_eval.ts | 33 +++++-- A00_Common/spreadsheet/spreadsheet_graph.ts | 67 +++++++++++--- A00_Common/spreadsheet/spreadsheet_types.ts | 7 +- .../spreadsheet/helper_spreadsheet_engine.cjs | 82 ++++++++++++++++- 5 files changed, 246 insertions(+), 31 deletions(-) diff --git a/A00_Common/spreadsheet/spreadsheet_commands.ts b/A00_Common/spreadsheet/spreadsheet_commands.ts index 1d3606b4..5eee4ddb 100644 --- a/A00_Common/spreadsheet/spreadsheet_commands.ts +++ b/A00_Common/spreadsheet/spreadsheet_commands.ts @@ -84,9 +84,25 @@ function internStyle(book: Workbook, style: CellStyle): number { return book.서식.length - 1; } -/** 칸 자리에서 물려받는 서식(칸 서식 없을 때) — 행 · 열 · 0 */ +function setComments(s: Sheet, notes: Record) { + if (Object.keys(notes).length) s.comments = notes; + else delete s.comments; +} + +/** 시트 통째 서식 */ +const sheetStyle = (s: Sheet) => s.기본?.서식 ?? 0; + +/** 칸 자리에서 물려받는 서식(칸 서식 없을 때) — 행 · 열 · 시트 · 0 */ const inherited = (s: Sheet, r: number, c: number) => - s.행?.[r + 1]?.서식 ?? s.열?.[colName(c)]?.서식 ?? 0; + s.행?.[r + 1]?.서식 ?? s.열?.[colName(c)]?.서식 ?? sheetStyle(s); + +/** 시트 `기본` 한 칸 — undefined 면 지움 · 빈 `기본` 도 지움 */ +function setSheetDefault(s: Sheet, key: "서식" | "열폭" | "행높이", value: number | undefined) { + const d = (s.기본 ??= {}); + if (value === undefined) delete d[key]; + else d[key] = value; + if (!Object.keys(d).length) delete s.기본; +} const addr = (시트: string, r: number, c: number): SheetCellAddress => ({ 시트, r, c }); @@ -179,8 +195,8 @@ function restyle( const setInfo = (axis: "행" | "열", key: string) => { const table = (s[axis] ??= {}); const info = (table[key] ??= {}); - const idx = f(info.서식 ?? 0); - if (idx) info.서식 = idx; + const idx = f(info.서식 ?? sheetStyle(s)); + if (idx !== sheetStyle(s)) info.서식 = idx; else delete info.서식; if (!Object.keys(info).length) delete table[key]; }; @@ -201,8 +217,9 @@ function restyle( const fullRows = isFull(rg, true); const fullCols = isFull(rg, false); if (fullRows && fullCols) { - // ponytail: 시트 통째 서식은 든 칸 · 있는 행 · 열 서식만 — 시트 기본 서식 칸이 계약에 없음 whole = true; + const idx = f(sheetStyle(s)); + setSheetDefault(s, "서식", idx || undefined); for (const k of Object.keys(s.행 ?? {})) setInfo("행", k); for (const k of Object.keys(s.열 ?? {})) setInfo("열", k); } else if (fullRows || fullCols) { @@ -265,6 +282,15 @@ function insertDelete( if (i !== undefined) cells[rows ? toA1(i, p.c) : toA1(p.r, i)] = cell; } s.칸 = cells; + if (s.comments) { + const notes: Record = {}; + for (const [a1, text] of Object.entries(s.comments)) { + const p = parseA1(a1); + const i = p && span(rows ? p.r : p.c); + if (p && i !== undefined && i !== null) notes[rows ? toA1(i, p.c) : toA1(p.r, i)] = text; + } + setComments(s, notes); + } const merges: CellRange[] = []; for (const m of mergesOf(s)) { @@ -289,6 +315,23 @@ function insertDelete( else delete s[axis]; } + // 넣은 줄은 위(왼) 줄 서식 · 폭 · 높이를 따라감(엑셀 「위와 같은 서식」) — 첫 줄 앞 넣기는 안 따라감 + if (insert && at > 0) { + for (const [a1, cell] of Object.entries(s.칸)) { + const p = parseA1(a1)!; + if ((rows ? p.r : p.c) !== at - 1 || cell.서식 === undefined) continue; + for (let k = 0; k < n; k++) + s.칸[rows ? toA1(at + k, p.c) : toA1(p.r, at + k)] = { 서식: cell.서식 }; + } + const info = s[axis]?.[rows ? String(at) : colName(at - 1)]; + if (info) { + const { 숨김: _hidden, ...look } = info; + if (Object.keys(look).length) + for (let k = 0; k < n; k++) + s[axis]![rows ? String(at + k + 1) : colName(at + k)] = { ...look }; + } + } + const key = rows ? "행" : "열"; const frozen = s.틀고정?.[key]; if (frozen && at < frozen) @@ -326,6 +369,23 @@ function move(book: Workbook, cmd: Of<"옮기기">): CommandEffect { 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 carriedNotes: [string, string][] = []; + const srcNotes = { ...s.comments }; + for (const [a1, text] of Object.entries(srcNotes)) { + const p = parseA1(a1); + if (p && inRange(src, p.r, p.c)) { + carriedNotes.push([toA1(p.r + dr, p.c + dc), text]); + delete srcNotes[a1]; + } + } + setComments(s, srcNotes); + const dstNotes = { ...t.comments }; + for (const a1 of Object.keys(dstNotes)) { + const p = parseA1(a1); + if (p && inRange(dst, p.r, p.c)) delete dstNotes[a1]; + } + setComments(t, { ...dstNotes, ...Object.fromEntries(carriedNotes) }); const sm = mergesOf(s); const carried = sm.filter((m) => rangeInside(m, src)); @@ -410,6 +470,20 @@ function setAxis( return { 종류: "묶음", 명령: [...groups].map(([g, list]) => undoOf(list, JSON.parse(g))) }; } +/** 모두 고르기 폭 · 높이 — 시트 `기본` 에 두고 줄마다 값은 지움(엑셀 기본 폭 · 높이) */ +function sheetSize(s: Sheet, axis: "행" | "열", value: number | null): CommandEffect { + const before = clone(s); + const prop = axis === "열" ? "폭" : "높이"; + setSheetDefault(s, axis === "열" ? "열폭" : "행높이", value ?? undefined); + const table = s[axis] as Record> | undefined; + for (const [k, info] of Object.entries(table ?? {})) { + delete info[prop]; + if (!Object.keys(info).length) delete table![k]; + } + if (table && !Object.keys(table).length) delete s[axis]; + return { undo: { 종류: "시트통째", 시트: before }, cells: [], rebuild: false }; +} + // ── 시트 ──────────────────────────────────────────────────────────────────── function addSheet(book: Workbook, cmd: Of<"시트더하기">): CommandEffect { @@ -506,6 +580,7 @@ export function applyCommand(book: Workbook, command: Command): CommandEffect { return unmerge(book, command); case "열폭": { const s = sheetOf(book, command.시트); + if (new Set(command.열).size >= MAX_COLS) return sheetSize(s, "열", command.폭); return plain( setAxis(s, "열", command.열, "폭", command.폭, (열, 폭) => ({ 종류: "열폭", @@ -517,6 +592,7 @@ export function applyCommand(book: Workbook, command: Command): CommandEffect { } case "행높이": { const s = sheetOf(book, command.시트); + if (new Set(command.행).size >= MAX_ROWS) return sheetSize(s, "행", command.높이); return plain( setAxis(s, "행", command.행, "높이", command.높이, (행, 높이) => ({ 종류: "행높이", @@ -611,7 +687,7 @@ export function compactStyles(book: Workbook): void { 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.열 ?? {})]) + for (const info of [...Object.values(s.행 ?? {}), ...Object.values(s.열 ?? {}), s.기본 ?? {}]) if (info.서식 !== undefined) info.서식 = map(info.서식); } book.서식 = next; diff --git a/A00_Common/spreadsheet/spreadsheet_eval.ts b/A00_Common/spreadsheet/spreadsheet_eval.ts index 7b48adfd..bb2ea3da 100644 --- a/A00_Common/spreadsheet/spreadsheet_eval.ts +++ b/A00_Common/spreadsheet/spreadsheet_eval.ts @@ -66,6 +66,25 @@ function single(value: EvalResult): Scalar { : err("#VALUE!", "범위를 값 하나로 못 씀"); } +/** 엔진이 만든 범위 — 적힌 자리(끝 자름 전) · 암묵 교차에 씀 */ +export interface PlacedRange extends RangeValue { + r0: number; + c0: number; + r1: number; + c1: number; +} + +/** 범위 → 칸 하나 — 1×1 이면 그 칸 · 아니면 식 든 행(한 열 범위) · 열(한 행 범위)과 겹친 칸(엑셀 암묵 교차) */ +export function intersect(value: EvalResult, at: { r: number; c: number }): Scalar { + if (!isRange(value) || (value.rows === 1 && value.cols === 1)) return single(value); + const p = value as Partial; + if (p.r0 !== undefined && p.c0 !== undefined && p.r1 !== undefined && p.c1 !== undefined) { + if (p.c0 === p.c1 && at.r >= p.r0 && at.r <= p.r1) return value.at(at.r - p.r0, 0); + if (p.r0 === p.r1 && at.c >= p.c0 && at.c <= p.c1) return value.at(0, at.c - p.c0); + } + return err("#VALUE!", "범위가 식 든 행 · 열과 안 겹침"); +} + /** 수로 — 빈 칸 0 · 참거짓 1/0 · 수 글 · 아니면 `#VALUE!` · 범위는 칸 하나일 때만 */ export function asNumber(value: EvalResult): Frac | ErrorValue { const v = single(value); @@ -237,15 +256,15 @@ export function evaluate(node: FormulaNode, ctx: EvalContext): EvalResult { 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 v = intersect(evaluate(node.arg, ctx), ctx); + if (node.op === "+") return 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); + const left = intersect(evaluate(node.left, ctx), ctx); + const right = intersect(evaluate(node.right, ctx), ctx); if (node.op === "&") { const a = asText(left); if (isError(a)) return a; @@ -258,10 +277,8 @@ export function evaluate(node: FormulaNode, ctx: EvalContext): EvalResult { 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); + if (isError(left)) return left; + return isError(right) ? right : compareOp(node.op, left, right); } case "call": { const fn = getFunction(node.name); diff --git a/A00_Common/spreadsheet/spreadsheet_graph.ts b/A00_Common/spreadsheet/spreadsheet_graph.ts index 92ed4dc7..9487d333 100644 --- a/A00_Common/spreadsheet/spreadsheet_graph.ts +++ b/A00_Common/spreadsheet/spreadsheet_graph.ts @@ -8,7 +8,8 @@ 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 { err, evaluate, inputValue, intersect, toCalcValue } from "./spreadsheet_eval"; +import type { PlacedRange } from "./spreadsheet_eval"; import { parseFormula } from "./spreadsheet_parser"; import type { CalcEngine, @@ -19,7 +20,6 @@ import type { EvalResult, FormulaNode, ParseResult, - RangeValue, Scalar, SheetCellAddress, Workbook, @@ -175,7 +175,7 @@ export function createCalcEngine(book: Workbook): CalcEngine { if (at) index(idx, cellId(at.r, at.c), cell); } } - for (const key of precs.keys()) compute(key); + for (const key of precs.keys()) ensure(key); } function ctxFor(sheet: string, r: number, c: number): EvalContext { @@ -188,7 +188,7 @@ export function createCalcEngine(book: Workbook): CalcEngine { return vals.get(key) ?? null; } - function range(sheet: string, rg: CellRange): RangeValue { + function range(sheet: string, rg: CellRange): PlacedRange { const s = sheets.get(sheet); // ponytail: 행 · 열 전체 범위는 값 든 끝까지만 — INDEX(A:A, 끝 너머)는 #REF! 로 갈림 const r1 = Math.min(rg.r1, Math.max(rg.r0, s?.maxR ?? 0)); @@ -198,9 +198,54 @@ export function createCalcEngine(book: Workbook): CalcEngine { rows: r1 - rg.r0 + 1, cols: c1 - rg.c0 + 1, at: (i, j) => read(sheet, rg.r0 + i, rg.c0 + j), + ...rg, }; } + /** 가리키는 식 칸들(범위는 넓이 · 든 칸 수 중 작은 쪽으로 훑음) */ + function formulaPrecs(key: string): string[] { + const out: string[] = []; + for (const d of precs.get(key) ?? []) { + const s = sheets.get(d.sheet); + if (!s) continue; + const r1 = Math.min(d.range.r1, s.maxR); + const c1 = Math.min(d.range.c1, s.maxC); + if ((r1 - d.range.r0 + 1) * (c1 - d.range.c0 + 1) <= s.cells.size) { + for (let r = d.range.r0; r <= r1; r++) + for (let c = d.range.c0; c <= c1; c++) { + const k = keyOf(d.sheet, cellId(r, c)); + if (precs.has(k)) out.push(k); + } + } else + for (const id of s.cells.keys()) { + const k = keyOf(d.sheet, id); + const { r, c } = fromCellId(id); + if (precs.has(k) && inRange(d.range, r, c)) out.push(k); + } + } + return out; + } + + /** 앞 식 칸부터 반복으로 풂 — 깊은 사슬에도 스택이 안 넘침 · 고리는 compute 가 잡음 */ + function ensure(key: string): Scalar { + const todo: [string, boolean][] = [[key, false]]; + const seen = new Set(); + while (todo.length) { + const [k, ready] = todo.pop()!; + if (state.get(k) === DONE) continue; + if (ready) { + compute(k); + continue; + } + if (seen.has(k)) continue; + seen.add(k); + todo.push([k, true]); + for (const d of formulaPrecs(k)) + if (!seen.has(d) && state.get(d) !== DONE) todo.push([d, false]); + } + return vals.get(key) ?? null; + } + /** 식 칸 하나 풂 — 풀이 중인 칸을 다시 만나면 그 사이가 고리 */ function compute(key: string): Scalar { const st = state.get(key); @@ -217,7 +262,6 @@ export function createCalcEngine(book: Workbook): CalcEngine { 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); @@ -249,10 +293,8 @@ export function createCalcEngine(book: Workbook): CalcEngine { 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; + const one = intersect(v, ctx); + return one === null ? ZERO : one; } function dependents(key: string, out: Set) { @@ -291,12 +333,13 @@ export function createCalcEngine(book: Workbook): CalcEngine { state.delete(k); cycle.delete(k); } - for (const k of dirty) if (precs.has(k)) compute(k); + for (const k of dirty) if (precs.has(k)) ensure(k); return [...dirty].map(splitKey); }, rebuild, value(sheet, r, c) { - return read(sheet, r, c); + const key = keyOf(sheet, cellId(r, c)); + return precs.has(key) ? ensure(key) : (vals.get(key) ?? null); }, precedents(sheet, r, c) { return (precs.get(keyOf(sheet, cellId(r, c))) ?? []).map((d) => ({ @@ -308,7 +351,7 @@ export function createCalcEngine(book: Workbook): CalcEngine { const out: CalcValues = {}; for (const key of precs.keys()) { const { 시트, r, c } = splitKey(key); - (out[시트] ??= {})[toA1(r, c)] = toCalcValue(compute(key)); + (out[시트] ??= {})[toA1(r, c)] = toCalcValue(ensure(key)); } return out; }, diff --git a/A00_Common/spreadsheet/spreadsheet_types.ts b/A00_Common/spreadsheet/spreadsheet_types.ts index c4e84397..0612f6b1 100644 --- a/A00_Common/spreadsheet/spreadsheet_types.ts +++ b/A00_Common/spreadsheet/spreadsheet_types.ts @@ -78,7 +78,7 @@ export interface Cell { 값?: CellInput; /** A1 식 — 앞 `=` 뺌(`ROUNDDOWN((G31+J31/2)*P31,2)`). 행열을 넣고 지우면 글을 고쳐 적음. */ 식?: string; - /** `서식` 표 번호 — 없으면 행 · 열 서식 · 그다음 0(기본). */ + /** `서식` 표 번호 — 없으면 행 · 열 서식 · 시트 `기본.서식` · 그다음 0(기본). */ 서식?: number; } @@ -142,6 +142,11 @@ export interface Sheet { /** 위에서 고정할 행 수 · 왼쪽에서 고정할 열 수. */ 틀고정?: { 행?: number; 열?: number }; 보기?: { 눈금선?: boolean }; + /** 시트 통째(모두 고르기) — 서식 번호 · 열 폭(글자 단위) · 행 높이(pt). 칸 · 행 · 열 값이 없을 때 씀 · + * 없으면 통합문서 `기본`. */ + 기본?: { 서식?: number; 열폭?: number; 행높이?: number }; + /** 칸 메모 — A1 열쇠 → 메모 글. 행열 넣기 · 지우기 · 잘라 붙이기 때 칸과 같이 옮김(지운 칸 메모는 지움). */ + comments?: Record; } /** 새 시트 기본값 — 값은 사용자 협의로 확정(9_엑셀검토 8장 · 임의 확정 금지). 없으면 A 의 임시값. */ diff --git a/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs b/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs index afa240df..3abde1ea 100644 --- a/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs +++ b/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs @@ -364,10 +364,14 @@ section("graph", () => { 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 ib = book({ A1: 1, A2: 5, A3: 7, C5: 3, D5: 4, B2: "=A1:A3*2", B3: "=A1:A3", B7: "=A1:A3", D1: "=C5:D5+1", E1: "=C5:D5", F9: "=A:A+1", B4: "=-A1:A3" }); + const ie = G.createCalcEngine(ib); + eq("암묵 교차", ["B2", "B3", "B7", "D1", "E1", "F9", "B4"].map((a) => val(ie, "s1", a)), ["10", "7", "#VALUE!", "5", "#VALUE!", "1", "#VALUE!"]); // 긴 사슬(끌어 풀기 재귀 깊이) 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"); + for (let i = 2; i <= 20000; i++) chain[`A${i}`] = `=A${i - 1}+1`; + eq("사슬 2만", val(G.createCalcEngine(book(chain)), "s1", "A20000"), "20000"); }); // ── 7. 참조 옮김 엑셀 규칙표 ──────────────────────────────────────────────── @@ -508,7 +512,7 @@ section("commands", () => { roundTrip("행넣기", { 종류: "행넣기", 시트: "s1", at: 3, 수: 1 }, (b) => { const c = s1(b).칸; eq("행넣기 칸 · 식 · 병합", [c.H4, c.H5.식, c.H6.식, c.H7.식, c.J5.식, s1(b).병합], [ - undefined, + { 서식: 2 }, "ROUNDDOWN((J5+J6)/2*J7,2)", "ROUNDDOWN(H5*0.3,2)", "SUM(H5:H6)", @@ -564,6 +568,75 @@ section("commands", () => { { 종류: "병합", 시트: "s1", 범위: A.parseRange("K2:L2") }, ], }); + // 넣은 줄은 위(왼) 줄 서식 · 높이 · 폭을 따라감 + roundTrip("행넣기 위 줄 따라감", { 종류: "행넣기", 시트: "s1", at: 1, 수: 2 }, (b) => { + const t = s1(b); + eq("행 높이 · 칸 서식", [t.행["2"], t.행["3"], t.칸.A2, t.칸.A3, t.칸.A1.값], [ + { 높이: 20 }, + { 높이: 20 }, + { 서식: 1 }, + { 서식: 1 }, + "□수로형 토사개거 산출근거", + ]); + }); + roundTrip("열넣기 왼 줄 따라감", { 종류: "열넣기", 시트: "s1", at: 3, 수: 1 }, (b) => { + const t = s1(b); + eq("열 폭 · 칸 서식", [t.열.D, t.칸.D3, t.칸.D4, t.칸.E3.서식], [{ 폭: 6 }, { 서식: 2 }, { 서식: 4 }, 2]); + }); + roundTrip("숨긴 줄은 숨김 안 따라감", { 종류: "열넣기", 시트: "s1", at: 10, 수: 1 }, (b) => + eq("숨김", [s1(b).열.K, s1(b).열.J], [undefined, { 숨김: true }]), + ); + // 시트 통째 서식 · 폭 · 높이 + roundTrip("시트 통째 서식", { 종류: "서식", 시트: "s2", 범위: [A.parseRange("1:1048576")], 바꿀: { 글꼴: "굴림", 크기: 9 } }, (b, fx) => { + const t = b.시트[1]; + const st = b.서식[t.기본.서식]; + eq("시트 기본 서식", [st.글꼴, st.크기, b.서식[t.칸.B1.서식].크기, b.서식[t.칸.B1.서식].형식, t.칸.A1.서식, fx.rebuild], [ + "굴림", + 9, + 9, + "0.00", + undefined, + true, + ]); + }); + roundTrip("시트 통째 뒤 서식지움", { + 종류: "묶음", + 명령: [ + { 종류: "서식", 시트: "s2", 범위: [A.parseRange("1:1048576")], 바꿀: { 크기: 9 } }, + { 종류: "서식지움", 시트: "s2", 범위: [A.parseRange("A1:A1")] }, + ], + }, (b) => eq("시트 기본을 덮는 0", b.시트[1].칸.A1.서식, 0)); + const allCols = Array.from({ length: 16384 }, (_, i) => i); + roundTrip("시트 통째 열 폭", { 종류: "열폭", 시트: "s1", 열: allCols, 폭: 4 }, (b) => { + const t = s1(b); + eq("기본 열폭 · 줄 폭 지움", [t.기본, t.열.A, t.열.J], [{ 열폭: 4 }, undefined, { 숨김: true }]); + }); + roundTrip("시트 통째 행 높이", { 종류: "행높이", 시트: "s1", 행: Array.from({ length: 1048576 }, (_, i) => i), 높이: 15 }, (b) => + eq("기본 행높이", [s1(b).기본, s1(b).행], [{ 행높이: 15 }, undefined]), + ); + // 메모 — 칸과 같이 옮김 · 지운 칸 메모는 지움 + const withNotes = (cmd, check) => { + const b = fresh(); + b.시트[0].comments = { H4: "터파기 풀이", J5: "밑폭", A1: "제목" }; + const before = sheetsJson(b); + const fx = C.applyCommand(b, cmd); + check(b); + C.applyCommand(b, fx.undo); + eq(`메모 되돌림 ${cmd.종류}`, sheetsJson(b) === before, true); + }; + withNotes({ 종류: "행넣기", 시트: "s1", at: 3, 수: 2 }, (b) => + eq("메모 행넣기", s1(b).comments, { H6: "터파기 풀이", J7: "밑폭", A1: "제목" }), + ); + withNotes({ 종류: "행지우기", 시트: "s1", at: 3, 수: 1 }, (b) => + eq("메모 행지우기", s1(b).comments, { J4: "밑폭", A1: "제목" }), + ); + withNotes({ 종류: "열지우기", 시트: "s1", at: 7, 수: 3 }, (b) => eq("메모 열지우기", s1(b).comments, { A1: "제목" })); + withNotes({ 종류: "옮기기", 시트: "s1", 범위: A.parseRange("H4:J5"), 대상시트: "s2", r: 0, c: 0 }, (b) => { + eq("메모 옮기기", [s1(b).comments, b.시트[1].comments], [{ A1: "제목" }, { A1: "터파기 풀이", C2: "밑폭" }]); + }); + withNotes({ 종류: "옮기기", 시트: "s1", 범위: A.parseRange("J5"), 대상시트: "s1", r: 3, c: 7 }, (b) => + eq("메모 덮어씀", canon(s1(b).comments), canon({ H4: "밑폭", A1: "제목" })), + ); // 막힘 const blocked = [ ["병합 가운데 칸 쓰기", { 종류: "칸", 시트: "s1", 칸: { B1: { 값: 1 } } }], @@ -616,8 +689,9 @@ section("commands", () => { const cs = fresh(); cs.서식.push({ 굵게: true }, { 크기: 12, 굵게: true }); cs.시트[0].칸.K1 = { 값: 1, 서식: 8 }; + cs.시트[1].기본 = { 서식: 8 }; C.compactStyles(cs); - eq("compactStyles", [cs.서식.length, cs.시트[0].칸.K1.서식, cs.시트[0].칸.A1.서식], [7, 1, 1]); + eq("compactStyles", [cs.서식.length, cs.시트[0].칸.K1.서식, cs.시트[0].칸.A1.서식, cs.시트[1].기본.서식], [7, 1, 1, 1]); }); // ── 9. 되돌리기 스택 · 엔진과 잇기 · 한 벌 풀이 ────────────────────────────── From b58073730fb3b00be76528a36701c747d5a44d78 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 20:25:30 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test(spreadsheet):=20A=20=EC=97=94=EC=A7=84?= =?UTF-8?q?=20=EB=AC=B4=EA=B2=8C=20=EC=8B=9C=ED=97=98=20=E2=80=94=20?= =?UTF-8?q?=EC=B9=B8=201=EB=A7=8C=20=C2=B7=20=EC=8B=9D=205=EC=B2=9C=20?= =?UTF-8?q?=EC=9E=AC=EA=B3=84=EC=82=B0=20=C2=B7=206=EB=A7=8C=208=EC=B2=9C?= =?UTF-8?q?=20=EC=B9=B8=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EB=82=98=EB=88=97=EC=85=88=20=EC=82=AC=EC=8A=AC=20=EB=B6=84?= =?UTF-8?q?=EB=AA=A8=20=EC=83=81=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bench_spreadsheet_engine.cjs — 얕은 시트 · 깊은 사슬 5천 · 큰 시트(271 행 × 253 열) · 소수 역수 합 · 곱나눗셈 사슬 수치 - test_spreadsheet_bench.py — 딸린 칸 수 · 시간 한도(실측 몇 배) · 분모 41 자리 안 · double 과 상대 오차 1e-12 안 - helper_spreadsheet_engine.cjs — 적재 · 가짜 함수를 무게 시험이 같이 씀 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF --- .../spreadsheet/bench_spreadsheet_engine.cjs | 113 ++++++++++++++++++ .../spreadsheet/helper_spreadsheet_engine.cjs | 4 + .../spreadsheet/test_spreadsheet_bench.py | 54 +++++++++ 3 files changed, 171 insertions(+) create mode 100644 resources/tester/spreadsheet/bench_spreadsheet_engine.cjs create mode 100644 resources/tester/spreadsheet/test_spreadsheet_bench.py diff --git a/resources/tester/spreadsheet/bench_spreadsheet_engine.cjs b/resources/tester/spreadsheet/bench_spreadsheet_engine.cjs new file mode 100644 index 00000000..72175187 --- /dev/null +++ b/resources/tester/spreadsheet/bench_spreadsheet_engine.cjs @@ -0,0 +1,113 @@ +/* 스프레드시트 엔진 A 무게 시험 — 큰 시트 불러오기 · 칸 하나 고칠 때 재계산 · 나눗셈 사슬 분모. + * 수치를 JSON 으로 냄 → `test_spreadsheet_bench.py` 가 한도 안인지 봄. 적재 · 가짜 함수는 엔진 시험 헬퍼 것. + */ +const { A, C, E, F, G } = require("./helper_spreadsheet_engine.cjs"); + +const ms = (fn) => { + const t = process.hrtime.bigint(); + const out = fn(); + return [Number(process.hrtime.bigint() - t) / 1e6, out]; +}; +const round = (x) => Math.round(x * 100) / 100; + +/** 칸 표 → 시트 하나 통합문서 JSON 글(불러오기 = JSON.parse + 엔진 만들기) */ +function bookText(cells) { + const 칸 = {}; + for (const [k, v] of Object.entries(cells)) 칸[k] = typeof v === "string" ? { 식: v } : { 값: v }; + return JSON.stringify({ 종류: "통합문서", 판: 1, 열: "b", 서식: [{}], 시트: [{ id: "s1", 이름: "무게", 칸 }] }); +} + +/** 불러오기 · 칸 하나 고치기(가운데 · 맨 앞) 시간 */ +function scenario(cells, edits) { + const text = bookText(cells); + const [parseMs, book] = ms(() => JSON.parse(text)); + const [loadMs, engine] = ms(() => G.createCalcEngine(book)); + const out = { cells: Object.keys(cells).length, kb: Math.round(text.length / 1024), parseMs: round(parseMs), loadMs: round(loadMs) }; + for (const [label, a1, value] of edits) { + const times = []; + let redone = 0; + for (let i = 0; i < 5; i++) { + const fx = C.applyCommand(book, { 종류: "칸", 시트: "s1", 칸: { [a1]: { 값: value + i } } }); + const [t, list] = ms(() => engine.update(fx.cells)); + times.push(t); + redone = list.length; + } + times.sort((x, y) => x - y); + out[label] = { ms: round(times[2]), redone }; + } + return [out, engine]; +} + +const N = 5000; +const result = {}; + +// ① 얕은 시트 — 칸 1만(값 5천 · 식 5천) · 식마다 이웃 두 칸 · 끝에 합계 하나 +{ + const cells = {}; + for (let i = 1; i <= N; i++) cells[`A${i}`] = (i % 97) / 10; + for (let i = 1; i < N; i++) cells[`B${i}`] = `ROUNDDOWN((A${i}+A${i + 1}/2)*1.3,2)`; + cells[`B${N}`] = `SUM(B1:B${N - 1})`; + const [out, engine] = scenario(cells, [ + ["editMiddle", "A2500", 3], + ["editFirst", "A1", 3], + ]); + out.total = E.toCalcValue(engine.value("s1", N - 1, 1)); + result.shallow = out; +} + +// ② 깊은 사슬 — 칸 1만 · 식 5천이 한 줄로 이어짐(맨 앞 고치면 5천 칸 모두 다시) +{ + const cells = {}; + for (let i = 1; i <= N; i++) cells[`A${i}`] = 1; + cells.B1 = "A1"; + for (let i = 2; i <= N; i++) cells[`B${i}`] = `B${i - 1}+A${i}*1.1`; + const [out, engine] = scenario(cells, [ + ["editLast", `A${N}`, 2], + ["editFirst", "A1", 2], + ]); + out.end = E.toCalcValue(engine.value("s1", N - 1, 1)); + result.chain = out; +} + +// ③ 큰 시트 불러오기 — 실무 최대 모양(271 행 × 253 열) 에 가까운 6만 8천 칸 · 절반이 식 +{ + const cells = {}; + for (let r = 1; r <= 271; r++) + for (let c = 0; c < 253; c++) { + const a1 = A.toA1(r - 1, c); + cells[a1] = c % 2 === 0 ? r + c / 100 : `${A.toA1(r - 1, c - 1)}*2+${A.toA1(Math.max(0, r - 2), c - 1)}/3`; + } + const [out] = scenario(cells, [["editOne", "A1", 5]]); + result.bigSheet = out; +} + +// ④ 나눗셈 사슬 — 분모가 커지는 식(1/소수 더하기 · 소수로 나누기) · 상한(10^40) 넘으면 30 자리 십진으로 굳힘 +{ + const primes = []; + for (let n = 2; primes.length < 2000; n++) if (primes.every((p) => n % p)) primes.push(n); + const cells = { A1: 1, C1: 1 }; + for (let i = 2; i <= 2000; i++) { + cells[`A${i}`] = `A${i - 1}+1/${primes[i - 1]}`; // 역수 합 — 분모가 소수 곱으로 부풂 + cells[`C${i}`] = `C${i - 1}/${primes[(i * 7) % 2000]}*${primes[(i * 13) % 2000]}`; // 곱 · 나눗셈 사슬 + } + const book = JSON.parse(bookText(cells)); + const [loadMs, engine] = ms(() => G.createCalcEngine(book)); + let maxDigits = 0; + for (let i = 1; i <= 2000; i++) + for (const c of [0, 2]) { + const v = engine.value("s1", i - 1, c); + maxDigits = Math.max(maxDigits, v.d.toString().length); + } + let dbl = 1; + for (let i = 2; i <= 2000; i++) dbl += 1 / primes[i - 1]; + const exact = Number(F.fracToString(engine.value("s1", 1999, 0))); + result.divChain = { + cells: 4000, + loadMs: round(loadMs), + maxDenominatorDigits: maxDigits, + sum: F.fracToString(engine.value("s1", 1999, 0)), + relErrVsDouble: Math.abs(exact - dbl) / dbl, + }; +} + +process.stdout.write(JSON.stringify(result)); diff --git a/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs b/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs index 3abde1ea..0747dd8a 100644 --- a/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs +++ b/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs @@ -98,6 +98,10 @@ const C = load("spreadsheet_commands"); const H = load("spreadsheet_history"); const RC = load("spreadsheet_recalc"); +// 무게 시험(`bench_spreadsheet_engine.cjs`)이 같은 적재 · 가짜 함수를 씀 — 불려 올 때는 시험을 안 돌림 +module.exports = { A, C, E, F, G }; +if (require.main !== module) return; + const results = {}; let group = ""; const fails = () => (results[group] ??= []); diff --git a/resources/tester/spreadsheet/test_spreadsheet_bench.py b/resources/tester/spreadsheet/test_spreadsheet_bench.py new file mode 100644 index 00000000..130bed31 --- /dev/null +++ b/resources/tester/spreadsheet/test_spreadsheet_bench.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +"""산출근거 스프레드시트 엔진 A 무게 시험 — 칸 1만 · 식 5천 시트에서 칸 하나 고칠 때 재계산 · +6만 8천 칸 불러오기 · 분모가 커지는 나눗셈 사슬(상한 10^40 넘으면 30 자리 십진으로 굳힘). + +Node 헬퍼(`bench_spreadsheet_engine.cjs`)가 수치를 냄. 한도는 이 PC 실측의 몇 배로 넉넉히 둠 — +실측이 한도 가까이 오르면 느려진 것. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def bench(): + proc = subprocess.run( + ["node", os.path.join(HERE, "bench_spreadsheet_engine.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=300, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_얕은_시트_칸_하나는_딸린_칸만(bench): + s = bench["shallow"] + assert s["editMiddle"]["redone"] == 4 # 고친 칸 + 이웃 식 둘 + 합계 + assert s["editMiddle"]["ms"] < 100 + assert s["loadMs"] < 3000 + + +def test_깊은_사슬_5천_칸(bench): + c = bench["chain"] + assert c["editFirst"]["redone"] == 5001 + assert c["editFirst"]["ms"] < 1000 + assert c["editLast"]["redone"] == 2 + + +def test_큰_시트_불러오기(bench): + b = bench["bigSheet"] + assert b["cells"] > 60000 + assert b["parseMs"] + b["loadMs"] < 15000 + + +def test_나눗셈_사슬_분모_상한(bench): + d = bench["divChain"] + assert d["maxDenominatorDigits"] <= 41 + assert d["relErrVsDouble"] < 1e-12