Merge remote-tracking branches 'origin/sub_laptop_3', 'origin/sub_laptop_4', 'origin/sub_laptop_5' and 'origin/sub_laptop_7' into sub_laptop_1

This commit is contained in:
2026-09-27 21:18:44 +09:00
36 changed files with 2819 additions and 101 deletions
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<title>수식 분석 시험 틀</title>
<link rel="stylesheet" href="../../../ui_template/ui_template_theme.css" />
<style>
body { margin: 0; font-family: sans-serif; }
#bar { margin: 8px; display: flex; gap: 8px; }
#host { height: 520px; margin: 8px; border: 1px solid #999; }
</style>
</head>
<body>
<div id="bar">
<button id="btn-prec">선행 참조 추적</button>
<button id="btn-dep">종속 참조 추적</button>
<button id="btn-clear">화살표 지우기</button>
<button id="btn-steps">수식 계산</button>
</div>
<div id="host"></div>
<script>
window.ssErrors = [];
window.addEventListener("error", (e) => window.ssErrors.push(String(e.error?.stack || e.message)));
</script>
<script src="../../../tmp/spreadsheet_analysis/harness.js"></script>
</body>
</html>
@@ -0,0 +1,72 @@
/* 수식 분석 화면 시험 틀 — 진짜 부품(createSpreadsheet)에 추적 화살 · 수식 계산 창 · 오류 ⚠ 를 붙임.
* sub7 잇기 전이라 `inspect` 로 문맥(ctx)을 꺼내고, grid.render 뒤 refresh 를 부름.
* 번들: node harness_analysis_bundle.cjs → tmp/spreadsheet_analysis/harness.js (git 밖) */
import { createSpreadsheet } from "../../../A00_Common/spreadsheet/spreadsheet";
import { attachTrace } from "../../../A00_Common/spreadsheet/spreadsheet_trace";
import { openEvalSteps } from "../../../A00_Common/spreadsheet/spreadsheet_eval_steps";
import { attachErrorTip } from "../../../A00_Common/spreadsheet/spreadsheet_errcheck";
import type { SpreadsheetContext } from "../../../A00_Common/spreadsheet/spreadsheet_view_types";
import type { Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types";
const w = window as unknown as Record<string, unknown>;
const book: Workbook = {
종류: "통합문서",
판: 1,
이름: "harness",
서식: [{}],
시트: [
{
id: "s1",
이름: "분석",
칸: {
A1: { 값: "단가" },
B1: { 값: "수량" },
C1: { 값: "금액" },
A2: { 값: 1200 },
B2: { 값: 3 },
C2: { 식: "A2*B2" },
A3: { 값: 800 },
B3: { 값: "5" },
C3: { 식: "A3*B3" },
A4: { 값: 50 },
B4: { 값: 0 },
C4: { 식: "A4/B4" },
C6: { 식: "SUM(C2:C3)+IF(B2>2,C2*10%,0)" },
D6: { 식: "C6*2" },
E2: { 식: "E3+1" },
E3: { 식: "E2+1" },
},
},
{ id: "s2", 이름: "요약", 칸: { A1: { 식: "분석!D6" } } },
],
활성: "s1",
};
const host = document.getElementById("host")!;
w.ss = createSpreadsheet(host, book, { inspect: true });
const c = w.__aisloSheet as SpreadsheetContext;
const trace = attachTrace(c);
const tip = attachErrorTip(c, trace);
const render = c.grid.render.bind(c.grid);
c.grid.render = () => {
render();
trace.refresh();
tip.refresh();
};
const renderSel = c.grid.renderSelection.bind(c.grid);
c.grid.renderSelection = () => {
renderSel();
tip.refresh();
};
w.ssCtx = c;
w.ssTrace = trace;
w.ssTip = tip;
w.ssSteps = () => openEvalSteps(c);
for (const [id, run] of [
["btn-prec", () => trace.precedents()],
["btn-dep", () => trace.dependents()],
["btn-clear", () => trace.clear()],
["btn-steps", () => openEvalSteps(c)],
] as const)
document.getElementById(id)!.addEventListener("click", run);
@@ -0,0 +1,29 @@
/* 수식 분석 시험 틀 번들 — rolldown(이미 설치) 한 파일 · css 는 <style> 로 넣음 · @ui 별칭.
* node harness_analysis_bundle.cjs → tmp/spreadsheet_analysis/harness.js (git 밖) */
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "..", "..");
const { build } = require(path.join(ROOT, "config", "node_modules", "rolldown"));
build({
input: path.join(__dirname, "harness_analysis.ts"),
resolve: { alias: { "@ui": path.join(ROOT, "ui_template") } },
plugins: [
{
name: "css-inline",
load(id) {
if (!id.endsWith(".css")) return null;
const css = JSON.stringify(fs.readFileSync(id, "utf8"));
const code = `const s = document.createElement("style"); s.textContent = ${css}; document.head.append(s);`;
return { code, moduleType: "js" };
},
},
],
output: { file: path.join(ROOT, "tmp", "spreadsheet_analysis", "harness.js"), format: "iife" },
}).then(
() => console.log("ok"),
(e) => {
console.error(e.message);
process.exit(1);
},
);
@@ -1,7 +1,7 @@
/* 정렬 · 자동 필터 화면 시험 틀 입구 — 진짜 부품(createSpreadsheet)에 정렬 대화 상자 · 필터 단추를 얹음.
* sub7 잇기 전이라 `parts.mountTabs` 를 감싸 문맥(ctx)을 꺼내고, grid.render 뒤 filter.refresh 를 부름.
* sub7 잇기 전이라 `inspect` 로 문맥(ctx)을 꺼내고, grid.render 뒤 filter.refresh 를 부름.
* 번들: node harness_sortfilter_bundle.cjs → tmp/spreadsheet_sortfilter/harness.js (git 밖) */
import { createSpreadsheet, parts } from "../../../A00_Common/spreadsheet/spreadsheet";
import { createSpreadsheet } from "../../../A00_Common/spreadsheet/spreadsheet";
import {
openSortDialog,
sortSelection,
@@ -11,12 +11,6 @@ import type { SpreadsheetContext } from "../../../A00_Common/spreadsheet/spreads
import type { Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types";
const w = window as unknown as Record<string, unknown>;
let ctx: SpreadsheetContext | null = null;
const origTabs = parts.mountTabs;
parts.mountTabs = (c: SpreadsheetContext) => {
ctx = c;
return origTabs(c);
};
const rows: [string, string, number][] = [
["터파기", "토사", 12.5],
@@ -54,9 +48,10 @@ const book: Workbook = {
const host = document.getElementById("host")!;
w.ss = createSpreadsheet(host, book, {
inspect: true,
onChange: () => (w.ssChanges = Number(w.ssChanges ?? 0) + 1),
});
const c = ctx as unknown as SpreadsheetContext;
const c = w.__aisloSheet as SpreadsheetContext;
const filter = attachFilter(c);
const render = c.grid.render.bind(c.grid);
c.grid.render = () => {
@@ -0,0 +1,167 @@
/* 수식 분석 엔진 시험 헬퍼 — 추적 화살표 단계 · 수식 계산 걸음 · 오류 검사 까닭을 묶음마다 돌려 틀린 것 목록을 JSON 으로.
* 적재 · 가짜 함수는 `helper_spreadsheet_engine.cjs` 것을 씀.
*/
const { A, G, load } = require("./helper_spreadsheet_engine.cjs");
const TR = load("spreadsheet_trace");
const ST = load("spreadsheet_eval_steps");
const EC = load("spreadsheet_errcheck");
const results = {};
let group = "";
const eq = (label, got, want) => {
const g = JSON.stringify(got);
const w = JSON.stringify(want);
if (g !== w) results[group].push(`${label}: got ${g} want ${w}`);
};
const section = (name, fn) => {
group = name;
results[name] = [];
try {
fn();
} catch (e) {
results[name].push(`예외: ${e && e.stack ? e.stack : e}`);
}
};
const cellsOf = (cells) => {
const 칸 = {};
for (const [k, v] of Object.entries(cells))
칸[k] = typeof v === "string" && v.startsWith("=") ? { 식: v.slice(1) } : { 값: v };
return 칸;
};
function book(cells, sheet2) {
const 시트 = [{ id: "s1", 이름: "본", 칸: cellsOf(cells) }];
if (sheet2) 시트.push({ id: "s2", 이름: "둘", 칸: cellsOf(sheet2) });
return { 종류: "통합문서", 판: 1, 이름: "t", 서식: [{}], 시트 };
}
const at = (a1, 시트 = "s1") => ({ 시트, ...A.parseA1(a1) });
const name = (a) => `${a.시트}!${A.toA1(a.r, a.c)}`;
const arrowText = (a) =>
`${a.from.시트}!${A.rangeToA1(a.from.범위)}>${name(a.to)}${a.오류 ? "!" : ""}`;
// ── 1. 선행 · 종속 추적 ──────────────────────────────────────────────────────
section("trace", () => {
const b = book(
{ A1: 1, A2: 2, B1: "=A1+A2", C1: "=B1*2", D1: "=SUM(A1:A2)", A3: "=1/0", B3: "=A3+1" },
{ E1: "=본!C1" },
);
const e = G.createCalcEngine(b);
const st = { arrows: [], opened: new Set() };
// 선행: C1 → 한 단계씩
eq("선행 1", TR.traceStep(b, e, st, "선행", at("C1")), true);
eq("선행 1 화살", st.arrows.map(arrowText), ["s1!B1>s1!C1"]);
eq("선행 2", TR.traceStep(b, e, st, "선행", at("C1")), true);
eq("선행 2 화살", st.arrows.map(arrowText).slice(1), ["s1!A1>s1!B1", "s1!A2>s1!B1"]);
eq("선행 3 더 없음", TR.traceStep(b, e, st, "선행", at("C1")), false);
// 종속: A1 → B1 · D1(범위로) → C1 → 둘!E1(다른 시트)
const sd = { arrows: [], opened: new Set() };
TR.traceStep(b, e, sd, "종속", at("A1"));
eq("종속 1", sd.arrows.map((a) => name(a.to)).sort(), ["s1!B1", "s1!D1"]);
TR.traceStep(b, e, sd, "종속", at("A1"));
eq("종속 2", sd.arrows.map((a) => name(a.to)).slice(2), ["s1!C1"]);
TR.traceStep(b, e, sd, "종속", at("A1"));
eq("종속 3 다른 시트", sd.arrows.map(arrowText).slice(3), ["s1!C1>s2!E1"]);
eq("종속 4 더 없음", TR.traceStep(b, e, sd, "종속", at("A1")), false);
// 범위 선행 · 오류 화살
const sr = { arrows: [], opened: new Set() };
TR.traceStep(b, e, sr, "선행", at("D1"));
eq("범위 화살", sr.arrows.map(arrowText), ["s1!A1:A2>s1!D1"]);
const se = { arrows: [], opened: new Set() };
TR.traceStep(b, e, se, "선행", at("B3"));
eq("오류 칸 화살 빨강", se.arrows.map(arrowText), ["s1!A3>s1!B3!"]);
eq("값 칸은 선행 없음", TR.traceStep(b, e, { arrows: [], opened: new Set() }, "선행", at("A1")), false);
eq(
"dependentsOf",
TR.dependentsOf(b, e, "s1", 0, 1).map(name),
["s1!C1"],
);
});
// ── 2. 수식 계산 걸음 ────────────────────────────────────────────────────────
const stepsOf = (cells, a1) => {
const b = book(cells);
const e = G.createCalcEngine(b);
const p = A.parseA1(a1);
return ST.evalSteps(b, e, "s1", p.r, p.c).map((s) =>
s.밑줄 ? `${s.글.slice(0, s.밑줄[0])}[${s.글.slice(s.밑줄[0], s.밑줄[1])}]${s.글.slice(s.밑줄[1])}` : s.글,
);
};
section("eval_steps", () => {
eq("차례 · 빈 칸 0 · 범위는 함수가", stepsOf({ A1: 3, A3: 0, B1: "=A1*2+SUM(A1:A3)-A2" }, "B1"), [
"[A1]*2+SUM(A1:A3)-A2",
"[3*2]+SUM(A1:A3)-A2",
"6+[SUM(A1:A3)]-A2",
"[6+3]-A2",
"9-[A2]",
"[9-0]",
"9",
]);
eq("괄호 안 밑줄", stepsOf({ A1: 3, B1: "=(A1+1)*2" }, "B1"), [
"([A1]+1)*2",
"([3+1])*2",
"[4*2]",
"8",
]);
eq("IF 는 첫 인자만 먼저 · 안 고른 쪽 오류 무시", stepsOf({ A1: 3, B1: '=IF(A1>2,"큼",1/0)' }, "B1"), [
'IF([A1]>2,"큼",1/0)',
'IF([3>2],"큼",1/0)',
'[IF(TRUE,"큼",1/0)]',
'"큼"',
]);
eq("글 따옴표", stepsOf({ A1: 'a"b', B1: '=A1&"x"' }, "B1"), [
'[A1]&"x"',
'["a""b"&"x"]',
'"a""bx"',
]);
eq("음수 글자는 그대로", stepsOf({ B1: "=-5+1" }, "B1"), ["[-5+1]", "-4"]);
eq("오류 전파", stepsOf({ A1: 0, B1: "=1/A1+1" }, "B1"), [
"1/[A1]+1",
"[1/0]+1",
"[#DIV/0!+1]",
"#DIV/0!",
]);
eq("값 칸은 null", ST.evalSteps(book({ A1: 1 }), G.createCalcEngine(book({ A1: 1 })), "s1", 0, 0), null);
eq("상수 식", stepsOf({ B1: "=5" }, "B1"), ["5"]);
});
// ── 3. 오류 검사 까닭 ────────────────────────────────────────────────────────
section("errcheck", () => {
const b = book({
A1: "1,234",
A2: "abc",
A3: "12a",
A4: 5,
A5: "=1/0",
A6: "=A7",
A7: "=A6",
A8: " 1e3 ",
A9: "=A5+1",
A10: "=A4*2",
});
const e = G.createCalcEngine(b);
const w = (a1) => {
const p = A.parseA1(a1);
const x = EC.cellWarning(b, e, "s1", p.r, p.c);
return x && x.종류;
};
eq(
"종류",
["A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9", "A10", "B1"].map(w),
["글숫자", null, null, null, "오류", "순환", "순환", "글숫자", "오류", null, null],
);
const why = EC.cellWarning(b, e, "s1", 4, 0).까닭;
eq("#DIV/0! 까닭", why.startsWith("#DIV/0! 0 으로 나눔"), true);
eq("순환 까닭", EC.cellWarning(b, e, "s1", 5, 0).까닭.includes("A6 → A7"), true);
eq("numberLike", ["1,234", " 1e3 ", "1,23", ".5", "-", "+7"].map(EC.numberLike), [
1234,
1000,
null,
0.5,
null,
7,
]);
});
process.stdout.write(JSON.stringify(results));
@@ -21,6 +21,7 @@ function loadTs(file) {
cache.set(full, mod);
const req = (r) => {
if (r === "./spreadsheet_functions") return fakeFunctions;
if (r.endsWith(".css")) return {};
if (r.startsWith("@ui/")) return loadTs(path.join(ROOT, "ui_template", `${r.slice(4)}.ts`));
if (r.startsWith(".")) return loadTs(path.join(path.dirname(full), `${r}.ts`));
return require(r);
@@ -294,6 +294,117 @@ errEq("NA", call("NA"), "#N/A");
fracEq("YEAR(TODAY()) = 지금 연도", call("YEAR", lit(todaySerial)), String(wantYear));
}
// ── 통계(3차) ────────────────────────────────────────────────────────────
fracEq("MEDIAN 홀수", call("MEDIAN", n(1), n(2), n(3), n(4), n(5)), "3");
fracEq("MEDIAN 짝수", call("MEDIAN", n(1), n(2), n(3), n(4)), "2.5");
fracEq("MODE 최빈값", call("MODE", n(1), n(2), n(2), n(3), n(3), n(3)), "3");
errEq("MODE 겹침 없음", call("MODE", n(1), n(2), n(3)), "#N/A");
fracEq("VAR 표본분산", call("VAR", n(1), n(2), n(3)), "1");
fracEq("STDEV 표본표준편차", call("STDEV", n(1), n(2), n(3)), "1");
fracEq("STDEV.S", call("STDEV.S", n(1), n(2), n(3)), "1");
fracEq("STDEV.P 모표준편차", call("STDEV.P", n(2), n(4), n(4), n(4), n(5), n(5), n(7), n(9)), "2");
{
const RANKS = range(1, 4, [[frac(10n), frac(20n), frac(30n), frac(40n)]]);
fracEq("RANK 내림차순(기본)", call("RANK", n(30), RANKS), "2");
fracEq("RANK 오름차순", call("RANK", n(30), RANKS, n(1)), "3");
fracEq("RANK.EQ", call("RANK.EQ", n(30), RANKS), "2");
errEq("RANK 없는 값", call("RANK", n(99), RANKS), "#N/A");
fracEq("LARGE 1위", call("LARGE", RANKS, n(1)), "40");
fracEq("LARGE 2위", call("LARGE", RANKS, n(2)), "30");
fracEq("SMALL 1위", call("SMALL", RANKS, n(1)), "10");
errEq("LARGE 범위밖", call("LARGE", RANKS, n(9)), "#NUM!");
}
{
const NUMS4 = range(1, 4, [[frac(1n), frac(2n), frac(3n), frac(4n)]]);
fracEq("PERCENTILE 중간", call("PERCENTILE", NUMS4, n("0.5")), "2.5");
fracEq("PERCENTILE 0", call("PERCENTILE", NUMS4, n(0)), "1");
fracEq("PERCENTILE 1", call("PERCENTILE", NUMS4, n(1)), "4");
fracEq("QUARTILE 중앙값(2)", call("QUARTILE", NUMS4, n(2)), "2.5");
fracEq("QUARTILE 최솟값(0)", call("QUARTILE", NUMS4, n(0)), "1");
errEq("QUARTILE 범위밖", call("QUARTILE", NUMS4, n(9)), "#NUM!");
}
fracEq("COUNTBLANK", call("COUNTBLANK", range(1, 4, [[frac(1n), null, "x", null]])), "2");
// ── 날짜 · 시간(3차) ──────────────────────────────────────────────────────
function dateSerial(y: number, m: number, day: number): EvalResult {
return call("DATE", n(y), n(m), n(day));
}
{
const serial = dateSerial(2026, 3, 15);
const wantDow = new Date(Date.UTC(2026, 2, 15)).getUTCDay(); // 0=일..6=토
fracEq("WEEKDAY 기본(1=일)", call("WEEKDAY", lit(serial)), String(wantDow + 1));
fracEq(
"WEEKDAY 유형2(1=월)",
call("WEEKDAY", lit(serial), n(2)),
String(((wantDow + 6) % 7) + 1),
);
}
{
// 월말 넘침 — 1/31 + 1개월은 다음 달로 안 넘고 그 달 말일(2/28)로.
const edated = call("EDATE", lit(dateSerial(2026, 1, 31)), n(1));
fracEq("EDATE 월말 눌러담음(월)", call("MONTH", lit(edated)), "2");
fracEq("EDATE 월말 눌러담음(일)", call("DAY", lit(edated)), "28");
const normal = call("EDATE", lit(dateSerial(2026, 1, 15)), n(1));
fracEq("EDATE 평범한 경우(일)", call("DAY", lit(normal)), "15");
}
{
const eom = call("EOMONTH", lit(dateSerial(2026, 2, 10)), n(0));
fracEq("EOMONTH 이번달 말일", call("DAY", lit(eom)), "28");
const eomNext = call("EOMONTH", lit(dateSerial(2026, 2, 10)), n(1));
fracEq("EOMONTH 다음달 말일", call("DAY", lit(eomNext)), "31");
}
fracEq(
"DATEDIF Y",
call("DATEDIF", lit(dateSerial(2020, 1, 1)), lit(dateSerial(2023, 1, 1)), txt("Y")),
"3",
);
fracEq(
"DATEDIF Y(생일 전)",
call("DATEDIF", lit(dateSerial(2020, 6, 15)), lit(dateSerial(2023, 1, 1)), txt("Y")),
"2",
);
fracEq(
"DATEDIF M",
call("DATEDIF", lit(dateSerial(2020, 1, 1)), lit(dateSerial(2023, 1, 1)), txt("M")),
"36",
);
fracEq(
"DATEDIF D",
call("DATEDIF", lit(dateSerial(2024, 1, 1)), lit(dateSerial(2024, 1, 31)), txt("D")),
"30",
);
errEq(
"DATEDIF 끝이 이름(#NUM!)",
call("DATEDIF", lit(dateSerial(2024, 1, 10)), lit(dateSerial(2024, 1, 1)), txt("D")),
"#NUM!",
);
{
const wantMon = new Date(Date.UTC(2024, 0, 1)).getUTCDay() === 1;
ok("2024-01-01 은 월요일(전제)", wantMon);
const nw = call("NETWORKDAYS", lit(dateSerial(2024, 1, 1)), lit(dateSerial(2024, 1, 7)));
fracEq("NETWORKDAYS 한 주", nw, "5");
const nwHoliday = call(
"NETWORKDAYS",
lit(dateSerial(2024, 1, 1)),
lit(dateSerial(2024, 1, 7)),
lit(dateSerial(2024, 1, 3)),
);
fracEq("NETWORKDAYS 휴일 뺌", nwHoliday, "4");
}
fracEq("HOUR", call("HOUR", lit(call("TIME", n(13), n(45), n(30)))), "13");
fracEq("MINUTE", call("MINUTE", lit(call("TIME", n(13), n(45), n(30)))), "45");
{
const t = call("TIME", n(6), n(0), n(0));
ok(
"TIME(6,0,0) = 0.25",
isFrac(t) && fracToString(t) === "0.25",
isFrac(t) ? fracToString(t) : String(t),
);
}
// ── 숫자 형식 ────────────────────────────────────────────────────────────
function fmt(value: Scalar, code: string): string {
@@ -346,6 +457,21 @@ ok(
);
ok("분수 분모 고정", fmt(frac(4n, 10n), "# ?/8") === " 3/8", fmt(frac(4n, 10n), "# ?/8"));
ok("분수 정확히 나눠떨어짐(정수만)", fmt(frac(3n), "# ?/?") === "3", fmt(frac(3n), "# ?/?"));
// 날짜 형식(3차)
{
const serial = dateSerial(2026, 3, 5) as Scalar;
ok("yyyy-mm-dd", fmt(serial, "yyyy-mm-dd") === "2026-03-05", fmt(serial, "yyyy-mm-dd"));
ok("m/d(자리 없는 달·일)", fmt(serial, "m/d") === "3/5", fmt(serial, "m/d"));
const wd = new Date(Date.UTC(2026, 2, 5)).getUTCDay();
const WEEKDAY_SHORT = ["일", "월", "화", "수", "목", "금", "토"];
ok(
"요일 aaa",
fmt(serial, "aaa") === WEEKDAY_SHORT[wd],
`got=${fmt(serial, "aaa")} want=${WEEKDAY_SHORT[wd]}`,
);
ok("요일 aaaa", fmt(serial, "aaaa") === `${WEEKDAY_SHORT[wd]}요일`, fmt(serial, "aaaa"));
}
ok("글자 형식 @", fmt("abc", "@@") === "abcabc", fmt("abc", "@@"));
ok(
"일반(General)",
@@ -156,6 +156,7 @@ ctx.root.append(grid.root);
grid,
sheet,
book,
ctx,
colIndex,
countCells: () => grid.root.querySelectorAll(".aislo-cell").length,
};
@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
"""산출근거 스프레드시트 수식 분석 — 엑셀 규칙표.
선행 · 종속 추적(누를 때마다 한 단계 · 범위 화살 · 오류 칸 빨강 · 다른 시트 · 더 없으면 false) ·
수식 계산 걸음(왼쪽 먼저 · 빈 칸 0 · 범위는 함수가 · IF 첫 인자만 · 괄호 안 밑줄 · 오류 전파) ·
오류 검사(순환 · 식 결과 오류 · 숫자처럼 보이는 글).
화면(화살 겹판 · 계산 창 · ⚠ 풍선)은 harness_analysis.html 을 ORCA 로.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
GROUPS = ["trace", "eval_steps", "errcheck"]
@pytest.fixture(scope="module")
def results():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_spreadsheet_analysis.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,60 @@
/* =============================================================================
* test_stage3_autosum.ts — spreadsheet_autosum 시험. 돌리기: npx tsx <이 파일>.
* ========================================================================== */
import { applyAutoSum, autoSumRange } from "../../../A00_Common/spreadsheet/spreadsheet_autosum.ts";
import { assertEqual, fracLiteral, makeBook, makeCtx, makeSheet } from "./test_stage2_helpers.ts";
// 위로 연속 숫자 셋 — A1,A2,A3 숫자 · A4(활성) 빈칸
const sheet = makeSheet("s1", {});
const book = makeBook([sheet]);
const ctxUp = makeCtx(book, "s1", {
s1: { A1: fracLiteral(1), A2: fracLiteral(2), A3: fracLiteral(3) },
});
ctxUp.selection.활성 = { r: 3, c: 0 };
ctxUp.selection.범위 = [{ r0: 3, c0: 0, r1: 3, c1: 0 }];
assertEqual(autoSumRange(ctxUp), { r0: 0, c0: 0, r1: 2, c1: 0 }, "위로 연속 숫자를 잡음");
applyAutoSum(ctxUp);
assertEqual(
ctxUp.applied.at(-1),
{ 종류: "칸", 시트: "s1", 칸: { A4: { 식: "SUM(A1:A3)" } } },
"applyAutoSum 이 칸 명령을 냄",
);
// 위가 빈칸 · 왼쪽으로 연속 숫자 — B1,C1 숫자 · D1(활성)
const ctxLeft = makeCtx(book, "s1", {
s1: { B1: fracLiteral(5), C1: fracLiteral(7) },
});
ctxLeft.selection.활성 = { r: 0, c: 3 };
ctxLeft.selection.범위 = [{ r0: 0, c0: 3, r1: 0, c1: 3 }];
assertEqual(
autoSumRange(ctxLeft),
{ r0: 0, c0: 1, r1: 0, c1: 2 },
"위가 없으면 왼쪽 연속 숫자를 잡음",
);
// 위 · 왼쪽 다 없음 — null
const ctxNone = makeCtx(book, "s1");
ctxNone.selection.활성 = { r: 5, c: 5 };
ctxNone.selection.범위 = [{ r0: 5, c0: 5, r1: 5, c1: 5 }];
assertEqual(autoSumRange(ctxNone), null, "위 · 왼쪽 다 없으면 null");
applyAutoSum(ctxNone);
assertEqual(
ctxNone.applied.at(-1),
{ 종류: "칸", 시트: "s1", 칸: { F6: { 식: "SUM()" } } },
"찾은 범위가 없으면 빈 SUM()",
);
// 여러 칸을 고른 채 — 활성이 맨 아래일 때 그 고름 안 위쪽만 잡음(가운데 빈칸이 있어도)
const ctxSel = makeCtx(book, "s1", { s1: { A10: fracLiteral(1), A11: fracLiteral(2) } });
ctxSel.selection.활성 = { r: 11, c: 0 };
ctxSel.selection.범위 = [{ r0: 9, c0: 0, r1: 11, c1: 0 }];
assertEqual(
autoSumRange(ctxSel),
{ r0: 9, c0: 0, r1: 10, c1: 0 },
"고른 범위 안에서 활성 위만 잡음",
);
if (process.exitCode) process.exit(process.exitCode);
console.log("spreadsheet_autosum 시험 끝");
@@ -0,0 +1,36 @@
/* =============================================================================
* test_stage3_sheet_extra.ts — spreadsheet_sheet_extra 시험. 돌리기: npx tsx <이 파일>.
* ========================================================================== */
import {
hideSheet,
setSheetTabColor,
unhideSheet,
} from "../../../A00_Common/spreadsheet/spreadsheet_sheet_extra.ts";
import { assertEqual, makeBook, makeCtx, makeSheet } from "./test_stage2_helpers.ts";
const s1 = makeSheet("s1", {});
const s2 = makeSheet("s2", {});
const book = makeBook([s1, s2]);
book.활성 = "s1";
const shown: string[] = [];
const ctx = { ...makeCtx(book, "s1"), showSheet: (id: string) => shown.push(id) };
assertEqual(hideSheet(ctx, "s1"), null, "hideSheet 성공 — 까닭 없음");
assertEqual(book.시트[0].숨김, true, "s1 이 숨겨짐");
assertEqual(ctx.applied.at(-1)?.종류, "시트통째", "hideSheet 은 시트통째로 나감");
assertEqual(shown, ["s2"], "활성 시트를 숨기면 다른 보이는 시트로 옮김");
assertEqual(hideSheet(ctx, "s2"), "마지막 보이는 시트는 못 숨김", "마지막 보이는 시트는 거절");
assertEqual(book.시트[1].숨김, undefined, "거절되면 안 바뀜");
unhideSheet(ctx, "s1");
assertEqual(book.시트[0].숨김, false, "unhideSheet 로 다시 보임");
setSheetTabColor(ctx, "s2", "#ff0000");
assertEqual(book.시트[1].탭색, "#ff0000", "탭 색 지정");
setSheetTabColor(ctx, "s2", null);
assertEqual(book.시트[1].탭색, undefined, "탭 색 지움");
if (process.exitCode) process.exit(process.exitCode);
console.log("spreadsheet_sheet_extra 시험 끝");
@@ -0,0 +1,47 @@
/* =============================================================================
* test_stage3_statusbar.ts — spreadsheet_statusbar 시험. 돌리기: npx tsx <이 파일>.
* ========================================================================== */
import { computeSelectionStats } from "../../../A00_Common/spreadsheet/spreadsheet_statusbar.ts";
import {
assertEqual,
fracLiteral,
makeBook,
makeCtx,
makeSheet,
v,
} from "./test_stage2_helpers.ts";
const sheet = makeSheet("s1", { A1: v("사과"), A2: v("배") });
const book = makeBook([sheet]);
const ctx = makeCtx(book, "s1", {
s1: { A1: fracLiteral(10), A2: fracLiteral(20), A3: "글자" },
});
ctx.selection.범위 = [{ r0: 0, c0: 0, r1: 2, c1: 0 }];
const stats = computeSelectionStats(ctx);
assertEqual(stats.합계, 10 + 20, "합계 — 숫자만 더함");
assertEqual(stats.평균, 15, "평균");
assertEqual(stats.개수, 3, "개수 — 빈칸 아닌 칸 전부(COUNTA)");
assertEqual(stats.숫자개수, 2, "숫자개수 — 숫자만(COUNT)");
assertEqual(stats.최소, 10, "최소");
assertEqual(stats.최대, 20, "최대");
// 빈 범위 — 다 null · 0
const empty = makeCtx(book, "s1");
empty.selection.범위 = [{ r0: 5, c0: 5, r1: 5, c1: 5 }];
const emptyStats = computeSelectionStats(empty);
assertEqual(emptyStats.합계, null, "빈 범위 합계는 null");
assertEqual(emptyStats.개수, 0, "빈 범위 개수는 0");
// 겹치는 범위는 한 번만 셈
const overlap = makeCtx(book, "s1", { s1: { A1: fracLiteral(5) } });
overlap.selection.범위 = [
{ r0: 0, c0: 0, r1: 0, c1: 0 },
{ r0: 0, c0: 0, r1: 0, c1: 0 },
];
assertEqual(computeSelectionStats(overlap).숫자개수, 1, "겹치는 범위는 한 번만 셈");
if (process.exitCode) process.exit(process.exitCode);
console.log("spreadsheet_statusbar 시험 끝");