/* ============================================================================= * 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("ROUND 음수 자릿수(십의 자리)", call("ROUND", n(1234), n(-2)), "1200"); fracEq("ROUNDUP 음수 자릿수", call("ROUNDUP", n(1234), n(-2)), "1300"); fracEq("ROUNDDOWN 음수 자릿수", call("ROUNDDOWN", n(1234), n(-2)), "1200"); 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!"); // 2단계 첫 묶음(2026-09-27) fracEq("MROUND", call("MROUND", n(10), n(3)), "9"); fracEq("MROUND 소수", call("MROUND", n("1.3"), n("0.2")), "1.4"); errEq("MROUND 부호 다름", call("MROUND", n(10), n(-3)), "#NUM!"); fracEq("ODD 양수", call("ODD", n("1.5")), "3"); fracEq("ODD 짝수 정수", call("ODD", n(2)), "3"); fracEq("ODD 0", call("ODD", n(0)), "1"); fracEq("ODD 음수", call("ODD", n(-2)), "-3"); fracEq("EVEN 양수", call("EVEN", n("1.5")), "2"); fracEq("EVEN 홀수 정수", call("EVEN", n(3)), "4"); fracEq("EVEN 음수", call("EVEN", n(-1)), "-2"); fracEq("LOG 밑 지정", call("LOG", n(8), n(2)), "3"); fracEq("LOG10", call("LOG10", n(100)), "2"); fracEq("LN(1) = 0", call("LN", n(1)), "0"); ok( "EXP(1) ≈ e", (() => { const v = call("EXP", n(1)); return isFrac(v) && fracToString(v).startsWith("2.71828182845905"); })(), ); errEq("LOG10 0 이하", call("LOG10", n(0)), "#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!"); textEq( "TEXTJOIN 빈칸 건너뜀", call("TEXTJOIN", txt("-"), bl(true), txt("a"), txt(""), txt("b")), "a-b", ); textEq("TRIM", call("TRIM", txt(" 가 나 ")), "가 나"); textEq("UPPER", call("UPPER", txt("abc")), "ABC"); textEq("LOWER", call("LOWER", txt("ABC")), "abc"); textEq("SUBSTITUTE 전부", call("SUBSTITUTE", txt("abcabc"), txt("a"), txt("X")), "XbcXbc"); textEq("SUBSTITUTE 몇번째", call("SUBSTITUTE", txt("abcabc"), txt("a"), txt("X"), n(2)), "abcXbc"); textEq("REPLACE", call("REPLACE", txt("abcdef"), n(2), n(3), txt("XYZ")), "aXYZef"); fracEq("FIND", call("FIND", txt("c"), txt("abcabc")), "3"); errEq("FIND 대소문자 가림", call("FIND", txt("C"), txt("abcabc")), "#VALUE!"); fracEq("SEARCH 대소문자 안가림", call("SEARCH", txt("C"), txt("abcabc")), "3"); fracEq("SEARCH 자리표시(*)", call("SEARCH", txt("a*c"), txt("xxabcyy")), "3"); textEq("REPT", call("REPT", txt("ab"), n(3)), "ababab"); // ── 찾기 ──────────────────────────────────────────────────────────────── 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", ); // 2단계 첫 묶음 — 여러 조건 · IFS 계열 · XLOOKUP const NAMES4 = range(1, 4, [["가", "나", "가", "다"]]); const AMOUNTS4 = range(1, 4, [[frac(10n), frac(20n), frac(30n), frac(40n)]]); const AMOUNTS4B = range(1, 4, [[frac(1n), frac(2n), frac(3n), frac(4n)]]); fracEq("SUMIFS 두 조건", call("SUMIFS", AMOUNTS4, NAMES4, txt("가"), AMOUNTS4B, txt(">1")), "30"); fracEq("COUNTIFS 두 조건", call("COUNTIFS", NAMES4, txt("가"), AMOUNTS4B, txt(">1")), "1"); fracEq("AVERAGEIF", call("AVERAGEIF", AMOUNTS4, txt(">15")), "30"); fracEq("AVERAGEIFS", call("AVERAGEIFS", AMOUNTS4, NAMES4, txt("가")), "20"); fracEq("MAXIFS", call("MAXIFS", AMOUNTS4, NAMES4, txt("가")), "30"); fracEq("MINIFS", call("MINIFS", AMOUNTS4, NAMES4, txt("가")), "10"); fracEq("IFS 처음 참", call("IFS", bl(false), n(1), bl(true), n(2), bl(true), n(3)), "2"); textEq( "SWITCH 맞음", call("SWITCH", n(2), n(1), txt("one"), n(2), txt("two"), n(3), txt("three")), "two", ); textEq("SWITCH 기본값", call("SWITCH", n(9), n(1), txt("one"), txt("default")), "default"); errEq("SWITCH 안 맞음(기본값 없음)", call("SWITCH", n(9), n(1), txt("one")), "#N/A"); const XNAMES = range(1, 3, [["가", "나", "다"]]); const XAMOUNTS = range(1, 3, [[frac(10n), frac(20n), frac(30n)]]); fracEq("XLOOKUP", call("XLOOKUP", txt("나"), XNAMES, XAMOUNTS), "20"); textEq("XLOOKUP 못찾음 대체값", call("XLOOKUP", txt("없음"), XNAMES, XAMOUNTS, txt("no")), "no"); errEq("XLOOKUP 못찾음 기본", call("XLOOKUP", txt("없음"), XNAMES, XAMOUNTS), "#N/A"); // 정보 · 날짜 boolEq("ISNUMBER 참", call("ISNUMBER", n(5)) as boolean, true); boolEq("ISNUMBER 거짓(글)", call("ISNUMBER", txt("x")) as boolean, false); boolEq("ISTEXT 참", call("ISTEXT", txt("x")) as boolean, true); boolEq("ISBLANK 참", call("ISBLANK", lit(null)) as boolean, true); boolEq("ISBLANK 거짓(0)", call("ISBLANK", n(0)) as boolean, false); boolEq("ISERROR 참", call("ISERROR", lit({ error: "#DIV/0!" } as ErrorValue)) as boolean, true); boolEq("ISERROR 거짓", call("ISERROR", n(1)) as boolean, false); errEq("NA", call("NA"), "#N/A"); { const serial = call("DATE", n(2026), n(1), n(15)); fracEq("YEAR(DATE(2026,1,15))", call("YEAR", lit(serial)), "2026"); fracEq("MONTH(DATE(2026,1,15))", call("MONTH", lit(serial)), "1"); fracEq("DAY(DATE(2026,1,15))", call("DAY", lit(serial)), "15"); } { const todaySerial = call("TODAY"); const wantYear = new Date().getUTCFullYear(); 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 { 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(frac(1n, 2n), "0/0") === "1/2", fmt(frac(1n, 2n), "0/0")); ok( "분수 정수부+분자(자동 분모)", fmt(frac(5n, 2n), "# ?/?") === "2 1/2", fmt(frac(5n, 2n), "# ?/?"), ); 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)", 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;