Files
Aislo/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs
T
eomsangdonandClaude Opus 5.5 279ef5fb4f 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
2026-09-27 20:23:32 +09:00

740 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 스프레드시트 엔진 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 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 <= 20000; i++) chain[`A${i}`] = `=A${i - 1}+1`;
eq("사슬 2만", val(G.createCalcEngine(book(chain)), "s1", "A20000"), "20000");
});
// ── 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).병합], [
{ 서식: 2 },
"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") },
],
});
// 넣은 줄은 위(왼) 줄 서식 · 높이 · 폭을 따라감
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 } } }],
["병합 가르는 옮기기", { 종류: "옮기기", 시트: "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 };
cs.시트[1].기본 = { 서식: 8 };
C.compactStyles(cs);
eq("compactStyles", [cs.서식.length, cs.시트[0].칸.K1.서식, cs.시트[0].칸.A1.서식, cs.시트[1].기본.서식], [7, 1, 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));