/* 스프레드시트 엔진 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.endsWith(".css")) return {}; 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"); // 무게 시험(`bench_spreadsheet_engine.cjs`)이 같은 적재 · 가짜 함수를 씀 — 불려 올 때는 시험을 안 돌림 module.exports = { A, C, E, F, G, load }; if (require.main !== module) return; 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)"], ["now()", "NOW()"], ['"say ""hi"""', '"say ""hi"""'], ["#REF!+1", "#REF!+1"], ["TRUE<>false", "TRUE<>FALSE"], ["1.50E+3", "1.50E+3"], ["_xlfn.CONCAT(1)", "CONCAT(1)"], ["미정의이름*2", "미정의이름*2"], ]; for (const [src, want] of round) { const p = P.parseFormula(src); eq(`왕복 ${src}`, p.ok ? P.formulaToText(p.node) : `실패 ${p.message}`, want); } const bad = ["1+", "(1", "SUM(1", '"abc', "1 2", ")", "", "A1:", "1+*2"]; for (const src of bad) eq(`실패 ${src}`, P.parseFormula(src).ok, false); const f = P.parseFormula("1+SUM(2"); eq("실패 자리", [f.ok, typeof f.at === "number", typeof f.message], [false, true, "string"]); eq("격자 밖 주소는 이름", P.parseFormula("XFE1").node, { type: "name", name: "XFE1" }); }); // ── 3. 우선순위 · 형 바꾸기 ───────────────────────────────────────────────── section("precedence", () => { const cases = [ ["-2^2", "4"], ["0-2^2", "-4"], ["2^3^2", "64"], ["2+3*4", "14"], ["(2+3)*4", "20"], ["10%*2", "0.2"], ["-10%", "-0.1"], ["2^50%", "1.4142135623731"], ['"a"&1+2', "a3"], ['1+2&""', "3"], ["1&2=12", false], ['1&2="12"', true], ["3>2=TRUE", true], ['"abc"="ABC"', true], ['1<"a"', true], ['"a""a"', true], ["0.1+0.2=0.3", true], ["1/3*3=1", true], ["A9", "0"], ["-A9", "0"], ['A9=""', true], ["A9=0", true], ['"12"+1', "13"], ["TRUE+1", "2"], ['" 1,234 "+1', "1235"], ['"50%"+0', "0.5"], ["2^0.5", "1.4142135623731"], ["2^-1", "0.5"], ["(-2)^3", "-8"], ['1/3&""', "0.333333333333333"], ['2^60&""', "1.15292150460685E+18"], ['(0.1+0.2)&""', "0.3"], ['0.000000001&""', "0.000000001"], ['TRUE&1', "TRUE1"], ["+\"x\"", "x"], ]; for (const [f, want] of cases) eq(f, calc(f), want); }); // ── 4. 오류 값 · 전파 ─────────────────────────────────────────────────────── section("errors", () => { const cases = [ ["1/0", "#DIV/0!"], ['"a"+1', "#VALUE!"], ["#N/A+1/0", "#N/A"], ["1/0&#N/A", "#DIV/0!"], ["1/0=#N/A", "#DIV/0!"], ["-#NUM!", "#NUM!"], ["(-8)^(1/3)", "#NUM!"], ["0^0", "#NUM!"], ["0^-1", "#DIV/0!"], ["없는시트!A1", "#REF!"], ["#REF!", "#REF!"], ["이름표*2", "#NAME?"], ["FOO(1)", "#NAME?"], ["IF(1)", "#NAME?"], ["SUM()", "#NAME?"], ["IF(1,2,3,4)", "#NAME?"], ["SUM(,)", "0"], ["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("IF 인수가 너무 적음(2 개부터)"), true); eq("입력 막음", [P.parseFormula("SUM()").message, P.parseFormula("IF(1,2,3,4)").message], ["SUM 인수가 너무 적음(1 개부터)", "IF 인수가 너무 많음(3 개까지)"]); 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));