Merge remote-tracking branch 'origin/dev' into sub_laptop_5
This commit is contained in:
@@ -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 시험 끝");
|
||||
Reference in New Issue
Block a user