diff --git a/A00_Common/spreadsheet/spreadsheet_functions.ts b/A00_Common/spreadsheet/spreadsheet_functions.ts index a92ef894..4211ccf6 100644 --- a/A00_Common/spreadsheet/spreadsheet_functions.ts +++ b/A00_Common/spreadsheet/spreadsheet_functions.ts @@ -1,8 +1,9 @@ /* ============================================================================= * spreadsheet_functions.ts (주인 B) - * 함수 표 — 이름(대문자) → 뜻. 몸은 `spreadsheet_functions_math.ts` · `_misc.ts` · `_lookup.ts` 에 - * 두고 여기서 모음. 1단계 약 45 + 2단계 첫 묶음(2026-09-27) — 아래 `FUNC_HELP_BATCH2`(sub1 의 - * `spreadsheet_func_help.ts` 가 읽어 이름 목록에 더할 것 — 그 파일은 sub1 몫이라 안 고침). + * 함수 표 — 이름(대문자) → 뜻. 몸은 `spreadsheet_functions_math.ts` · `_misc.ts` · `_lookup.ts` · + * `_stat.ts` 에 두고 여기서 모음. 1단계 약 45 + 2단계 묶음(2026-09-27) — 아래 `FUNC_HELP_BATCH2` · + * `FUNC_HELP_BATCH3`(sub1 의 `spreadsheet_func_help.ts` 가 읽어 이름 목록에 둘 다 더할 것 — + * 그 파일은 sub1 몫이라 안 고침). * 없는 이름 = `#NAME?` · 인자 수가 min · max 밖이면 A 의 eval 이 `#VALUE!` + 까닭(엑셀은 입력을 막음). * ========================================================================== */ @@ -10,11 +11,13 @@ import type { SheetFunction } from "./spreadsheet_types"; import { MATH_FUNCTIONS } from "./spreadsheet_functions_math"; import { MISC_FUNCTIONS } from "./spreadsheet_functions_misc"; import { LOOKUP_FUNCTIONS } from "./spreadsheet_functions_lookup"; +import { STAT_FUNCTIONS } from "./spreadsheet_functions_stat"; const TABLE: Record = { ...MATH_FUNCTIONS, ...MISC_FUNCTIONS, ...LOOKUP_FUNCTIONS, + ...STAT_FUNCTIONS, }; export function getFunction(name: string): SheetFunction | undefined { @@ -119,3 +122,33 @@ export const FUNC_HELP_BATCH2: FuncHelpEntry[] = [ { 이름: "MONTH", 꼴: "MONTH(일련번호)", 뜻: "월" }, { 이름: "DAY", 꼴: "DAY(일련번호)", 뜻: "일" }, ]; + +/** 2단계 셋째 묶음(2026-09-27) — 위 `FUNC_HELP_BATCH2` 와 같은 모양 · 같이 sub1 표에 더할 것. */ +export const FUNC_HELP_BATCH3: FuncHelpEntry[] = [ + { 이름: "MEDIAN", 꼴: "MEDIAN(수1, [수2], …)", 뜻: "가운데 값" }, + { 이름: "MODE", 꼴: "MODE(수1, [수2], …)", 뜻: "가장 자주 나오는 값" }, + { 이름: "STDEV", 꼴: "STDEV(수1, [수2], …)", 뜻: "표본 표준편차" }, + { 이름: "STDEV.S", 꼴: "STDEV.S(수1, [수2], …)", 뜻: "표본 표준편차" }, + { 이름: "STDEV.P", 꼴: "STDEV.P(수1, [수2], …)", 뜻: "모집단 표준편차" }, + { 이름: "VAR", 꼴: "VAR(수1, [수2], …)", 뜻: "표본 분산" }, + { 이름: "RANK", 꼴: "RANK(수, 범위, [순서=0])", 뜻: "범위 안 순위(0=내림차순)" }, + { 이름: "RANK.EQ", 꼴: "RANK.EQ(수, 범위, [순서=0])", 뜻: "범위 안 순위(0=내림차순)" }, + { 이름: "LARGE", 꼴: "LARGE(범위, k)", 뜻: "k 번째로 큰 값" }, + { 이름: "SMALL", 꼴: "SMALL(범위, k)", 뜻: "k 번째로 작은 값" }, + { 이름: "PERCENTILE", 꼴: "PERCENTILE(범위, k)", 뜻: "k(0~1) 자리 백분위 값" }, + { 이름: "QUARTILE", 꼴: "QUARTILE(범위, 사분위수)", 뜻: "사분위수(0~4)" }, + { 이름: "COUNTBLANK", 꼴: "COUNTBLANK(범위)", 뜻: "빈 칸 개수" }, + { 이름: "NOW", 꼴: "NOW()", 뜻: "지금 날짜 · 시각(일련번호)" }, + { 이름: "WEEKDAY", 꼴: "WEEKDAY(일련번호, [유형=1])", 뜻: "요일 번호" }, + { 이름: "EDATE", 꼴: "EDATE(시작일, 개월수)", 뜻: "개월 뒤(앞) 같은 날" }, + { 이름: "EOMONTH", 꼴: "EOMONTH(시작일, 개월수)", 뜻: "개월 뒤(앞) 달의 말일" }, + { 이름: "DATEDIF", 꼴: "DATEDIF(시작일, 종료일, 단위)", 뜻: "두 날짜 차(단위 Y·M·D·YM·MD·YD)" }, + { + 이름: "NETWORKDAYS", + 꼴: "NETWORKDAYS(시작일, 종료일, [휴일범위])", + 뜻: "평일 일수(토·일 · 휴일 뺌)", + }, + { 이름: "HOUR", 꼴: "HOUR(일련번호)", 뜻: "시" }, + { 이름: "MINUTE", 꼴: "MINUTE(일련번호)", 뜻: "분" }, + { 이름: "TIME", 꼴: "TIME(시, 분, 초)", 뜻: "시각 일련번호를 만듦" }, +]; diff --git a/A00_Common/spreadsheet/spreadsheet_functions_misc.ts b/A00_Common/spreadsheet/spreadsheet_functions_misc.ts index dd33073f..5f3bcfc3 100644 --- a/A00_Common/spreadsheet/spreadsheet_functions_misc.ts +++ b/A00_Common/spreadsheet/spreadsheet_functions_misc.ts @@ -102,6 +102,58 @@ function dateToSerial(d: Date): number { return d.getTime() / MS_PER_DAY + UNIX_EPOCH_AS_EXCEL_SERIAL; } +/** DATEDIF 낱자리(Y·M·D·YM·MD·YD) — 끝이 처음보다 이르면 `#NUM!` */ +function dateDiff(startSerial: number, endSerial: number, unit: string): number | ErrorValue { + const start = serialToDate(startSerial); + const end = serialToDate(endSerial); + if (end.getTime() < start.getTime()) return mkErr("#NUM!"); + const u = unit.toUpperCase(); + const sy = start.getUTCFullYear(); + const sm = start.getUTCMonth(); + const sd = start.getUTCDate(); + const ey = end.getUTCFullYear(); + const em = end.getUTCMonth(); + const ed = end.getUTCDate(); + if (u === "Y") { + let y = ey - sy; + if (em < sm || (em === sm && ed < sd)) y--; + return y; + } + if (u === "M") { + let m = (ey - sy) * 12 + (em - sm); + if (ed < sd) m--; + return m; + } + if (u === "D") return Math.round((end.getTime() - start.getTime()) / MS_PER_DAY); + if (u === "YM") { + let m = em - sm; + if (ed < sd) m--; + return ((m % 12) + 12) % 12; + } + if (u === "MD") { + let d = ed - sd; + if (d < 0) d += new Date(Date.UTC(ey, em, 0)).getUTCDate(); + return d; + } + if (u === "YD") { + let days = Math.round((end.getTime() - Date.UTC(ey, sm, sd)) / MS_PER_DAY); + if (days < 0) days = Math.round((end.getTime() - Date.UTC(ey - 1, sm, sd)) / MS_PER_DAY); + return days; + } + return mkErr("#NUM!"); +} + +/** 일련번호의 소수부(하루 안 시각) → 시 · 분 · 초 */ +function timeParts(serial: number): { h: number; mnt: number; s: number } { + const dayFrac = serial - Math.floor(serial); + const totalSeconds = Math.round(dayFrac * 86400); + return { + h: Math.floor(totalSeconds / 3600) % 24, + mnt: Math.floor(totalSeconds / 60) % 60, + s: totalSeconds % 60, + }; +} + // ── 함수 표 ────────────────────────────────────────────────────────────── export const MISC_FUNCTIONS: Record = { @@ -466,4 +518,139 @@ export const MISC_FUNCTIONS: Record = { return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCDate())); }, }, + NOW: { min: 0, max: 0, call: () => fromDouble(dateToSerial(new Date())) }, + WEEKDAY: { + min: 1, + max: 2, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + const typeArg = args.length > 1 ? num(args[1]()) : ONE; + if (isErr(typeArg)) return typeArg; + const type = Math.trunc(fracToDouble(typeArg)); + const dow = serialToDate(fracToDouble(n)).getUTCDay(); // 0=일 .. 6=토 + if (type === 2) return frac(BigInt(((dow + 6) % 7) + 1)); + if (type === 3) return frac(BigInt((dow + 6) % 7)); + return frac(BigInt(dow + 1)); + }, + }, + EDATE: { + min: 2, + max: 2, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + const mArg = num(args[1]()); + if (isErr(mArg)) return mArg; + const months = Math.trunc(fracToDouble(mArg)); + const d = serialToDate(fracToDouble(n)); + const targetMonth = d.getUTCMonth() + months; + // 달 끝을 넘는 날짜(1/31 + 1개월 등)는 다음 달로 넘기지 않고 그 달 말일로 눌러 담음(엑셀과 같음). + const lastDay = new Date(Date.UTC(d.getUTCFullYear(), targetMonth + 1, 0)).getUTCDate(); + const day = Math.min(d.getUTCDate(), lastDay); + const date = new Date(Date.UTC(d.getUTCFullYear(), targetMonth, day)); + return fromDouble(dateToSerial(date)); + }, + }, + EOMONTH: { + min: 2, + max: 2, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + const mArg = num(args[1]()); + if (isErr(mArg)) return mArg; + const months = Math.trunc(fracToDouble(mArg)); + const d = serialToDate(fracToDouble(n)); + const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + months + 1, 0)); + return fromDouble(dateToSerial(date)); + }, + }, + DATEDIF: { + min: 3, + max: 3, + call: (args) => { + const s = num(args[0]()); + if (isErr(s)) return s; + const e = num(args[1]()); + if (isErr(e)) return e; + const unit = str(args[2]()); + if (isErr(unit)) return unit; + const result = dateDiff(fracToDouble(s), fracToDouble(e), unit); + return isErr(result) ? result : frac(BigInt(result)); + }, + }, + NETWORKDAYS: { + min: 2, + max: 3, + call: (args) => { + const s = num(args[0]()); + if (isErr(s)) return s; + const e = num(args[1]()); + if (isErr(e)) return e; + let startSerial = Math.round(fracToDouble(s)); + let endSerial = Math.round(fracToDouble(e)); + let sign = 1; + if (startSerial > endSerial) { + [startSerial, endSerial] = [endSerial, startSerial]; + sign = -1; + } + const holidays = new Set(); + if (args.length > 2) { + const holidayArg = args[2](); + if (isRange(holidayArg)) { + for (let r = 0; r < holidayArg.rows; r++) { + for (let c = 0; c < holidayArg.cols; c++) { + const cell = holidayArg.at(r, c); + if (isFrac(cell)) holidays.add(Math.round(fracToDouble(cell))); + } + } + } else if (isFrac(holidayArg)) { + holidays.add(Math.round(fracToDouble(holidayArg))); + } + } + let count = 0; + for (let day = startSerial; day <= endSerial; day++) { + const dow = serialToDate(day).getUTCDay(); + if (dow === 0 || dow === 6) continue; + if (holidays.has(day)) continue; + count++; + } + return frac(BigInt(sign * count)); + }, + }, + HOUR: { + min: 1, + max: 1, + call: (args) => { + const n = num(args[0]()); + return isErr(n) ? n : frac(BigInt(timeParts(fracToDouble(n)).h)); + }, + }, + MINUTE: { + min: 1, + max: 1, + call: (args) => { + const n = num(args[0]()); + return isErr(n) ? n : frac(BigInt(timeParts(fracToDouble(n)).mnt)); + }, + }, + TIME: { + min: 3, + max: 3, + call: (args) => { + const hArg = num(args[0]()); + if (isErr(hArg)) return hArg; + const mArg = num(args[1]()); + if (isErr(mArg)) return mArg; + const sArg = num(args[2]()); + if (isErr(sArg)) return sArg; + const totalSeconds = + Math.trunc(fracToDouble(hArg)) * 3600 + + Math.trunc(fracToDouble(mArg)) * 60 + + Math.trunc(fracToDouble(sArg)); + const dayFraction = ((totalSeconds % 86400) + 86400) % 86400; + return fromDouble(dayFraction / 86400); + }, + }, }; diff --git a/A00_Common/spreadsheet/spreadsheet_functions_stat.ts b/A00_Common/spreadsheet/spreadsheet_functions_stat.ts new file mode 100644 index 00000000..b64e73e5 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_functions_stat.ts @@ -0,0 +1,228 @@ +/* ============================================================================= + * spreadsheet_functions_stat.ts (주인 B · 3차 · 2026-09-27) + * 통계 함수 — MEDIAN · MODE · STDEV(.S/.P) · VAR · RANK(.EQ) · LARGE · SMALL · + * PERCENTILE · QUARTILE · COUNTBLANK. 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀. + * ========================================================================== */ + +import { ZERO, add, cmp, div, frac, mul, sub, type Frac } from "@ui/sheet/ui_template_sheet_frac"; +import type { + ErrorValue, + EvalContext, + EvalResult, + LazyArg, + RangeValue, + SheetFunction, +} from "./spreadsheet_types"; +import { + collectNumbers, + fracToDouble, + fromDouble, + isErr, + isFrac, + isRange, + mkErr, + num, +} from "./spreadsheet_functions_math"; + +function rangeNumbers(range: RangeValue): Frac[] | ErrorValue { + const out: Frac[] = []; + for (let r = 0; r < range.rows; r++) { + for (let c = 0; c < range.cols; c++) { + const cell = range.at(r, c); + if (isErr(cell)) return cell; + if (isFrac(cell)) out.push(cell); + } + } + return out; +} + +function meanAndSumSq(values: Frac[]): { mean: Frac; sumSq: Frac } { + const n = frac(BigInt(values.length)); + const mean = div(values.reduce(add, ZERO), n); + const sumSq = values.reduce((acc, v) => add(acc, mul(sub(v, mean), sub(v, mean))), ZERO); + return { mean, sumSq }; +} + +function sampleVariance(values: Frac[]): Frac | ErrorValue { + if (values.length < 2) return mkErr("#DIV/0!"); + return div(meanAndSumSq(values).sumSq, frac(BigInt(values.length - 1))); +} + +function populationVariance(values: Frac[]): Frac | ErrorValue { + if (values.length < 1) return mkErr("#DIV/0!"); + return div(meanAndSumSq(values).sumSq, frac(BigInt(values.length))); +} + +function stdevOf(variance: Frac | ErrorValue): Frac | ErrorValue { + if (isErr(variance)) return variance; + return fromDouble(Math.sqrt(fracToDouble(variance))); +} + +/** 정렬한 값에서 0 ≤ k ≤ 1 자리의 백분위(직선 보간 · 엑셀 PERCENTILE.INC 와 같음) */ +function percentileOf(values: Frac[], k: number): Frac | ErrorValue { + if (values.length === 0) return mkErr("#NUM!"); + if (k < 0 || k > 1) return mkErr("#NUM!"); + const sorted = [...values].sort((a, b) => cmp(a, b)); + const pos = k * (sorted.length - 1); + const lowIdx = Math.floor(pos); + const highIdx = Math.ceil(pos); + if (lowIdx === highIdx) return sorted[lowIdx]; + const weight = fromDouble(pos - lowIdx); + if (isErr(weight)) return weight; + return add(sorted[lowIdx], mul(weight, sub(sorted[highIdx], sorted[lowIdx]))); +} + +function nthOrderStat(range: RangeValue, k: number, largest: boolean): Frac | ErrorValue { + const values = rangeNumbers(range); + if (isErr(values)) return values; + if (k < 1 || k > values.length) return mkErr("#NUM!"); + const sorted = [...values].sort((a, b) => cmp(a, b)); + return sorted[largest ? sorted.length - k : k - 1]; +} + +function rankCall(args: LazyArg[], _ctx: EvalContext): EvalResult { + const target = num(args[0]()); + if (isErr(target)) return target; + const range = args[1](); + if (!isRange(range)) return mkErr("#VALUE!"); + const orderArg = args.length > 2 ? num(args[2]()) : ZERO; + if (isErr(orderArg)) return orderArg; + const ascending = cmp(orderArg, ZERO) !== 0; + let rank = 1; + let found = false; + for (let r = 0; r < range.rows; r++) { + for (let c = 0; c < range.cols; c++) { + const cell = range.at(r, c); + if (!isFrac(cell)) continue; + if (cmp(cell, target) === 0) found = true; + if (ascending ? cmp(cell, target) < 0 : cmp(cell, target) > 0) rank++; + } + } + return found ? frac(BigInt(rank)) : mkErr("#N/A"); +} + +export const STAT_FUNCTIONS: Record = { + MEDIAN: { + min: 1, + call: (args) => { + const values = collectNumbers(args, true, true); + if (isErr(values)) return values; + if (values.length === 0) return mkErr("#NUM!"); + const sorted = [...values].sort((a, b) => cmp(a, b)); + const mid = Math.floor(sorted.length / 2); + if (sorted.length % 2 === 1) return sorted[mid]; + return div(add(sorted[mid - 1], sorted[mid]), frac(2n)); + }, + }, + MODE: { + min: 1, + call: (args) => { + const values = collectNumbers(args, true, true); + if (isErr(values)) return values; + let bestIdx = -1; + let bestCount = 1; + for (let i = 0; i < values.length; i++) { + let count = 0; + for (const v of values) if (cmp(values[i], v) === 0) count++; + if (count > bestCount) { + bestCount = count; + bestIdx = i; + } + } + return bestIdx < 0 ? mkErr("#N/A") : values[bestIdx]; + }, + }, + STDEV: { + min: 1, + call: (args) => { + const values = collectNumbers(args, true, true); + return isErr(values) ? values : stdevOf(sampleVariance(values)); + }, + }, + "STDEV.S": { + min: 1, + call: (args) => { + const values = collectNumbers(args, true, true); + return isErr(values) ? values : stdevOf(sampleVariance(values)); + }, + }, + "STDEV.P": { + min: 1, + call: (args) => { + const values = collectNumbers(args, true, true); + return isErr(values) ? values : stdevOf(populationVariance(values)); + }, + }, + VAR: { + min: 1, + call: (args) => { + const values = collectNumbers(args, true, true); + return isErr(values) ? values : sampleVariance(values); + }, + }, + RANK: { min: 2, max: 3, call: rankCall }, + "RANK.EQ": { min: 2, max: 3, call: rankCall }, + LARGE: { + min: 2, + max: 2, + call: (args) => { + const range = args[0](); + if (!isRange(range)) return mkErr("#VALUE!"); + const kArg = num(args[1]()); + if (isErr(kArg)) return kArg; + return nthOrderStat(range, Math.trunc(fracToDouble(kArg)), true); + }, + }, + SMALL: { + min: 2, + max: 2, + call: (args) => { + const range = args[0](); + if (!isRange(range)) return mkErr("#VALUE!"); + const kArg = num(args[1]()); + if (isErr(kArg)) return kArg; + return nthOrderStat(range, Math.trunc(fracToDouble(kArg)), false); + }, + }, + PERCENTILE: { + min: 2, + max: 2, + call: (args) => { + const range = args[0](); + if (!isRange(range)) return mkErr("#VALUE!"); + const kArg = num(args[1]()); + if (isErr(kArg)) return kArg; + const values = rangeNumbers(range); + if (isErr(values)) return values; + return percentileOf(values, fracToDouble(kArg)); + }, + }, + QUARTILE: { + min: 2, + max: 2, + call: (args) => { + const range = args[0](); + if (!isRange(range)) return mkErr("#VALUE!"); + const qArg = num(args[1]()); + if (isErr(qArg)) return qArg; + const q = Math.trunc(fracToDouble(qArg)); + if (q < 0 || q > 4) return mkErr("#NUM!"); + const values = rangeNumbers(range); + if (isErr(values)) return values; + return percentileOf(values, q / 4); + }, + }, + COUNTBLANK: { + min: 1, + max: 1, + call: (args) => { + const range = args[0](); + if (!isRange(range)) return mkErr("#VALUE!"); + let count = 0; + for (let r = 0; r < range.rows; r++) { + for (let c = 0; c < range.cols; c++) if (range.at(r, c) === null) count++; + } + return frac(BigInt(count)); + }, + }, +}; diff --git a/A00_Common/spreadsheet/spreadsheet_numfmt.ts b/A00_Common/spreadsheet/spreadsheet_numfmt.ts index b6533dff..1c9fcb4f 100644 --- a/A00_Common/spreadsheet/spreadsheet_numfmt.ts +++ b/A00_Common/spreadsheet/spreadsheet_numfmt.ts @@ -3,7 +3,8 @@ * 숫자 형식 코드 → 보이는 글 — 엑셀 코드 부분 집합: 일반 · `0` `#` `?` `.` `,`(천 단위 · 끝 쉼표 = 천으로 나눔) · * 구역 `;`(양 · 음 · 0 · 글) · `"글"` · `\x` · `_x`(폭만큼 띄움) · `*x`(채움) · `[Red]` 등 색 · `%` · `@` · `E+00`. * 실무 형식(`0.00` · `_-* #,##0.00_-;\-* #,##0.00_-;_-* "-"_-;_-@_-` · `0.00_);[Red]\(0.00\)` …)은 모두 맞아야 함. - * 끝수는 분수로 사사오입(엑셀 표시와 같음). 날짜 · 시간 형식은 1단계 밖(글 그대로). + * 끝수는 분수로 사사오입(엑셀 표시와 같음). 날짜 형식(`yyyy-mm-dd` · `m/d` · 요일 `aaa`/`aaaa`)도 봄 + * (3차 · 2026-09-27) — 시간(`h:mm:ss`)은 아직 밖(글 그대로). * ========================================================================== */ import { @@ -364,6 +365,93 @@ function generalFormat(value: Frac): string { return fracToString(value); } +// ── 날짜 형식(`yyyy-mm-dd` · `m/d` · 요일 `aaa`/`aaaa`) ─────────────────────── +// 엑셀 일련번호(1970-01-01 = 25569) → 달력. 1900 윤년 버그는 misc.ts 와 같이 안 다룸. + +const MS_PER_DAY = 86400000; +const UNIX_EPOCH_AS_EXCEL_SERIAL = 25569; +const WEEKDAY_SHORT = ["일", "월", "화", "수", "목", "금", "토"]; + +function toDouble(x: Frac): number { + return Number(x.n) / Number(x.d); +} + +function serialToDateUTC(serial: number): Date { + return new Date(Math.round((serial - UNIX_EPOCH_AS_EXCEL_SERIAL) * MS_PER_DAY)); +} + +type DatePart = + { k: "lit"; text: string } | { k: "y" | "m" | "d"; width: 1 | 2 | 4 } | { k: "a"; width: 3 | 4 }; + +/** `y/m/d/a` 낱말 뜀(자리수가 아는 폭이 아니면 그대로 글로) — 없으면 날짜 형식 아님(null). */ +function detectDateFormat(s: string): DatePart[] | null { + const parts: DatePart[] = []; + let hasDateToken = false; + let i = 0; + while (i < s.length) { + const ch = s[i]; + if (ch === '"') { + let j = i + 1; + let text = ""; + while (j < s.length && s[j] !== '"') { + text += s[j]; + j++; + } + parts.push({ k: "lit", text }); + i = j + 1; + continue; + } + if (ch === "\\" && i + 1 < s.length) { + parts.push({ k: "lit", text: s[i + 1] }); + i += 2; + continue; + } + const lower = ch.toLowerCase(); + if (lower === "y" || lower === "m" || lower === "d" || lower === "a") { + let j = i; + while (j < s.length && s[j].toLowerCase() === lower) j++; + const width = j - i; + if (lower === "a" && (width === 3 || width === 4)) { + parts.push({ k: "a", width }); + hasDateToken = true; + } else if (lower !== "a" && (width === 1 || width === 2 || (lower === "y" && width === 4))) { + parts.push({ k: lower as "y" | "m" | "d", width: width as 1 | 2 | 4 }); + hasDateToken = true; + } else { + parts.push({ k: "lit", text: s.slice(i, j) }); + } + i = j; + continue; + } + parts.push({ k: "lit", text: ch }); + i++; + } + return hasDateToken ? parts : null; +} + +function renderDatePart(part: DatePart, date: Date): string { + if (part.k === "lit") return part.text; + if (part.k === "y") { + const y = date.getUTCFullYear(); + return part.width === 4 ? String(y) : String(y % 100).padStart(2, "0"); + } + if (part.k === "m") { + const m = date.getUTCMonth() + 1; + return part.width === 1 ? String(m) : String(m).padStart(2, "0"); + } + if (part.k === "d") { + const d = date.getUTCDate(); + return part.width === 1 ? String(d) : String(d).padStart(2, "0"); + } + const wd = WEEKDAY_SHORT[date.getUTCDay()]; + return part.width === 3 ? wd : `${wd}요일`; +} + +function renderDateSection(parts: DatePart[], value: Frac): string { + const date = serialToDateUTC(toDouble(value)); + return parts.map((p) => renderDatePart(p, date)).join(""); +} + function chooseSection(sections: string[], value: Frac): { code: string; forceMinus: boolean } { const isNeg = cmp(value, ZERO) < 0; const isZero = cmp(value, ZERO) === 0; @@ -385,6 +473,12 @@ function formatNumber(value: Frac, code: string | undefined): FormattedText { const sections = splitSections(code); const { code: chosen, forceMinus } = chooseSection(sections, value); const { rest, color } = extractColor(chosen); + const datePlan = detectDateFormat(rest); + if (datePlan) { + const extra: Partial = {}; + if (color) extra.색 = color; + return { 글: renderDateSection(datePlan, value), 정렬: "right", ...extra }; + } const { toks, fillChar } = tokenize(rest); const extra: Partial = {}; if (color) extra.색 = color; diff --git a/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts b/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts index 129b4d08..068901cf 100644 --- a/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts +++ b/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts @@ -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)",