브레인이 짐작한 [$-412](로캘만 · 기호 없음) 이 통화 기호로 잘못 바뀌는지 여러 실제 꼴(단순 태그·회계 네 구역·접두/접미 위치)로 대조해 봤으나 지금 코드는 이미 옳게 기호를 안 보임(회귀 시험으로 고정). 그 김에 회계 0 구역의 `"-"??` 관용구가 진짜 0 값에서 정수부 `?` 를 「0」 으로 채우던 것을 고쳐 엑셀처럼 빈칸으로 보이게 함. 사용자가 보고한 ₩ 는 이 두 경로로는 재현이 안 돼 — main_desktop_1 의 fixture_excel_user_won.html 이 아직 안 와서 실제 원문으로는 대조 못 함(오면 바로 대조). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
607 lines
25 KiB
TypeScript
607 lines
25 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("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(
|
|
"회계형 0 구역 '-'?? 는 자리만(0 안 보임)",
|
|
fmt(frac(0n), '_-[$-412]* #,##0.00_-;-[$-412]* #,##0.00_-;_-[$-412]* "-"??_-') === " - ",
|
|
JSON.stringify(fmt(frac(0n), '_-[$-412]* #,##0.00_-;-[$-412]* #,##0.00_-;_-[$-412]* "-"??_-')),
|
|
);
|
|
ok(
|
|
"통화 로캘 태그만([$-412] · 기호 없음)은 통화 기호를 안 보임",
|
|
!fmt(frac(2n), "[$-412]#,##0.00").includes("₩") && fmt(frac(2n), "[$-412]#,##0.00") === "2.00",
|
|
fmt(frac(2n), "[$-412]#,##0.00"),
|
|
);
|
|
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"));
|
|
|
|
// 엑셀 「칸 형식 이름」(코드 대신 · mso-number-format:Fixed 등) — 2026-09-28 사용자 표 실측 회귀 방지
|
|
// (콘크리트 43.91 · 14,182 · 0.48 · 21.07 · 모르터 1.93 · 계 15,166 · 23.20)
|
|
ok("이름형식 Fixed", fmt(toFrac("43.91"), "Fixed") === "43.91", fmt(toFrac("43.91"), "Fixed"));
|
|
ok(
|
|
"이름형식 Fixed(소수 끝)",
|
|
fmt(toFrac("1.93"), "Fixed") === "1.93",
|
|
fmt(toFrac("1.93"), "Fixed"),
|
|
);
|
|
ok(
|
|
"이름형식 Standard(천 단위)",
|
|
fmt(toFrac("14182"), "Standard") === "14,182.00",
|
|
fmt(toFrac("14182"), "Standard"),
|
|
);
|
|
ok(
|
|
"이름형식 Standard 계",
|
|
fmt(toFrac("15166"), "Standard") === "15,166.00",
|
|
fmt(toFrac("15166"), "Standard"),
|
|
);
|
|
ok(
|
|
"이름형식 Currency(기호 없음 — 사용자 확인)",
|
|
fmt(toFrac("14182"), "Currency") === "14,182.00" &&
|
|
!fmt(toFrac("14182"), "Currency").includes("₩"),
|
|
fmt(toFrac("14182"), "Currency"),
|
|
);
|
|
ok("이름형식 Percent", fmt(toFrac("0.48"), "Percent") === "48.00%", fmt(toFrac("0.48"), "Percent"));
|
|
ok(
|
|
"이름형식 General Number",
|
|
fmt(toFrac("21.07"), "General Number") === "21.07",
|
|
fmt(toFrac("21.07"), "General Number"),
|
|
);
|
|
ok(
|
|
"이름형식 Scientific",
|
|
fmt(toFrac("43.91"), "Scientific") === "4.39E+01",
|
|
fmt(toFrac("43.91"), "Scientific"),
|
|
);
|
|
ok(
|
|
"이름형식 Short Date",
|
|
fmt(dateSerial(2026, 3, 5) as Scalar, "Short Date") === "2026-03-05",
|
|
fmt(dateSerial(2026, 3, 5) as Scalar, "Short Date"),
|
|
);
|
|
ok(
|
|
"이름형식 모르는 이름은 일반",
|
|
fmt(toFrac("23.20"), "AnUnknownStyleName") === "23.2",
|
|
fmt(toFrac("23.20"), "AnUnknownStyleName"),
|
|
);
|
|
ok(
|
|
"실무 코드는 이름표를 안 건드림(회귀)",
|
|
fmt(frac(1234567n), "#,##0") === "1,234,567",
|
|
fmt(frac(1234567n), "#,##0"),
|
|
);
|
|
|
|
// ── 채우기 ──────────────────────────────────────────────────────────────
|
|
|
|
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;
|