/* ============================================================================= * 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); } function isErr(v: unknown): v is ErrorValue { return typeof v === "object" && v !== null && "error" in (v as Record); } 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): 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;