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:
@@ -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 시험 끝");
|
||||
Reference in New Issue
Block a user