Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_2

This commit is contained in:
2026-09-27 20:29:44 +09:00
7 changed files with 417 additions and 31 deletions
@@ -0,0 +1,113 @@
/* 스프레드시트 엔진 A 무게 시험 — 큰 시트 불러오기 · 칸 하나 고칠 때 재계산 · 나눗셈 사슬 분모.
* 수치를 JSON 으로 냄 → `test_spreadsheet_bench.py` 가 한도 안인지 봄. 적재 · 가짜 함수는 엔진 시험 헬퍼 것.
*/
const { A, C, E, F, G } = require("./helper_spreadsheet_engine.cjs");
const ms = (fn) => {
const t = process.hrtime.bigint();
const out = fn();
return [Number(process.hrtime.bigint() - t) / 1e6, out];
};
const round = (x) => Math.round(x * 100) / 100;
/** 칸 표 → 시트 하나 통합문서 JSON 글(불러오기 = JSON.parse + 엔진 만들기) */
function bookText(cells) {
const 칸 = {};
for (const [k, v] of Object.entries(cells)) 칸[k] = typeof v === "string" ? { 식: v } : { 값: v };
return JSON.stringify({ 종류: "통합문서", 판: 1, 열: "b", 서식: [{}], 시트: [{ id: "s1", 이름: "무게", 칸 }] });
}
/** 불러오기 · 칸 하나 고치기(가운데 · 맨 앞) 시간 */
function scenario(cells, edits) {
const text = bookText(cells);
const [parseMs, book] = ms(() => JSON.parse(text));
const [loadMs, engine] = ms(() => G.createCalcEngine(book));
const out = { cells: Object.keys(cells).length, kb: Math.round(text.length / 1024), parseMs: round(parseMs), loadMs: round(loadMs) };
for (const [label, a1, value] of edits) {
const times = [];
let redone = 0;
for (let i = 0; i < 5; i++) {
const fx = C.applyCommand(book, { 종류: "칸", 시트: "s1", 칸: { [a1]: { 값: value + i } } });
const [t, list] = ms(() => engine.update(fx.cells));
times.push(t);
redone = list.length;
}
times.sort((x, y) => x - y);
out[label] = { ms: round(times[2]), redone };
}
return [out, engine];
}
const N = 5000;
const result = {};
// ① 얕은 시트 — 칸 1만(값 5천 · 식 5천) · 식마다 이웃 두 칸 · 끝에 합계 하나
{
const cells = {};
for (let i = 1; i <= N; i++) cells[`A${i}`] = (i % 97) / 10;
for (let i = 1; i < N; i++) cells[`B${i}`] = `ROUNDDOWN((A${i}+A${i + 1}/2)*1.3,2)`;
cells[`B${N}`] = `SUM(B1:B${N - 1})`;
const [out, engine] = scenario(cells, [
["editMiddle", "A2500", 3],
["editFirst", "A1", 3],
]);
out.total = E.toCalcValue(engine.value("s1", N - 1, 1));
result.shallow = out;
}
// ② 깊은 사슬 — 칸 1만 · 식 5천이 한 줄로 이어짐(맨 앞 고치면 5천 칸 모두 다시)
{
const cells = {};
for (let i = 1; i <= N; i++) cells[`A${i}`] = 1;
cells.B1 = "A1";
for (let i = 2; i <= N; i++) cells[`B${i}`] = `B${i - 1}+A${i}*1.1`;
const [out, engine] = scenario(cells, [
["editLast", `A${N}`, 2],
["editFirst", "A1", 2],
]);
out.end = E.toCalcValue(engine.value("s1", N - 1, 1));
result.chain = out;
}
// ③ 큰 시트 불러오기 — 실무 최대 모양(271 행 × 253 열) 에 가까운 6만 8천 칸 · 절반이 식
{
const cells = {};
for (let r = 1; r <= 271; r++)
for (let c = 0; c < 253; c++) {
const a1 = A.toA1(r - 1, c);
cells[a1] = c % 2 === 0 ? r + c / 100 : `${A.toA1(r - 1, c - 1)}*2+${A.toA1(Math.max(0, r - 2), c - 1)}/3`;
}
const [out] = scenario(cells, [["editOne", "A1", 5]]);
result.bigSheet = out;
}
// ④ 나눗셈 사슬 — 분모가 커지는 식(1/소수 더하기 · 소수로 나누기) · 상한(10^40) 넘으면 30 자리 십진으로 굳힘
{
const primes = [];
for (let n = 2; primes.length < 2000; n++) if (primes.every((p) => n % p)) primes.push(n);
const cells = { A1: 1, C1: 1 };
for (let i = 2; i <= 2000; i++) {
cells[`A${i}`] = `A${i - 1}+1/${primes[i - 1]}`; // 역수 합 — 분모가 소수 곱으로 부풂
cells[`C${i}`] = `C${i - 1}/${primes[(i * 7) % 2000]}*${primes[(i * 13) % 2000]}`; // 곱 · 나눗셈 사슬
}
const book = JSON.parse(bookText(cells));
const [loadMs, engine] = ms(() => G.createCalcEngine(book));
let maxDigits = 0;
for (let i = 1; i <= 2000; i++)
for (const c of [0, 2]) {
const v = engine.value("s1", i - 1, c);
maxDigits = Math.max(maxDigits, v.d.toString().length);
}
let dbl = 1;
for (let i = 2; i <= 2000; i++) dbl += 1 / primes[i - 1];
const exact = Number(F.fracToString(engine.value("s1", 1999, 0)));
result.divChain = {
cells: 4000,
loadMs: round(loadMs),
maxDenominatorDigits: maxDigits,
sum: F.fracToString(engine.value("s1", 1999, 0)),
relErrVsDouble: Math.abs(exact - dbl) / dbl,
};
}
process.stdout.write(JSON.stringify(result));
@@ -98,6 +98,10 @@ const C = load("spreadsheet_commands");
const H = load("spreadsheet_history");
const RC = load("spreadsheet_recalc");
// 무게 시험(`bench_spreadsheet_engine.cjs`)이 같은 적재 · 가짜 함수를 씀 — 불려 올 때는 시험을 안 돌림
module.exports = { A, C, E, F, G };
if (require.main !== module) return;
const results = {};
let group = "";
const fails = () => (results[group] ??= []);
@@ -364,10 +368,14 @@ section("graph", () => {
eq("다른 시트 값", v("F1"), "20");
eq("앞 칸", e.precedents("s1", at("E1").r, at("E1").c), [{ 시트: "s1", 범위: { r0: 0, c0: 0, r1: 2, c1: 0 } }]);
eq("snapshot 식 칸만", Object.keys(e.snapshot().s1).sort(), ["B1", "C1", "D1", "E1", "F1"]);
// 엑셀 암묵 교차 — 한 열 범위는 식 든 행 · 한 행 범위는 식 든 열
const ib = book({ A1: 1, A2: 5, A3: 7, C5: 3, D5: 4, B2: "=A1:A3*2", B3: "=A1:A3", B7: "=A1:A3", D1: "=C5:D5+1", E1: "=C5:D5", F9: "=A:A+1", B4: "=-A1:A3" });
const ie = G.createCalcEngine(ib);
eq("암묵 교차", ["B2", "B3", "B7", "D1", "E1", "F9", "B4"].map((a) => val(ie, "s1", a)), ["10", "7", "#VALUE!", "5", "#VALUE!", "1", "#VALUE!"]);
// 긴 사슬(끌어 풀기 재귀 깊이)
const chain = { A1: 1 };
for (let i = 2; i <= 1500; i++) chain[`A${i}`] = `=A${i - 1}+1`;
eq("사슬 1500", val(G.createCalcEngine(book(chain)), "s1", "A1500"), "1500");
for (let i = 2; i <= 20000; i++) chain[`A${i}`] = `=A${i - 1}+1`;
eq("사슬 2만", val(G.createCalcEngine(book(chain)), "s1", "A20000"), "20000");
});
// ── 7. 참조 옮김 엑셀 규칙표 ────────────────────────────────────────────────
@@ -508,7 +516,7 @@ section("commands", () => {
roundTrip("행넣기", { 종류: "행넣기", 시트: "s1", at: 3, 수: 1 }, (b) => {
const c = s1(b).칸;
eq("행넣기 칸 · 식 · 병합", [c.H4, c.H5.식, c.H6.식, c.H7.식, c.J5.식, s1(b).병합], [
undefined,
{ 서식: 2 },
"ROUNDDOWN((J5+J6)/2*J7,2)",
"ROUNDDOWN(H5*0.3,2)",
"SUM(H5:H6)",
@@ -564,6 +572,75 @@ section("commands", () => {
{ 종류: "병합", 시트: "s1", 범위: A.parseRange("K2:L2") },
],
});
// 넣은 줄은 위(왼) 줄 서식 · 높이 · 폭을 따라감
roundTrip("행넣기 위 줄 따라감", { 종류: "행넣기", 시트: "s1", at: 1, 수: 2 }, (b) => {
const t = s1(b);
eq("행 높이 · 칸 서식", [t.행["2"], t.행["3"], t.칸.A2, t.칸.A3, t.칸.A1.값], [
{ 높이: 20 },
{ 높이: 20 },
{ 서식: 1 },
{ 서식: 1 },
"□수로형 토사개거 산출근거",
]);
});
roundTrip("열넣기 왼 줄 따라감", { 종류: "열넣기", 시트: "s1", at: 3, 수: 1 }, (b) => {
const t = s1(b);
eq("열 폭 · 칸 서식", [t.열.D, t.칸.D3, t.칸.D4, t.칸.E3.서식], [{ 폭: 6 }, { 서식: 2 }, { 서식: 4 }, 2]);
});
roundTrip("숨긴 줄은 숨김 안 따라감", { 종류: "열넣기", 시트: "s1", at: 10, 수: 1 }, (b) =>
eq("숨김", [s1(b).열.K, s1(b).열.J], [undefined, { 숨김: true }]),
);
// 시트 통째 서식 · 폭 · 높이
roundTrip("시트 통째 서식", { 종류: "서식", 시트: "s2", 범위: [A.parseRange("1:1048576")], 바꿀: { 글꼴: "굴림", 크기: 9 } }, (b, fx) => {
const t = b.시트[1];
const st = b.서식[t.기본.서식];
eq("시트 기본 서식", [st.글꼴, st.크기, b.서식[t.칸.B1.서식].크기, b.서식[t.칸.B1.서식].형식, t.칸.A1.서식, fx.rebuild], [
"굴림",
9,
9,
"0.00",
undefined,
true,
]);
});
roundTrip("시트 통째 뒤 서식지움", {
종류: "묶음",
명령: [
{ 종류: "서식", 시트: "s2", 범위: [A.parseRange("1:1048576")], 바꿀: { 크기: 9 } },
{ 종류: "서식지움", 시트: "s2", 범위: [A.parseRange("A1:A1")] },
],
}, (b) => eq("시트 기본을 덮는 0", b.시트[1].칸.A1.서식, 0));
const allCols = Array.from({ length: 16384 }, (_, i) => i);
roundTrip("시트 통째 열 폭", { 종류: "열폭", 시트: "s1", 열: allCols, 폭: 4 }, (b) => {
const t = s1(b);
eq("기본 열폭 · 줄 폭 지움", [t.기본, t.열.A, t.열.J], [{ 열폭: 4 }, undefined, { 숨김: true }]);
});
roundTrip("시트 통째 행 높이", { 종류: "행높이", 시트: "s1", 행: Array.from({ length: 1048576 }, (_, i) => i), 높이: 15 }, (b) =>
eq("기본 행높이", [s1(b).기본, s1(b).행], [{ 행높이: 15 }, undefined]),
);
// 메모 — 칸과 같이 옮김 · 지운 칸 메모는 지움
const withNotes = (cmd, check) => {
const b = fresh();
b.시트[0].comments = { H4: "터파기 풀이", J5: "밑폭", A1: "제목" };
const before = sheetsJson(b);
const fx = C.applyCommand(b, cmd);
check(b);
C.applyCommand(b, fx.undo);
eq(`메모 되돌림 ${cmd.종류}`, sheetsJson(b) === before, true);
};
withNotes({ 종류: "행넣기", 시트: "s1", at: 3, 수: 2 }, (b) =>
eq("메모 행넣기", s1(b).comments, { H6: "터파기 풀이", J7: "밑폭", A1: "제목" }),
);
withNotes({ 종류: "행지우기", 시트: "s1", at: 3, 수: 1 }, (b) =>
eq("메모 행지우기", s1(b).comments, { J4: "밑폭", A1: "제목" }),
);
withNotes({ 종류: "열지우기", 시트: "s1", at: 7, 수: 3 }, (b) => eq("메모 열지우기", s1(b).comments, { A1: "제목" }));
withNotes({ 종류: "옮기기", 시트: "s1", 범위: A.parseRange("H4:J5"), 대상시트: "s2", r: 0, c: 0 }, (b) => {
eq("메모 옮기기", [s1(b).comments, b.시트[1].comments], [{ A1: "제목" }, { A1: "터파기 풀이", C2: "밑폭" }]);
});
withNotes({ 종류: "옮기기", 시트: "s1", 범위: A.parseRange("J5"), 대상시트: "s1", r: 3, c: 7 }, (b) =>
eq("메모 덮어씀", canon(s1(b).comments), canon({ H4: "밑폭", A1: "제목" })),
);
// 막힘
const blocked = [
["병합 가운데 칸 쓰기", { 종류: "칸", 시트: "s1", 칸: { B1: { 값: 1 } } }],
@@ -616,8 +693,9 @@ section("commands", () => {
const cs = fresh();
cs.서식.push({ 굵게: true }, { 크기: 12, 굵게: true });
cs.시트[0].칸.K1 = { 값: 1, 서식: 8 };
cs.시트[1].기본 = { 서식: 8 };
C.compactStyles(cs);
eq("compactStyles", [cs.서식.length, cs.시트[0].칸.K1.서식, cs.시트[0].칸.A1.서식], [7, 1, 1]);
eq("compactStyles", [cs.서식.length, cs.시트[0].칸.K1.서식, cs.시트[0].칸.A1.서식, cs.시트[1].기본.서식], [7, 1, 1, 1]);
});
// ── 9. 되돌리기 스택 · 엔진과 잇기 · 한 벌 풀이 ──────────────────────────────
@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
"""산출근거 스프레드시트 엔진 A 무게 시험 — 칸 1만 · 식 5천 시트에서 칸 하나 고칠 때 재계산 ·
6만 8천 칸 불러오기 · 분모가 커지는 나눗셈 사슬(상한 10^40 넘으면 30 자리 십진으로 굳힘).
Node 헬퍼(`bench_spreadsheet_engine.cjs`)가 수치를 냄. 한도는 이 PC 실측의 몇 배로 넉넉히 둠 —
실측이 한도 가까이 오르면 느려진 것.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(scope="module")
def bench():
proc = subprocess.run(
["node", os.path.join(HERE, "bench_spreadsheet_engine.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=300,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_얕은_시트_칸_하나는_딸린_칸만(bench):
s = bench["shallow"]
assert s["editMiddle"]["redone"] == 4 # 고친 칸 + 이웃 식 둘 + 합계
assert s["editMiddle"]["ms"] < 100
assert s["loadMs"] < 3000
def test_깊은_사슬_5천_칸(bench):
c = bench["chain"]
assert c["editFirst"]["redone"] == 5001
assert c["editFirst"]["ms"] < 1000
assert c["editLast"]["redone"] == 2
def test_큰_시트_불러오기(bench):
b = bench["bigSheet"]
assert b["cells"] > 60000
assert b["parseMs"] + b["loadMs"] < 15000
def test_나눗셈_사슬_분모_상한(bench):
d = bench["divChain"]
assert d["maxDenominatorDigits"] <= 41
assert d["relErrVsDouble"] < 1e-12