Merge remote-tracking branch 'origin/dev' into sub_laptop_5

This commit is contained in:
2026-09-27 20:26:38 +09:00
159 changed files with 9391 additions and 3201 deletions
@@ -0,0 +1,17 @@
/* 스프레드시트 D 화면 시험 틀 입구 — 견본 통합문서를 띄우고 window.ss* 로 수치를 꺼냄(ORCA eval).
* 번들: node helper_spreadsheet_d_bundle.cjs harness → tmp/spreadsheet_d/harness.js */
const { createSpreadsheet } = require("../../../A00_Common/spreadsheet/spreadsheet");
const sample = require("../../../A00_Common/spreadsheet/spreadsheet_sample.json");
const host = document.getElementById("host");
window.ssSelect = null;
window.ssChanges = 0;
window.ssSample = JSON.parse(JSON.stringify(sample));
window.ss = createSpreadsheet(host, JSON.parse(JSON.stringify(sample)), {
onChange: () => window.ssChanges++,
onSelect: (s) => (window.ssSelect = s),
});
window.ssCreate = createSpreadsheet;
/** 칸 입력 값(A1) */
window.ssCell = (a1, sheet = "s1") =>
window.ss.getDoc().시트.find((s) => s.id === sheet).칸[a1] ?? null;
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<title>스프레드시트 D 시험 틀</title>
<style>
body {
margin: 0;
font-family: sans-serif;
}
#host {
height: 560px;
margin: 8px;
border: 1px solid #999;
}
#fakes {
margin: 8px;
color: #bc4c00;
font-size: 12px;
}
</style>
</head>
<body>
<div id="fakes"></div>
<div id="host"></div>
<script src="../../../tmp/spreadsheet_d/harness.js"></script>
<script>
document.getElementById("fakes").textContent =
"가짜 부품: " + (window.__ssFakes || []).join(", ");
</script>
</body>
</html>
@@ -0,0 +1,102 @@
/* 스프레드시트 D 순수 규칙 시험 도우미 — 고름 · 입력 글 규칙 · 가리키기 자리 · 채우기 끌기 목표.
* pytest(test_spreadsheet_d_input.py)가 부르고 결과 JSON 을 판정. */
const path = require("path");
const { load, SS } = require("./helper_spreadsheet_d_bundle.cjs");
const sel = load(path.join(SS, "spreadsheet_selection.ts"));
const ed = load(path.join(SS, "spreadsheet_editor.ts"));
const mouse = load(path.join(SS, "spreadsheet_mouse.ts"));
const addr = load(path.join(SS, "spreadsheet_address.ts"));
const sample = require(path.join(SS, "spreadsheet_sample.json"));
const s1 = sample.시트[0];
const a1 = (s) => `${addr.toA1(s.활성.r, s.활성.c)}|${addr.rangeToA1(s.범위[0])}`;
const cell = (r, c) => sel.selectCell(s1, r, c);
const keys = (start, list) => {
let s = start;
return list.map(([dr, dc, o]) => a1((s = sel.move(s1, s, dr, dc, o || {}))));
};
const out = {
// 병합 C4:F4 안을 누르면 병합 전체 · 활성은 왼위
mergeClick: a1(cell(3, 4)),
// 병합에서 오른쪽 → G4 · 왼쪽 → B4
mergeMove: keys(cell(3, 2), [
[0, 1],
[0, -1],
[0, -1],
]),
// Shift+→ 두 번(병합 걸침) · Shift+← 줄임 · Shift+↓
shiftExtend: keys(cell(3, 0), [
[0, 1, { extend: true }],
[0, 1, { extend: true }],
[0, -1, { extend: true }],
[1, 0, { extend: true }],
]),
// Ctrl+↓ 값 뭉치 끝 → 시트 끝 · Ctrl+↑ 되돌아 값
ctrlJump: keys(cell(2, 0), [
[1, 0, { jump: true }],
[1, 0, { jump: true }],
[-1, 0, { jump: true }],
]),
// 숨긴 열 J 건너뜀(I → K)
hiddenSkip: keys(cell(0, 8), [[0, 1]]).map((x) => x.split("|")[0]),
// 열 머리 C — 병합 C3:F3 걸쳐 C:F
colHead: addr.rangeToA1(sel.selectCols(s1, 2, 2).범위[0]),
// 고름 안 Enter 돌기(열 먼저) · Tab(행 먼저)
cycleEnter: (() => {
let s = {
시트: "s1",
범위: [{ r0: 10, c0: 10, r1: 11, c1: 11 }],
활성: { r: 10, c: 10 },
기준: { r: 10, c: 10 },
};
return [1, 1, 1, 1].map(() => addr.toA1((s = sel.cycle(s1, s, 1, 0)).활성.r, s.활성.c));
})(),
cycleTab: (() => {
let s = {
시트: "s1",
범위: [{ r0: 10, c0: 10, r1: 11, c1: 11 }],
활성: { r: 10, c: 10 },
기준: { r: 10, c: 10 },
};
return [1, 1, 1].map(() => addr.toA1((s = sel.cycle(s1, s, 0, 1)).활성.r, s.활성.c));
})(),
cycleSingle: sel.cycle(s1, cell(3, 2), 1, 0),
usedEnd: addr.toA1(sel.usedEnd(s1).r, sel.usedEnd(s1).c),
// 입력 글 → 칸
textToCell: ["12", "1,234.5", "50%", "-1e3", "true", "'=12", "=", "=A1+1", "", "1,23", "abc"].map(
(t) => ed.textToCell(t, { 서식: 4 }),
),
textToCellNoStyle: ed.textToCell("", {}),
cellToText: [
{ 값: "=12" },
{ 값: "12" },
{ 값: "abc" },
{ 값: 1.5 },
{ 값: false },
{ 식: "A1*2" },
undefined,
].map((c) => ed.cellToText(c)),
// 참조 넣을 자리
insertable: [
["=", 1],
["=A1+", 4],
["=A1", 3],
["=SUM(", 5],
["=SUM(A1,", 8],
["=1 + ", 5],
["abc", 3],
["=A1+B", 4],
].map(([t, p]) => ed.insertableAt(t, p)),
// 참조 색 — 같은 글은 같은 색 · 다른 시트
highlights: ed
.refHighlights("=A1+B2:C3*A1+치수!B1", "s1", (n) => (n === "치수" ? "s2" : null))
.map((h) => `${h.시트}:${addr.rangeToA1(h.범위)}:${h.색번호}`),
// 채우기 끌기 목표
fill: [[{ r: 9, c: 1 }], [{ r: 2, c: 6 }], [{ r: 3, c: 1 }], [{ r: 2, c: 1 }]].map(([p]) => {
const t = mouse.fillTarget({ r0: 1, c0: 1, r1: 3, c1: 2 }, p);
return [addr.rangeToA1(t.target), t.clear && addr.rangeToA1(t.clear)];
}),
};
console.log(JSON.stringify(out));
@@ -0,0 +1,127 @@
/* 스프레드시트 D 시험 번들 도우미 — TS 를 그 자리에서 CommonJS 로 풀어 모음.
* 남의 몫 파일이 아직 빈 몸(「아직 없음」)이면 spreadsheet_d_fakes.cjs 의 가짜로 바꿔 끼움.
* load(entry) Node 안에서 모듈을 돌려 exports 를 줌(pytest 도우미가 씀)
* node 이 파일 harness 브라우저 시험 틀 번들 → tmp/spreadsheet_d/harness.js (git 밖)
* `.cjs` = 루트 package.json 의 "type":"module" 과 무관하게 CommonJS. */
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "..", "..");
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
const SS = path.join(ROOT, "A00_Common", "spreadsheet");
const FAKES = path.join(__dirname, "spreadsheet_d_fakes.cjs");
const isStub = (file) => fs.readFileSync(file, "utf8").includes("아직 없음(");
/** 가져오기 글 → 모듈 id(파일 경로 · `fake:<이름>` · `css:<경로>`) */
function resolve(spec, from) {
if (spec === "@ui/ui_template_elements") return "fake:el";
let abs;
if (spec.startsWith("@ui/")) abs = path.join(ROOT, "ui_template", spec.slice(4));
else if (spec.startsWith(".")) abs = path.resolve(path.dirname(from), spec);
else throw new Error(`모르는 가져오기: ${spec} (${from})`);
if (/\.(css|json|cjs)$/.test(abs)) return (abs.endsWith(".css") ? "css:" : "") + abs;
if (!abs.endsWith(".ts")) abs += ".ts";
if (path.dirname(abs) === SS && isStub(abs))
return "fake:" + path.basename(abs, ".ts").replace(/^spreadsheet_/, "");
return abs;
}
/** 입구부터 딸린 모듈 전부 → id → {code, deps} */
function collect(entry, stubs = []) {
const mods = new Map();
const walk = (id) => {
if (mods.has(id)) return;
let code;
if (id.startsWith("fake:")) {
const name = id.slice(5);
if (name !== "el") stubs.push(name);
code = `module.exports = require("${FAKES.replace(/\\/g, "/")}")[${JSON.stringify(name)}];`;
mods.set(id, { code, deps: { [FAKES.replace(/\\/g, "/")]: FAKES } });
walk(FAKES);
return;
}
if (id.startsWith("css:")) {
const css = fs.readFileSync(id.slice(4), "utf8");
code = `if (typeof document !== "undefined") { const s = document.createElement("style"); s.textContent = ${JSON.stringify(css)}; document.head.append(s); }`;
mods.set(id, { code, deps: {} });
return;
}
const src = fs.readFileSync(id, "utf8");
if (id.endsWith(".json")) code = `module.exports = ${src};`;
else if (id.endsWith(".ts"))
code = ts.transpileModule(src, {
fileName: id,
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
}).outputText;
else code = src;
const deps = {};
mods.set(id, { code, deps });
for (const m of code.matchAll(/require\("([^"]+)"\)/g)) {
const dep = m[1] === FAKES.replace(/\\/g, "/") ? FAKES : resolve(m[1], id);
deps[m[1]] = dep;
walk(dep);
}
};
walk(entry);
return mods;
}
/** Node 안에서 돌림 */
function load(entry) {
const abs = path.resolve(entry);
const id = resolve("./" + path.basename(abs).replace(/\.ts$/, ""), abs); // 빈 몸이면 가짜
const mods = collect(id);
const cache = new Map();
const req = (id) => {
if (cache.has(id)) return cache.get(id).exports;
const m = mods.get(id);
const module = { exports: {} };
cache.set(id, module);
new Function("exports", "module", "require", m.code)(module.exports, module, (s) =>
req(m.deps[s]),
);
return module.exports;
};
return req(id);
}
/** 브라우저 한 파일 */
function bundle(entry, out) {
const stubs = [];
const mods = collect(path.resolve(entry), stubs);
const ids = [...mods.keys()];
const defs = ids
.map((id, i) => {
const m = mods.get(id);
const map = Object.fromEntries(Object.entries(m.deps).map(([s, d]) => [s, ids.indexOf(d)]));
return `[${JSON.stringify(map)}, function (exports, module, require) {\n${m.code}\n}]`;
})
.join(",\n");
const text = `(function () {
const defs = [${defs}];
const cache = [];
function req(i) {
if (cache[i]) return cache[i].exports;
const module = (cache[i] = { exports: {} });
defs[i][1](module.exports, module, (s) => req(defs[i][0][s]));
return module.exports;
}
window.__ssFakes = ${JSON.stringify([...new Set(stubs)])};
req(${ids.indexOf(path.resolve(entry))});
})();\n`;
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, text);
return { out, modules: ids.length, fakes: [...new Set(stubs)] };
}
module.exports = { load, bundle, ROOT, SS };
if (require.main === module && process.argv[2] === "harness") {
const r = bundle(
path.join(__dirname, "harness_spreadsheet_d.cjs"),
path.join(ROOT, "tmp", "spreadsheet_d", "harness.js"),
);
console.log(JSON.stringify(r));
}
@@ -0,0 +1,665 @@
/* 스프레드시트 엔진 A 시험 헬퍼 — `A00_Common/spreadsheet/*.ts` 를 그 자리에서 트랜스파일해 Node 로 돌림.
* 묶음마다 틀린 것 목록을 JSON 으로 냄 → `test_spreadsheet_engine.py` 가 묶음마다 빈 목록인지 봄.
* 함수 표(B 몫)는 가짜 넷(SUM · IF · IFERROR · ROUNDDOWN)으로 갈아 끼움 — A 의 부르는 길만 잼.
* 확장자 `.cjs` = 루트 package.json `"type": "module"` 때문.
*/
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "..", "..");
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
const DIR = path.join(ROOT, "A00_Common", "spreadsheet");
const cache = new Map();
let fakeFunctions = null;
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full).exports;
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
}).outputText;
const mod = { exports: {} };
cache.set(full, mod);
const req = (r) => {
if (r === "./spreadsheet_functions") return fakeFunctions;
if (r.startsWith("@ui/")) return loadTs(path.join(ROOT, "ui_template", `${r.slice(4)}.ts`));
if (r.startsWith(".")) return loadTs(path.join(path.dirname(full), `${r}.ts`));
return require(r);
};
new Function("exports", "module", "require", js)(mod.exports, mod, req);
return mod.exports;
}
const load = (name) => loadTs(path.join(DIR, `${name}.ts`));
fakeFunctions = {
getFunction: (name) => FAKE[name],
functionNames: () => Object.keys(FAKE),
};
const F = loadTs(path.join(ROOT, "ui_template", "sheet", "ui_template_sheet_frac.ts"));
const E = load("spreadsheet_eval");
const FAKE = {
SUM: {
min: 1,
call(args) {
let total = F.ZERO;
for (const a of args) {
const v = a();
if (E.isError(v)) return v;
if (v && v.kind === "range") {
for (let i = 0; i < v.rows; i++)
for (let j = 0; j < v.cols; j++) {
const x = v.at(i, j);
if (E.isError(x)) return x;
if (E.isFrac(x)) total = F.add(total, x);
}
} else {
const n = E.asNumber(v);
if (E.isError(n)) return n;
total = F.add(total, n);
}
}
return total;
},
},
IF: {
min: 2,
max: 3,
call(args) {
const c = E.asBool(args[0]());
if (E.isError(c)) return c;
return c ? args[1]() : args[2] ? args[2]() : false;
},
},
IFERROR: {
min: 2,
max: 2,
call(args) {
const v = args[0]();
return E.isError(v) ? args[1]() : v;
},
},
ROUNDDOWN: {
min: 2,
max: 2,
call(args) {
const x = E.asNumber(args[0]());
if (E.isError(x)) return x;
const d = E.asNumber(args[1]());
if (E.isError(d)) return d;
return F.roundAt(x, Number(F.fracToString(d)), "trunc");
},
},
};
const A = load("spreadsheet_address");
const P = load("spreadsheet_parser");
const G = load("spreadsheet_graph");
const R = load("spreadsheet_refshift");
const C = load("spreadsheet_commands");
const H = load("spreadsheet_history");
const RC = load("spreadsheet_recalc");
const results = {};
let group = "";
const fails = () => (results[group] ??= []);
const eq = (label, got, want) => {
const g = JSON.stringify(got);
const w = JSON.stringify(want);
if (g !== w) fails().push(`${label}: got ${g} want ${w}`);
};
const section = (name, fn) => {
group = name;
results[name] = [];
try {
fn();
} catch (e) {
fails().push(`예외: ${e && e.stack ? e.stack : e}`);
}
};
/** 값 보기 — 수는 십진 글 · 오류는 코드 */
function show(v) {
if (v === null) return null;
if (E.isError(v)) return v.error;
if (E.isFrac(v)) return F.fracToString(v);
return v;
}
/** 시트 둘(s1 「본」 · s2 「치수」) · 칸 표 → 통합문서 */
function book(cells1, cells2 = {}) {
const wrap = (cells) =>
Object.fromEntries(
Object.entries(cells).map(([k, v]) =>
typeof v === "string" && v.startsWith("=") ? [k, { 식: v.slice(1) }] : [k, { 값: v }],
),
);
return {
종류: "통합문서",
판: 1,
열: "t",
서식: [{}],
시트: [
{ id: "s1", 이름: "본", 칸: wrap(cells1) },
{ id: "s2", 이름: "치수", 칸: wrap(cells2) },
],
};
}
/** 식 하나 풀어 보기 */
function calc(formula, cells = {}) {
const b = book({ ...cells, Z99: "=" + formula });
const e = G.createCalcEngine(b);
const p = A.parseA1("Z99");
return show(e.value("s1", p.r, p.c));
}
const at = (a1) => A.parseA1(a1);
const val = (engine, sheet, a1) => show(engine.value(sheet, at(a1).r, at(a1).c));
// ── 1. 주소 ─────────────────────────────────────────────────────────────────
section("address", () => {
eq("colName", [0, 25, 26, 27, 701, 702, 16383].map(A.colName), ["A", "Z", "AA", "AB", "ZZ", "AAA", "XFD"]);
eq("colIndex", ["A", "ab", "XFD", "XFE", "", "A1"].map(A.colIndex), [0, 27, 16383, -1, -1, -1]);
eq("toA1", A.toA1(30, 4), "E31");
eq("parseA1", [A.parseA1("$E$31"), A.parseA1("e31"), A.parseA1("A0"), A.parseA1("XFE1")], [
{ r: 30, c: 4 },
{ r: 30, c: 4 },
null,
null,
]);
eq("parseRange 뒤집힘", A.parseRange("B5:A1"), { r0: 0, c0: 0, r1: 4, c1: 1 });
eq("rangeToA1", [
A.rangeToA1({ r0: 2, c0: 2, r1: 2, c1: 29 }),
A.rangeToA1({ r0: 30, c0: 4, r1: 30, c1: 4 }),
A.rangeToA1(A.parseRange("A:C")),
A.rangeToA1(A.parseRange("3:5")),
A.rangeToA1(A.parseRange("$B$2:$C$3")),
], ["C3:AD3", "E31", "A:C", "3:5", "B2:C3"]);
eq("quoteSheet", ["치수", "돌-골막이치수", "암거수량집계표(2×2)", "a'b", "A1", "Sheet 1", "TRUE", "_x"].map(A.quoteSheet), [
"치수",
"'돌-골막이치수'",
"'암거수량집계표(2×2)'",
"'a''b'",
"'A1'",
"'Sheet 1'",
"'TRUE'",
"_x",
]);
eq("cellId 왕복", A.fromCellId(A.cellId(1048575, 16383)), { r: 1048575, c: 16383 });
});
// ── 2. 파서 ─────────────────────────────────────────────────────────────────
section("parser", () => {
const kinds = (f) => P.tokenize(f).map((t) => `${t.kind}:${t.text}`);
eq("낱말", kinds("'돌-골막이'!D14+SUM(A1:B2, $C$3)&\"a\"\"b\""), [
"ref:'돌-골막이'!D14",
"op:+",
"func:SUM",
"paren:(",
"ref:A1:B2",
"comma:,",
"space: ",
"ref:$C$3",
"paren:)",
"op:&",
'string:"a""b"',
]);
eq("낱말 자리", P.tokenize("A1 + 2").map((t) => [t.start, t.end]), [[0, 2], [2, 3], [3, 4], [4, 5], [5, 6]]);
eq("낱말 행열 · 오류 · 참거짓", kinds("A:A+3:5+#N/A+치수!#REF!+true+LOG10(1)"), [
"ref:A:A", "op:+", "ref:3:5", "op:+", "error:#N/A", "op:+", "error:치수!#REF!", "op:+", "bool:true", "op:+",
"func:LOG10", "paren:(", "number:1", "paren:)",
]);
// 나무 → 글 왕복(괄호는 필요한 곳만)
const round = [
["ROUNDDOWN((G31+J31/2)*P31,2)", "ROUNDDOWN((G31+J31/2)*P31,2)"],
["=-2^2", "-2^2"],
["-(2^2)", "-(2^2)"],
["(1+2)*3", "(1+2)*3"],
["1-(2-3)", "1-(2-3)"],
["(1-2)-3", "1-2-3"],
["2^(3^2)", "2^(3^2)"],
['"a"&(1+2)', '"a"&1+2'],
["(A1&B1)=C1", "A1&B1=C1"],
["50%^2", "50%^2"],
["'돌-골막이치수'!D14*$A$1", "'돌-골막이치수'!D14*$A$1"],
["치수!B1:C2", "치수!B1:C2"],
["SUM($A:$A,3:3)", "SUM($A:$A,3:3)"],
["IF(A1,,1)", "IF(A1,,1)"],
["sum()", "SUM()"],
['"say ""hi"""', '"say ""hi"""'],
["#REF!+1", "#REF!+1"],
["TRUE<>false", "TRUE<>FALSE"],
["1.50E+3", "1.50E+3"],
["_xlfn.CONCAT(1)", "CONCAT(1)"],
["미정의이름*2", "미정의이름*2"],
];
for (const [src, want] of round) {
const p = P.parseFormula(src);
eq(`왕복 ${src}`, p.ok ? P.formulaToText(p.node) : `실패 ${p.message}`, want);
}
const bad = ["1+", "(1", "SUM(1", '"abc', "1 2", ")", "", "A1:", "1+*2"];
for (const src of bad) eq(`실패 ${src}`, P.parseFormula(src).ok, false);
const f = P.parseFormula("1+SUM(2");
eq("실패 자리", [f.ok, typeof f.at === "number", typeof f.message], [false, true, "string"]);
eq("격자 밖 주소는 이름", P.parseFormula("XFE1").node, { type: "name", name: "XFE1" });
});
// ── 3. 우선순위 · 형 바꾸기 ─────────────────────────────────────────────────
section("precedence", () => {
const cases = [
["-2^2", "4"],
["0-2^2", "-4"],
["2^3^2", "64"],
["2+3*4", "14"],
["(2+3)*4", "20"],
["10%*2", "0.2"],
["-10%", "-0.1"],
["2^50%", "1.4142135623731"],
['"a"&1+2', "a3"],
['1+2&""', "3"],
["1&2=12", false],
['1&2="12"', true],
["3>2=TRUE", true],
['"abc"="ABC"', true],
['1<"a"', true],
['"a"<TRUE', true],
['"b">"a"', true],
["0.1+0.2=0.3", true],
["1/3*3=1", true],
["A9", "0"],
["-A9", "0"],
['A9=""', true],
["A9=0", true],
['"12"+1', "13"],
["TRUE+1", "2"],
['" 1,234 "+1', "1235"],
['"50%"+0', "0.5"],
["2^0.5", "1.4142135623731"],
["2^-1", "0.5"],
["(-2)^3", "-8"],
['1/3&""', "0.333333333333333"],
['2^60&""', "1.15292150460685E+18"],
['(0.1+0.2)&""', "0.3"],
['0.000000001&""', "0.000000001"],
['TRUE&1', "TRUE1"],
["+\"x\"", "x"],
];
for (const [f, want] of cases) eq(f, calc(f), want);
});
// ── 4. 오류 값 · 전파 ───────────────────────────────────────────────────────
section("errors", () => {
const cases = [
["1/0", "#DIV/0!"],
['"a"+1', "#VALUE!"],
["#N/A+1/0", "#N/A"],
["1/0&#N/A", "#DIV/0!"],
["1/0=#N/A", "#DIV/0!"],
["-#NUM!", "#NUM!"],
["(-8)^(1/3)", "#NUM!"],
["0^0", "#NUM!"],
["0^-1", "#DIV/0!"],
["없는시트!A1", "#REF!"],
["#REF!", "#REF!"],
["이름표*2", "#NAME?"],
["FOO(1)", "#NAME?"],
["IF(1)", "#VALUE!"],
["1+", "#NAME?"],
["IF(TRUE,1,1/0)", "1"],
["IF(FALSE,1/0,2)", "2"],
["IFERROR(1/0,5)", "5"],
["IFERROR(A1,5)", "5"],
["A1:B2", "#VALUE!"],
["A1:A1", "#NULL!"],
["B1*2", "#DIV/0!"],
["SUM(B1:B2)", "#DIV/0!"],
];
for (const [f, want] of cases) eq(f, calc(f, { A1: "=#NULL!", B1: "=1/0" }), want);
const b = book({ A1: "=1+", B1: "=FOO(1)", C1: "=IF(1)" });
const e = G.createCalcEngine(b);
const why = (a1) => e.value("s1", at(a1).r, at(a1).c).why || "";
eq("까닭 파싱", why("A1").startsWith("식을 못 읽음"), true);
eq("까닭 함수", why("B1"), "모르는 함수: FOO");
eq("까닭 인자 수", why("C1").includes("인자 수 1"), true);
eq("toCalcValue 오류", E.toCalcValue(e.value("s1", 0, 1)), { 오류: "#NAME?", 까닭: "모르는 함수: FOO" });
});
// ── 5. 순환 ─────────────────────────────────────────────────────────────────
section("cycle", () => {
const b = book({ A1: "=B1+1", B1: "=A1+1", C1: "=A1*2", D1: 5, E1: "=E1", F1: "=SUM(F2:F3)", F3: "=F1", G1: "=D1*2" });
const e = G.createCalcEngine(b);
const v = (a1) => val(e, "s1", a1);
eq("고리 칸", ["A1", "B1", "E1", "F1", "F3"].map(v), ["#CYCLE!", "#CYCLE!", "#CYCLE!", "#CYCLE!", "#CYCLE!"]);
eq("고리를 읽는 칸", v("C1"), "#CYCLE!");
eq("나머지 칸", [v("D1"), v("G1")], ["5", "10"]);
const why = e.value("s1", 0, 0).why;
eq("까닭 = 고리 칸 목록", /A1/.test(why) && /B1/.test(why) && why.startsWith("순환 참조"), true);
// 고리를 끊으면 딸린 칸이 풀림
C.applyCommand(b, { 종류: "칸", 시트: "s1", 칸: { B1: { 값: 3 } } });
e.update([{ 시트: "s1", ...at("B1") }]);
eq("끊은 뒤", ["A1", "B1", "C1"].map(v), ["4", "3", "8"]);
// 다시 고리
C.applyCommand(b, { 종류: "칸", 시트: "s1", 칸: { B1: { 식: "C1" } } });
e.update([{ 시트: "s1", ...at("B1") }]);
eq("다시 고리", ["A1", "B1", "C1", "D1"].map(v), ["#CYCLE!", "#CYCLE!", "#CYCLE!", "5"]);
});
// ── 6. 딸린 칸만 다시 ───────────────────────────────────────────────────────
section("graph", () => {
const b = book({ A1: 1, A2: 2, B1: "=A1*2", C1: "=B1+1", D1: "=7", E1: "=SUM(A1:A3)", F1: "=치수!B1*10" }, { B1: 0.9 });
const e = G.createCalcEngine(b);
const v = (a1) => val(e, "s1", a1);
eq("처음", ["B1", "C1", "D1", "E1", "F1"].map(v), ["2", "3", "7", "3", "9"]);
b.시트[0].칸.A1 = { 값: 10 };
const redo = e.update([{ 시트: "s1", ...at("A1") }]).map((a) => A.toA1(a.r, a.c)).sort();
eq("다시 푼 칸", redo, ["A1", "B1", "C1", "E1"]);
eq("값", ["B1", "C1", "D1", "E1"].map(v), ["20", "21", "7", "12"]);
b.시트[0].칸.A3 = { 값: 5 };
eq("범위 안 새 칸", e.update([{ 시트: "s1", ...at("A3") }]).map((a) => A.toA1(a.r, a.c)).sort(), ["A3", "E1"]);
eq("범위 값", v("E1"), "17");
b.시트[1].칸.B1 = { 값: 2 };
eq("다른 시트", e.update([{ 시트: "s2", ...at("B1") }]).map((a) => `${a.시트}!${A.toA1(a.r, a.c)}`).sort(), [
"s1!F1",
"s2!B1",
]);
eq("다른 시트 값", v("F1"), "20");
eq("앞 칸", e.precedents("s1", at("E1").r, at("E1").c), [{ 시트: "s1", 범위: { r0: 0, c0: 0, r1: 2, c1: 0 } }]);
eq("snapshot 식 칸만", Object.keys(e.snapshot().s1).sort(), ["B1", "C1", "D1", "E1", "F1"]);
// 긴 사슬(끌어 풀기 재귀 깊이)
const chain = { A1: 1 };
for (let i = 2; i <= 1500; i++) chain[`A${i}`] = `=A${i - 1}+1`;
eq("사슬 1500", val(G.createCalcEngine(book(chain)), "s1", "A1500"), "1500");
});
// ── 7. 참조 옮김 엑셀 규칙표 ────────────────────────────────────────────────
section("refshift", () => {
const b = book({}, {});
const run = (label, cmd, rows, host = "s1") => {
for (const [src, want] of rows) eq(`${label} ${src}`, R.shiftForCommand(src, host, cmd, b), want);
};
run("행넣기 3행에 1", { 종류: "행넣기", 시트: "s1", at: 2, 수: 1 }, [
["A1", "A1"],
["A3", "A4"],
["A2:A5", "A2:A6"],
["A3:A5", "A4:A6"],
["$A$3", "$A$4"],
["A$3+$A3", "A$4+$A4"],
["A:A", "A:A"],
["3:5", "4:6"],
["2:5", "2:6"],
["치수!A3", "치수!A3"],
["본!A3", "본!A4"],
["( A3 + 1 )", "( A4 + 1 )"],
['"A3"&A3', '"A3"&A4'],
["A5:A2", "A2:A6"],
]);
run("행넣기 다른 시트에서", { 종류: "행넣기", 시트: "s1", at: 2, 수: 1 }, [["본!A3+A3", "본!A4+A3"]], "s2");
run("행지우기 3~4행", { 종류: "행지우기", 시트: "s1", at: 2, 수: 2 }, [
["A3", "#REF!"],
["A4", "#REF!"],
["A5", "A3"],
["A2:A6", "A2:A4"],
["A3:A4", "#REF!"],
["A1:A3", "A1:A2"],
["A4:A8", "A3:A6"],
["SUM(A3,A5)", "SUM(#REF!,A3)"],
["$B$9", "$B$7"],
["3:3", "#REF!"],
["B:B", "B:B"],
]);
run("열넣기 B열에 2", { 종류: "열넣기", 시트: "s1", at: 1, 수: 2 }, [
["B1", "D1"],
["A1:C1", "A1:E1"],
["A:B", "A:D"],
["3:3", "3:3"],
["$B$1", "$D$1"],
["A1", "A1"],
]);
run("열지우기 B열", { 종류: "열지우기", 시트: "s1", at: 1, 수: 1 }, [
["B1", "#REF!"],
["C1", "B1"],
["A1:C1", "A1:B1"],
["B:B", "#REF!"],
]);
const cut = { 종류: "옮기기", 시트: "s1", 범위: { r0: 0, c0: 0, r1: 1, c1: 1 }, 대상시트: "s1", r: 4, c: 3 };
run("옮기기 A1:B2 → D5", cut, [
["A1", "D5"],
["$B$2", "$E$6"],
["A1:B2", "D5:E6"],
["A1:C3", "A1:C3"],
["D5", "#REF!"],
["D5:E6", "#REF!"],
["C3", "C3"],
["치수!A1", "치수!A1"],
]);
const cutAway = { 종류: "옮기기", 시트: "s1", 범위: { r0: 0, c0: 0, r1: 0, c1: 0 }, 대상시트: "s2", r: 9, c: 0 };
run("옮기기 다른 시트로", cutAway, [["A1+B1", "치수!A10+B1"]]);
run("옮기기 다른 시트로(그쪽 식)", cutAway, [["본!A1+A1", "A10+A1"]], "s2");
eq("옮겨 간 식 자체", R.shiftForCommand("A1+B1", "s1", cutAway, b, "s2"), "A10+본!B1");
run("시트이름 치수 → 치 수", { 종류: "시트이름", 시트: "s2", 이름: "치 수" }, [
["치수!B1", "'치 수'!B1"],
["'치수'!B1:B2", "'치 수'!B1:B2"],
["치수!B1+B1", "'치 수'!B1+B1"],
]);
run("시트지우기", { 종류: "시트지우기", 시트: "s2" }, [
["치수!B1+1", "#REF!+1"],
["B1", "B1"],
]);
const mv = [
[["A1+$B$2+B$3+$C4", 1, 1], "B2+$B$2+C$3+$C5"],
[["A1", -1, 0], "#REF!"],
[["A:A+3:3", 5, 1], "B:B+8:8"],
[["SUM(A1:B2)*2", 2, 0], "SUM(A3:B4)*2"],
[["치수!A1", 0, 1], "치수!B1"],
[["XFD1", 0, 1], "#REF!"],
];
for (const [[f, dr, dc], want] of mv) eq(`복사 ${f} (${dr},${dc})`, R.moveFormula(f, dr, dc), want);
const r1 = [
[["=2*R[0]C[-1]", { r: 4, c: 3 }], "2*C5"],
[["=SUM(R[-2]C:R[-1]C)", { r: 4, c: 0 }], "SUM(A3:A4)"],
[["=R1C1", { r: 9, c: 9 }], "$A$1"],
[['="RC"&ROUND(RC[1],0)', { r: 0, c: 0 }], '"RC"&ROUND(B1,0)'],
[["='치수'!R[1]C", { r: 0, c: 0 }], "'치수'!A2"],
[["=R[-1]C", { r: 0, c: 0 }], "#REF!"],
];
for (const [[f, pos], want] of r1) eq(`R1C1 ${f}`, R.r1c1ToA1(f, pos), want);
});
// ── 8. 명령 · 되돌리기 ──────────────────────────────────────────────────────
const SAMPLE = JSON.parse(fs.readFileSync(path.join(DIR, "spreadsheet_sample.json"), "utf8"));
section("commands", () => {
const fresh = () => structuredClone(SAMPLE);
// 열쇠 차례 · 병합 차례는 뜻이 없음 — 바로 세워 견줌
const canon = (v, k) =>
Array.isArray(v)
? (k === "병합" ? [...v].sort() : v).map((x) => canon(x))
: v && typeof v === "object"
? Object.fromEntries(Object.keys(v).sort().map((key) => [key, canon(v[key], key)]))
: v;
const sheetsJson = (b) => JSON.stringify(canon(b.시트));
/** 적용 → 되돌림 → 처음과 같은지 · 다시 → 적용 뒤와 같은지 */
const roundTrip = (label, cmd, check) => {
const b = fresh();
const before = sheetsJson(b);
const fx = C.applyCommand(b, cmd);
const after = sheetsJson(b);
if (check) check(b, fx);
C.applyCommand(b, fx.undo);
eq(`${label} 되돌림`, sheetsJson(b) === before, true);
C.applyCommand(b, cmd);
eq(`${label} 다시`, sheetsJson(b) === after, true);
};
const s1 = (b) => b.시트[0];
roundTrip("칸", { 종류: "칸", 시트: "s1", 칸: { h4: { 값: 1 }, A1: null, Z9: { 식: "H4*2" } } }, (b, fx) => {
eq("칸 열쇠 바로 세움", [s1(b).칸.H4, s1(b).칸.A1, fx.cells.length, fx.rebuild], [{ 값: 1 }, undefined, 3, false]);
});
roundTrip("내용지움", { 종류: "내용지움", 시트: "s1", 범위: [A.parseRange("A3:H4")] }, (b, fx) => {
eq("서식은 남음", [s1(b).칸.A3, s1(b).칸.D3, fx.cells.length], [{ 서식: 2 }, { 서식: 2 }, 10]);
});
roundTrip("서식", { 종류: "서식", 시트: "s1", 범위: [A.parseRange("H4:H6")], 바꿀: { 굵게: true, 형식: null } }, (b) => {
const st = b.서식[s1(b).칸.H4.서식];
eq("서식 덮기", [st.굵게, st.형식, s1(b).칸.H4.서식 === s1(b).칸.H5.서식], [true, undefined, true]);
});
roundTrip("서식 열 전체", { 종류: "서식", 시트: "s1", 범위: [A.parseRange("B:B")], 바꿀: { 기울임: true } }, (b, fx) => {
eq("열 서식", [!!s1(b).열.B.서식, fx.rebuild, b.서식[s1(b).칸.B4.서식].기울임], [true, true, true]);
});
roundTrip("서식지움", { 종류: "서식지움", 시트: "s1", 범위: [A.parseRange("A3:B4")] }, (b) => {
eq("서식지움", [s1(b).칸.A3, s1(b).칸.A4], [{ 값: "공종" }, { 값: "터파기" }]);
});
roundTrip("행넣기", { 종류: "행넣기", 시트: "s1", at: 3, 수: 1 }, (b) => {
const c = s1(b).칸;
eq("행넣기 칸 · 식 · 병합", [c.H4, c.H5.식, c.H6.식, c.H7.식, c.J5.식, s1(b).병합], [
undefined,
"ROUNDDOWN((J5+J6)/2*J7,2)",
"ROUNDDOWN(H5*0.3,2)",
"SUM(H5:H6)",
"치수!B1",
["A1:H1", "C3:F3", "C5:F5", "C6:F6", "A7:G7"],
]);
});
roundTrip("행넣기 틀고정 위", { 종류: "행넣기", 시트: "s1", at: 0, 수: 2 }, (b) => {
eq("틀고정 · 행 높이 밀림", [s1(b).틀고정, s1(b).행], [{ 행: 5 }, { 3: { 높이: 20 } }]);
});
roundTrip("행지우기", { 종류: "행지우기", 시트: "s1", at: 3, 수: 1 }, (b) => {
const c = s1(b).칸;
eq("행지우기", [c.H4.식, c.H5.식, s1(b).병합], ["ROUNDDOWN(#REF!*0.3,2)", "SUM(H4:H4)", ["A1:H1", "C3:F3", "C4:F4", "A5:G5"]]);
});
roundTrip("열넣기 병합 안", { 종류: "열넣기", 시트: "s1", at: 2, 수: 2 }, (b) => {
eq("병합 늘어남 · 열 폭 밀림", [s1(b).병합[0], s1(b).열.E, s1(b).열.L, s1(b).칸.J4.식], ["A1:J1", { 폭: 6 }, { 숨김: true }, "ROUNDDOWN((L4+L5)/2*L6,2)"]);
});
roundTrip("열지우기", { 종류: "열지우기", 시트: "s1", at: 2, 수: 4 }, (b) => {
eq("병합 줄고 없어짐", s1(b).병합, ["A1:D1", "A6:C6"]);
});
roundTrip("옮기기", { 종류: "옮기기", 시트: "s1", 범위: A.parseRange("J4:J6"), 대상시트: "s2", r: 4, c: 0 }, (b) => {
const t = b.시트[1].칸;
eq("옮기기", [s1(b).칸.H4.식, t.A5.식, t.A6.값, s1(b).칸.J4], ["ROUNDDOWN((치수!A5+치수!A6)/2*치수!A7,2)", "B1", 0.3, undefined]);
});
roundTrip("옮기기 병합째", { 종류: "옮기기", 시트: "s1", 범위: A.parseRange("A5:H6"), 대상시트: "s1", r: 19, c: 0 }, (b) => {
eq("병합 따라감", s1(b).병합, ["A1:H1", "C3:F3", "C4:F4", "C20:F20", "A21:G21"]);
});
roundTrip("병합", { 종류: "병합", 시트: "s1", 범위: A.parseRange("A4:H5") }, (b, fx) => {
eq("왼위만 값", [s1(b).칸.A4.값, s1(b).칸.B4, s1(b).칸.H5, fx.cells.length], ["터파기", { 서식: 4 }, { 서식: 5 }, 9]);
eq("품은 병합 흡수", s1(b).병합, ["A1:H1", "C3:F3", "A6:G6", "A4:H5"]);
});
roundTrip("병합풀기", { 종류: "병합풀기", 시트: "s1", 범위: A.parseRange("A1:D4") }, (b) => {
eq("병합풀기", s1(b).병합, ["C5:F5", "A6:G6"]);
});
roundTrip("열폭", { 종류: "열폭", 시트: "s1", 열: [0, 1, 2], 폭: 9 }, (b) => eq("열폭", s1(b).열.A.폭, 9));
roundTrip("열폭 기본으로", { 종류: "열폭", 시트: "s1", 열: [0, 2], 폭: null }, (b) => eq("열폭 지움", s1(b).열.A, undefined));
roundTrip("행높이", { 종류: "행높이", 시트: "s1", 행: [0, 4], 높이: 30 });
roundTrip("숨김", { 종류: "숨김", 시트: "s1", 축: "열", 번호: [8, 9], 숨김: true });
roundTrip("보이기", { 종류: "숨김", 시트: "s1", 축: "열", 번호: [9], 숨김: false }, (b) => eq("보이기", s1(b).열.J, undefined));
roundTrip("틀고정", { 종류: "틀고정", 시트: "s1", 행: 0, 열: 2 }, (b) => eq("틀고정", s1(b).틀고정, { 열: 2 }));
roundTrip("눈금선", { 종류: "눈금선", 시트: "s1", 보임: true }, (b) => eq("눈금선", s1(b).보기, undefined));
roundTrip("시트더하기", { 종류: "시트더하기", 시트: C.emptySheet("s3", "새 시트"), 자리: 1 }, (b) =>
eq("시트 차례", b.시트.map((s) => s.id), ["s1", "s3", "s2"]),
);
roundTrip("시트지우기", { 종류: "시트지우기", 시트: "s2" }, (b) => eq("#REF!", s1(b).칸.J4.식, "#REF!"));
roundTrip("시트이름", { 종류: "시트이름", 시트: "s2", 이름: "치 수" }, (b) => eq("이름 따라감", s1(b).칸.J4.식, "'치 수'!B1"));
roundTrip("시트옮기기", { 종류: "시트옮기기", 시트: "s2", 자리: 0 });
roundTrip("묶음", {
종류: "묶음",
명령: [
{ 종류: "칸", 시트: "s1", 칸: { K1: { 값: 1 } } },
{ 종류: "행넣기", 시트: "s1", at: 0, 수: 1 },
{ 종류: "병합", 시트: "s1", 범위: A.parseRange("K2:L2") },
],
});
// 막힘
const blocked = [
["병합 가운데 칸 쓰기", { 종류: "칸", 시트: "s1", 칸: { B1: { 값: 1 } } }],
["병합 가르는 옮기기", { 종류: "옮기기", 시트: "s1", 범위: A.parseRange("A1:B1"), 대상시트: "s1", r: 10, c: 0 }],
["병합 걸침", { 종류: "병합", 시트: "s1", 범위: A.parseRange("B1:B2") }],
["같은 시트 이름", { 종류: "시트이름", 시트: "s1", 이름: "치수" }],
["못 쓰는 글자", { 종류: "시트이름", 시트: "s1", 이름: "a/b" }],
["격자 끝 밀림", { 종류: "열넣기", 시트: "s2", at: 0, 수: 16384 }],
];
for (const [label, cmd] of blocked) {
const b = fresh();
const before = sheetsJson(b);
let msg = null;
try {
C.applyCommand(b, cmd);
} catch (e) {
msg = e instanceof C.CommandError ? "CommandError" : String(e);
}
eq(`막힘 ${label}`, [msg, sheetsJson(b) === before], ["CommandError", true]);
}
const one = C.emptyWorkbook("x", "시트1");
let last = null;
try {
C.applyCommand(one, { 종류: "시트지우기", 시트: "s1" });
} catch (e) {
last = e instanceof C.CommandError;
}
eq("마지막 시트", last, true);
// 묶음은 통째로 — 뒤가 막히면 앞도 되돌림
const b = fresh();
const before = sheetsJson(b);
try {
C.applyCommand(b, {
종류: "묶음",
명령: [
{ 종류: "칸", 시트: "s1", 칸: { K1: { 값: 1 } } },
{ 종류: "칸", 시트: "s1", 칸: { B1: { 값: 1 } } },
],
});
} catch {}
eq("묶음 원자", sheetsJson(b) === before, true);
eq("emptyWorkbook", C.emptyWorkbook("h1", "시트1"), {
종류: "통합문서",
판: 1,
열: "h1",
서식: [{}],
시트: [{ id: "s1", 이름: "시트1", 칸: {} }],
활성: "s1",
});
const cs = fresh();
cs.서식.push({ 굵게: true }, { 크기: 12, 굵게: true });
cs.시트[0].칸.K1 = { 값: 1, 서식: 8 };
C.compactStyles(cs);
eq("compactStyles", [cs.서식.length, cs.시트[0].칸.K1.서식, cs.시트[0].칸.A1.서식], [7, 1, 1]);
});
// ── 9. 되돌리기 스택 · 엔진과 잇기 · 한 벌 풀이 ──────────────────────────────
section("history", () => {
const h = H.createHistory(2);
const c = (n) => ({ 종류: "칸", 시트: "s1", 칸: { A1: { 값: n } } });
eq("빈", [h.canUndo(), h.canRedo(), h.undo(), h.redo()], [false, false, null, null]);
h.push(c(1), c(-1));
h.push(c(2), c(-2));
h.push(c(3), c(-3));
eq("한도 넘어 버림", [h.undo(), h.undo(), h.undo()], [c(-3), c(-2), null]);
eq("다시", [h.canRedo(), h.redo(), h.redo(), h.redo()], [true, c(2), c(3), null]);
h.undo();
h.push(c(4), c(-4));
eq("새 명령은 다시 스택 비움", h.canRedo(), false);
h.clear();
eq("clear", [h.canUndo(), h.canRedo()], [false, false]);
// 명령 → 엔진 → Ctrl+Z · Ctrl+Y
const b = structuredClone(SAMPLE);
const e = G.createCalcEngine(b);
const hist = H.createHistory();
const run = (cmd) => {
const fx = C.applyCommand(b, cmd);
if (fx.rebuild) e.rebuild(b);
else e.update(fx.cells);
return fx;
};
const doCmd = (cmd) => hist.push(cmd, run(cmd).undo);
const v = (a1) => val(e, "s1", a1);
eq("견본 값", ["H4", "H5", "H6", "J4"].map(v), ["0.18", "0.05", "0.23", "0.9"]);
doCmd({ 종류: "칸", 시트: "s1", 칸: { J5: { 값: 0.5 } } });
eq("고친 뒤", ["H4", "H5", "H6"].map(v), ["0.21", "0.06", "0.27"]);
doCmd({ 종류: "행지우기", 시트: "s1", at: 3, 수: 1 });
eq("행 지운 뒤", [v("H4"), v("H5")], ["#REF!", "#REF!"]);
run(hist.undo());
eq("Ctrl+Z", ["H4", "H5", "H6"].map(v), ["0.21", "0.06", "0.27"]);
run(hist.undo());
eq("Ctrl+Z 둘", ["H4", "H5", "H6"].map(v), ["0.18", "0.05", "0.23"]);
run(hist.redo());
eq("Ctrl+Y", v("H6"), "0.27");
eq("recalcWorkbook = 견본 계산값", RC.recalcWorkbook(structuredClone(SAMPLE)), SAMPLE.계산값);
});
process.stdout.write(JSON.stringify(results));
@@ -0,0 +1,480 @@
/* 스프레드시트 D 시험용 가짜 부품 — 남의 몫(A 엔진 · B 채우기 · C 격자 · E 도구)이 아직 빈 몸일 때만
* 번들 도우미(helper_spreadsheet_d_bundle.cjs)가 이것으로 바꿔 끼움. 진짜가 들어오면 안 씀.
* 뜻은 엑셀 흉내의 최소 — D 입력(고름 · 편집 · 가리키기 · 채우기 끌기 · 되돌리기)을 재기 위한 것뿐. */
const MAX_ROWS = 1048576;
const MAX_COLS = 16384;
// ── @ui el ────────────────────────────────────────────────────────────────
function el(tag, o = {}) {
const n = document.createElement(tag);
if (o.className) n.className = o.className;
if (o.text !== undefined) n.textContent = o.text;
for (const [k, v] of Object.entries(o.attrs || {})) n.setAttribute(k, v);
for (const c of o.children || []) n.append(c);
return n;
}
// ── A 주소 ────────────────────────────────────────────────────────────────
function colName(c) {
let s = "";
for (c += 1; c > 0; c = Math.floor((c - 1) / 26))
s = String.fromCharCode(65 + ((c - 1) % 26)) + s;
return s;
}
function colIndex(t) {
if (!/^[A-Za-z]{1,3}$/.test(t)) return -1;
let n = 0;
for (const ch of t.toUpperCase()) n = n * 26 + ch.charCodeAt(0) - 64;
return n - 1;
}
const toA1 = (r, c) => colName(c) + (r + 1);
function parseA1(t) {
const m = /^\$?([A-Za-z]{1,3})\$?(\d+)$/.exec(t.trim());
if (!m) return null;
return { r: Number(m[2]) - 1, c: colIndex(m[1]) };
}
function rangeToA1(g) {
if (g.c0 === 0 && g.c1 === MAX_COLS - 1) return `${g.r0 + 1}:${g.r1 + 1}`;
if (g.r0 === 0 && g.r1 === MAX_ROWS - 1) return `${colName(g.c0)}:${colName(g.c1)}`;
if (g.r0 === g.r1 && g.c0 === g.c1) return toA1(g.r0, g.c0);
return `${toA1(g.r0, g.c0)}:${toA1(g.r1, g.c1)}`;
}
function parseRange(t) {
t = t.replace(/\$/g, "").trim();
const [a, b = a] = t.split(":");
const pa = parseA1(a);
const pb = parseA1(b);
if (pa && pb)
return {
r0: Math.min(pa.r, pb.r),
c0: Math.min(pa.c, pb.c),
r1: Math.max(pa.r, pb.r),
c1: Math.max(pa.c, pb.c),
};
if (/^\d+$/.test(a) && /^\d+$/.test(b))
return { r0: Math.min(a, b) - 1, r1: Math.max(a, b) - 1, c0: 0, c1: MAX_COLS - 1 };
if (colIndex(a) >= 0 && colIndex(b) >= 0)
return {
r0: 0,
r1: MAX_ROWS - 1,
c0: Math.min(colIndex(a), colIndex(b)),
c1: Math.max(colIndex(a), colIndex(b)),
};
return null;
}
const address = {
colName,
colIndex,
toA1,
parseA1,
rangeToA1,
parseRange,
quoteSheet: (n) => (/^[\w가-힣]+$/.test(n) ? n : `'${n.replace(/'/g, "''")}'`),
cellId: (r, c) => r * MAX_COLS + c,
fromCellId: (id) => ({ r: Math.floor(id / MAX_COLS), c: id % MAX_COLS }),
};
// ── A 낱말 (참조만 정확히) ──────────────────────────────────────────────────
const REF =
/(?:(?:'(?:[^']|'')+'|[A-Za-z_가-힣][\w.가-힣]*)!)?\$?[A-Za-z]{1,3}\$?\d+(?::\$?[A-Za-z]{1,3}\$?\d+)?/y;
function tokenize(f) {
const out = [];
let i = 0;
while (i < f.length) {
REF.lastIndex = i;
const m = REF.exec(f);
if (m && !/[A-Za-z0-9_]/.test(f[i - 1] || "") && f[i + m[0].length] !== "(") {
out.push({ kind: "ref", text: m[0], start: i, end: i + m[0].length });
i += m[0].length;
continue;
}
out.push({ kind: "unknown", text: f[i], start: i, end: i + 1 });
i++;
}
return out;
}
const parser = {
tokenize,
parseFormula: () => ({ ok: false, message: "가짜", at: 0 }),
formulaToText: () => "",
};
// ── A 참조 옮김 ────────────────────────────────────────────────────────────
function moveFormula(f, dr, dc) {
return f.replace(/(\$?)([A-Z]{1,3})(\$?)(\d+)/g, (all, ac, col, ar, row) => {
const c = ac ? colIndex(col) : colIndex(col) + dc;
const r = ar ? Number(row) - 1 : Number(row) - 1 + dr;
if (c < 0 || r < 0) return "#REF!";
return `${ac}${colName(c)}${ar}${r + 1}`;
});
}
const refshift = { moveFormula, shiftForCommand: (f) => f, r1c1ToA1: (f) => f };
// ── B 채우기 (복사 + 두 수 간격만) ─────────────────────────────────────────
function fillCells(book, sheetId, src, target) {
const sheet = book.시트.find((s) => s.id === sheetId);
const out = {};
const vertical = target.r0 !== src.r0 || target.r1 !== src.r1;
const len = vertical ? src.r1 - src.r0 + 1 : src.c1 - src.c0 + 1;
for (let r = target.r0; r <= target.r1; r++)
for (let c = target.c0; c <= target.c1; c++) {
if (r >= src.r0 && r <= src.r1 && c >= src.c0 && c <= src.c1) continue;
const k = vertical ? (((r - src.r0) % len) + len) % len : (((c - src.c0) % len) + len) % len;
const sr = vertical ? src.r0 + k : r;
const sc = vertical ? c : src.c0 + k;
const cell = sheet.칸[toA1(sr, sc)];
if (!cell) {
out[toA1(r, c)] = null;
continue;
}
const next = { ...cell };
if (cell.식) next.식 = moveFormula(cell.식, r - sr, c - sc);
// 수 두 칸 이상이면 간격 이음
const first = sheet.칸[toA1(vertical ? src.r0 : r, vertical ? c : src.c0)];
const second = sheet.칸[toA1(vertical ? src.r0 + 1 : r, vertical ? c : src.c0 + 1)];
if (len >= 2 && typeof first?.값 === "number" && typeof second?.값 === "number") {
const step = second.값 - first.값;
const n = vertical ? r - src.r0 : c - src.c0;
next.값 = first.값 + step * n;
}
out[toA1(r, c)] = next;
}
return out;
}
const fill = { fillCells };
// ── A 명령 · 이력 (칸 · 내용지움 · 묶음) ────────────────────────────────────
class CommandError extends Error {}
function cellsIn(sheet, ranges) {
const keys = [];
for (const key of Object.keys(sheet.칸)) {
const a = parseA1(key);
if (ranges.some((g) => a.r >= g.r0 && a.r <= g.r1 && a.c >= g.c0 && a.c <= g.c1))
keys.push(key);
}
return keys;
}
function applyCommand(book, cmd) {
const sheetOf = (id) => book.시트.find((s) => s.id === id);
if (cmd.종류 === "칸") {
const sheet = sheetOf(cmd.시트);
const undo = {};
const cells = [];
for (const [k, v] of Object.entries(cmd.칸)) {
undo[k] = sheet.칸[k] ? structuredClone(sheet.칸[k]) : null;
if (v === null) delete sheet.칸[k];
else sheet.칸[k] = structuredClone(v);
cells.push({ 시트: cmd.시트, ...parseA1(k) });
}
return { undo: { 종류: "칸", 시트: cmd.시트, 칸: undo }, cells, rebuild: false };
}
if (cmd.종류 === "내용지움") {
const sheet = sheetOf(cmd.시트);
const next = {};
for (const k of cellsIn(sheet, cmd.범위)) {
const { 서식 } = sheet.칸[k];
next[k] = 서식 === undefined ? null : { 서식 };
}
return applyCommand(book, { 종류: "칸", 시트: cmd.시트, 칸: next });
}
if (cmd.종류 === "묶음") {
const undos = [];
const cells = [];
for (const c of cmd.명령) {
const e = applyCommand(book, c);
undos.unshift(e.undo);
cells.push(...e.cells);
}
return { undo: { 종류: "묶음", 명령: undos }, cells, rebuild: false };
}
throw new CommandError(`가짜 명령 없음: ${cmd.종류}`);
}
const commands = {
CommandError,
applyCommand,
emptyWorkbook: (col, name) => ({
종류: "통합문서",
판: 1,
열: col,
서식: [{}],
시트: [{ id: "s1", 이름: name, 칸: {} }],
}),
emptySheet: (id, name) => ({ id, 이름: name, 칸: {} }),
compactStyles: () => {},
};
const history = {
createHistory(limit = 100) {
let done = [];
let undone = [];
return {
push(cmd, undo) {
done.push({ cmd, undo });
if (done.length > limit) done.shift();
undone = [];
},
undo() {
const e = done.pop();
if (!e) return null;
undone.push(e);
return e.undo;
},
redo() {
const e = undone.pop();
if (!e) return null;
done.push(e);
return e.cmd;
},
canUndo: () => done.length > 0,
canRedo: () => undone.length > 0,
clear() {
done = [];
undone = [];
},
};
},
};
// ── A 계산 엔진 (사칙 · SUM 만 · double) ────────────────────────────────────
function createCalcEngine(book0) {
let book = book0;
const sheetOf = (id) => book.시트.find((s) => s.id === id);
function value(sid, r, c, depth = 0) {
const cell = sheetOf(sid)?.칸[toA1(r, c)];
if (!cell) return null;
if (cell.식 === undefined) return cell.값 ?? null;
if (depth > 50) return { error: "#CYCLE!" };
const num = (v) => (typeof v === "number" ? v : v === true ? 1 : 0);
let f = cell.식.replace(/SUM\(([A-Z]+\d+):([A-Z]+\d+)\)/gi, (_, a, b) => {
const g = parseRange(`${a}:${b}`);
let s = 0;
for (let rr = g.r0; rr <= g.r1; rr++)
for (let cc = g.c0; cc <= g.c1; cc++) s += num(value(sid, rr, cc, depth + 1));
return `(${s})`;
});
f = f.replace(/\$?([A-Z]{1,3})\$?(\d+)/g, (_, col, row) => {
const v = value(sid, Number(row) - 1, colIndex(col), depth + 1);
return `(${num(v)})`;
});
if (!/^[\d+\-*/(). e]*$/.test(f)) return { error: "#NAME?" };
try {
return Function(`return (${f})`)();
} catch {
return { error: "#NAME?" };
}
}
return {
update: (changed) => {
const out = [...changed];
for (const s of book.시트)
for (const k of Object.keys(s.칸))
if (s.칸[k].식 !== undefined) out.push({ 시트: s.id, ...parseA1(k) });
return out;
},
rebuild: (b) => (book = b),
value: (s, r, c) => value(s, r, c),
precedents: () => [],
snapshot: () => ({}),
};
}
const graph = { createCalcEngine };
// ── C 격자 (고정 칸 크기 · 병합 · 고름 · 참조 테두리 · 채우기 손잡이) ────────────
const W = 72;
const H = 22;
const HW = 40;
const HH = 22;
const ROWS = 60;
const COLS = 20;
const REF_COLORS = ["#1f6feb", "#cf222e", "#8250df", "#1a7f37", "#bc4c00"];
function createGrid(ctx) {
const root = el("div", { className: "fg" });
root.style.cssText = "position:relative;overflow:auto;height:420px;border:1px solid #ccc";
const content = el("div");
content.style.cssText = `position:relative;width:${HW + COLS * W}px;height:${HH + ROWS * H}px;font:12px sans-serif`;
root.append(content);
const cellLayer = el("div");
const overlay = el("div");
content.append(cellLayer, overlay);
let refs = [];
const merges = () => (ctx.sheet().병합 || []).map(parseRange).filter(Boolean);
const mergeAt = (r, c) => merges().find((m) => r >= m.r0 && r <= m.r1 && c >= m.c0 && c <= m.c1);
function cellBox(r, c) {
const m = mergeAt(r, c) || { r0: r, c0: c, r1: r, c1: c };
return {
x: HW + m.c0 * W,
y: HH + m.r0 * H,
w: (m.c1 - m.c0 + 1) * W,
h: (m.r1 - m.r0 + 1) * H,
};
}
const rangeBox = (g) => {
const a = cellBox(g.r0, g.c0);
const b = cellBox(Math.min(g.r1, ROWS - 1), Math.min(g.c1, COLS - 1));
return { x: a.x, y: a.y, w: b.x + b.w - a.x, h: b.y + b.h - a.y };
};
const place = (n, b) =>
Object.assign(n.style, {
position: "absolute",
left: `${b.x}px`,
top: `${b.y}px`,
width: `${b.w}px`,
height: `${b.h}px`,
boxSizing: "border-box",
});
function show(v) {
if (v === null || v === undefined) return "";
if (typeof v === "object") return v.error;
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
return String(v);
}
function render() {
cellLayer.textContent = "";
const sheet = ctx.sheet();
for (let c = 0; c < COLS; c++) {
const h = el("div", { text: colName(c) });
place(h, { x: HW + c * W, y: 0, w: W, h: HH });
h.style.cssText += ";background:#f3f3f3;border:1px solid #ddd;text-align:center";
cellLayer.append(h);
}
for (let r = 0; r < ROWS; r++) {
const h = el("div", { text: String(r + 1) });
place(h, { x: 0, y: HH + r * H, w: HW, h: H });
h.style.cssText += ";background:#f3f3f3;border:1px solid #ddd;text-align:center";
cellLayer.append(h);
}
for (let r = 0; r < ROWS; r++)
for (let c = 0; c < COLS; c++) {
const m = mergeAt(r, c);
if (m && (m.r0 !== r || m.c0 !== c)) continue;
const d = el("div", {
className: "fg-cell",
attrs: { "data-a1": toA1(r, c) },
text: show(ctx.engine.value(sheet.id, r, c)),
});
place(d, cellBox(r, c));
d.style.cssText +=
";border-right:1px solid #eee;border-bottom:1px solid #eee;padding:2px 3px;overflow:hidden;white-space:nowrap";
if (typeof ctx.engine.value(sheet.id, r, c) === "number") d.style.textAlign = "right";
cellLayer.append(d);
}
renderSelection();
}
function renderSelection() {
overlay.textContent = "";
const sel = ctx.selection;
for (const g of sel.범위) {
const b = el("div", { className: "fg-sel" });
place(b, rangeBox(g));
b.style.cssText +=
";border:2px solid #217346;background:rgba(33,115,70,.08);pointer-events:none";
overlay.append(b);
}
const g = sel.범위[0];
const rb = rangeBox(g);
const hd = el("div", { className: "fg-handle" });
place(hd, { x: rb.x + rb.w - 4, y: rb.y + rb.h - 4, w: 7, h: 7 });
hd.style.cssText += ";background:#217346;border:1px solid #fff;pointer-events:none";
overlay.append(hd);
refs.forEach((x) => {
if (x.시트 !== ctx.selection.시트) return;
const b = el("div", { className: "fg-ref", attrs: { "data-ref": rangeToA1(x.범위) } });
place(b, rangeBox(x.범위));
const col = REF_COLORS[x.색번호 % REF_COLORS.length];
b.style.cssText += `;border:2px solid ${col};pointer-events:none`;
overlay.append(b);
});
}
function hitTest(cx, cy) {
const rect = content.getBoundingClientRect();
const x = cx - rect.left;
const y = cy - rect.top;
const outer = root.getBoundingClientRect();
if (cx < outer.left || cx > outer.right || cy < outer.top || cy > outer.bottom) return null;
const c = Math.floor((x - HW) / W);
const r = Math.floor((y - HH) / H);
const rb = rangeBox(ctx.selection.범위[0]);
if (Math.abs(x - (rb.x + rb.w)) <= 4 && Math.abs(y - (rb.y + rb.h)) <= 4)
return {
kind: "fillHandle",
r: Math.min(ctx.selection.범위[0].r1, ROWS - 1),
c: Math.min(ctx.selection.범위[0].c1, COLS - 1),
};
if (x < HW && y < HH) return { kind: "corner", r: -1, c: -1 };
if (y < HH) return { kind: "colHead", r: -1, c: Math.max(0, Math.min(COLS - 1, c)) };
if (x < HW) return { kind: "rowHead", r: Math.max(0, Math.min(ROWS - 1, r)), c: -1 };
return {
kind: "cell",
r: Math.max(0, Math.min(ROWS - 1, r)),
c: Math.max(0, Math.min(COLS - 1, c)),
};
}
return {
root,
render,
invalidate: () => render(),
renderSelection,
setRefHighlights(list) {
refs = list;
renderSelection();
},
cellBox,
hitTest,
reveal(r, c) {
const b = cellBox(Math.min(r, ROWS - 1), Math.min(c, COLS - 1));
if (b.y - HH < root.scrollTop) root.scrollTop = b.y - HH;
if (b.y + b.h > root.scrollTop + root.clientHeight)
root.scrollTop = b.y + b.h - root.clientHeight;
if (b.x - HW < root.scrollLeft) root.scrollLeft = b.x - HW;
if (b.x + b.w > root.scrollLeft + root.clientWidth)
root.scrollLeft = b.x + b.w - root.clientWidth;
},
visibleRange() {
const r0 = Math.floor(root.scrollTop / H);
return { r0, r1: r0 + Math.floor(root.clientHeight / H) - 2, c0: 0, c1: COLS - 1 };
},
editorSlot: (r, c) => ({ layer: content, box: cellBox(r, c) }),
destroy: () => root.remove(),
};
}
const grid = { createGrid };
// ── E 부품 (탭만 단추 · 나머지 빈 손잡이) ───────────────────────────────────
const none = () => ({ root: null, refresh() {}, destroy() {} });
function mountTabs(ctx) {
const root = el("div", { className: "fg-tabs" });
root.style.cssText = "display:flex;gap:4px;padding:4px";
function refresh() {
root.textContent = "";
for (const s of ctx.book.시트) {
const b = el("button", { text: s.이름, attrs: { "data-sheet": s.id } });
if (s.id === ctx.selection.시트) b.style.fontWeight = "bold";
b.addEventListener("click", () => ctx.showSheet(s.id));
root.append(b);
}
}
refresh();
return { root, refresh, destroy: () => root.remove() };
}
module.exports = {
el: { el },
address,
parser,
refshift,
fill,
commands,
history,
graph,
grid,
toolbar: { mountToolbar: none },
tabs: { mountTabs },
menu: { attachMenu: none },
clipboard: {
attachClipboard: none,
parseClipboard: () => null,
blockToClipboard: () => ({ html: "", text: "" }),
},
};
@@ -0,0 +1,56 @@
// 스프레드시트 크기 재기 — vite 로 줄여 묶은 뒤 gzip 크기(바이트). `test_spreadsheet_size.py` 가 부름.
// node spreadsheet_size.mjs <저장소 뿌리> → {"engine": {js, css}, "total": {js, css}}
// 앱 번들에 이미 있는 `@ui/ui_template_elements*` 는 빼고 잼(스프레드시트가 새로 더하는 몫만).
import path from "node:path";
import { pathToFileURL } from "node:url";
import { gzipSync } from "node:zlib";
const root = path.resolve(process.argv[2] ?? ".");
const dir = path.join(root, "A00_Common", "spreadsheet");
const { build } = await import(
pathToFileURL(path.join(root, "config/node_modules/vite/dist/node/index.js")).href
);
const ENGINE = [
"spreadsheet_address",
"spreadsheet_parser",
"spreadsheet_eval",
"spreadsheet_graph",
"spreadsheet_refshift",
"spreadsheet_commands",
"spreadsheet_history",
"spreadsheet_recalc",
"spreadsheet_functions",
"spreadsheet_numfmt",
"spreadsheet_fill",
];
async function measure(entries) {
const out = await build({
configFile: false,
logLevel: "silent",
root,
resolve: { alias: { "@ui": path.join(root, "ui_template") } },
build: {
write: false,
minify: true,
target: "es2022",
cssCodeSplit: false,
lib: { entry: entries, formats: ["es"] },
rollupOptions: { external: [/ui_template_elements/] },
},
});
const outputs = (Array.isArray(out) ? out : [out]).flatMap((o) => o.output);
const size = { js: 0, css: 0 };
for (const o of outputs) {
const body = o.type === "chunk" ? o.code : o.source;
const gz = gzipSync(typeof body === "string" ? body : Buffer.from(body)).length;
if (o.fileName.endsWith(".css")) size.css += gz;
else if (o.fileName.endsWith(".js")) size.js += gz;
}
return size;
}
const engine = await measure(ENGINE.map((name) => path.join(dir, `${name}.ts`)));
const total = await measure([path.join(dir, "spreadsheet.ts")]);
console.log(JSON.stringify({ engine, total }));
@@ -0,0 +1,83 @@
# -*- coding: utf-8 -*-
"""스프레드시트 D 입력 — 고름 · 입력 글 규칙 · 참조 가리키기 자리 · 채우기 끌기 목표(엑셀 동작 기준).
Node 도우미(helper_spreadsheet_d.cjs)가 TS 를 풀어 돌리고 JSON 을 줌 — 남의 몫이 빈 몸이면 가짜 부품.
화면 조작(한글 첫 글자 · 수식 입력줄 · 끌기)은 harness_spreadsheet_d.html 을 ORCA 로.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(scope="module")
def r():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_spreadsheet_d.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_merge_selection(r):
assert r["mergeClick"] == "C4|C4:F4"
assert r["mergeMove"] == ["G4|G4", "C4|C4:F4", "B4|B4"]
assert r["colHead"] == "A:H" # 걸친 병합(A1:H1 · C3:F3 · A6:G6)만큼 늘림
def test_shift_ctrl_arrows(r):
assert r["shiftExtend"] == ["A4|A4:B4", "A4|A4:F4", "A4|A4:B4", "A4|A4:B5"]
assert r["ctrlJump"] == ["A6|A6:G6", "A1048576|A1048576", "A6|A6:G6"]
assert r["hiddenSkip"] == ["K1"]
assert r["usedEnd"] == "J6"
def test_cycle_in_selection(r):
assert r["cycleEnter"] == ["K12", "L11", "L12", "K11"]
assert r["cycleTab"] == ["L11", "K12", "L12"]
assert r["cycleSingle"] is None
def test_text_to_cell(r):
got = r["textToCell"]
assert [c.get("값", c.get("식")) for c in got] == [
12,
1234.5,
0.5,
-1000,
True,
"=12",
"=",
"A1+1",
None,
"1,23",
"abc",
]
assert "식" in got[7] and all(c["서식"] == 4 for c in got)
assert r["textToCellNoStyle"] is None
def test_cell_to_text(r):
assert r["cellToText"] == ["'=12", "'12", "abc", "1.5", "FALSE", "=A1*2", ""]
def test_pointing(r):
assert r["insertable"] == [True, True, False, True, True, True, False, False]
assert r["highlights"] == ["s1:A1:0", "s1:B2:C3:1", "s1:A1:0", "s2:B1:2"]
def test_fill_target(r):
assert r["fill"] == [
["B2:C10", None],
["B2:G4", None],
["B2:B4", "C2:C4"],
["B2:C3", "B4:C4"],
]
@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
"""산출근거 스프레드시트 엔진 A — 주소 · 파서 · 우선순위 · 오류 전파 · 순환 · 딸린 칸 재계산 ·
참조 옮김 엑셀 규칙표 · 명령 되돌림 짝 · 되돌리기 스택.
Node 헬퍼(`helper_spreadsheet_engine.cjs`)가 TS 를 그 자리에서 트랜스파일해 돌리고
묶음마다 틀린 것 목록을 냄 — 여기서는 묶음마다 빈 목록인지 봄. 규칙표는 헬퍼 안에 있음.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
GROUPS = ["address", "parser", "precedence", "errors", "cycle", "graph", "refshift", "commands", "history"]
@pytest.fixture(scope="module")
def results():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_spreadsheet_engine.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
@pytest.mark.parametrize("group", GROUPS)
def test_묶음(results, group):
assert group in results
assert results[group] == []
@@ -0,0 +1,129 @@
"""F — 금빛 시험: 실무 구조도 xlsx 세 벌의 수식을 새 엔진으로 풀어 엑셀 캐시값과 대조.
xlsx 는 **시험에서만** openpyxl 로 읽음(앱에는 가져오기 없음). 식 = 엑셀 글 그대로 · 값 = 칸 값 ·
바깥 통합문서 참조(`[172]관집계!…`)는 식을 못 푸니 캐시값을 값으로 굳힘(대조에서 뺌).
갈린 칸은 조용히 맞추지 않음 — 목록을 `tmp/spreadsheet_golden_report.json` 에 적고 시험을 떨굼.
엔진(A · B)이 빈 몸이면 건너뜀 — 엔진이 차는 대로 다시 돌림.
"""
import datetime as dt
import json
import re
import shutil
from decimal import Decimal, InvalidOperation
from pathlib import Path
import pytest
openpyxl = pytest.importorskip("openpyxl")
from openpyxl.utils.datetime import to_excel # noqa: E402
from common_util import common_util_spreadsheet as spreadsheet # noqa: E402
ROOT = Path(__file__).resolve().parents[3]
PRACTICE = ROOT / "resources" / "knowledge" / "original" / "실무문서"
NAMES = ("07-구조도-소광리.xlsx", "5. 구조도(기번3).xlsx", "구조도(변경).xlsx")
FILES = [next(PRACTICE.rglob(name)) for name in NAMES]
REPORT = ROOT / "tmp" / "spreadsheet_golden_report.json"
EXPECTED_FORMULAS = 2699
_EXTERNAL = re.compile(r"\[\d+\]")
_ERRORS = {"#NULL!", "#DIV/0!", "#VALUE!", "#REF!", "#NAME?", "#NUM!", "#N/A"}
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def _plain(value):
"""칸 값 → JSON(날짜 · 시각 형식 칸은 엑셀 일련수)."""
if isinstance(value, (dt.datetime, dt.date, dt.time, dt.timedelta)):
return to_excel(value)
return value
def _book(path: Path):
"""xlsx → 통합문서 + 대조할 칸 `[(시트 id, A1, 캐시값)]`."""
formulas = openpyxl.load_workbook(path, data_only=False)
cached = openpyxl.load_workbook(path, data_only=True)
sheets, checks = [], []
for i, ws in enumerate(formulas.worksheets, start=1):
sid, cells = f"s{i}", {}
values = cached[ws.title]
for row in ws.iter_rows():
for c in row:
if c.value is None:
continue
keep = _plain(values[c.coordinate].value)
if c.data_type == "f" and isinstance(c.value, str):
if _EXTERNAL.search(c.value):
if keep is not None:
cells[c.coordinate] = {"값": keep}
continue
cells[c.coordinate] = {"식": c.value[1:]}
checks.append((sid, c.coordinate, keep))
elif c.data_type != "f":
cells[c.coordinate] = {"값": _plain(c.value)}
sheets.append({"id": sid, "이름": ws.title, "칸": cells})
book = {"종류": "통합문서", "판": 1, "열": path.stem, "서식": [{}], "시트": sheets}
return book, checks
def _same(want, got) -> bool:
if got is None:
return False
if isinstance(want, bool):
return got.get("참") is want
if isinstance(want, (int, float)):
try:
a, b = Decimal(got["수"]), Decimal(repr(float(want)))
except (KeyError, InvalidOperation):
return False
return abs(a - b) <= Decimal("1e-9") * max(Decimal(1), abs(b))
if want is None or want == "":
return got.get("글") == "" or got.get("수") == "0"
if isinstance(want, str) and want in _ERRORS:
return got.get("오류") == want
return got.get("글") == want
def test_실무_구조도_수식_엑셀과_같음():
loaded = [(path, *_book(path)) for path in FILES]
total = sum(len(checks) for _p, _b, checks in loaded)
external = 6 # 소광리 `[172]관집계` — 값으로 굳혀 대조에서 뺌
assert total + external == EXPECTED_FORMULAS
try:
results = spreadsheet.recalc_workbooks([book for _p, book, _c in loaded])
except spreadsheet.RecalcError as error:
if "아직 없음" in str(error):
pytest.skip(f"엔진 아직 — {error}")
raise
mismatches = []
for (path, book, checks), values in zip(loaded, results, strict=True):
names = {s["id"]: s["이름"] for s in book["시트"]}
for sid, a1, want in checks:
got = values.get(sid, {}).get(a1)
if not _same(want, got):
formula = next(s for s in book["시트"] if s["id"] == sid)["칸"][a1]["식"]
mismatches.append(
{
"파일": path.name,
"시트": names[sid],
"칸": a1,
"식": formula,
"엑셀": want,
"엔진": got,
}
)
REPORT.parent.mkdir(exist_ok=True)
REPORT.write_text(
json.dumps(
{"대조": total, "갈림": len(mismatches), "목록": mismatches},
ensure_ascii=False,
indent=1,
default=str,
),
encoding="utf-8",
)
head = "\n".join(
f"{m['파일']} {m['시트']}!{m['칸']} ={m['식']} 엑셀 {m['엑셀']!r} · 엔진 {m['엔진']}"
for m in mismatches[:30]
)
assert not mismatches, f"{len(mismatches)}/{total} 칸 갈림 — 전체 {REPORT}\n{head}"
@@ -0,0 +1,37 @@
"""F — 서버 재계산 다리: 파이썬 → Node 번들(`build:spreadsheet`) → 화면과 같은 TS 엔진.
엔진(A · B)이 아직 빈 몸이면 `아직 없음` 까닭이 그대로 올라와 저장을 막는지 봄 ·
엔진이 차면 저장 견본(`spreadsheet_sample.json`)의 `계산값` 과 서버 풀이가 같은지 봄.
"""
import json
import shutil
from pathlib import Path
import pytest
from common_util import common_util_spreadsheet as spreadsheet
ROOT = Path(__file__).resolve().parents[3]
SAMPLE = ROOT / "A00_Common" / "spreadsheet" / "spreadsheet_sample.json"
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_견본_계산값_같음():
sample = json.loads(SAMPLE.read_text(encoding="utf-8"))
try:
saved = spreadsheet.with_server_values({**sample, "계산값": {"s1": {"H4": {"수": "틀림"}}}})
except spreadsheet.RecalcError as error:
assert "아직 없음" in str(error), error # 엔진이 빈 몸일 때만 봐줌
pytest.skip(f"엔진 아직 — {error}")
assert saved["계산값"] == sample["계산값"]
assert {k: v for k, v in saved.items() if k != "계산값"} == {
k: v for k, v in sample.items() if k != "계산값"
}
def test_못_푸는_문서는_까닭과_함께_막음():
with pytest.raises(spreadsheet.RecalcError) as caught:
spreadsheet.recalc_workbooks([{"종류": "통합문서", "시트": "깨짐"}])
assert "스프레드시트 계산 실패" in str(caught.value)
@@ -0,0 +1,74 @@
"""F — 스프레드시트 크기 예산 · M02 만 늦게 불러옴(계약 `spreadsheet_types.ts` 머리).
예산(압축 gzip 뒤) — 엔진 A+B 22 KB · 화면 C+D+E 28 KB · css 4 KB · 합 54 KB.
엔진은 파일마다 따로 묶어 더하므로 실제보다 조금 크게 잼(넘치면 넘친 것).
"""
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[3]
SCRIPT = Path(__file__).with_name("spreadsheet_size.mjs")
KB = 1024
BUDGET = {"engine": 22 * KB, "view": 28 * KB, "css": 4 * KB, "total": 54 * KB}
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_크기_예산():
out = subprocess.run(
["node", str(SCRIPT), str(ROOT)],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=300,
check=True,
)
size = json.loads(out.stdout.strip().splitlines()[-1])
engine, total = size["engine"]["js"], size["total"]
view = max(0, total["js"] - engine)
print(
f"gzip — 엔진 {engine} · 화면 {view} · css {total['css']} · 합 {total['js'] + total['css']}"
)
assert engine <= BUDGET["engine"], f"엔진 {engine} B > {BUDGET['engine']}"
assert view <= BUDGET["view"], f"화면 {view} B > {BUDGET['view']}"
assert total["css"] <= BUDGET["css"], f"css {total['css']} B > {BUDGET['css']}"
assert total["js"] + total["css"] <= BUDGET["total"]
# 정적 import 금지 — 타입만(`import type`) · 늦게(`import(`) 는 됨
_STATIC = re.compile(
r"^\s*import\s+(?!type\b)[^;]*?from\s+[\"'][^\"']*spreadsheet/spreadsheet", re.M
)
_SIDE = re.compile(r"^\s*import\s+[\"'][^\"']*spreadsheet/spreadsheet", re.M)
def test_M02_만_늦게_불러옴():
offenders, users = [], []
listed = subprocess.run(
["git", "ls-files", "--cached", "--others", "--exclude-standard", "*.ts"],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
check=True,
).stdout.splitlines()
for rel in listed:
if rel.startswith("A00_Common/spreadsheet/") or not (ROOT / rel).is_file():
continue
text = (ROOT / rel).read_text(encoding="utf-8", errors="replace")
if "spreadsheet/spreadsheet" not in text:
continue
if _STATIC.search(text) or _SIDE.search(text):
offenders.append(rel)
if "import(" in text:
users.append(rel)
# 서버 Node 진입만 정적(브라우저 번들 밖)
assert offenders == ["common_util/common_util_spreadsheet_node.ts"], offenders
assert users and all(u.startswith("M02_MasterTemplete/") for u in users), users
@@ -0,0 +1,138 @@
"""F — 산출근거 저장(basis 종류) · 옛 산출근거 옮김 · [저장] 때 서버 계산값 · 임시 시험 주소.
서버 Node 풀이는 `test_spreadsheet_node.py` 가 따로 봄 — 여기는 `with_server_values` 를 바꿔 끼워
「브라우저 값은 버리고 서버 값만 적는다」 · 「못 풀면 저장을 막는다」 만 잼.
"""
import json
import shutil
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util import common_util_spreadsheet as spreadsheet
from M02_MasterTemplete import M02_MasterTemplete_Router as router_module
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Template_Layers as layers
REAL = store.FOLDER
SERVER = {"s1": {"A1": {"수": "42"}}}
@pytest.fixture()
def client(tmp_path, monkeypatch):
for kind in store.KINDS:
(tmp_path / kind).mkdir()
shutil.copy(REAL / "table/구조물집계표.json", tmp_path / "table")
monkeypatch.setattr(store, "FOLDER", tmp_path)
monkeypatch.setattr(router_module, "TRIAL_PATH", tmp_path / "trial.json")
def fake(book):
clean = {k: v for k, v in book.items() if k != "계산값"}
return {**clean, "계산값": SERVER}
monkeypatch.setattr(spreadsheet, "with_server_values", fake)
app = FastAPI()
app.include_router(router_module.router)
return TestClient(app)
def _old_structure(folder, column, basis):
doc = {"양식": "구조물도면", "종류": "structure", "판": 1, "열": column, "도번": "구-01"}
doc.update({"도면": {"format": 6, "entities": []}, "산출근거": basis})
(folder / "structure" / f"{column}.json").write_text(
json.dumps(doc, ensure_ascii=False), encoding="utf-8"
)
def test_옛_산출근거를_따로_파일로_옮김(client, tmp_path):
old = {
"열": [
{"id": "work", "머리": ["공종"]},
{"id": "detail", "머리": ["산출", "내역"]},
{"id": "qty", "머리": ["수량(m당)"]},
],
"줄": [{"id": "1", "값": {"work": "터파기", "qty": 1.5}}, {"id": "2", "값": {}}],
"보기": {"열너비": {"detail": 210}},
}
_old_structure(tmp_path, "pp_len", old)
done = store.migrate_basis()
assert done["옮김"] == ["pp_len"]
columns = store._column_ids()
assert set(done["새로"]) == set(columns) - {"pp_len"}
structure = json.loads((tmp_path / "structure/pp_len.json").read_text(encoding="utf-8"))
assert "산출근거" not in structure and structure["도번"] == "구-01"
basis = store.read("basis", "pp_len")["문서"]
sheet = basis["시트"][0]
assert (basis["종류"], basis["열"], sheet["id"]) == ("통합문서", "pp_len", "s1")
assert sheet["칸"] == {
"A1": {"값": "공종", "서식": 1},
"B1": {"값": "산출 내역", "서식": 1},
"C1": {"값": "수량(m당)", "서식": 1},
"A2": {"값": "터파기"},
"C2": {"값": 1.5},
}
assert sheet["열"] == {"B": {"폭": 30.0}}
assert "계산값" not in basis # 식 없는 새 문서 — 첫 [저장] 때 서버가 적음
# 두 번 돌려도 같음
before = {p.name: p.read_bytes() for p in (tmp_path / "basis").iterdir()}
assert store.migrate_basis() == {"옮김": [], "새로": []}
assert before == {p.name: p.read_bytes() for p in (tmp_path / "basis").iterdir()}
def test_새_구조물_도면은_산출근거_따로(client):
made = client.post("/api/m02/structures/rv").json()["문서"]
assert "산출근거" not in made
basis = client.get("/api/m02/templates/basis/rv").json()["문서"]
heads = [basis["시트"][0]["칸"][f"{c}1"]["값"] for c in "ABCDE"]
assert heads == ["공종", "규격", "산출 내역", "단위", "수량(m당)"]
def test_저장은_서버_계산값만_적음(client):
store.ensure_basis("rv")
got = client.get("/api/m02/templates/basis/rv").json()
doc = {**got["문서"], "계산값": {"s1": {"A1": {"수": "브라우저"}}}}
saved = client.put("/api/m02/templates/basis/rv", json={"판": got["판"], "문서": doc})
assert saved.status_code == 200
assert client.get("/api/m02/templates/basis/rv").json()["문서"]["계산값"] == SERVER
# 뼈대 없는 문서 · 판이 다르면 막음
bad = client.put("/api/m02/templates/basis/rv", json={"판": saved.json()["판"], "문서": {}})
assert bad.status_code == 400
stale = client.put("/api/m02/templates/basis/rv", json={"판": got["판"], "문서": doc})
assert stale.status_code == 409
def test_못_풀면_저장을_막음(client, monkeypatch):
store.ensure_basis("rv")
got = client.get("/api/m02/templates/basis/rv").json()
def broken(_book):
raise spreadsheet.RecalcError("스프레드시트 계산 실패 — 시험")
monkeypatch.setattr(spreadsheet, "with_server_values", broken)
r = client.put("/api/m02/templates/basis/rv", json={"판": got["판"], "문서": got["문서"]})
assert r.status_code == 503
assert client.get("/api/m02/templates/basis/rv").json()["판"] == got["판"]
def test_층_규칙과_프로젝트_복사(client, tmp_path, monkeypatch):
store.ensure_basis("rv")
doc = store.read("basis", "rv")["문서"]
layers.check_skeleton("basis", doc)
for bad in ({}, {**doc, "시트": {}}):
with pytest.raises(ValueError):
layers.check_skeleton("basis", bad)
layers.check_skeleton("structure", {"도면": {"entities": []}}) # 산출근거 없어도 됨
monkeypatch.setattr(layers, "SYSTEM_ROOT", tmp_path)
copied = layers.seed_project(tmp_path / "project")
assert "basis/rv" in copied["작업본"] and "basis/rv" in copied["초기"]
def test_임시_시험_주소(client):
first = client.get("/api/m02/spreadsheet-trial").json()["문서"]
assert [s["이름"] for s in first["시트"]] == ["시트1", "시트2", "시트3"]
first["시트"][0]["칸"] = {"A1": {"식": "1+41"}}
saved = client.put("/api/m02/spreadsheet-trial", json={"문서": first}).json()["문서"]
assert saved["계산값"] == SERVER
assert client.get("/api/m02/spreadsheet-trial").json()["문서"] == saved
@@ -0,0 +1,32 @@
/* =============================================================================
* test_stage2_autofit.ts — spreadsheet_autofit 시험. 돌리기: node --experimental-strip-types <이 파일>
* ========================================================================== */
import {
autofitColumn,
estimateColumnWidth,
} from "../../../A00_Common/spreadsheet/spreadsheet_autofit.ts";
import { assertEqual, makeBook, makeCtx, makeSheet, v } from "./test_stage2_helpers.ts";
const sheet = makeSheet("s1", {
A1: v("짧음"),
A2: v("이것은 조금 더 긴 글자"),
B1: v(123),
});
const book = makeBook([sheet]);
const widthA = estimateColumnWidth(book, "s1", 0);
const widthB = estimateColumnWidth(book, "s1", 1);
assertEqual(widthA > widthB, true, "A 열이 B 열보다 글자가 길어 폭도 큼");
assertEqual(estimateColumnWidth(book, "s1", 5), 4, "빈 열은 최소 폭");
const ctx = makeCtx(book, "s1");
autofitColumn(ctx, 0);
assertEqual(sheet.열?.A.폭, widthA, "autofitColumn 이 열폭 명령으로 반영");
// 재는 함수를 바꿔 끼울 수 있음(C 격자가 실제 글꼴로 잴 때 대비)
const fixedWidth = estimateColumnWidth(book, "s1", 0, () => 7);
assertEqual(fixedWidth, 9, "measure 를 넘기면 그걸로 어림(7 + 여백 2)");
if (process.exitCode) process.exit(process.exitCode);
console.log("spreadsheet_autofit 시험 끝");
@@ -0,0 +1,60 @@
/* =============================================================================
* test_stage2_find.ts — spreadsheet_find 시험. 돌리기: node --experimental-strip-types <이 파일>
* ========================================================================== */
import {
findAll,
findNext,
replaceAll,
replaceOne,
} from "../../../A00_Common/spreadsheet/spreadsheet_find.ts";
import { assertEqual, makeBook, makeCtx, makeSheet, v, f } from "./test_stage2_helpers.ts";
const sheet = makeSheet("s1", {
A1: v("돌뒷길이"),
A2: v("돌뒷길이2"),
B1: v(10),
B2: f('A1&"-"'),
});
const book = makeBook([sheet]);
assertEqual(
findAll(book, "돌뒷길이").map((m) => `${m.r},${m.c}`),
["0,0", "1,0"],
"findAll 기본(값 칸 둘)",
);
assertEqual(
findAll(book, "돌뒷길이", { 전체칸: true }).map((m) => `${m.r},${m.c}`),
["0,0"],
"findAll 전체칸",
);
assertEqual(
findAll(book, "A1", { 수식안: true }).map((m) => `${m.r},${m.c}`),
["1,1"],
"findAll 수식안(식 글 속)",
);
assertEqual(
findAll(book, "돌뒷길이", { 대소문자: true }).length,
2,
"findAll 대소문자 켜도 완전 일치 글자는 그대로 찾음",
);
const next = findNext(book, { 시트: "s1", r: 0, c: 0 }, "돌뒷길이");
assertEqual(next, { 시트: "s1", r: 1, c: 0 }, "findNext 다음 칸으로");
const wrapped = findNext(book, { 시트: "s1", r: 1, c: 0 }, "돌뒷길이");
assertEqual(wrapped, { 시트: "s1", r: 0, c: 0 }, "findNext 한 바퀴 돌아 처음으로");
const ctx = makeCtx(book, "s1");
replaceOne(ctx, { 시트: "s1", r: 0, c: 0 }, "돌뒷길이", "돌앞길이");
assertEqual(book.시트[0].칸.A1.값, "돌앞길이", "replaceOne 값 칸");
const n = replaceAll(ctx, "-", "_", { 수식안: true });
assertEqual(n, 1, "replaceAll 바뀐 칸 수");
assertEqual(book.시트[0].칸.B2.식, 'A1&"_"', "replaceAll 식 칸 속 글자");
if (process.exitCode) process.exit(process.exitCode);
console.log("spreadsheet_find 시험 끝");
@@ -0,0 +1,46 @@
/* =============================================================================
* test_stage2_format_painter.ts — spreadsheet_format_painter 시험. 돌리기: node --experimental-strip-types <이 파일>
* ========================================================================== */
import {
createFormatPainter,
resolveCellStyle,
} from "../../../A00_Common/spreadsheet/spreadsheet_format_painter.ts";
import { assertEqual, makeBook, makeCtx, makeSheet, v } from "./test_stage2_helpers.ts";
const sheet = makeSheet(
"s1",
{ A1: v(1, 1), B1: v(2) },
{ 열: { B: { 서식: 2 } }, 행: { "1": { 서식: 3 } } },
);
const book = makeBook([sheet], [{}, { 굵게: true }, { 글자색: "#ff0000" }, { 밑줄: true }]);
assertEqual(resolveCellStyle(book, "s1", 0, 0), { 굵게: true }, "칸 자기 서식 우선");
// Cell.서식 규칙(0 계약 주석) = 칸 → 행 → 열 → 기본. B1 은 칸 서식 없음 · 행 1 서식(3) · 열 B 서식(2) 둘 다 있음 → 행이 이김.
assertEqual(
resolveCellStyle(book, "s1", 0, 1),
{ 밑줄: true },
"칸 서식 없으면 행 서식(열보다 앞섬)",
);
delete sheet.행!["1"];
assertEqual(resolveCellStyle(book, "s1", 0, 1), { 글자색: "#ff0000" }, "행 서식도 없으면 열 서식");
assertEqual(resolveCellStyle(book, "s1", 5, 5), {}, "아무 서식도 없으면 기본(0)");
const ctx = makeCtx(book, "s1");
const painter = createFormatPainter(ctx);
ctx.selection.활성 = { r: 0, c: 0 }; // A1 = 굵게
painter.pick();
assertEqual(painter.active(), true, "pick 뒤 active");
painter.paint({ r0: 4, c0: 4, r1: 4, c1: 4 });
assertEqual(resolveCellStyle(book, "s1", 4, 4), { 굵게: true }, "paint 로 서식이 옮음");
assertEqual(painter.active(), false, "한 번 쓰면 꺼짐(sticky 아님)");
painter.pick();
painter.paint({ r0: 6, c0: 6, r1: 6, c1: 6 }, true);
assertEqual(painter.active(), true, "sticky 는 계속 켜짐");
painter.clear();
assertEqual(painter.active(), false, "clear 로 끔");
if (process.exitCode) process.exit(process.exitCode);
console.log("spreadsheet_format_painter 시험 끝");
@@ -0,0 +1,48 @@
/* =============================================================================
* test_stage2_func_help.ts — spreadsheet_func_help 시험. 돌리기: node --experimental-strip-types <이 파일>
* ========================================================================== */
import {
funcHelp,
knownFunctionCount,
suggestFunctions,
} from "../../../A00_Common/spreadsheet/spreadsheet_func_help.ts";
import { assertEqual } from "./test_stage2_helpers.ts";
assertEqual(knownFunctionCount(), 48, "1단계 함수 48 개 다 있음");
assertEqual(
suggestFunctions("ROU").map((f) => f.이름),
["ROUND", "ROUNDUP", "ROUNDDOWN"],
"접두사로 자동 완성 후보",
);
assertEqual(suggestFunctions("rou").map((f) => f.이름).length, 3, "대소문자 안 가림");
assertEqual(suggestFunctions("").length, 0, "빈 접두사는 후보 없음");
assertEqual(suggestFunctions("ZZZ").length, 0, "안 맞으면 없음");
const help = funcHelp("vlookup");
assertEqual(help?.이름, "VLOOKUP", "이름은 대소문자 안 가림");
assertEqual(help?.꼴.includes("("), true, "꼴에 괄호가 있음");
assertEqual(funcHelp("없는함수"), null, "없는 함수는 null");
// 실무 13 가지가 모두 표에 있는지(9_엑셀검토 1장)
const 실무13 = [
"ROUND",
"SUM",
"ROUNDDOWN",
"INT",
"SQRT",
"IF",
"FIXED",
"VLOOKUP",
"ROUNDUP",
"CONCATENATE",
"TAN",
"PI",
"COUNTA",
];
for (const name of 실무13) assertEqual(funcHelp(name) !== null, true, `실무 함수 ${name} 있음`);
if (process.exitCode) process.exit(process.exitCode);
console.log("spreadsheet_func_help 시험 끝");
@@ -0,0 +1,166 @@
/* =============================================================================
* test_stage2_helpers.ts
* PLAN 11-8 2단계 시험 공용 — 최소 통합문서 견본 · 가짜 SpreadsheetContext(dispatch 가 「칸」·
* 「서식」·「묶음」·「열폭」 명령만 그대로 적용 — A(spreadsheet_commands)가 아직 없어도 2단계
* 파일 하나하나를 검증하기 위한 최소한). 돌리기 — `node --experimental-strip-types <시험 파일>`.
* ========================================================================== */
import type {
Cell,
CellStyle,
Command,
Frac,
Scalar,
Sheet,
Workbook,
} from "../../../A00_Common/spreadsheet/spreadsheet_types";
import type { SpreadsheetContext } from "../../../A00_Common/spreadsheet/spreadsheet_view_types";
export function assertEqual<T>(actual: T, expected: T, label: string): void {
const a = JSON.stringify(actual);
const b = JSON.stringify(expected);
if (a !== b) {
console.error(`FAIL ${label}\n 실제: ${a}\n 기대: ${b}`);
process.exitCode = 1;
} else {
console.log(`ok ${label}`);
}
}
export function makeSheet(id: string, 칸: Record<string, Cell>, extra: Partial<Sheet> = {}): Sheet {
return { id, 이름: id, 칸, ...extra };
}
export function makeBook(sheets: Sheet[], 서식: CellStyle[] = [{}]): Workbook {
return { 종류: "통합문서", 판: 1, 열: "test", 서식, 시트: sheets };
}
/** 값 칸 하나 짜기(수는 그대로 넣음 — Frac 아님, 이 시험 계층에선 안 씀). */
export const v = (값: Cell["값"], 서식?: number): Cell =>
서식 === undefined ? { 값 } : { 값, 서식 };
export const f = (식: string, 서식?: number): Cell => (서식 === undefined ? { 식 } : { 식, 서식 });
export interface FakeEngineValues {
[sheetId: string]: { [a1: string]: Scalar };
}
function toFrac(n: number): Frac {
return { n: BigInt(Math.round(n * 1_000_000)), d: 1_000_000n } as unknown as Frac;
}
export function makeCtx(
book: Workbook,
sheetId: string,
values: FakeEngineValues = {},
): SpreadsheetContext & { applied: Command[] } {
const applied: Command[] = [];
const apply = (cmd: Command): void => {
applied.push(cmd);
if (cmd.종류 === "묶음") {
for (const c of cmd.명령) apply(c);
return;
}
if (cmd.종류 === "칸") {
const sheet = book.시트.find((s) => s.id === cmd.시트);
if (!sheet) return;
for (const [key, cell] of Object.entries(cmd.칸)) {
if (cell === null) delete sheet.칸[key];
else sheet.칸[key] = cell;
}
} else if (cmd.종류 === "서식") {
const sheet = book.시트.find((s) => s.id === cmd.시트);
if (!sheet) return;
let idx = book.서식.findIndex((s) => JSON.stringify(s) === JSON.stringify(cmd.바꿀));
if (idx < 0) {
idx = book.서식.length;
book.서식.push(cmd.바꿀 as CellStyle);
}
for (const 범위 of cmd.범위) {
for (let r = 범위.r0; r <= 범위.r1; r++) {
for (let c = 범위.c0; c <= 범위.c1; c++) {
const key = a1(r, c);
sheet.칸[key] = { ...sheet.칸[key], 서식: idx };
}
}
}
} else if (cmd.종류 === "열폭") {
const sheet = book.시트.find((s) => s.id === cmd.시트);
if (!sheet) return;
sheet.열 = sheet.열 ?? {};
for (const c of cmd.열)
sheet.열[colLetters(c)] = { ...sheet.열[colLetters(c)], 폭: cmd.폭 ?? undefined };
}
};
return {
root: {} as HTMLElement,
book,
readOnly: false,
engine: {
update: () => [],
rebuild: () => {},
value: (sheet: string, r: number, c: number): Scalar => values[sheet]?.[a1(r, c)] ?? null,
precedents: () => [],
snapshot: () => ({}),
},
history: {
push: () => {},
undo: () => null,
redo: () => null,
canUndo: () => false,
canRedo: () => false,
clear: () => {},
},
selection: {
시트: sheetId,
범위: [{ r0: 0, c0: 0, r1: 0, c1: 0 }],
활성: { r: 0, c: 0 },
기준: { r: 0, c: 0 },
},
sheet: () => book.시트.find((s) => s.id === sheetId)!,
dispatch: (cmd: Command) => apply(cmd),
undo: () => {},
redo: () => {},
select: () => {},
showSheet: () => {},
grid: {
root: {} as HTMLElement,
render: () => {},
invalidate: () => {},
renderSelection: () => {},
setRefHighlights: () => {},
cellBox: () => ({ x: 0, y: 0, w: 0, h: 0 }),
hitTest: () => null,
reveal: () => {},
visibleRange: () => ({ r0: 0, c0: 0, r1: 0, c1: 0 }),
editorSlot: () => ({ layer: {} as HTMLElement, box: { x: 0, y: 0, w: 0, h: 0 } }),
destroy: () => {},
},
editor: {
editing: () => false,
begin: () => {},
commit: () => {},
cancel: () => {},
sync: () => {},
},
applied,
};
}
export function a1(r: number, c: number): string {
let col = c + 1;
let letters = "";
while (col > 0) {
const rem = (col - 1) % 26;
letters = String.fromCharCode(65 + rem) + letters;
col = Math.floor((col - 1) / 26);
}
return `${letters}${r + 1}`;
}
function colLetters(c: number): string {
return a1(0, c).replace(/\d+$/, "");
}
export const fracLiteral = toFrac;
@@ -0,0 +1,51 @@
/* =============================================================================
* test_stage2_paste_special.ts — spreadsheet_paste_special 시험.
* 돌리기: npx tsx <이 파일> (`@ui/...` 별칭을 씀 — node 만으로는 안 풀림. F 가 진짜 시험
* 틀을 놓으면 그걸로 옮김.)
* ========================================================================== */
import {
pasteSpecial,
shiftRelativeRefs,
} from "../../../A00_Common/spreadsheet/spreadsheet_paste_special.ts";
import { assertEqual, makeBook, makeCtx, makeSheet, v, f } from "./test_stage2_helpers.ts";
import type { ClipBlock } from "../../../A00_Common/spreadsheet/spreadsheet_types.ts";
assertEqual(shiftRelativeRefs("A1+B2", 1, 1), "B2+C3", "옮김 기본");
assertEqual(shiftRelativeRefs("$A$1+B2", 1, 1), "$A$1+C3", "절대참조는 안 옮김");
assertEqual(shiftRelativeRefs("A1", -5, 0), "#REF!", "행 밖(1 보다 앞)으로 나가면 #REF!");
const sheet = makeSheet("s1", { A1: v(10, 1), B1: v("글") });
const book = makeBook([sheet], [{}, { 굵게: true }]);
const ctx = makeCtx(book, "s1");
const block: ClipBlock = {
rows: 1,
cols: 1,
칸: { "0,0": { 값: 99, 서식: { 밑줄: true } } },
병합: [],
원점: { r: 0, c: 0 },
출처: "aislo",
};
pasteSpecial(ctx, block, { r: 4, c: 4 }, "값");
assertEqual(sheet.칸.E5?.값, 99, "값만 — 값 붙음");
assertEqual(sheet.칸.E5?.서식, undefined, "값만 — 서식은 안 옮음(대상 것 그대로)");
pasteSpecial(ctx, block, { r: 6, c: 6 }, "서식");
assertEqual(book.서식[sheet.칸.G7!.서식!], { 밑줄: true }, "서식만 — 서식이 옮음");
assertEqual(sheet.칸.G7?.값, undefined, "서식만 — 값은 안 옮음");
const formulaBlock: ClipBlock = {
rows: 1,
cols: 1,
칸: { "0,0": { 식: "A1+1" } },
병합: [],
원점: { r: 0, c: 0 },
출처: "aislo",
};
pasteSpecial(ctx, formulaBlock, { r: 2, c: 2 }, "수식");
assertEqual(sheet.칸.C3?.식, "C3+1", "수식만 — 상대참조를 옮겨 붙임(원점 0,0 → 대상 2,2 만큼)");
if (process.exitCode) process.exit(process.exitCode);
console.log("spreadsheet_paste_special 시험 끝");