feat(spreadsheet): A 엔진 뺀 셋 채움 · 메모 옮김 — 계약 더함(Sheet.기본 · Sheet.comments)
- 계약 더함: Sheet.기본 { 서식 · 열폭 · 행높이 } — 모두 고르기 서식 · 폭 · 높이 · 칸 서식 물려받기 = 칸 → 행 → 열 → 시트 기본 → 0
- 계약 더함: Sheet.comments — A1 → 메모 글 · 행열 넣기 · 지우기 · 잘라 붙이기 때 칸과 같이 옮김(지운 칸 · 덮은 칸 메모는 지움)
- 행 · 열 넣기 = 위(왼) 줄 칸 서식 · 높이 · 폭 따라감(숨김은 안 따라감 · 첫 줄 앞 넣기는 안 따라감)
- 범위를 칸 하나 자리에 넣으면 엑셀 암묵 교차(한 열 범위 = 식 든 행 · 한 행 범위 = 식 든 열)
- 풀이 차례를 반복 위상 순서로 — 2만 칸 사슬도 스택 안 넘침
- 시험 resources/tester/spreadsheet/test_spreadsheet_engine.py 규칙 더함
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
This commit is contained in:
@@ -84,9 +84,25 @@ function internStyle(book: Workbook, style: CellStyle): number {
|
||||
return book.서식.length - 1;
|
||||
}
|
||||
|
||||
/** 칸 자리에서 물려받는 서식(칸 서식 없을 때) — 행 · 열 · 0 */
|
||||
function setComments(s: Sheet, notes: Record<string, string>) {
|
||||
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<string, string> = {};
|
||||
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<V>(
|
||||
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<string, Record<string, unknown>> | 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;
|
||||
|
||||
@@ -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<PlacedRange>;
|
||||
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);
|
||||
|
||||
@@ -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<string>();
|
||||
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<string>) {
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
/** 새 시트 기본값 — 값은 사용자 협의로 확정(9_엑셀검토 8장 · 임의 확정 금지). 없으면 A 의 임시값. */
|
||||
|
||||
@@ -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. 되돌리기 스택 · 엔진과 잇기 · 한 벌 풀이 ──────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user