Files
Aislo/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts
T
eomsangdonandClaude Sonnet 5 b93e03e505 feat(spreadsheet): fill.ts를 A 엔진 실 구현으로 잇기 · numfmt 실무 서식 대조 고침
fill.ts가 A의 실제 moveFormula·toA1을 쓰도록 정리(식 채우기 시험 통과).
실무 구조도 xlsx 세 벌 실측 형식 코드 66개 대조 — `_x` trailing 공백을 trim이 삼키던 버그,
글 값에 순수 수 형식이 적용되던 버그, `General"단위"` 접미 버릇, `[$통화-로캘]` 태그 미지원을 고침.
분수 형식(`0/0`)은 1단계 형식 부분집합 밖이라 남겨둠. 회귀 시험 추가.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
2026-09-27 20:39:03 +09:00

323 lines
13 KiB
TypeScript

/* =============================================================================
* spreadsheet_b_test_entry.ts (B 자기 시험)
* B 가 채운 함수 · 숫자 형식 · 채우기를 Node 로 바로 돌리는 시험 진입.
* `npm run` 스크립트 없이 vite `--ssr` 로 번들해 node 로 실행(F 의 build:spreadsheet 앞).
* 경계값은 실제 엑셀 결과와 대조(수학 함수 다수는 손으로 계산해 확인한 값).
* ========================================================================== */
import { frac, fracToString, toFrac, type Frac } from "@ui/sheet/ui_template_sheet_frac";
import { getFunction, functionNames } from "../../../A00_Common/spreadsheet/spreadsheet_functions";
import { formatValue } from "../../../A00_Common/spreadsheet/spreadsheet_numfmt";
import { fillCells } from "../../../A00_Common/spreadsheet/spreadsheet_fill";
import type {
Cell,
ErrorValue,
EvalContext,
EvalResult,
LazyArg,
RangeValue,
Scalar,
Workbook,
} from "../../../A00_Common/spreadsheet/spreadsheet_types";
let pass = 0;
let fail = 0;
const failures: string[] = [];
function ok(name: string, cond: boolean, detail?: string): void {
if (cond) {
pass++;
} else {
fail++;
failures.push(detail ? `${name} — ${detail}` : name);
}
}
// ── 도우미 ───────────────────────────────────────────────────────────────
const DUMMY_CTX: EvalContext = {
sheet: "s1",
r: 0,
c: 0,
cell: () => null,
range: () => ({ kind: "range", rows: 0, cols: 0, at: () => null }),
sheetId: () => null,
};
function n(v: number | string): LazyArg {
const f = toFrac(v);
return () => f;
}
function lit(v: EvalResult): LazyArg {
return () => v;
}
function txt(v: string): LazyArg {
return () => v;
}
function bl(v: boolean): LazyArg {
return () => v;
}
function range(rows: number, cols: number, data: Scalar[][]): LazyArg {
const rv: RangeValue = { kind: "range", rows, cols, at: (r, c) => data[r][c] };
return () => rv;
}
function call(name: string, ...args: LazyArg[]): EvalResult {
const fn = getFunction(name);
if (!fn) throw new Error(`함수 없음: ${name}`);
return fn.call(args, DUMMY_CTX);
}
function isFrac(v: unknown): v is Frac {
return typeof v === "object" && v !== null && "n" in (v as Record<string, unknown>);
}
function isErr(v: unknown): v is ErrorValue {
return typeof v === "object" && v !== null && "error" in (v as Record<string, unknown>);
}
function fracEq(name: string, got: EvalResult, expect: string): void {
ok(
name,
isFrac(got) && fracToString(got) === expect,
`got=${isFrac(got) ? fracToString(got) : JSON.stringify(got)} want=${expect}`,
);
}
function textEq(name: string, got: EvalResult, expect: string): void {
ok(name, got === expect, `got=${JSON.stringify(got)} want=${expect}`);
}
function errEq(name: string, got: EvalResult, expect: string): void {
ok(name, isErr(got) && got.error === expect, `got=${JSON.stringify(got)} want=${expect}`);
}
function boolEq(name: string, got: EvalResult, expect: boolean): void {
ok(name, got === expect, `got=${JSON.stringify(got)} want=${expect}`);
}
// ── 함수 표 자체 ─────────────────────────────────────────────────────────
ok("함수 이름 45+ 개", functionNames().length >= 45, `count=${functionNames().length}`);
ok("모르는 함수 = undefined", getFunction("없는함수123") === undefined);
// ── 수학 · 끝수 (경계값은 엑셀 실측) ─────────────────────────────────────
fracEq("SUM", call("SUM", n(1), n(2), n(3)), "6");
fracEq("SUM 범위", call("SUM", range(2, 1, [[frac(1n)], [frac(2n)]])), "3");
fracEq("SUM 범위+글자 건너뜀", call("SUM", range(1, 2, [["x", frac(5n)]])), "5");
fracEq("PRODUCT", call("PRODUCT", n(2), n(3), n(4)), "24");
fracEq(
"SUMPRODUCT",
call("SUMPRODUCT", range(1, 2, [[frac(2n), frac(3n)]]), range(1, 2, [[frac(4n), frac(5n)]])),
"23",
); // 2*4+3*5
fracEq("ROUND", call("ROUND", n("1.155"), n(2)), "1.16"); // 분수 반올림 — 부동소수 함정 없음
fracEq("ROUNDUP", call("ROUNDUP", n("1.001"), n(2)), "1.01");
fracEq("ROUNDDOWN", call("ROUNDDOWN", n("1.999"), n(2)), "1.99");
fracEq("INT 양수", call("INT", n("8.9")), "8");
fracEq("INT 음수(floor)", call("INT", n("-8.9")), "-9");
fracEq("TRUNC", call("TRUNC", n("8.9")), "8");
fracEq("CEILING", call("CEILING", n("2.5"), n(1)), "3");
fracEq("FLOOR", call("FLOOR", n("2.5"), n(1)), "2");
fracEq("ABS", call("ABS", n("-3.5")), "3.5");
fracEq("MOD 양수", call("MOD", n(7), n(3)), "1");
fracEq("MOD 음수 나눔값 부호", call("MOD", n(-7), n(3)), "2"); // 엑셀 MOD(-7,3)=2
fracEq("POWER 정수", call("POWER", n(2), n(10)), "1024");
fracEq("POWER 음수지수", call("POWER", n(2), n(-1)), "0.5");
ok(
"SQRT(2) 유효 15 자리",
(() => {
const v = call("SQRT", n(2));
return isFrac(v) && fracToString(v).startsWith("1.4142135623731");
})(),
);
ok(
"PI() 근사",
(() => {
const v = call("PI");
return isFrac(v) && fracToString(v).startsWith("3.14159265358979");
})(),
);
fracEq("MIN", call("MIN", n(5), n(2), n(9)), "2");
fracEq("MAX", call("MAX", n(5), n(2), n(9)), "9");
fracEq("AVERAGE", call("AVERAGE", n(2), n(4), n(6)), "4");
errEq("AVERAGE 빈 값 없음", call("AVERAGE", range(1, 1, [["x"]])), "#DIV/0!");
errEq("MOD 0 나눔", call("MOD", n(1), n(0)), "#DIV/0!");
errEq("CEILING 부호 다름", call("CEILING", n(-2), n(1)), "#NUM!");
// ── 논리 · 글 ────────────────────────────────────────────────────────────
fracEq("IF 참 값", call("IF", bl(true), n(1), n(2)), "1");
fracEq("IF 거짓 값", call("IF", bl(false), n(1), n(2)), "2");
boolEq("AND", call("AND", bl(true), bl(true), n(1)) as boolean, true);
boolEq("OR", call("OR", bl(false), n(0), n(1)) as boolean, true);
boolEq("NOT", call("NOT", bl(true)) as boolean, false);
fracEq("IFERROR 정상값", call("IFERROR", n(5), n(0)), "5");
fracEq("IFERROR 오류 대체", call("IFERROR", lit({ error: "#DIV/0!" }), n(9)), "9");
textEq("CONCATENATE", call("CONCATENATE", txt("가"), txt("나"), n(1)), "가나1");
textEq("FIXED 기본", call("FIXED", n("1234.5")), "1,234.50");
textEq("FIXED 콤마 없음", call("FIXED", n("1234.5"), n(1), bl(true)), "1234.5");
textEq("TEXT", call("TEXT", n("1234.5"), txt("#,##0.00")), "1,234.50");
fracEq("LEN", call("LEN", txt("가나다")), "3");
textEq("LEFT", call("LEFT", txt("가나다"), n(2)), "가나");
textEq("RIGHT", call("RIGHT", txt("가나다"), n(2)), "나다");
textEq("MID", call("MID", txt("가나다라"), n(2), n(2)), "나다");
fracEq("VALUE", call("VALUE", txt("1,234.5")), "1234.5");
errEq("VALUE 실패", call("VALUE", txt("가나다")), "#VALUE!");
// ── 찾기 ────────────────────────────────────────────────────────────────
const TABLE = range(3, 2, [
[frac(1n), "일"],
[frac(2n), "이"],
[frac(3n), "삼"],
]);
textEq("VLOOKUP 정확", call("VLOOKUP", n(2), TABLE, n(2), bl(false)), "이");
errEq("VLOOKUP 없음", call("VLOOKUP", n(9), TABLE, n(2), bl(false)), "#N/A");
textEq("VLOOKUP 근사", call("VLOOKUP", n("2.5"), TABLE, n(2)), "이");
const HTABLE = range(2, 3, [
[frac(1n), frac(2n), frac(3n)],
["일", "이", "삼"],
]);
textEq("HLOOKUP", call("HLOOKUP", n(2), HTABLE, n(2), bl(false)), "이");
fracEq("INDEX 칸", call("INDEX", TABLE, n(2), n(1)), "2");
fracEq(
"MATCH 오름차순",
call("MATCH", n("2.5"), range(3, 1, [[frac(1n)], [frac(2n)], [frac(3n)]]), n(1)),
"2",
);
textEq("CHOOSE", call("CHOOSE", n(2), txt("가"), txt("나"), txt("다")), "나");
// ── 세기 ────────────────────────────────────────────────────────────────
fracEq("COUNT", call("COUNT", range(1, 3, [[frac(1n), "x", frac(2n)]])), "2");
fracEq("COUNTA", call("COUNTA", range(1, 3, [[frac(1n), "x", null]])), "2");
fracEq(
"COUNTIF 조건",
call("COUNTIF", range(1, 4, [[frac(1n), frac(2n), frac(3n), frac(4n)]]), txt(">2")),
"2",
);
fracEq(
"SUMIF",
call(
"SUMIF",
range(1, 3, [["가", "나", "가"]]),
txt("가"),
range(1, 3, [[frac(10n), frac(20n), frac(30n)]]),
),
"40",
);
// ── 숫자 형식 ────────────────────────────────────────────────────────────
function fmt(value: Scalar, code: string): string {
return formatValue(value, code).글;
}
ok("0.00", fmt(frac(BigInt(150), 100n), "0.00") === "1.50", fmt(frac(150n, 100n), "0.00"));
ok("#,##0", fmt(frac(1234567n), "#,##0") === "1,234,567", fmt(frac(1234567n), "#,##0"));
ok("천 나눔 끝쉼표", fmt(frac(1234000n), "#,##0,") === "1,234", fmt(frac(1234000n), "#,##0,"));
ok("퍼센트", fmt(frac(50n, 100n), "0%") === "50%", fmt(frac(50n, 100n), "0%"));
// 실무 구조도 xlsx 세 벌 실측 코드(2026-09-27 대조) — 회귀 방지.
ok(
"_x 폭띄움 끝쉼표",
fmt(frac(3n), "0.00_ ") === "3.00 ",
JSON.stringify(fmt(frac(3n), "0.00_ ")),
);
ok(
"_x 글에는 안 먹음(순수 수 형식)",
fmt("외부벽체", "0.00_ ") === "외부벽체",
fmt("외부벽체", "0.00_ "),
);
ok(
"General 단위접미(실무 버릇)",
fmt(frac(4n), 'General"열"') === "4열",
fmt(frac(4n), 'General"열"'),
);
ok(
"회계형(끝 구역 글)",
fmt(frac(1044n, 1000n), '_-* #,##0.00_-;\\-* #,##0.00_-;_-* "-"_-;_-@_-') === " 1.04 ",
fmt(frac(1044n, 1000n), '_-* #,##0.00_-;\\-* #,##0.00_-;_-* "-"_-;_-@_-'),
);
ok(
"통화 태그([$기호-로캘])",
fmt(frac(2n), '_-[$€-2]* #,##0.00_-;-[$€-2]* #,##0.00_-;_-[$€-2]* "-"??_-') === " €2.00 ",
fmt(frac(2n), '_-[$€-2]* #,##0.00_-;-[$€-2]* #,##0.00_-;_-[$€-2]* "-"??_-'),
);
ok("따옴표 접두·접미", fmt(frac(800n), '"φ"###') === "φ800", fmt(frac(800n), '"φ"###'));
ok(
"음수 회계형",
fmt(frac(-150n, 100n), "0.00_);[Red](0.00)") === "(1.50)",
fmt(frac(-150n, 100n), "0.00_);[Red](0.00)"),
);
ok("글자 형식 @", fmt("abc", "@@") === "abcabc", fmt("abc", "@@"));
ok(
"일반(General)",
fmt(frac(15n, 10n), undefined as unknown as string) === "1.5",
fmt(frac(15n, 10n), undefined as unknown as string),
);
ok(
"오류 값은 코드 글 그대로",
fmt({ error: "#DIV/0!" }, "0.00") === "#DIV/0!",
fmt({ error: "#DIV/0!" }, "0.00"),
);
ok("참거짓은 형식 무시", fmt(true, "0.00") === "TRUE", fmt(true, "0.00"));
// ── 채우기 ──────────────────────────────────────────────────────────────
function bookWith(cells: Record<string, Cell>): Workbook {
return {
종류: "통합문서",
판: 1,
열: "test",
서식: [{}],
시트: [{ id: "s1", 이름: "s1", 칸: cells }],
};
}
{
const book = bookWith({ A1: { 값: 5 } });
const out = fillCells(book, "s1", { r0: 0, c0: 0, r1: 0, c1: 0 }, { r0: 0, c0: 0, r1: 3, c1: 0 });
ok(
"채우기 수 하나 = 복사",
out.A2?.값 === 5 && out.A3?.값 === 5 && out.A4?.값 === 5,
JSON.stringify(out),
);
}
{
const book = bookWith({ A1: { 값: 1 }, A2: { 값: 2 } });
const out = fillCells(book, "s1", { r0: 0, c0: 0, r1: 1, c1: 0 }, { r0: 0, c0: 0, r1: 4, c1: 0 });
ok(
"채우기 등차 연속",
out.A3?.값 === 3 && out.A4?.값 === 4 && out.A5?.값 === 5,
JSON.stringify(out),
);
}
{
const book = bookWith({ A1: { 값: "구-01" } });
const out = fillCells(book, "s1", { r0: 0, c0: 0, r1: 0, c1: 0 }, { r0: 0, c0: 0, r1: 2, c1: 0 });
ok("채우기 글 끝 번호", out.A2?.값 === "구-02" && out.A3?.값 === "구-03", JSON.stringify(out));
}
{
const book = bookWith({ A1: { 값: "고정글" } });
const out = fillCells(book, "s1", { r0: 0, c0: 0, r1: 0, c1: 0 }, { r0: 0, c0: 0, r1: 2, c1: 0 });
ok(
"채우기 끝번호 없는 글 = 복사",
out.A2?.값 === "고정글" && out.A3?.값 === "고정글",
JSON.stringify(out),
);
}
{
const book = bookWith({ B1: { 식: "A1*2" } });
const out = fillCells(book, "s1", { r0: 0, c0: 1, r1: 0, c1: 1 }, { r0: 0, c0: 1, r1: 2, c1: 1 });
ok(
"채우기 식 상대 이동(A moveFormula)",
out.B2?.식 === "A2*2" && out.B3?.식 === "A3*2",
JSON.stringify(out),
);
}
// ── 요약 ────────────────────────────────────────────────────────────────
console.log(JSON.stringify({ pass, fail, failures }, null, 2));
if (fail > 0) process.exitCode = 1;