feat(spreadsheet): 분수 형식 · 함수 2차 묶음(30개) · misc 파일 쪼갬
numfmt — 분수 형식(0/0 · # ?/? · # ??/?? · 분모 고정 # ?/8) 엑셀과 같게 구현. 함수 — MROUND·ODD·EVEN·LOG·LOG10·LN·EXP(math) · SUMIFS·COUNTIFS·AVERAGEIF(S)·MAXIFS·MINIFS· IFS·SWITCH·XLOOKUP(찾기·조건부 집계) · TEXTJOIN·TRIM·UPPER·LOWER·SUBSTITUTE·REPLACE·FIND· SEARCH·REPT·ISNUMBER·ISTEXT·ISBLANK·ISERROR·NA·TODAY·DATE·YEAR·MONTH·DAY(misc) 더함. misc.ts 가 700 줄을 넘어 찾기·조건부 집계를 spreadsheet_functions_lookup.ts(새 파일)로 쪼갬. sub1 의 func_help 이름 목록에 더할 것을 spreadsheet_functions.ts 의 FUNC_HELP_BATCH2 에 둠 (그 파일은 안 고침 · 머리 주석에 읽는 길 적음). 경계값 시험 135건 통과. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
This commit is contained in:
@@ -1,18 +1,21 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_functions.ts (주인 B)
|
||||
* 함수 표 — 이름(대문자) → 뜻. 몸은 `spreadsheet_functions_math.ts` · `_misc.ts` 에 두고 여기서 모음.
|
||||
* 1단계 약 45 — 수학 SUM PRODUCT SUMPRODUCT ROUND ROUNDUP ROUNDDOWN INT TRUNC CEILING FLOOR ABS MOD
|
||||
* POWER SQRT PI SIN COS TAN ASIN ACOS ATAN RADIANS DEGREES MIN MAX AVERAGE · 논리 IF AND OR NOT IFERROR ·
|
||||
* 글 CONCATENATE FIXED TEXT LEN LEFT RIGHT MID VALUE · 찾기 VLOOKUP HLOOKUP INDEX MATCH CHOOSE ·
|
||||
* 세기 COUNT COUNTA COUNTIF SUMIF. 뜻 · 경계값은 엑셀과 같게(Formula.js 는 시험 대조 답으로만).
|
||||
* 함수 표 — 이름(대문자) → 뜻. 몸은 `spreadsheet_functions_math.ts` · `_misc.ts` · `_lookup.ts` 에
|
||||
* 두고 여기서 모음. 1단계 약 45 + 2단계 첫 묶음(2026-09-27) — 아래 `FUNC_HELP_BATCH2`(sub1 의
|
||||
* `spreadsheet_func_help.ts` 가 읽어 이름 목록에 더할 것 — 그 파일은 sub1 몫이라 안 고침).
|
||||
* 없는 이름 = `#NAME?` · 인자 수가 min · max 밖이면 A 의 eval 이 `#VALUE!` + 까닭(엑셀은 입력을 막음).
|
||||
* ========================================================================== */
|
||||
|
||||
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";
|
||||
|
||||
const TABLE: Record<string, SheetFunction> = { ...MATH_FUNCTIONS, ...MISC_FUNCTIONS };
|
||||
const TABLE: Record<string, SheetFunction> = {
|
||||
...MATH_FUNCTIONS,
|
||||
...MISC_FUNCTIONS,
|
||||
...LOOKUP_FUNCTIONS,
|
||||
};
|
||||
|
||||
export function getFunction(name: string): SheetFunction | undefined {
|
||||
return TABLE[name.toUpperCase()];
|
||||
@@ -22,3 +25,97 @@ export function getFunction(name: string): SheetFunction | undefined {
|
||||
export function functionNames(): string[] {
|
||||
return Object.keys(TABLE).sort();
|
||||
}
|
||||
|
||||
/** 2단계 첫 묶음(2026-09-27) — sub1 `spreadsheet_func_help.ts` 의 `TABLE` 에 이 모양대로 더할 것.
|
||||
* `이름` · `꼴`(인자 모양 · 대괄호 = 생략 가능) · `뜻`(한 줄). */
|
||||
export interface FuncHelpEntry {
|
||||
이름: string;
|
||||
꼴: string;
|
||||
뜻: string;
|
||||
}
|
||||
|
||||
export const FUNC_HELP_BATCH2: FuncHelpEntry[] = [
|
||||
{ 이름: "MROUND", 꼴: "MROUND(수, 배수)", 뜻: "배수의 가장 가까운 정수 배로 반올림" },
|
||||
{ 이름: "ODD", 꼴: "ODD(수)", 뜻: "홀수로 올림(0 에서 먼 쪽)" },
|
||||
{ 이름: "EVEN", 꼴: "EVEN(수)", 뜻: "짝수로 올림(0 에서 먼 쪽)" },
|
||||
{ 이름: "LOG", 꼴: "LOG(수, [밑=10])", 뜻: "로그(밑 지정)" },
|
||||
{ 이름: "LOG10", 꼴: "LOG10(수)", 뜻: "상용로그" },
|
||||
{ 이름: "LN", 꼴: "LN(수)", 뜻: "자연로그" },
|
||||
{ 이름: "EXP", 꼴: "EXP(수)", 뜻: "e 의 거듭제곱" },
|
||||
{
|
||||
이름: "SUMIFS",
|
||||
꼴: "SUMIFS(합할범위, 조건범위1, 조건1, [조건범위2, 조건2], …)",
|
||||
뜻: "여러 조건에 다 맞는 칸의 합",
|
||||
},
|
||||
{
|
||||
이름: "COUNTIFS",
|
||||
꼴: "COUNTIFS(조건범위1, 조건1, [조건범위2, 조건2], …)",
|
||||
뜻: "여러 조건에 다 맞는 칸 개수",
|
||||
},
|
||||
{
|
||||
이름: "AVERAGEIF",
|
||||
꼴: "AVERAGEIF(범위, 조건, [평균범위])",
|
||||
뜻: "조건에 맞는 칸(또는 짝 칸)의 평균",
|
||||
},
|
||||
{
|
||||
이름: "AVERAGEIFS",
|
||||
꼴: "AVERAGEIFS(평균범위, 조건범위1, 조건1, …)",
|
||||
뜻: "여러 조건에 다 맞는 칸의 평균",
|
||||
},
|
||||
{
|
||||
이름: "MAXIFS",
|
||||
꼴: "MAXIFS(최대범위, 조건범위1, 조건1, …)",
|
||||
뜻: "여러 조건에 다 맞는 칸의 최댓값",
|
||||
},
|
||||
{
|
||||
이름: "MINIFS",
|
||||
꼴: "MINIFS(최소범위, 조건범위1, 조건1, …)",
|
||||
뜻: "여러 조건에 다 맞는 칸의 최솟값",
|
||||
},
|
||||
{
|
||||
이름: "IFS",
|
||||
꼴: "IFS(조건1, 값1, [조건2, 값2], …)",
|
||||
뜻: "처음으로 참인 조건의 값",
|
||||
},
|
||||
{
|
||||
이름: "SWITCH",
|
||||
꼴: "SWITCH(식, 값1, 결과1, [값2, 결과2], …, [기본값])",
|
||||
뜻: "식과 같은 값을 찾아 그 결과",
|
||||
},
|
||||
{
|
||||
이름: "XLOOKUP",
|
||||
꼴: "XLOOKUP(찾을값, 찾을범위, 돌려줄범위, [못찾으면], [맞춤꼴], [찾는방향])",
|
||||
뜻: "찾을범위에서 찾아 돌려줄범위의 같은 자리 값",
|
||||
},
|
||||
{
|
||||
이름: "TEXTJOIN",
|
||||
꼴: "TEXTJOIN(구분자, 빈칸건너뜀, 글1, [글2], …)",
|
||||
뜻: "구분자로 여러 글을 이어 붙임",
|
||||
},
|
||||
{ 이름: "TRIM", 꼴: "TRIM(글)", 뜻: "앞뒤 · 겹친 빈칸을 뺌" },
|
||||
{ 이름: "UPPER", 꼴: "UPPER(글)", 뜻: "대문자로" },
|
||||
{ 이름: "LOWER", 꼴: "LOWER(글)", 뜻: "소문자로" },
|
||||
{
|
||||
이름: "SUBSTITUTE",
|
||||
꼴: "SUBSTITUTE(글, 옛글, 새글, [몇번째])",
|
||||
뜻: "글 안 옛글을 새글로 바꿈",
|
||||
},
|
||||
{ 이름: "REPLACE", 꼴: "REPLACE(글, 시작, 글자수, 새글)", 뜻: "자리로 콕 집어 바꿈" },
|
||||
{ 이름: "FIND", 꼴: "FIND(찾을글, 안글, [시작])", 뜻: "글자 그대로 찾음(대소문자 가림)" },
|
||||
{
|
||||
이름: "SEARCH",
|
||||
꼴: "SEARCH(찾을글, 안글, [시작])",
|
||||
뜻: "찾음(대소문자 안 가림 · * ? 자리표시)",
|
||||
},
|
||||
{ 이름: "REPT", 꼴: "REPT(글, 번)", 뜻: "글을 번 만큼 되풀이" },
|
||||
{ 이름: "ISNUMBER", 꼴: "ISNUMBER(값)", 뜻: "수면 참" },
|
||||
{ 이름: "ISTEXT", 꼴: "ISTEXT(값)", 뜻: "글이면 참" },
|
||||
{ 이름: "ISBLANK", 꼴: "ISBLANK(값)", 뜻: "빈 칸이면 참" },
|
||||
{ 이름: "ISERROR", 꼴: "ISERROR(값)", 뜻: "오류 값이면 참" },
|
||||
{ 이름: "NA", 꼴: "NA()", 뜻: "#N/A 오류를 만듦" },
|
||||
{ 이름: "TODAY", 꼴: "TODAY()", 뜻: "오늘 날짜(일련번호)" },
|
||||
{ 이름: "DATE", 꼴: "DATE(년, 월, 일)", 뜻: "날짜 일련번호를 만듦" },
|
||||
{ 이름: "YEAR", 꼴: "YEAR(일련번호)", 뜻: "연도" },
|
||||
{ 이름: "MONTH", 꼴: "MONTH(일련번호)", 뜻: "월" },
|
||||
{ 이름: "DAY", 꼴: "DAY(일련번호)", 뜻: "일" },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_functions_lookup.ts (주인 B · misc 700 줄 넘어 2026-09-27 쪼갬)
|
||||
* 찾기 · 조건부 세기·합·평균·최대·최소 함수. `spreadsheet_functions_misc.ts` 에서
|
||||
* VLOOKUP · HLOOKUP · INDEX · MATCH · CHOOSE · COUNT · COUNTA · COUNTIF · SUMIF ·
|
||||
* SUMIFS · COUNTIFS · AVERAGEIF(S) · MAXIFS · MINIFS · IFS · SWITCH · XLOOKUP 를 옮겨 옴.
|
||||
* 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
ZERO,
|
||||
add,
|
||||
cmp,
|
||||
div,
|
||||
frac,
|
||||
parseDecimal,
|
||||
type Frac,
|
||||
} from "@ui/sheet/ui_template_sheet_frac";
|
||||
import type { ErrorValue, LazyArg, RangeValue, Scalar, SheetFunction } from "./spreadsheet_types";
|
||||
import {
|
||||
bool,
|
||||
collectNumbers,
|
||||
fracToDouble,
|
||||
isErr,
|
||||
isFrac,
|
||||
isRange,
|
||||
mkErr,
|
||||
num,
|
||||
toNumber,
|
||||
toScalar,
|
||||
} from "./spreadsheet_functions_math";
|
||||
|
||||
const ONE = frac(1n);
|
||||
|
||||
// ── 값 견줌(찾기 · 세기 함수 공용) ───────────────────────────────────────────
|
||||
|
||||
function typeRank(v: Scalar): number {
|
||||
if (v === null) return -1;
|
||||
if (isFrac(v)) return 0;
|
||||
if (typeof v === "string") return 1;
|
||||
if (typeof v === "boolean") return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
function compareScalars(a: Scalar, b: Scalar): number {
|
||||
if (isFrac(a) && isFrac(b)) return cmp(a, b);
|
||||
if (typeof a === "string" && typeof b === "string") {
|
||||
const la = a.toUpperCase();
|
||||
const lb = b.toUpperCase();
|
||||
return la < lb ? -1 : la > lb ? 1 : 0;
|
||||
}
|
||||
if (typeof a === "boolean" && typeof b === "boolean") return a === b ? 0 : a ? 1 : -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function equalScalars(a: Scalar, b: Scalar): boolean {
|
||||
return typeRank(a) === typeRank(b) && compareScalars(a, b) === 0;
|
||||
}
|
||||
|
||||
function wrapScalar(v: Scalar): RangeValue {
|
||||
return { kind: "range", rows: 1, cols: 1, at: () => v };
|
||||
}
|
||||
|
||||
// ── 조건 글(`>10` · `<=5` · `사과*`) ─────────────────────────────────────────
|
||||
|
||||
function wildcardToRegex(text: string): RegExp {
|
||||
const escaped = text
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*/g, ".*")
|
||||
.replace(/\?/g, ".");
|
||||
return new RegExp(`^${escaped}$`, "i");
|
||||
}
|
||||
|
||||
function parseCriteria(crit: Scalar): (cell: Scalar) => boolean {
|
||||
if (crit === null) return (cell) => cell === null;
|
||||
if (typeof crit === "boolean") return (cell) => typeof cell === "boolean" && cell === crit;
|
||||
if (isFrac(crit)) return (cell) => isFrac(cell) && cmp(cell, crit) === 0;
|
||||
if (isErr(crit)) return () => false;
|
||||
const m = /^(<=|>=|<>|<|>|=)?(.*)$/.exec(crit);
|
||||
const op = m?.[1] ?? "";
|
||||
const rest = m?.[2] ?? crit;
|
||||
let numRest: Frac | null = null;
|
||||
try {
|
||||
numRest = rest.trim() === "" ? null : parseDecimal(rest);
|
||||
} catch {
|
||||
numRest = null;
|
||||
}
|
||||
if (numRest !== null) {
|
||||
const nr = numRest;
|
||||
return (cell) => {
|
||||
if (!isFrac(cell)) return false;
|
||||
const c = cmp(cell, nr);
|
||||
if (op === "<=") return c <= 0;
|
||||
if (op === ">=") return c >= 0;
|
||||
if (op === "<>") return c !== 0;
|
||||
if (op === "<") return c < 0;
|
||||
if (op === ">") return c > 0;
|
||||
return c === 0;
|
||||
};
|
||||
}
|
||||
const pat = wildcardToRegex(rest);
|
||||
if (op === "<>") return (cell) => !(typeof cell === "string" && pat.test(cell));
|
||||
return (cell) => typeof cell === "string" && pat.test(cell);
|
||||
}
|
||||
|
||||
/** `args[from]` 부터 (범위,조건) 짝을 이어가며 모두 맞는 자리만 참 — *IFS 함수 공용.
|
||||
* `shape` 범위와 크기가 다른 조건 범위가 있으면 `#VALUE!`. */
|
||||
function criteriaMask(args: LazyArg[], from: number, shape: RangeValue): boolean[] | ErrorValue {
|
||||
const mask = new Array<boolean>(shape.rows * shape.cols).fill(true);
|
||||
for (let i = from; i + 1 < args.length; i += 2) {
|
||||
const rangeArg = args[i]();
|
||||
if (!isRange(rangeArg)) return mkErr("#VALUE!");
|
||||
if (rangeArg.rows !== shape.rows || rangeArg.cols !== shape.cols) {
|
||||
return mkErr("#VALUE!", "조건 범위 크기가 다름");
|
||||
}
|
||||
const crit = toScalar(args[i + 1]());
|
||||
if (isErr(crit)) return crit;
|
||||
const pred = parseCriteria(crit);
|
||||
let k = 0;
|
||||
for (let r = 0; r < shape.rows; r++) {
|
||||
for (let c = 0; c < shape.cols; c++) {
|
||||
if (mask[k] && !pred(rangeArg.at(r, c))) mask[k] = false;
|
||||
k++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
// ── 함수 표 ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const LOOKUP_FUNCTIONS: Record<string, SheetFunction> = {
|
||||
VLOOKUP: {
|
||||
min: 3,
|
||||
max: 4,
|
||||
call: (args) => {
|
||||
const lookup = toScalar(args[0]());
|
||||
if (isErr(lookup)) return lookup;
|
||||
const table = args[1]();
|
||||
if (!isRange(table)) return mkErr("#VALUE!");
|
||||
const colN = num(args[2]());
|
||||
if (isErr(colN)) return colN;
|
||||
const col = Math.trunc(fracToDouble(colN)) - 1;
|
||||
if (col < 0 || col >= table.cols) return mkErr("#REF!");
|
||||
const approxRaw = args.length > 3 ? bool(args[3]()) : true;
|
||||
if (isErr(approxRaw)) return approxRaw;
|
||||
if (approxRaw) {
|
||||
let best = -1;
|
||||
for (let r = 0; r < table.rows; r++) {
|
||||
const key = table.at(r, 0);
|
||||
if (typeRank(key) !== typeRank(lookup)) {
|
||||
if (best >= 0) break;
|
||||
continue;
|
||||
}
|
||||
if (compareScalars(key, lookup) <= 0) best = r;
|
||||
else break;
|
||||
}
|
||||
if (best < 0) return mkErr("#N/A");
|
||||
return table.at(best, col);
|
||||
}
|
||||
for (let r = 0; r < table.rows; r++) {
|
||||
if (equalScalars(table.at(r, 0), lookup)) return table.at(r, col);
|
||||
}
|
||||
return mkErr("#N/A");
|
||||
},
|
||||
},
|
||||
HLOOKUP: {
|
||||
min: 3,
|
||||
max: 4,
|
||||
call: (args) => {
|
||||
const lookup = toScalar(args[0]());
|
||||
if (isErr(lookup)) return lookup;
|
||||
const table = args[1]();
|
||||
if (!isRange(table)) return mkErr("#VALUE!");
|
||||
const rowN = num(args[2]());
|
||||
if (isErr(rowN)) return rowN;
|
||||
const row = Math.trunc(fracToDouble(rowN)) - 1;
|
||||
if (row < 0 || row >= table.rows) return mkErr("#REF!");
|
||||
const approxRaw = args.length > 3 ? bool(args[3]()) : true;
|
||||
if (isErr(approxRaw)) return approxRaw;
|
||||
if (approxRaw) {
|
||||
let best = -1;
|
||||
for (let c = 0; c < table.cols; c++) {
|
||||
const key = table.at(0, c);
|
||||
if (typeRank(key) !== typeRank(lookup)) {
|
||||
if (best >= 0) break;
|
||||
continue;
|
||||
}
|
||||
if (compareScalars(key, lookup) <= 0) best = c;
|
||||
else break;
|
||||
}
|
||||
if (best < 0) return mkErr("#N/A");
|
||||
return table.at(row, best);
|
||||
}
|
||||
for (let c = 0; c < table.cols; c++) {
|
||||
if (equalScalars(table.at(0, c), lookup)) return table.at(row, c);
|
||||
}
|
||||
return mkErr("#N/A");
|
||||
},
|
||||
},
|
||||
INDEX: {
|
||||
min: 2,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const rangeArg = args[0]();
|
||||
if (isErr(rangeArg)) return rangeArg;
|
||||
const range = isRange(rangeArg) ? rangeArg : wrapScalar(rangeArg);
|
||||
const rowN = num(args[1]());
|
||||
if (isErr(rowN)) return rowN;
|
||||
const row = Math.trunc(fracToDouble(rowN));
|
||||
const colN = args.length > 2 ? num(args[2]()) : frac(range.cols === 1 ? 1n : 0n);
|
||||
if (isErr(colN)) return colN;
|
||||
const col = Math.trunc(fracToDouble(colN));
|
||||
if (row === 0 && col === 0) return mkErr("#VALUE!");
|
||||
if (row === 0) {
|
||||
if (col < 1 || col > range.cols) return mkErr("#REF!");
|
||||
const c = col - 1;
|
||||
return { kind: "range", rows: range.rows, cols: 1, at: (r: number) => range.at(r, c) };
|
||||
}
|
||||
if (col === 0) {
|
||||
if (row < 1 || row > range.rows) return mkErr("#REF!");
|
||||
const r = row - 1;
|
||||
return {
|
||||
kind: "range",
|
||||
rows: 1,
|
||||
cols: range.cols,
|
||||
at: (_r: number, c: number) => range.at(r, c),
|
||||
};
|
||||
}
|
||||
if (row < 1 || row > range.rows || col < 1 || col > range.cols) return mkErr("#REF!");
|
||||
return range.at(row - 1, col - 1);
|
||||
},
|
||||
},
|
||||
MATCH: {
|
||||
min: 2,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const lookup = toScalar(args[0]());
|
||||
if (isErr(lookup)) return lookup;
|
||||
const rangeArg = args[1]();
|
||||
if (!isRange(rangeArg)) return mkErr("#VALUE!");
|
||||
if (rangeArg.rows !== 1 && rangeArg.cols !== 1) {
|
||||
return mkErr("#VALUE!", "한 줄 · 한 칸짜리 범위만");
|
||||
}
|
||||
const n = rangeArg.rows === 1 ? rangeArg.cols : rangeArg.rows;
|
||||
const at = (i: number): Scalar =>
|
||||
rangeArg.rows === 1 ? rangeArg.at(0, i) : rangeArg.at(i, 0);
|
||||
const modeN = args.length > 2 ? num(args[2]()) : ONE;
|
||||
if (isErr(modeN)) return modeN;
|
||||
const mode = Math.trunc(fracToDouble(modeN));
|
||||
if (mode === 0) {
|
||||
for (let i = 0; i < n; i++) if (equalScalars(at(i), lookup)) return frac(BigInt(i + 1));
|
||||
return mkErr("#N/A");
|
||||
}
|
||||
let best = -1;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const key = at(i);
|
||||
if (typeRank(key) !== typeRank(lookup)) {
|
||||
if (best >= 0) break;
|
||||
continue;
|
||||
}
|
||||
const c = compareScalars(key, lookup);
|
||||
if (mode > 0 ? c <= 0 : c >= 0) best = i;
|
||||
else break;
|
||||
}
|
||||
if (best < 0) return mkErr("#N/A");
|
||||
return frac(BigInt(best + 1));
|
||||
},
|
||||
},
|
||||
CHOOSE: {
|
||||
min: 2,
|
||||
call: (args) => {
|
||||
const idxN = num(args[0]());
|
||||
if (isErr(idxN)) return idxN;
|
||||
const idx = Math.trunc(fracToDouble(idxN));
|
||||
if (idx < 1 || idx > args.length - 1) return mkErr("#VALUE!");
|
||||
return args[idx]();
|
||||
},
|
||||
},
|
||||
COUNT: {
|
||||
min: 0,
|
||||
call: (args) => {
|
||||
const values = collectNumbers(args, false, false);
|
||||
return isErr(values) ? values : frac(BigInt(values.length));
|
||||
},
|
||||
},
|
||||
COUNTA: {
|
||||
min: 0,
|
||||
call: (args) => {
|
||||
let count = 0;
|
||||
for (const a of args) {
|
||||
const v = a();
|
||||
if (isRange(v)) {
|
||||
for (let r = 0; r < v.rows; r++) {
|
||||
for (let c = 0; c < v.cols; c++) if (v.at(r, c) !== null) count++;
|
||||
}
|
||||
} else if (v !== null) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return frac(BigInt(count));
|
||||
},
|
||||
},
|
||||
COUNTIF: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const range = args[0]();
|
||||
if (!isRange(range)) return mkErr("#VALUE!");
|
||||
const crit = toScalar(args[1]());
|
||||
if (isErr(crit)) return crit;
|
||||
const pred = parseCriteria(crit);
|
||||
let count = 0;
|
||||
for (let r = 0; r < range.rows; r++) {
|
||||
for (let c = 0; c < range.cols; c++) if (pred(range.at(r, c))) count++;
|
||||
}
|
||||
return frac(BigInt(count));
|
||||
},
|
||||
},
|
||||
SUMIF: {
|
||||
min: 2,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const range = args[0]();
|
||||
if (!isRange(range)) return mkErr("#VALUE!");
|
||||
const crit = toScalar(args[1]());
|
||||
if (isErr(crit)) return crit;
|
||||
const pred = parseCriteria(crit);
|
||||
const sumRange = args.length > 2 ? args[2]() : range;
|
||||
if (!isRange(sumRange)) return mkErr("#VALUE!");
|
||||
if (sumRange.rows !== range.rows || sumRange.cols !== range.cols) {
|
||||
return mkErr("#VALUE!", "합 범위 크기가 다름");
|
||||
}
|
||||
let total = ZERO;
|
||||
for (let r = 0; r < range.rows; r++) {
|
||||
for (let c = 0; c < range.cols; c++) {
|
||||
if (!pred(range.at(r, c))) continue;
|
||||
const n = toNumber(sumRange.at(r, c));
|
||||
if (isErr(n)) return n;
|
||||
total = add(total, n);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
},
|
||||
},
|
||||
SUMIFS: {
|
||||
min: 3,
|
||||
call: (args) => {
|
||||
const sumRange = args[0]();
|
||||
if (!isRange(sumRange)) return mkErr("#VALUE!");
|
||||
const mask = criteriaMask(args, 1, sumRange);
|
||||
if (isErr(mask)) return mask;
|
||||
let total = ZERO;
|
||||
let k = 0;
|
||||
for (let r = 0; r < sumRange.rows; r++) {
|
||||
for (let c = 0; c < sumRange.cols; c++) {
|
||||
if (mask[k]) {
|
||||
const n = toNumber(sumRange.at(r, c));
|
||||
if (isErr(n)) return n;
|
||||
total = add(total, n);
|
||||
}
|
||||
k++;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
},
|
||||
},
|
||||
COUNTIFS: {
|
||||
min: 2,
|
||||
call: (args) => {
|
||||
const first = args[0]();
|
||||
if (!isRange(first)) return mkErr("#VALUE!");
|
||||
const mask = criteriaMask(args, 0, first);
|
||||
if (isErr(mask)) return mask;
|
||||
return frac(BigInt(mask.filter(Boolean).length));
|
||||
},
|
||||
},
|
||||
AVERAGEIF: {
|
||||
min: 2,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const range = args[0]();
|
||||
if (!isRange(range)) return mkErr("#VALUE!");
|
||||
const crit = toScalar(args[1]());
|
||||
if (isErr(crit)) return crit;
|
||||
const pred = parseCriteria(crit);
|
||||
const avgRange = args.length > 2 ? args[2]() : range;
|
||||
if (!isRange(avgRange)) return mkErr("#VALUE!");
|
||||
if (avgRange.rows !== range.rows || avgRange.cols !== range.cols) {
|
||||
return mkErr("#VALUE!", "평균 범위 크기가 다름");
|
||||
}
|
||||
let total = ZERO;
|
||||
let count = 0;
|
||||
for (let r = 0; r < range.rows; r++) {
|
||||
for (let c = 0; c < range.cols; c++) {
|
||||
if (!pred(range.at(r, c))) continue;
|
||||
const n = toNumber(avgRange.at(r, c));
|
||||
if (isErr(n)) return n;
|
||||
total = add(total, n);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count === 0 ? mkErr("#DIV/0!") : div(total, frac(BigInt(count)));
|
||||
},
|
||||
},
|
||||
AVERAGEIFS: {
|
||||
min: 3,
|
||||
call: (args) => {
|
||||
const avgRange = args[0]();
|
||||
if (!isRange(avgRange)) return mkErr("#VALUE!");
|
||||
const mask = criteriaMask(args, 1, avgRange);
|
||||
if (isErr(mask)) return mask;
|
||||
let total = ZERO;
|
||||
let count = 0;
|
||||
let k = 0;
|
||||
for (let r = 0; r < avgRange.rows; r++) {
|
||||
for (let c = 0; c < avgRange.cols; c++) {
|
||||
if (mask[k]) {
|
||||
const n = toNumber(avgRange.at(r, c));
|
||||
if (isErr(n)) return n;
|
||||
total = add(total, n);
|
||||
count++;
|
||||
}
|
||||
k++;
|
||||
}
|
||||
}
|
||||
return count === 0 ? mkErr("#DIV/0!") : div(total, frac(BigInt(count)));
|
||||
},
|
||||
},
|
||||
MAXIFS: {
|
||||
min: 3,
|
||||
call: (args) => {
|
||||
const targetRange = args[0]();
|
||||
if (!isRange(targetRange)) return mkErr("#VALUE!");
|
||||
const mask = criteriaMask(args, 1, targetRange);
|
||||
if (isErr(mask)) return mask;
|
||||
let best: Frac | null = null;
|
||||
let k = 0;
|
||||
for (let r = 0; r < targetRange.rows; r++) {
|
||||
for (let c = 0; c < targetRange.cols; c++) {
|
||||
if (mask[k]) {
|
||||
const cell = targetRange.at(r, c);
|
||||
if (isFrac(cell) && (best === null || cmp(cell, best) > 0)) best = cell;
|
||||
}
|
||||
k++;
|
||||
}
|
||||
}
|
||||
return best ?? ZERO;
|
||||
},
|
||||
},
|
||||
MINIFS: {
|
||||
min: 3,
|
||||
call: (args) => {
|
||||
const targetRange = args[0]();
|
||||
if (!isRange(targetRange)) return mkErr("#VALUE!");
|
||||
const mask = criteriaMask(args, 1, targetRange);
|
||||
if (isErr(mask)) return mask;
|
||||
let best: Frac | null = null;
|
||||
let k = 0;
|
||||
for (let r = 0; r < targetRange.rows; r++) {
|
||||
for (let c = 0; c < targetRange.cols; c++) {
|
||||
if (mask[k]) {
|
||||
const cell = targetRange.at(r, c);
|
||||
if (isFrac(cell) && (best === null || cmp(cell, best) < 0)) best = cell;
|
||||
}
|
||||
k++;
|
||||
}
|
||||
}
|
||||
return best ?? ZERO;
|
||||
},
|
||||
},
|
||||
IFS: {
|
||||
min: 2,
|
||||
call: (args) => {
|
||||
for (let i = 0; i + 1 < args.length; i += 2) {
|
||||
const c = bool(args[i]());
|
||||
if (isErr(c)) return c;
|
||||
if (c) return args[i + 1]();
|
||||
}
|
||||
return mkErr("#N/A");
|
||||
},
|
||||
},
|
||||
SWITCH: {
|
||||
min: 3,
|
||||
call: (args) => {
|
||||
const target = toScalar(args[0]());
|
||||
if (isErr(target)) return target;
|
||||
let i = 1;
|
||||
for (; i + 1 < args.length; i += 2) {
|
||||
const cand = toScalar(args[i]());
|
||||
if (isErr(cand)) return cand;
|
||||
if (equalScalars(target, cand)) return args[i + 1]();
|
||||
}
|
||||
return i < args.length ? args[i]() : mkErr("#N/A");
|
||||
},
|
||||
},
|
||||
XLOOKUP: {
|
||||
min: 3,
|
||||
max: 6,
|
||||
call: (args) => {
|
||||
const lookup = toScalar(args[0]());
|
||||
if (isErr(lookup)) return lookup;
|
||||
const lookupRange = args[1]();
|
||||
const returnRange = args[2]();
|
||||
if (!isRange(lookupRange) || !isRange(returnRange)) return mkErr("#VALUE!");
|
||||
const n = lookupRange.rows * lookupRange.cols;
|
||||
if (n !== returnRange.rows * returnRange.cols) {
|
||||
return mkErr("#VALUE!", "찾을 범위 · 돌려줄 범위 크기가 다름");
|
||||
}
|
||||
const at = (rv: RangeValue, i: number): Scalar => (rv.rows === 1 ? rv.at(0, i) : rv.at(i, 0));
|
||||
const modeN = args.length > 4 ? num(args[4]()) : ZERO;
|
||||
if (isErr(modeN)) return modeN;
|
||||
const mode = Math.trunc(fracToDouble(modeN));
|
||||
const searchN = args.length > 5 ? num(args[5]()) : ONE;
|
||||
if (isErr(searchN)) return searchN;
|
||||
const reverse = Math.trunc(fracToDouble(searchN)) < 0;
|
||||
let best = -1;
|
||||
for (let step = 0; step < n; step++) {
|
||||
const i = reverse ? n - 1 - step : step;
|
||||
const key = at(lookupRange, i);
|
||||
if (typeRank(key) !== typeRank(lookup)) continue;
|
||||
const c = compareScalars(key, lookup);
|
||||
if (c === 0) {
|
||||
best = i;
|
||||
break;
|
||||
}
|
||||
if (mode === 1 && c > 0) {
|
||||
if (best < 0 || compareScalars(key, at(lookupRange, best)) < 0) best = i;
|
||||
} else if (mode === -1 && c < 0) {
|
||||
if (best < 0 || compareScalars(key, at(lookupRange, best)) > 0) best = i;
|
||||
}
|
||||
}
|
||||
if (best < 0) return args.length > 3 ? args[3]() : mkErr("#N/A");
|
||||
return at(returnRange, best);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -415,4 +415,76 @@ export const MATH_FUNCTIONS: Record<string, SheetFunction> = {
|
||||
return div(values.reduce(add, ZERO), frac(BigInt(values.length)));
|
||||
},
|
||||
},
|
||||
MROUND: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const m = num(args[1]());
|
||||
if (isErr(m)) return m;
|
||||
if (cmp(m, ZERO) === 0) return ZERO;
|
||||
if (cmp(n, ZERO) !== 0 && cmp(n, ZERO) > 0 !== cmp(m, ZERO) > 0) {
|
||||
return mkErr("#NUM!", "수 · 배수의 부호가 다름");
|
||||
}
|
||||
return mul(frac(toInteger(div(n, m), "round")), m);
|
||||
},
|
||||
},
|
||||
ODD: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
let v = toInteger(n, "away");
|
||||
if (v === 0n) v = 1n;
|
||||
else if (v % 2n === 0n) v += v < 0n ? -1n : 1n;
|
||||
return frac(v);
|
||||
},
|
||||
},
|
||||
EVEN: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
let v = toInteger(n, "away");
|
||||
if (v % 2n !== 0n) v += v < 0n ? -1n : 1n;
|
||||
return frac(v);
|
||||
},
|
||||
},
|
||||
LOG: {
|
||||
min: 1,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
if (cmp(n, ZERO) <= 0) return mkErr("#NUM!");
|
||||
const base = args.length > 1 ? num(args[1]()) : frac(10n);
|
||||
if (isErr(base)) return base;
|
||||
if (cmp(base, ZERO) <= 0 || cmp(base, ONE) === 0) return mkErr("#NUM!");
|
||||
return fromDouble(Math.log(fracToDouble(n)) / Math.log(fracToDouble(base)));
|
||||
},
|
||||
},
|
||||
LOG10: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
if (cmp(n, ZERO) <= 0) return mkErr("#NUM!");
|
||||
return fromDouble(Math.log10(fracToDouble(n)));
|
||||
},
|
||||
},
|
||||
LN: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
if (cmp(n, ZERO) <= 0) return mkErr("#NUM!");
|
||||
return fromDouble(Math.log(fracToDouble(n)));
|
||||
},
|
||||
},
|
||||
EXP: unaryDouble(Math.exp),
|
||||
};
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_functions_misc.ts (주인 B)
|
||||
* 논리 · 글 · 찾기 · 세기 함수. 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀.
|
||||
* 뜻 · 경계값은 엑셀과 같게(Formula.js 는 시험 대조 답으로만).
|
||||
* 논리 · 글 · 정보 · 날짜 함수. 찾기 · 조건부 세기/합/평균/최대/최소는
|
||||
* `spreadsheet_functions_lookup.ts` 로 옮김(700 줄 규칙 · 2026-09-27).
|
||||
* 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
ZERO,
|
||||
add,
|
||||
cmp,
|
||||
frac,
|
||||
mul,
|
||||
@@ -15,12 +15,12 @@ import {
|
||||
toInteger,
|
||||
type Frac,
|
||||
} from "@ui/sheet/ui_template_sheet_frac";
|
||||
import type { ErrorValue, LazyArg, RangeValue, Scalar, SheetFunction } from "./spreadsheet_types";
|
||||
import type { ErrorValue, LazyArg, SheetFunction } from "./spreadsheet_types";
|
||||
import { formatValue } from "./spreadsheet_numfmt";
|
||||
import {
|
||||
bool,
|
||||
collectNumbers,
|
||||
fracToDouble,
|
||||
fromDouble,
|
||||
isErr,
|
||||
isFrac,
|
||||
isRange,
|
||||
@@ -28,82 +28,19 @@ import {
|
||||
num,
|
||||
str,
|
||||
toBool,
|
||||
toNumber,
|
||||
toScalar,
|
||||
toText,
|
||||
} from "./spreadsheet_functions_math";
|
||||
|
||||
const ONE = frac(1n);
|
||||
|
||||
// ── 값 견줌(찾기 · 세기 함수 공용) ───────────────────────────────────────────
|
||||
|
||||
function typeRank(v: Scalar): number {
|
||||
if (v === null) return -1;
|
||||
if (isFrac(v)) return 0;
|
||||
if (typeof v === "string") return 1;
|
||||
if (typeof v === "boolean") return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
function compareScalars(a: Scalar, b: Scalar): number {
|
||||
if (isFrac(a) && isFrac(b)) return cmp(a, b);
|
||||
if (typeof a === "string" && typeof b === "string") {
|
||||
const la = a.toUpperCase();
|
||||
const lb = b.toUpperCase();
|
||||
return la < lb ? -1 : la > lb ? 1 : 0;
|
||||
}
|
||||
if (typeof a === "boolean" && typeof b === "boolean") return a === b ? 0 : a ? 1 : -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function equalScalars(a: Scalar, b: Scalar): boolean {
|
||||
return typeRank(a) === typeRank(b) && compareScalars(a, b) === 0;
|
||||
}
|
||||
|
||||
function wrapScalar(v: Scalar): RangeValue {
|
||||
return { kind: "range", rows: 1, cols: 1, at: () => v };
|
||||
}
|
||||
|
||||
// ── 조건 글(`>10` · `<=5` · `사과*`) ─────────────────────────────────────────
|
||||
|
||||
function wildcardToRegex(text: string): RegExp {
|
||||
/** SEARCH — 통짜 맞춤이 아니라 안 어디서든 찾음(자리 없이 그대로 씀). */
|
||||
function wildcardToSearchRegex(text: string): RegExp {
|
||||
const escaped = text
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*/g, ".*")
|
||||
.replace(/\?/g, ".");
|
||||
return new RegExp(`^${escaped}$`, "i");
|
||||
}
|
||||
|
||||
function parseCriteria(crit: Scalar): (cell: Scalar) => boolean {
|
||||
if (crit === null) return (cell) => cell === null;
|
||||
if (typeof crit === "boolean") return (cell) => typeof cell === "boolean" && cell === crit;
|
||||
if (isFrac(crit)) return (cell) => isFrac(cell) && cmp(cell, crit) === 0;
|
||||
if (isErr(crit)) return () => false;
|
||||
const m = /^(<=|>=|<>|<|>|=)?(.*)$/.exec(crit);
|
||||
const op = m?.[1] ?? "";
|
||||
const rest = m?.[2] ?? crit;
|
||||
let numRest: Frac | null = null;
|
||||
try {
|
||||
numRest = rest.trim() === "" ? null : parseDecimal(rest);
|
||||
} catch {
|
||||
numRest = null;
|
||||
}
|
||||
if (numRest !== null) {
|
||||
const nr = numRest;
|
||||
return (cell) => {
|
||||
if (!isFrac(cell)) return false;
|
||||
const c = cmp(cell, nr);
|
||||
if (op === "<=") return c <= 0;
|
||||
if (op === ">=") return c >= 0;
|
||||
if (op === "<>") return c !== 0;
|
||||
if (op === "<") return c < 0;
|
||||
if (op === ">") return c > 0;
|
||||
return c === 0;
|
||||
};
|
||||
}
|
||||
const pat = wildcardToRegex(rest);
|
||||
if (op === "<>") return (cell) => !(typeof cell === "string" && pat.test(cell));
|
||||
return (cell) => typeof cell === "string" && pat.test(cell);
|
||||
return new RegExp(escaped, "i");
|
||||
}
|
||||
|
||||
// ── 숫자 → 고정 소수 글(FIXED) ───────────────────────────────────────────────
|
||||
@@ -126,8 +63,6 @@ function formatFixed(n: Frac, decimals: number, commas: boolean): string {
|
||||
return negative ? `-${body}` : body;
|
||||
}
|
||||
|
||||
// ── 함수 표 ──────────────────────────────────────────────────────────────
|
||||
|
||||
function collectBools(args: LazyArg[]): boolean[] | ErrorValue {
|
||||
const out: boolean[] = [];
|
||||
for (const a of args) {
|
||||
@@ -152,6 +87,23 @@ function collectBools(args: LazyArg[]): boolean[] | ErrorValue {
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── 날짜 일련번호(엑셀 1900 체계 · 1970-01-01 = 25569) ───────────────────────
|
||||
// 1900 을 윤년 취급하는 옛 버그는 그대로 두지 않음(1900 년대 자체를 실무가 안 씀) —
|
||||
// 유닉스 자정 기준으로 바로 셈해 1900 년 2 월 안쪽만 어긋남(1단계 밖).
|
||||
|
||||
const MS_PER_DAY = 86400000;
|
||||
const UNIX_EPOCH_AS_EXCEL_SERIAL = 25569;
|
||||
|
||||
function serialToDate(serial: number): Date {
|
||||
return new Date(Math.round((serial - UNIX_EPOCH_AS_EXCEL_SERIAL) * MS_PER_DAY));
|
||||
}
|
||||
|
||||
function dateToSerial(d: Date): number {
|
||||
return d.getTime() / MS_PER_DAY + UNIX_EPOCH_AS_EXCEL_SERIAL;
|
||||
}
|
||||
|
||||
// ── 함수 표 ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const MISC_FUNCTIONS: Record<string, SheetFunction> = {
|
||||
IF: {
|
||||
min: 2,
|
||||
@@ -311,217 +263,207 @@ export const MISC_FUNCTIONS: Record<string, SheetFunction> = {
|
||||
}
|
||||
},
|
||||
},
|
||||
VLOOKUP: {
|
||||
TEXTJOIN: {
|
||||
min: 3,
|
||||
max: 4,
|
||||
call: (args) => {
|
||||
const lookup = toScalar(args[0]());
|
||||
if (isErr(lookup)) return lookup;
|
||||
const table = args[1]();
|
||||
if (!isRange(table)) return mkErr("#VALUE!");
|
||||
const colN = num(args[2]());
|
||||
if (isErr(colN)) return colN;
|
||||
const col = Math.trunc(fracToDouble(colN)) - 1;
|
||||
if (col < 0 || col >= table.cols) return mkErr("#REF!");
|
||||
const approxRaw = args.length > 3 ? bool(args[3]()) : true;
|
||||
if (isErr(approxRaw)) return approxRaw;
|
||||
if (approxRaw) {
|
||||
let best = -1;
|
||||
for (let r = 0; r < table.rows; r++) {
|
||||
const key = table.at(r, 0);
|
||||
if (typeRank(key) !== typeRank(lookup)) {
|
||||
if (best >= 0) break;
|
||||
continue;
|
||||
}
|
||||
if (compareScalars(key, lookup) <= 0) best = r;
|
||||
else break;
|
||||
}
|
||||
if (best < 0) return mkErr("#N/A");
|
||||
return table.at(best, col);
|
||||
}
|
||||
for (let r = 0; r < table.rows; r++) {
|
||||
if (equalScalars(table.at(r, 0), lookup)) return table.at(r, col);
|
||||
}
|
||||
return mkErr("#N/A");
|
||||
},
|
||||
},
|
||||
HLOOKUP: {
|
||||
min: 3,
|
||||
max: 4,
|
||||
call: (args) => {
|
||||
const lookup = toScalar(args[0]());
|
||||
if (isErr(lookup)) return lookup;
|
||||
const table = args[1]();
|
||||
if (!isRange(table)) return mkErr("#VALUE!");
|
||||
const rowN = num(args[2]());
|
||||
if (isErr(rowN)) return rowN;
|
||||
const row = Math.trunc(fracToDouble(rowN)) - 1;
|
||||
if (row < 0 || row >= table.rows) return mkErr("#REF!");
|
||||
const approxRaw = args.length > 3 ? bool(args[3]()) : true;
|
||||
if (isErr(approxRaw)) return approxRaw;
|
||||
if (approxRaw) {
|
||||
let best = -1;
|
||||
for (let c = 0; c < table.cols; c++) {
|
||||
const key = table.at(0, c);
|
||||
if (typeRank(key) !== typeRank(lookup)) {
|
||||
if (best >= 0) break;
|
||||
continue;
|
||||
}
|
||||
if (compareScalars(key, lookup) <= 0) best = c;
|
||||
else break;
|
||||
}
|
||||
if (best < 0) return mkErr("#N/A");
|
||||
return table.at(row, best);
|
||||
}
|
||||
for (let c = 0; c < table.cols; c++) {
|
||||
if (equalScalars(table.at(0, c), lookup)) return table.at(row, c);
|
||||
}
|
||||
return mkErr("#N/A");
|
||||
},
|
||||
},
|
||||
INDEX: {
|
||||
min: 2,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const rangeArg = args[0]();
|
||||
if (isErr(rangeArg)) return rangeArg;
|
||||
const range = isRange(rangeArg) ? rangeArg : wrapScalar(rangeArg);
|
||||
const rowN = num(args[1]());
|
||||
if (isErr(rowN)) return rowN;
|
||||
const row = Math.trunc(fracToDouble(rowN));
|
||||
const colN = args.length > 2 ? num(args[2]()) : frac(range.cols === 1 ? 1n : 0n);
|
||||
if (isErr(colN)) return colN;
|
||||
const col = Math.trunc(fracToDouble(colN));
|
||||
if (row === 0 && col === 0) return mkErr("#VALUE!");
|
||||
if (row === 0) {
|
||||
if (col < 1 || col > range.cols) return mkErr("#REF!");
|
||||
const c = col - 1;
|
||||
return { kind: "range", rows: range.rows, cols: 1, at: (r: number) => range.at(r, c) };
|
||||
}
|
||||
if (col === 0) {
|
||||
if (row < 1 || row > range.rows) return mkErr("#REF!");
|
||||
const r = row - 1;
|
||||
return {
|
||||
kind: "range",
|
||||
rows: 1,
|
||||
cols: range.cols,
|
||||
at: (_r: number, c: number) => range.at(r, c),
|
||||
};
|
||||
}
|
||||
if (row < 1 || row > range.rows || col < 1 || col > range.cols) return mkErr("#REF!");
|
||||
return range.at(row - 1, col - 1);
|
||||
},
|
||||
},
|
||||
MATCH: {
|
||||
min: 2,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const lookup = toScalar(args[0]());
|
||||
if (isErr(lookup)) return lookup;
|
||||
const rangeArg = args[1]();
|
||||
if (!isRange(rangeArg)) return mkErr("#VALUE!");
|
||||
if (rangeArg.rows !== 1 && rangeArg.cols !== 1) {
|
||||
return mkErr("#VALUE!", "한 줄 · 한 칸짜리 범위만");
|
||||
}
|
||||
const n = rangeArg.rows === 1 ? rangeArg.cols : rangeArg.rows;
|
||||
const at = (i: number): Scalar =>
|
||||
rangeArg.rows === 1 ? rangeArg.at(0, i) : rangeArg.at(i, 0);
|
||||
const modeN = args.length > 2 ? num(args[2]()) : ONE;
|
||||
if (isErr(modeN)) return modeN;
|
||||
const mode = Math.trunc(fracToDouble(modeN));
|
||||
if (mode === 0) {
|
||||
for (let i = 0; i < n; i++) if (equalScalars(at(i), lookup)) return frac(BigInt(i + 1));
|
||||
return mkErr("#N/A");
|
||||
}
|
||||
let best = -1;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const key = at(i);
|
||||
if (typeRank(key) !== typeRank(lookup)) {
|
||||
if (best >= 0) break;
|
||||
continue;
|
||||
}
|
||||
const c = compareScalars(key, lookup);
|
||||
if (mode > 0 ? c <= 0 : c >= 0) best = i;
|
||||
else break;
|
||||
}
|
||||
if (best < 0) return mkErr("#N/A");
|
||||
return frac(BigInt(best + 1));
|
||||
},
|
||||
},
|
||||
CHOOSE: {
|
||||
min: 2,
|
||||
call: (args) => {
|
||||
const idxN = num(args[0]());
|
||||
if (isErr(idxN)) return idxN;
|
||||
const idx = Math.trunc(fracToDouble(idxN));
|
||||
if (idx < 1 || idx > args.length - 1) return mkErr("#VALUE!");
|
||||
return args[idx]();
|
||||
},
|
||||
},
|
||||
COUNT: {
|
||||
min: 0,
|
||||
call: (args) => {
|
||||
const values = collectNumbers(args, false, false);
|
||||
return isErr(values) ? values : frac(BigInt(values.length));
|
||||
},
|
||||
},
|
||||
COUNTA: {
|
||||
min: 0,
|
||||
call: (args) => {
|
||||
let count = 0;
|
||||
for (const a of args) {
|
||||
const v = a();
|
||||
const delim = str(args[0]());
|
||||
if (isErr(delim)) return delim;
|
||||
const ignoreEmpty = bool(args[1]());
|
||||
if (isErr(ignoreEmpty)) return ignoreEmpty;
|
||||
const parts: string[] = [];
|
||||
for (let i = 2; i < args.length; i++) {
|
||||
const v = args[i]();
|
||||
if (isRange(v)) {
|
||||
for (let r = 0; r < v.rows; r++) {
|
||||
for (let c = 0; c < v.cols; c++) if (v.at(r, c) !== null) count++;
|
||||
for (let c = 0; c < v.cols; c++) {
|
||||
const t = toText(v.at(r, c));
|
||||
if (isErr(t)) return t;
|
||||
if (!(ignoreEmpty && t === "")) parts.push(t);
|
||||
}
|
||||
}
|
||||
} else if (v !== null) {
|
||||
count++;
|
||||
} else {
|
||||
const t = str(v);
|
||||
if (isErr(t)) return t;
|
||||
if (!(ignoreEmpty && t === "")) parts.push(t);
|
||||
}
|
||||
}
|
||||
return frac(BigInt(count));
|
||||
return parts.join(delim);
|
||||
},
|
||||
},
|
||||
COUNTIF: {
|
||||
TRIM: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const s = str(args[0]());
|
||||
return isErr(s) ? s : s.split(" ").filter(Boolean).join(" ");
|
||||
},
|
||||
},
|
||||
UPPER: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const s = str(args[0]());
|
||||
return isErr(s) ? s : s.toUpperCase();
|
||||
},
|
||||
},
|
||||
LOWER: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const s = str(args[0]());
|
||||
return isErr(s) ? s : s.toLowerCase();
|
||||
},
|
||||
},
|
||||
SUBSTITUTE: {
|
||||
min: 3,
|
||||
max: 4,
|
||||
call: (args) => {
|
||||
const text = str(args[0]());
|
||||
if (isErr(text)) return text;
|
||||
const oldT = str(args[1]());
|
||||
if (isErr(oldT)) return oldT;
|
||||
const newT = str(args[2]());
|
||||
if (isErr(newT)) return newT;
|
||||
if (oldT === "") return text;
|
||||
if (args.length <= 3) return text.split(oldT).join(newT);
|
||||
const nthArg = num(args[3]());
|
||||
if (isErr(nthArg)) return nthArg;
|
||||
const nth = Math.trunc(fracToDouble(nthArg));
|
||||
let idx = -1;
|
||||
let count = 0;
|
||||
let pos = 0;
|
||||
for (;;) {
|
||||
const found = text.indexOf(oldT, pos);
|
||||
if (found < 0) break;
|
||||
count++;
|
||||
if (count === nth) {
|
||||
idx = found;
|
||||
break;
|
||||
}
|
||||
pos = found + oldT.length;
|
||||
}
|
||||
return idx < 0 ? text : text.slice(0, idx) + newT + text.slice(idx + oldT.length);
|
||||
},
|
||||
},
|
||||
REPLACE: {
|
||||
min: 4,
|
||||
max: 4,
|
||||
call: (args) => {
|
||||
const text = str(args[0]());
|
||||
if (isErr(text)) return text;
|
||||
const startN = num(args[1]());
|
||||
if (isErr(startN)) return startN;
|
||||
const lenN = num(args[2]());
|
||||
if (isErr(lenN)) return lenN;
|
||||
const newText = str(args[3]());
|
||||
if (isErr(newText)) return newText;
|
||||
const start = Math.trunc(fracToDouble(startN));
|
||||
const len = Math.max(0, Math.trunc(fracToDouble(lenN)));
|
||||
if (start < 1) return mkErr("#VALUE!");
|
||||
return text.slice(0, start - 1) + newText + text.slice(start - 1 + len);
|
||||
},
|
||||
},
|
||||
FIND: {
|
||||
min: 2,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const findT = str(args[0]());
|
||||
if (isErr(findT)) return findT;
|
||||
const within = str(args[1]());
|
||||
if (isErr(within)) return within;
|
||||
const startArg = args.length > 2 ? num(args[2]()) : ONE;
|
||||
if (isErr(startArg)) return startArg;
|
||||
const start = Math.trunc(fracToDouble(startArg));
|
||||
if (start < 1) return mkErr("#VALUE!");
|
||||
const idx = within.indexOf(findT, start - 1);
|
||||
return idx < 0 ? mkErr("#VALUE!") : frac(BigInt(idx + 1));
|
||||
},
|
||||
},
|
||||
SEARCH: {
|
||||
min: 2,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const findT = str(args[0]());
|
||||
if (isErr(findT)) return findT;
|
||||
const within = str(args[1]());
|
||||
if (isErr(within)) return within;
|
||||
const startArg = args.length > 2 ? num(args[2]()) : ONE;
|
||||
if (isErr(startArg)) return startArg;
|
||||
const start = Math.trunc(fracToDouble(startArg));
|
||||
if (start < 1) return mkErr("#VALUE!");
|
||||
const pat = wildcardToSearchRegex(findT);
|
||||
const m = pat.exec(within.slice(start - 1));
|
||||
return m ? frac(BigInt(m.index + start)) : mkErr("#VALUE!");
|
||||
},
|
||||
},
|
||||
REPT: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const range = args[0]();
|
||||
if (!isRange(range)) return mkErr("#VALUE!");
|
||||
const crit = toScalar(args[1]());
|
||||
if (isErr(crit)) return crit;
|
||||
const pred = parseCriteria(crit);
|
||||
let count = 0;
|
||||
for (let r = 0; r < range.rows; r++) {
|
||||
for (let c = 0; c < range.cols; c++) if (pred(range.at(r, c))) count++;
|
||||
}
|
||||
return frac(BigInt(count));
|
||||
const s = str(args[0]());
|
||||
if (isErr(s)) return s;
|
||||
const nArg = num(args[1]());
|
||||
if (isErr(nArg)) return nArg;
|
||||
const n = Math.trunc(fracToDouble(nArg));
|
||||
return n < 0 ? mkErr("#VALUE!") : s.repeat(n);
|
||||
},
|
||||
},
|
||||
SUMIF: {
|
||||
min: 2,
|
||||
ISNUMBER: { min: 1, max: 1, call: (args) => isFrac(toScalar(args[0]())) },
|
||||
ISTEXT: { min: 1, max: 1, call: (args) => typeof toScalar(args[0]()) === "string" },
|
||||
ISBLANK: { min: 1, max: 1, call: (args) => toScalar(args[0]()) === null },
|
||||
ISERROR: { min: 1, max: 1, call: (args) => isErr(toScalar(args[0]())) },
|
||||
NA: { min: 0, max: 0, call: () => mkErr("#N/A") },
|
||||
TODAY: {
|
||||
min: 0,
|
||||
max: 0,
|
||||
call: () => {
|
||||
const now = new Date();
|
||||
const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||
return fromDouble(dateToSerial(new Date(midnight)));
|
||||
},
|
||||
},
|
||||
DATE: {
|
||||
min: 3,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const range = args[0]();
|
||||
if (!isRange(range)) return mkErr("#VALUE!");
|
||||
const crit = toScalar(args[1]());
|
||||
if (isErr(crit)) return crit;
|
||||
const pred = parseCriteria(crit);
|
||||
const sumRange = args.length > 2 ? args[2]() : range;
|
||||
if (!isRange(sumRange)) return mkErr("#VALUE!");
|
||||
if (sumRange.rows !== range.rows || sumRange.cols !== range.cols) {
|
||||
return mkErr("#VALUE!", "합 범위 크기가 다름");
|
||||
}
|
||||
let total = ZERO;
|
||||
for (let r = 0; r < range.rows; r++) {
|
||||
for (let c = 0; c < range.cols; c++) {
|
||||
if (!pred(range.at(r, c))) continue;
|
||||
const n = toNumber(sumRange.at(r, c));
|
||||
if (isErr(n)) return n;
|
||||
total = add(total, n);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
const y = num(args[0]());
|
||||
if (isErr(y)) return y;
|
||||
const m = num(args[1]());
|
||||
if (isErr(m)) return m;
|
||||
const d = num(args[2]());
|
||||
if (isErr(d)) return d;
|
||||
const date = new Date(
|
||||
Date.UTC(
|
||||
Math.trunc(fracToDouble(y)),
|
||||
Math.trunc(fracToDouble(m)) - 1,
|
||||
Math.trunc(fracToDouble(d)),
|
||||
),
|
||||
);
|
||||
return fromDouble(dateToSerial(date));
|
||||
},
|
||||
},
|
||||
YEAR: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCFullYear()));
|
||||
},
|
||||
},
|
||||
MONTH: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCMonth() + 1));
|
||||
},
|
||||
},
|
||||
DAY: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCDate()));
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
fracToString,
|
||||
mul,
|
||||
roundAt,
|
||||
sub,
|
||||
toInteger,
|
||||
type Frac,
|
||||
} from "@ui/sheet/ui_template_sheet_frac";
|
||||
@@ -235,6 +236,130 @@ function renderNumericSection(toks: Tok[], magnitude: Frac): string {
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── 분수 형식(`0/0` · `# ?/?` · `# ??/??` · 분모 고정 `# ?/8`) ───────────────
|
||||
|
||||
interface FracPlan {
|
||||
prefix: Tok[];
|
||||
wholeDigits: number; // 0 = 정수부 자리 없음(가분수로 통째)
|
||||
sepToks: Tok[]; // 정수부 · 분자 사이 리터럴(보통 칸 하나)
|
||||
fixedDenom: bigint | null; // null = 분모 자리표시(자동 찾기) · 있으면 그 값 고정
|
||||
denomDigits: number; // 자동일 때 자리표시 개수(최대 분모 = 10^자리 - 1)
|
||||
suffix: Tok[];
|
||||
}
|
||||
|
||||
/** 슬래시 앞뒤 자리표시(0/#/?)·고정 숫자 자리를 찾음 — 없으면 분수 형식이 아님(null). */
|
||||
function detectFraction(toks: Tok[]): FracPlan | null {
|
||||
if (toks.some((t) => t.k === "point")) return null;
|
||||
const slashIdx = toks.findIndex((t) => t.k === "lit" && t.ch === "/");
|
||||
if (slashIdx < 0) return null;
|
||||
|
||||
let i = slashIdx - 1;
|
||||
while (i >= 0 && toks[i].k === "digit") i--;
|
||||
const numeratorStart = i + 1;
|
||||
if (numeratorStart === slashIdx) return null; // 분자 자리표시 없음
|
||||
|
||||
let j = slashIdx + 1;
|
||||
let denomDigits = 0;
|
||||
let fixedDigits = "";
|
||||
let mode: "auto" | "fixed" | null = null;
|
||||
while (j < toks.length) {
|
||||
const t = toks[j];
|
||||
if (t.k === "digit") {
|
||||
if (mode === "fixed") break;
|
||||
mode = "auto";
|
||||
denomDigits++;
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
if (t.k === "lit" && /[0-9]/.test(t.ch)) {
|
||||
if (mode === "auto") break;
|
||||
mode = "fixed";
|
||||
fixedDigits += t.ch;
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (mode === null) return null;
|
||||
const denomEnd = j;
|
||||
|
||||
const sepToks: Tok[] = [];
|
||||
let k = numeratorStart - 1;
|
||||
while (k >= 0 && toks[k].k !== "digit") {
|
||||
sepToks.unshift(toks[k]);
|
||||
k--;
|
||||
}
|
||||
let wholeDigits = 0;
|
||||
while (k >= 0 && toks[k].k === "digit") {
|
||||
wholeDigits++;
|
||||
k--;
|
||||
}
|
||||
const prefix = wholeDigits > 0 ? toks.slice(0, k + 1) : toks.slice(0, numeratorStart);
|
||||
if (wholeDigits === 0) sepToks.length = 0;
|
||||
|
||||
return {
|
||||
prefix,
|
||||
wholeDigits,
|
||||
sepToks,
|
||||
fixedDenom: mode === "fixed" ? BigInt(fixedDigits) : null,
|
||||
denomDigits,
|
||||
suffix: toks.slice(denomEnd),
|
||||
};
|
||||
}
|
||||
|
||||
/** 분모가 `maxDenom` 이하인 가장 가까운 분수(0 ≤ x) — 다 훑어봐도 999 번 안쪽(자리표시 최대 3 개). */
|
||||
function bestFraction(x: Frac, maxDenom: bigint): { num: bigint; den: bigint } {
|
||||
let bestNum = 0n;
|
||||
let bestDen = 1n;
|
||||
let bestDiff: Frac | null = null;
|
||||
for (let den = 1n; den <= maxDenom; den++) {
|
||||
const num = toInteger(mul(x, frac(den)), "round");
|
||||
const gap = sub(x, frac(num, den));
|
||||
const diff = frac(gap.n < 0n ? -gap.n : gap.n, gap.d);
|
||||
if (bestDiff === null || cmp(diff, bestDiff) < 0) {
|
||||
bestDiff = diff;
|
||||
bestNum = num;
|
||||
bestDen = den;
|
||||
}
|
||||
}
|
||||
return { num: bestNum, den: bestDen };
|
||||
}
|
||||
|
||||
function renderFractionSection(plan: FracPlan, magnitude: Frac): string {
|
||||
let whole = 0n;
|
||||
let remainder = magnitude;
|
||||
if (plan.wholeDigits > 0) {
|
||||
whole = toInteger(magnitude, "trunc");
|
||||
remainder = sub(magnitude, frac(whole));
|
||||
}
|
||||
let num: bigint;
|
||||
let den: bigint;
|
||||
if (plan.fixedDenom !== null) {
|
||||
den = plan.fixedDenom;
|
||||
num = toInteger(mul(remainder, frac(den)), "round");
|
||||
} else {
|
||||
const maxDenom = 10n ** BigInt(plan.denomDigits) - 1n;
|
||||
const best = bestFraction(remainder, maxDenom);
|
||||
num = best.num;
|
||||
den = best.den;
|
||||
}
|
||||
if (plan.wholeDigits > 0 && den > 0n && num >= den) {
|
||||
whole += num / den;
|
||||
num %= den;
|
||||
}
|
||||
const fracText = num !== 0n ? `${num}/${den}` : "";
|
||||
|
||||
let out = "";
|
||||
for (const t of plan.prefix) if (t.k === "lit") out += t.ch;
|
||||
if (plan.wholeDigits > 0) {
|
||||
if (whole !== 0n || fracText === "") out += whole.toString();
|
||||
if (fracText !== "") for (const t of plan.sepToks) if (t.k === "lit") out += t.ch;
|
||||
}
|
||||
out += fracText;
|
||||
for (const t of plan.suffix) if (t.k === "lit") out += t.ch;
|
||||
return out;
|
||||
}
|
||||
|
||||
function generalFormat(value: Frac): string {
|
||||
return fracToString(value);
|
||||
}
|
||||
@@ -265,6 +390,11 @@ function formatNumber(value: Frac, code: string | undefined): FormattedText {
|
||||
if (color) extra.색 = color;
|
||||
if (fillChar) extra.채움글 = fillChar;
|
||||
const magnitude = cmp(value, ZERO) < 0 ? frac(-value.n, value.d) : value;
|
||||
const fraction = detectFraction(toks);
|
||||
if (fraction) {
|
||||
const body = renderFractionSection(fraction, magnitude);
|
||||
return { 글: (forceMinus ? "-" : "") + body, 정렬: "right", ...extra };
|
||||
}
|
||||
if (!toks.some((t) => t.k === "digit" || t.k === "general")) {
|
||||
let out = "";
|
||||
for (const t of toks) if (t.k === "lit") out += t.ch;
|
||||
|
||||
@@ -111,6 +111,9 @@ fracEq(
|
||||
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");
|
||||
@@ -142,6 +145,29 @@ 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");
|
||||
@@ -161,6 +187,22 @@ 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");
|
||||
|
||||
// ── 찾기 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -207,6 +249,51 @@ fracEq(
|
||||
"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));
|
||||
}
|
||||
|
||||
// ── 숫자 형식 ────────────────────────────────────────────────────────────
|
||||
|
||||
function fmt(value: Scalar, code: string): string {
|
||||
@@ -249,6 +336,16 @@ 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), "# ?/?"));
|
||||
ok("글자 형식 @", fmt("abc", "@@") === "abcabc", fmt("abc", "@@"));
|
||||
ok(
|
||||
"일반(General)",
|
||||
|
||||
Reference in New Issue
Block a user