diff --git a/A00_Common/spreadsheet/spreadsheet_fill.ts b/A00_Common/spreadsheet/spreadsheet_fill.ts index 9619ea06..556d8033 100644 --- a/A00_Common/spreadsheet/spreadsheet_fill.ts +++ b/A00_Common/spreadsheet/spreadsheet_fill.ts @@ -2,21 +2,104 @@ * spreadsheet_fill.ts (주인 B) * 채우기 핸들 — 원본 범위를 대상 범위로 늘림: 수 하나 = 복사 · 수 둘 이상 = 등차 연속 · * 글 끝 번호(`구-01` → `구-02`) · 식 = 상대 참조 옮김(A `moveFormula`) · 서식 같이. - * 결과는 `{ 종류: "칸" }` 명령에 넣을 칸 뭉치. 0 계약 머리 — 몸은 B 가 채움. + * 결과는 `{ 종류: "칸" }` 명령에 넣을 칸 뭉치. * ========================================================================== */ -import type { Cell, CellRange, Workbook } from "./spreadsheet_types"; +import type { Cell, CellRange, FillDirection, Workbook } from "./spreadsheet_types"; +import { moveFormula } from "./spreadsheet_refshift"; -const todo = (): never => { - throw new Error("spreadsheet_fill: 아직 없음(B)"); -}; +/** 0 → `A` · 27 → `AB`(엔진 A 의 `spreadsheet_address.ts` 와 같은 규칙 · 채우기는 자기 것을 씀) */ +function colLetters(c: number): string { + let n = c + 1; + let s = ""; + while (n > 0) { + const rem = (n - 1) % 26; + s = String.fromCharCode(65 + rem) + s; + n = Math.floor((n - 1) / 26); + } + return s; +} + +function a1(r: number, c: number): string { + return `${colLetters(c)}${r + 1}`; +} + +function detectDirection(source: CellRange, target: CellRange): FillDirection { + if (target.r1 > source.r1 && target.c0 === source.c0 && target.c1 === source.c1) return "down"; + if (target.r0 < source.r0 && target.c0 === source.c0 && target.c1 === source.c1) return "up"; + if (target.c1 > source.c1 && target.r0 === source.r0 && target.r1 === source.r1) return "right"; + return "left"; +} + +/** 글 끝 숫자를 옮김(`구-01`,step 1 → `구-02`) · 끝 숫자가 없으면 그대로 */ +function incrementTrailingNumber(text: string, step: number): string { + const m = /^(.*?)(\d+)$/.exec(text); + if (!m) return text; + const [, prefix, digits] = m; + const next = BigInt(digits) + BigInt(step); + const clamped = next < 0n ? 0n : next; + return prefix + clamped.toString().padStart(digits.length, "0"); +} /** `target` 은 `source` 를 한 방향으로 늘린 범위(방향은 둘을 견줘 앎) · A1 → 칸(null = 비움) */ export function fillCells( - _book: Workbook, - _sheet: string, - _source: CellRange, - _target: CellRange, + book: Workbook, + sheet: string, + source: CellRange, + target: CellRange, ): Record { - return todo(); + const sheetObj = book.시트.find((s) => s.id === sheet); + const out: Record = {}; + if (!sheetObj) return out; + + const direction = detectDirection(source, target); + const vertical = direction === "down" || direction === "up"; + const lineCount = vertical ? source.c1 - source.c0 + 1 : source.r1 - source.r0 + 1; + + for (let li = 0; li < lineCount; li++) { + const fixed = vertical ? source.c0 + li : source.r0 + li; + const sourceStart = vertical ? source.r0 : source.c0; + const sourceEnd = vertical ? source.r1 : source.c1; + const targetStart = vertical ? target.r0 : target.c0; + const targetEnd = vertical ? target.r1 : target.c1; + const lineLen = sourceEnd - sourceStart + 1; + + const key = (pos: number): string => (vertical ? a1(pos, fixed) : a1(fixed, pos)); + const sourceCells: (Cell | undefined)[] = []; + for (let p = sourceStart; p <= sourceEnd; p++) sourceCells.push(sheetObj.칸[key(p)]); + + const allNumeric = sourceCells.every( + (c) => c !== undefined && c.식 === undefined && typeof c.값 === "number", + ); + const nums = allNumeric ? sourceCells.map((c) => c!.값 as number) : []; + const step = nums.length > 1 ? (nums[nums.length - 1] - nums[0]) / (nums.length - 1) : 0; + + for (let p = targetStart; p <= targetEnd; p++) { + if (p >= sourceStart && p <= sourceEnd) continue; + const rel = p - sourceStart; + const slot = ((rel % lineLen) + lineLen) % lineLen; + const period = (rel - slot) / lineLen; + const template = sourceCells[slot]; + const outKey = key(p); + if (template === undefined) { + out[outKey] = null; + continue; + } + const cell: Cell = {}; + if (template.서식 !== undefined) cell.서식 = template.서식; + if (allNumeric) { + cell.값 = nums[0] + step * rel; + } else if (template.식 !== undefined) { + const dr = vertical ? period * lineLen : 0; + const dc = vertical ? 0 : period * lineLen; + cell.식 = moveFormula(template.식, dr, dc); + } else if (typeof template.값 === "string" && lineLen === 1) { + cell.값 = incrementTrailingNumber(template.값, rel); + } else if (template.값 !== undefined) { + cell.값 = template.값; + } + out[outKey] = cell; + } + } + return out; } diff --git a/A00_Common/spreadsheet/spreadsheet_functions.ts b/A00_Common/spreadsheet/spreadsheet_functions.ts index 5727a888..d0cabc22 100644 --- a/A00_Common/spreadsheet/spreadsheet_functions.ts +++ b/A00_Common/spreadsheet/spreadsheet_functions.ts @@ -6,20 +6,19 @@ * 글 CONCATENATE FIXED TEXT LEN LEFT RIGHT MID VALUE · 찾기 VLOOKUP HLOOKUP INDEX MATCH CHOOSE · * 세기 COUNT COUNTA COUNTIF SUMIF. 뜻 · 경계값은 엑셀과 같게(Formula.js 는 시험 대조 답으로만). * 없는 이름 = `#NAME?` · 인자 수가 min · max 밖이면 A 의 eval 이 `#VALUE!` + 까닭(엑셀은 입력을 막음). - * 0 계약 머리 — 몸은 B 가 채움. * ========================================================================== */ import type { SheetFunction } from "./spreadsheet_types"; +import { MATH_FUNCTIONS } from "./spreadsheet_functions_math"; +import { MISC_FUNCTIONS } from "./spreadsheet_functions_misc"; -const todo = (): never => { - throw new Error("spreadsheet_functions: 아직 없음(B)"); -}; +const TABLE: Record = { ...MATH_FUNCTIONS, ...MISC_FUNCTIONS }; -export function getFunction(_name: string): SheetFunction | undefined { - return todo(); +export function getFunction(name: string): SheetFunction | undefined { + return TABLE[name.toUpperCase()]; } /** 아는 함수 이름 전부(가나다 · 자동 완성은 2단계) */ export function functionNames(): string[] { - return todo(); + return Object.keys(TABLE).sort(); } diff --git a/A00_Common/spreadsheet/spreadsheet_functions_math.ts b/A00_Common/spreadsheet/spreadsheet_functions_math.ts new file mode 100644 index 00000000..99f21e12 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_functions_math.ts @@ -0,0 +1,418 @@ +/* ============================================================================= + * spreadsheet_functions_math.ts (주인 B) + * 수학 · 삼각 · 끝수 함수 + 함수 표 전체가 쓰는 형 바꾸기 도우미(공용은 여기서 export). + * 뜻 · 경계값은 엑셀과 같게(Formula.js 는 시험 대조 답으로만 씀 · 속에 안 넣음). + * 무리수(SQRT · 삼각 · PI · 소수 거듭제곱)는 double → 유효 15 자리 십진 → 분수. + * ========================================================================== */ + +import { + ZERO, + add, + cmp, + div, + frac, + mul, + parseDecimal, + roundAt, + sub, + toInteger, + type Frac, +} from "@ui/sheet/ui_template_sheet_frac"; +import type { + ErrorCode, + ErrorValue, + EvalResult, + LazyArg, + RangeValue, + Scalar, + SheetFunction, +} from "./spreadsheet_types"; + +const ONE = frac(1n); + +// ── 공용 도우미(math · misc 둘 다 씀) ──────────────────────────────────────── + +export function mkErr(code: ErrorCode, why?: string): ErrorValue { + return why === undefined ? { error: code } : { error: code, why }; +} + +export function isErr(v: unknown): v is ErrorValue { + return typeof v === "object" && v !== null && "error" in (v as Record); +} + +export function isFrac(v: unknown): v is Frac { + return typeof v === "object" && v !== null && "n" in (v as Record); +} + +export function isRange(v: EvalResult): v is RangeValue { + return typeof v === "object" && v !== null && (v as RangeValue).kind === "range"; +} + +/** 범위면 1×1 일 때만 칸 하나로 · 아니면 `#VALUE!` */ +export function toScalar(v: EvalResult): Scalar | ErrorValue { + if (!isRange(v)) return v; + if (v.rows === 1 && v.cols === 1) return v.at(0, 0); + return mkErr("#VALUE!", "범위를 칸 하나 자리에 씀"); +} + +/** 빈 칸 0 · 참거짓 1/0 · 수 글 · 아니면 `#VALUE!` */ +export function toNumber(v: Scalar): Frac | ErrorValue { + if (v === null) return ZERO; + if (isErr(v)) return v; + if (typeof v === "boolean") return v ? ONE : ZERO; + if (typeof v === "string") { + try { + return parseDecimal(v); + } catch { + return mkErr("#VALUE!", `글을 수로 못 읽음: ${v}`); + } + } + return v; +} + +/** 인자 하나(범위는 1×1 만) → 수 */ +export function num(v: EvalResult): Frac | ErrorValue { + const s = toScalar(v); + return isErr(s) ? s : toNumber(s); +} + +/** 글로 — 수는 일반 형식(간단 십진) · 참거짓 TRUE/FALSE */ +export function toText(v: Scalar): string | ErrorValue { + if (v === null) return ""; + if (isErr(v)) return v; + if (typeof v === "string") return v; + if (typeof v === "boolean") return v ? "TRUE" : "FALSE"; + return generalNumberText(v); +} + +export function str(v: EvalResult): string | ErrorValue { + const s = toScalar(v); + return isErr(s) ? s : toText(s); +} + +export function toBool(v: Scalar): boolean | ErrorValue { + if (v === null) return false; + if (isErr(v)) return v; + if (typeof v === "boolean") return v; + if (typeof v === "string") { + const up = v.trim().toUpperCase(); + if (up === "TRUE") return true; + if (up === "FALSE") return false; + return mkErr("#VALUE!", `글을 참거짓으로 못 읽음: ${v}`); + } + return cmp(v, ZERO) !== 0; +} + +export function bool(v: EvalResult): boolean | ErrorValue { + const s = toScalar(v); + return isErr(s) ? s : toBool(s); +} + +/** double → 유효 15 자리 십진 → 분수 · NaN · 무한은 `#NUM!` */ +export function fromDouble(x: number): Frac | ErrorValue { + if (!Number.isFinite(x)) return mkErr("#NUM!"); + if (x === 0) return ZERO; + return parseDecimal(x.toPrecision(15)); +} + +export function fracToDouble(x: Frac): number { + return Number(x.n) / Number(x.d); +} + +/** 엑셀 「일반」 형식과 같은 십진 글(수식 `&` · TEXT 안 형식 자리가 없을 때) */ +export function generalNumberText(x: Frac): string { + const scaled = toInteger(mul(x, frac(10n ** 15n)), "round"); + const negative = scaled < 0n; + const digits = (negative ? -scaled : scaled).toString().padStart(16, "0"); + const whole = digits.slice(0, -15).replace(/^0+(?=\d)/, ""); + const fraction = digits.slice(-15).replace(/0+$/, ""); + const body = fraction ? `${whole}.${fraction}` : whole; + return negative && body !== "0" ? `-${body}` : body; +} + +/** 인자 목록에서 수만 모음 — 범위는 수 아닌 값 조용히 건너뜀 · 낱값은 형 바꿈(실패시 strict 면 오류) + * `propagateErrors` = 범위 · 낱값 오류를 그대로 돌려줄지(COUNT 는 false) */ +export function collectNumbers( + args: LazyArg[], + propagateErrors: boolean, + strictScalar: boolean, +): Frac[] | ErrorValue { + const out: Frac[] = []; + 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++) { + const cell = v.at(r, c); + if (isErr(cell)) { + if (propagateErrors) return cell; + continue; + } + if (isFrac(cell)) out.push(cell); + } + } + } else if (isErr(v)) { + if (propagateErrors) return v; + } else if (v !== null) { + const n = toNumber(v); + if (isErr(n)) { + if (strictScalar) return n; + } else { + out.push(n); + } + } + } + return out; +} + +function digitsArg(args: LazyArg[], index: number, fallback: number): number | ErrorValue { + if (index >= args.length) return fallback; + const n = num(args[index]()); + return isErr(n) ? n : Math.trunc(fracToDouble(n)); +} + +function unaryDouble(fn: (x: number) => number): SheetFunction { + return { + min: 1, + max: 1, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + return fromDouble(fn(fracToDouble(n))); + }, + }; +} + +// ── 함수 표 ────────────────────────────────────────────────────────────── + +export const MATH_FUNCTIONS: Record = { + SUM: { + min: 0, + call: (args) => { + const values = collectNumbers(args, true, true); + if (isErr(values)) return values; + return values.reduce(add, ZERO); + }, + }, + PRODUCT: { + min: 0, + call: (args) => { + const values = collectNumbers(args, true, true); + if (isErr(values)) return values; + return values.length === 0 ? ZERO : values.reduce(mul, ONE); + }, + }, + SUMPRODUCT: { + min: 1, + call: (args) => { + const arrays = args.map((a) => a()); + const grids = arrays.map((v) => + isRange(v) ? v : { rows: 1, cols: 1, at: () => (isErr(v) ? v : (v as Scalar)) }, + ); + const rows = grids[0].rows; + const cols = grids[0].cols; + for (const g of grids) { + if (g.rows !== rows || g.cols !== cols) return mkErr("#VALUE!", "배열 크기가 다름"); + } + let sum = ZERO; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + let prod = ONE; + for (const g of grids) { + const n = toNumber(g.at(r, c)); + if (isErr(n)) return n; + prod = mul(prod, n); + } + sum = add(sum, prod); + } + } + return sum; + }, + }, + ROUND: { + min: 2, + max: 2, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + const d = digitsArg(args, 1, 0); + if (isErr(d)) return d; + return roundAt(n, d, "round"); + }, + }, + ROUNDUP: { + min: 2, + max: 2, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + const d = digitsArg(args, 1, 0); + if (isErr(d)) return d; + return roundAt(n, d, "away"); + }, + }, + ROUNDDOWN: { + min: 2, + max: 2, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + const d = digitsArg(args, 1, 0); + if (isErr(d)) return d; + return roundAt(n, d, "trunc"); + }, + }, + INT: { + min: 1, + max: 1, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + return frac(toInteger(n, "floor")); + }, + }, + TRUNC: { + min: 1, + max: 2, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + const d = digitsArg(args, 1, 0); + if (isErr(d)) return d; + return roundAt(n, d, "trunc"); + }, + }, + CEILING: { + min: 2, + max: 2, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + const sig = num(args[1]()); + if (isErr(sig)) return sig; + if (cmp(sig, ZERO) === 0) return ZERO; + if (cmp(n, ZERO) !== 0 && cmp(n, ZERO) > 0 !== cmp(sig, ZERO) > 0) { + return mkErr("#NUM!", "수 · 기준의 부호가 다름"); + } + return mul(frac(toInteger(div(n, sig), "away")), sig); + }, + }, + FLOOR: { + min: 2, + max: 2, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + const sig = num(args[1]()); + if (isErr(sig)) return sig; + if (cmp(sig, ZERO) === 0) return ZERO; + if (cmp(n, ZERO) !== 0 && cmp(n, ZERO) > 0 !== cmp(sig, ZERO) > 0) { + return mkErr("#NUM!", "수 · 기준의 부호가 다름"); + } + return mul(frac(toInteger(div(n, sig), "trunc")), sig); + }, + }, + ABS: { + min: 1, + max: 1, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + return frac(n.n < 0n ? -n.n : n.n, n.d); + }, + }, + MOD: { + min: 2, + max: 2, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + const d = num(args[1]()); + if (isErr(d)) return d; + if (cmp(d, ZERO) === 0) return mkErr("#DIV/0!"); + const q = frac(toInteger(div(n, d), "floor")); + return sub(n, mul(d, q)); + }, + }, + POWER: { + min: 2, + max: 2, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + const p = num(args[1]()); + if (isErr(p)) return p; + if (p.d === 1n) { + const exp = p.n; + if (exp >= 0n) return frac(n.n ** exp, n.d ** exp); + if (n.n === 0n) return mkErr("#DIV/0!"); + return frac(n.d ** -exp, n.n ** -exp); + } + return fromDouble(Math.pow(fracToDouble(n), fracToDouble(p))); + }, + }, + SQRT: { + 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.sqrt(fracToDouble(n))); + }, + }, + PI: { min: 0, max: 0, call: () => fromDouble(Math.PI) }, + SIN: unaryDouble(Math.sin), + COS: unaryDouble(Math.cos), + TAN: unaryDouble(Math.tan), + ASIN: { + min: 1, + max: 1, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + const x = fracToDouble(n); + if (x < -1 || x > 1) return mkErr("#NUM!"); + return fromDouble(Math.asin(x)); + }, + }, + ACOS: { + min: 1, + max: 1, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + const x = fracToDouble(n); + if (x < -1 || x > 1) return mkErr("#NUM!"); + return fromDouble(Math.acos(x)); + }, + }, + ATAN: unaryDouble(Math.atan), + RADIANS: unaryDouble((x) => (x * Math.PI) / 180), + DEGREES: unaryDouble((x) => (x * 180) / Math.PI), + MIN: { + min: 0, + call: (args) => { + const values = collectNumbers(args, true, true); + if (isErr(values)) return values; + return values.length === 0 ? ZERO : values.reduce((a, b) => (cmp(a, b) <= 0 ? a : b)); + }, + }, + MAX: { + min: 0, + call: (args) => { + const values = collectNumbers(args, true, true); + if (isErr(values)) return values; + return values.length === 0 ? ZERO : values.reduce((a, b) => (cmp(a, b) >= 0 ? a : b)); + }, + }, + AVERAGE: { + min: 1, + call: (args) => { + const values = collectNumbers(args, true, true); + if (isErr(values)) return values; + if (values.length === 0) return mkErr("#DIV/0!"); + return div(values.reduce(add, ZERO), frac(BigInt(values.length))); + }, + }, +}; diff --git a/A00_Common/spreadsheet/spreadsheet_functions_misc.ts b/A00_Common/spreadsheet/spreadsheet_functions_misc.ts new file mode 100644 index 00000000..10d4916a --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_functions_misc.ts @@ -0,0 +1,527 @@ +/* ============================================================================= + * spreadsheet_functions_misc.ts (주인 B) + * 논리 · 글 · 찾기 · 세기 함수. 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀. + * 뜻 · 경계값은 엑셀과 같게(Formula.js 는 시험 대조 답으로만). + * ========================================================================== */ + +import { + ZERO, + add, + cmp, + frac, + mul, + parseDecimal, + roundAt, + toInteger, + type Frac, +} from "@ui/sheet/ui_template_sheet_frac"; +import type { ErrorValue, LazyArg, RangeValue, Scalar, SheetFunction } from "./spreadsheet_types"; +import { formatValue } from "./spreadsheet_numfmt"; +import { + bool, + collectNumbers, + fracToDouble, + isErr, + isFrac, + isRange, + mkErr, + 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 { + 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); +} + +// ── 숫자 → 고정 소수 글(FIXED) ─────────────────────────────────────────────── + +function groupThousands(digits: string): string { + return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ","); +} + +function formatFixed(n: Frac, decimals: number, commas: boolean): string { + const d = Math.max(0, decimals); + const rounded = roundAt(n, d, "round"); + const negative = cmp(rounded, ZERO) < 0; + const magnitude = negative ? frac(-rounded.n, rounded.d) : rounded; + const scaled = toInteger(mul(magnitude, frac(10n ** BigInt(d))), "round"); + const digits = scaled.toString().padStart(d + 1, "0"); + let whole = d > 0 ? digits.slice(0, -d) : digits; + const fraction = d > 0 ? digits.slice(-d) : ""; + if (commas) whole = groupThousands(whole); + const body = fraction ? `${whole}.${fraction}` : whole; + return negative ? `-${body}` : body; +} + +// ── 함수 표 ────────────────────────────────────────────────────────────── + +function collectBools(args: LazyArg[]): boolean[] | ErrorValue { + const out: boolean[] = []; + 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++) { + const cell = v.at(r, c); + if (isErr(cell)) return cell; + if (typeof cell === "boolean") out.push(cell); + else if (isFrac(cell)) out.push(cmp(cell, ZERO) !== 0); + } + } + } else if (isErr(v)) { + return v; + } else if (v !== null) { + const b = toBool(v); + if (isErr(b)) return b; + out.push(b); + } + } + return out; +} + +export const MISC_FUNCTIONS: Record = { + IF: { + min: 2, + max: 3, + call: (args) => { + const c = bool(args[0]()); + if (isErr(c)) return c; + if (c) return args[1](); + return args.length > 2 ? args[2]() : false; + }, + }, + AND: { + min: 1, + call: (args) => { + const bs = collectBools(args); + if (isErr(bs)) return bs; + if (bs.length === 0) return mkErr("#VALUE!"); + return bs.every(Boolean); + }, + }, + OR: { + min: 1, + call: (args) => { + const bs = collectBools(args); + if (isErr(bs)) return bs; + if (bs.length === 0) return mkErr("#VALUE!"); + return bs.some(Boolean); + }, + }, + NOT: { + min: 1, + max: 1, + call: (args) => { + const b = bool(args[0]()); + return isErr(b) ? b : !b; + }, + }, + IFERROR: { + min: 2, + max: 2, + call: (args) => { + const v = args[0](); + if (!isRange(v) && isErr(v)) return args[1](); + return v; + }, + }, + CONCATENATE: { + min: 1, + call: (args) => { + let out = ""; + 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++) { + const t = toText(v.at(r, c)); + if (isErr(t)) return t; + out += t; + } + } + } else { + const t = str(v); + if (isErr(t)) return t; + out += t; + } + } + return out; + }, + }, + FIXED: { + min: 1, + max: 3, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + const decArg = args.length > 1 ? num(args[1]()) : frac(2n); + if (isErr(decArg)) return decArg; + const decimals = Math.trunc(fracToDouble(decArg)); + const noCommaArg = args.length > 2 ? bool(args[2]()) : false; + if (isErr(noCommaArg)) return noCommaArg; + return formatFixed(n, decimals, !noCommaArg); + }, + }, + TEXT: { + min: 2, + max: 2, + call: (args) => { + const v = toScalar(args[0]()); + if (isErr(v)) return v; + const code = str(args[1]()); + if (isErr(code)) return code; + return formatValue(v, code).글; + }, + }, + LEN: { + min: 1, + max: 1, + call: (args) => { + const s = str(args[0]()); + return isErr(s) ? s : frac(BigInt(s.length)); + }, + }, + LEFT: { + min: 1, + max: 2, + call: (args) => { + const s = str(args[0]()); + if (isErr(s)) return s; + const nArg = args.length > 1 ? num(args[1]()) : ONE; + if (isErr(nArg)) return nArg; + const n = Math.max(0, Math.trunc(fracToDouble(nArg))); + return s.slice(0, n); + }, + }, + RIGHT: { + min: 1, + max: 2, + call: (args) => { + const s = str(args[0]()); + if (isErr(s)) return s; + const nArg = args.length > 1 ? num(args[1]()) : ONE; + if (isErr(nArg)) return nArg; + const n = Math.max(0, Math.trunc(fracToDouble(nArg))); + return n === 0 ? "" : s.slice(-n); + }, + }, + MID: { + min: 3, + max: 3, + call: (args) => { + const s = str(args[0]()); + if (isErr(s)) return s; + const startN = num(args[1]()); + if (isErr(startN)) return startN; + const lenN = num(args[2]()); + if (isErr(lenN)) return lenN; + const start = Math.trunc(fracToDouble(startN)); + const len = Math.max(0, Math.trunc(fracToDouble(lenN))); + if (start < 1) return mkErr("#VALUE!"); + return s.slice(start - 1, start - 1 + len); + }, + }, + VALUE: { + min: 1, + max: 1, + call: (args) => { + const s = str(args[0]()); + if (isErr(s)) return s; + const trimmed = s.trim(); + const pct = trimmed.endsWith("%"); + const body = pct ? trimmed.slice(0, -1) : trimmed; + try { + const v = parseDecimal(body); + return pct ? { n: v.n, d: v.d * 100n } : v; + } catch { + return mkErr("#VALUE!", `글을 수로 못 읽음: ${s}`); + } + }, + }, + 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; + }, + }, +}; diff --git a/A00_Common/spreadsheet/spreadsheet_numfmt.ts b/A00_Common/spreadsheet/spreadsheet_numfmt.ts index 5142e4d4..adde4c58 100644 --- a/A00_Common/spreadsheet/spreadsheet_numfmt.ts +++ b/A00_Common/spreadsheet/spreadsheet_numfmt.ts @@ -4,16 +4,288 @@ * 구역 `;`(양 · 음 · 0 · 글) · `"글"` · `\x` · `_x`(폭만큼 띄움) · `*x`(채움) · `[Red]` 등 색 · `%` · `@` · `E+00`. * 실무 형식(`0.00` · `_-* #,##0.00_-;\-* #,##0.00_-;_-* "-"_-;_-@_-` · `0.00_);[Red]\(0.00\)` …)은 모두 맞아야 함. * 끝수는 분수로 사사오입(엑셀 표시와 같음). 날짜 · 시간 형식은 1단계 밖(글 그대로). - * 0 계약 머리 — 몸은 B 가 채움. * ========================================================================== */ -import type { FormattedText, Scalar } from "./spreadsheet_types"; +import { + ZERO, + cmp, + div, + frac, + fracToString, + mul, + roundAt, + toInteger, + type Frac, +} from "@ui/sheet/ui_template_sheet_frac"; +import type { ErrorValue, FormattedText, Scalar } from "./spreadsheet_types"; -const todo = (): never => { - throw new Error("spreadsheet_numfmt: 아직 없음(B)"); -}; +function isErrorScalar(v: Scalar): v is ErrorValue { + return ( + typeof v === "object" && v !== null && "error" in (v as unknown as Record) + ); +} + +function groupThousands(digits: string): string { + return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ","); +} + +// ── 구역 나누기(`;` · 따옴표 · `\` 뒤는 안 나눔) ───────────────────────────── + +function splitSections(code: string): string[] { + const sections: string[] = []; + let cur = ""; + let inQuote = false; + for (let i = 0; i < code.length; i++) { + const ch = code[i]; + if (ch === '"') { + inQuote = !inQuote; + cur += ch; + continue; + } + if (ch === "\\" && i + 1 < code.length) { + cur += ch + code[i + 1]; + i++; + continue; + } + if (ch === ";" && !inQuote) { + sections.push(cur); + cur = ""; + continue; + } + cur += ch; + } + sections.push(cur); + return sections; +} + +const COLOR_NAMES = ["black", "white", "red", "green", "blue", "yellow", "magenta", "cyan"]; + +function extractColor(section: string): { rest: string; color?: string } { + let color: string | undefined; + const rest = section.replace(/\[([^\]]+)\]/g, (_m, inner: string) => { + const lower = inner.toLowerCase(); + if (COLOR_NAMES.includes(lower)) color = lower; + return ""; // 조건 태그(`[>100]` 등)는 1단계에서 무시 + }); + return { rest, color }; +} + +// ── 낱말 나누기 ────────────────────────────────────────────────────────── + +type Tok = + | { k: "digit"; ch: "0" | "#" | "?" } + | { k: "comma" } + | { k: "point" } + | { k: "percent" } + | { k: "text" } + | { k: "lit"; ch: string }; + +function tokenize(s: string): { toks: Tok[]; fillChar?: string } { + const toks: Tok[] = []; + let fillChar: string | undefined; + for (let i = 0; i < s.length; i++) { + const ch = s[i]; + if (ch === '"') { + let j = i + 1; + while (j < s.length && s[j] !== '"') { + toks.push({ k: "lit", ch: s[j] }); + j++; + } + i = j; + continue; + } + if (ch === "\\" && i + 1 < s.length) { + toks.push({ k: "lit", ch: s[i + 1] }); + i++; + continue; + } + if (ch === "_" && i + 1 < s.length) { + toks.push({ k: "lit", ch: " " }); + i++; + continue; + } + if (ch === "*" && i + 1 < s.length) { + fillChar = s[i + 1]; + i++; + continue; + } + if (ch === "0" || ch === "#" || ch === "?") { + toks.push({ k: "digit", ch }); + continue; + } + if (ch === ",") { + toks.push({ k: "comma" }); + continue; + } + if (ch === ".") { + toks.push({ k: "point" }); + continue; + } + if (ch === "%") { + toks.push({ k: "percent" }); + continue; + } + if (ch === "@") { + toks.push({ k: "text" }); + continue; + } + toks.push({ k: "lit", ch }); + } + return { toks, fillChar }; +} + +// ── 수 구역 그리기 ─────────────────────────────────────────────────────── + +function renderNumericSection(toks: Tok[], magnitude: Frac): string { + const pointIdx = toks.findIndex((t) => t.k === "point"); + const hasPercent = toks.some((t) => t.k === "percent"); + const scaledMag = hasPercent ? mul(magnitude, frac(100n)) : magnitude; + + let lastDigit = -1; + toks.forEach((t, i) => { + if (t.k === "digit") lastDigit = i; + }); + + let scale = 0; + for (let i = lastDigit + 1; i < toks.length; i++) if (toks[i].k === "comma") scale++; + + let hasGrouping = false; + const introEnd = pointIdx >= 0 ? pointIdx : toks.length; + for (let i = 0; i < introEnd && i <= lastDigit; i++) + if (toks[i].k === "comma") hasGrouping = true; + + const intRun = pointIdx >= 0 ? toks.slice(0, pointIdx) : toks; + const fracRun = pointIdx >= 0 ? toks.slice(pointIdx + 1) : []; + const intDigitToks = intRun.filter((t): t is Extract => t.k === "digit"); + const fracDigitToks = fracRun.filter((t): t is Extract => t.k === "digit"); + + const scaleDiv = scale > 0 ? 1000n ** BigInt(scale) : 1n; + const adjusted = scaleDiv === 1n ? scaledMag : div(scaledMag, frac(scaleDiv)); + + const rounded = roundAt(adjusted, fracDigitToks.length, "round"); + const scaleUp = frac(10n ** BigInt(fracDigitToks.length)); + const scaledInt = toInteger(mul(rounded, scaleUp), "round"); + const allDigits = scaledInt.toString().padStart(fracDigitToks.length + 1, "0"); + const rawIntStr = + fracDigitToks.length > 0 ? allDigits.slice(0, -fracDigitToks.length) : allDigits; + const rawFracStr = fracDigitToks.length > 0 ? allDigits.slice(-fracDigitToks.length) : ""; + + let intStr = rawIntStr; + if (intDigitToks.length > intStr.length) { + const need = intDigitToks.length - intStr.length; + let pad = ""; + for (let i = 0; i < need; i++) pad += intDigitToks[i].ch === "0" ? "0" : ""; + intStr = pad + intStr; + } + if (hasGrouping) intStr = groupThousands(intStr); + + let keepUntil = fracDigitToks.length; + for (let i = fracDigitToks.length - 1; i >= 0; i--) { + if (fracDigitToks[i].ch === "0") break; + if (rawFracStr[i] !== "0") break; + keepUntil = i; + } + const fracStr = rawFracStr.slice(0, keepUntil); + + let out = ""; + let intEmitted = false; + let fracEmitted = false; + for (let i = 0; i < toks.length; i++) { + const t = toks[i]; + if (t.k === "digit" || t.k === "comma") { + const inFrac = pointIdx >= 0 && i > pointIdx; + if (inFrac) { + if (!fracEmitted) { + out += fracStr; + fracEmitted = true; + } + } else if (!intEmitted) { + out += intStr; + intEmitted = true; + } + continue; + } + if (t.k === "point") { + if (fracStr.length > 0) out += "."; + continue; + } + if (t.k === "percent") { + out += "%"; + continue; + } + if (t.k === "text") continue; // 수 구역엔 의미 없음(방어) + out += t.ch; + } + return out; +} + +function generalFormat(value: Frac): string { + return fracToString(value); +} + +function chooseSection(sections: string[], value: Frac): { code: string; forceMinus: boolean } { + const isNeg = cmp(value, ZERO) < 0; + const isZero = cmp(value, ZERO) === 0; + if (sections.length === 1) return { code: sections[0], forceMinus: isNeg }; + if (sections.length === 2) { + return isNeg + ? { code: sections[1], forceMinus: false } + : { code: sections[0], forceMinus: false }; + } + if (isZero) return { code: sections[2] ?? sections[0], forceMinus: false }; + if (isNeg) return { code: sections[1], forceMinus: false }; + return { code: sections[0], forceMinus: false }; +} + +function formatNumber(value: Frac, code: string | undefined): FormattedText { + const trimmed = code?.trim(); + if (!trimmed || trimmed.toLowerCase() === "general") { + return { 글: generalFormat(value), 정렬: "right" }; + } + const sections = splitSections(trimmed); + const { code: chosen, forceMinus } = chooseSection(sections, value); + const { rest, color } = extractColor(chosen); + const { toks, fillChar } = tokenize(rest); + const extra: Partial = {}; + if (color) extra.색 = color; + if (fillChar) extra.채움글 = fillChar; + if (!toks.some((t) => t.k === "digit")) { + let out = ""; + for (const t of toks) if (t.k === "lit") out += t.ch; + return { 글: out, 정렬: "right", ...extra }; + } + const magnitude = cmp(value, ZERO) < 0 ? frac(-value.n, value.d) : value; + const body = renderNumericSection(toks, magnitude); + return { 글: (forceMinus ? "-" : "") + body, 정렬: "right", ...extra }; +} + +function formatText(value: string, code: string | undefined): FormattedText { + const trimmed = code?.trim(); + if (!trimmed) return { 글: value, 정렬: "left" }; + const sections = splitSections(trimmed); + // 구역 하나뿐(`;` 없음) = 그 구역이 수 · 글 모두에 적용(`General` 과 같은 이치) · 2~3 구역엔 글 자리가 없어 그대로. + const textCode = + sections.length >= 4 ? sections[3] : sections.length === 1 ? sections[0] : undefined; + if (textCode === undefined) return { 글: value, 정렬: "left" }; + const { rest, color } = extractColor(textCode); + const { toks, fillChar } = tokenize(rest); + const extra: Partial = {}; + if (color) extra.색 = color; + if (fillChar) extra.채움글 = fillChar; + let out = ""; + for (const t of toks) { + if (t.k === "text") out += value; + else if (t.k === "lit") out += t.ch; + else if (t.k === "percent") out += "%"; + } + return { 글: out, 정렬: "left", ...extra }; +} /** `code` 없음 = 일반(수는 유효 11 자리 안쪽 · 칸 폭은 격자 몫) · 오류 값은 코드 글 그대로 */ -export function formatValue(_value: Scalar, _code: string | undefined): FormattedText { - return todo(); +export function formatValue(value: Scalar, code: string | undefined): FormattedText { + if (isErrorScalar(value)) return { 글: value.error, 정렬: "center" }; + if (value === null) return { 글: "", 정렬: "right" }; + if (typeof value === "boolean") return { 글: value ? "TRUE" : "FALSE", 정렬: "center" }; + if (typeof value === "string") return formatText(value, code); + return formatNumber(value, code); } diff --git a/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts b/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts new file mode 100644 index 00000000..7f78fccb --- /dev/null +++ b/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts @@ -0,0 +1,285 @@ +/* ============================================================================= + * spreadsheet_b_test_entry.ts (B 자기 시험) + * B 가 채운 함수 · 숫자 형식 · 채우기를 Node 로 바로 돌리는 시험 진입. + * `npm run` 스크립트 없이 vite `--ssr` 로 번들해 node 로 실행(F 의 build:spreadsheet 앞). + * 경계값은 실제 엑셀 결과와 대조(수학 함수 다수는 손으로 계산해 확인한 값). + * ========================================================================== */ + +import { frac, fracToString, toFrac, type Frac } from "@ui/sheet/ui_template_sheet_frac"; +import { getFunction, functionNames } from "../../../A00_Common/spreadsheet/spreadsheet_functions"; +import { formatValue } from "../../../A00_Common/spreadsheet/spreadsheet_numfmt"; +import { fillCells } from "../../../A00_Common/spreadsheet/spreadsheet_fill"; +import type { + Cell, + ErrorValue, + EvalContext, + EvalResult, + LazyArg, + RangeValue, + Scalar, + Workbook, +} from "../../../A00_Common/spreadsheet/spreadsheet_types"; + +let pass = 0; +let fail = 0; +const failures: string[] = []; + +function ok(name: string, cond: boolean, detail?: string): void { + if (cond) { + pass++; + } else { + fail++; + failures.push(detail ? `${name} — ${detail}` : name); + } +} + +// ── 도우미 ─────────────────────────────────────────────────────────────── + +const DUMMY_CTX: EvalContext = { + sheet: "s1", + r: 0, + c: 0, + cell: () => null, + range: () => ({ kind: "range", rows: 0, cols: 0, at: () => null }), + sheetId: () => null, +}; + +function n(v: number | string): LazyArg { + const f = toFrac(v); + return () => f; +} +function lit(v: EvalResult): LazyArg { + return () => v; +} +function txt(v: string): LazyArg { + return () => v; +} +function bl(v: boolean): LazyArg { + return () => v; +} +function range(rows: number, cols: number, data: Scalar[][]): LazyArg { + const rv: RangeValue = { kind: "range", rows, cols, at: (r, c) => data[r][c] }; + return () => rv; +} + +function call(name: string, ...args: LazyArg[]): EvalResult { + const fn = getFunction(name); + if (!fn) throw new Error(`함수 없음: ${name}`); + return fn.call(args, DUMMY_CTX); +} + +function isFrac(v: unknown): v is Frac { + return typeof v === "object" && v !== null && "n" in (v as Record); +} +function isErr(v: unknown): v is ErrorValue { + return typeof v === "object" && v !== null && "error" in (v as Record); +} + +function fracEq(name: string, got: EvalResult, expect: string): void { + ok( + name, + isFrac(got) && fracToString(got) === expect, + `got=${isFrac(got) ? fracToString(got) : JSON.stringify(got)} want=${expect}`, + ); +} +function textEq(name: string, got: EvalResult, expect: string): void { + ok(name, got === expect, `got=${JSON.stringify(got)} want=${expect}`); +} +function errEq(name: string, got: EvalResult, expect: string): void { + ok(name, isErr(got) && got.error === expect, `got=${JSON.stringify(got)} want=${expect}`); +} +function boolEq(name: string, got: EvalResult, expect: boolean): void { + ok(name, got === expect, `got=${JSON.stringify(got)} want=${expect}`); +} + +// ── 함수 표 자체 ───────────────────────────────────────────────────────── + +ok("함수 이름 45+ 개", functionNames().length >= 45, `count=${functionNames().length}`); +ok("모르는 함수 = undefined", getFunction("없는함수123") === undefined); + +// ── 수학 · 끝수 (경계값은 엑셀 실측) ───────────────────────────────────── + +fracEq("SUM", call("SUM", n(1), n(2), n(3)), "6"); +fracEq("SUM 범위", call("SUM", range(2, 1, [[frac(1n)], [frac(2n)]])), "3"); +fracEq("SUM 범위+글자 건너뜀", call("SUM", range(1, 2, [["x", frac(5n)]])), "5"); +fracEq("PRODUCT", call("PRODUCT", n(2), n(3), n(4)), "24"); +fracEq( + "SUMPRODUCT", + call("SUMPRODUCT", range(1, 2, [[frac(2n), frac(3n)]]), range(1, 2, [[frac(4n), frac(5n)]])), + "23", +); // 2*4+3*5 +fracEq("ROUND", call("ROUND", n("1.155"), n(2)), "1.16"); // 분수 반올림 — 부동소수 함정 없음 +fracEq("ROUNDUP", call("ROUNDUP", n("1.001"), n(2)), "1.01"); +fracEq("ROUNDDOWN", call("ROUNDDOWN", n("1.999"), n(2)), "1.99"); +fracEq("INT 양수", call("INT", n("8.9")), "8"); +fracEq("INT 음수(floor)", call("INT", n("-8.9")), "-9"); +fracEq("TRUNC", call("TRUNC", n("8.9")), "8"); +fracEq("CEILING", call("CEILING", n("2.5"), n(1)), "3"); +fracEq("FLOOR", call("FLOOR", n("2.5"), n(1)), "2"); +fracEq("ABS", call("ABS", n("-3.5")), "3.5"); +fracEq("MOD 양수", call("MOD", n(7), n(3)), "1"); +fracEq("MOD 음수 나눔값 부호", call("MOD", n(-7), n(3)), "2"); // 엑셀 MOD(-7,3)=2 +fracEq("POWER 정수", call("POWER", n(2), n(10)), "1024"); +fracEq("POWER 음수지수", call("POWER", n(2), n(-1)), "0.5"); +ok( + "SQRT(2) 유효 15 자리", + (() => { + const v = call("SQRT", n(2)); + return isFrac(v) && fracToString(v).startsWith("1.4142135623731"); + })(), +); +ok( + "PI() 근사", + (() => { + const v = call("PI"); + return isFrac(v) && fracToString(v).startsWith("3.14159265358979"); + })(), +); +fracEq("MIN", call("MIN", n(5), n(2), n(9)), "2"); +fracEq("MAX", call("MAX", n(5), n(2), n(9)), "9"); +fracEq("AVERAGE", call("AVERAGE", n(2), n(4), n(6)), "4"); +errEq("AVERAGE 빈 값 없음", call("AVERAGE", range(1, 1, [["x"]])), "#DIV/0!"); +errEq("MOD 0 나눔", call("MOD", n(1), n(0)), "#DIV/0!"); +errEq("CEILING 부호 다름", call("CEILING", n(-2), n(1)), "#NUM!"); + +// ── 논리 · 글 ──────────────────────────────────────────────────────────── + +fracEq("IF 참 값", call("IF", bl(true), n(1), n(2)), "1"); +fracEq("IF 거짓 값", call("IF", bl(false), n(1), n(2)), "2"); +boolEq("AND", call("AND", bl(true), bl(true), n(1)) as boolean, true); +boolEq("OR", call("OR", bl(false), n(0), n(1)) as boolean, true); +boolEq("NOT", call("NOT", bl(true)) as boolean, false); +fracEq("IFERROR 정상값", call("IFERROR", n(5), n(0)), "5"); +fracEq("IFERROR 오류 대체", call("IFERROR", lit({ error: "#DIV/0!" }), n(9)), "9"); +textEq("CONCATENATE", call("CONCATENATE", txt("가"), txt("나"), n(1)), "가나1"); +textEq("FIXED 기본", call("FIXED", n("1234.5")), "1,234.50"); +textEq("FIXED 콤마 없음", call("FIXED", n("1234.5"), n(1), bl(true)), "1234.5"); +textEq("TEXT", call("TEXT", n("1234.5"), txt("#,##0.00")), "1,234.50"); +fracEq("LEN", call("LEN", txt("가나다")), "3"); +textEq("LEFT", call("LEFT", txt("가나다"), n(2)), "가나"); +textEq("RIGHT", call("RIGHT", txt("가나다"), n(2)), "나다"); +textEq("MID", call("MID", txt("가나다라"), n(2), n(2)), "나다"); +fracEq("VALUE", call("VALUE", txt("1,234.5")), "1234.5"); +errEq("VALUE 실패", call("VALUE", txt("가나다")), "#VALUE!"); + +// ── 찾기 ──────────────────────────────────────────────────────────────── + +const TABLE = range(3, 2, [ + [frac(1n), "일"], + [frac(2n), "이"], + [frac(3n), "삼"], +]); +textEq("VLOOKUP 정확", call("VLOOKUP", n(2), TABLE, n(2), bl(false)), "이"); +errEq("VLOOKUP 없음", call("VLOOKUP", n(9), TABLE, n(2), bl(false)), "#N/A"); +textEq("VLOOKUP 근사", call("VLOOKUP", n("2.5"), TABLE, n(2)), "이"); + +const HTABLE = range(2, 3, [ + [frac(1n), frac(2n), frac(3n)], + ["일", "이", "삼"], +]); +textEq("HLOOKUP", call("HLOOKUP", n(2), HTABLE, n(2), bl(false)), "이"); + +fracEq("INDEX 칸", call("INDEX", TABLE, n(2), n(1)), "2"); +fracEq( + "MATCH 오름차순", + call("MATCH", n("2.5"), range(3, 1, [[frac(1n)], [frac(2n)], [frac(3n)]]), n(1)), + "2", +); +textEq("CHOOSE", call("CHOOSE", n(2), txt("가"), txt("나"), txt("다")), "나"); + +// ── 세기 ──────────────────────────────────────────────────────────────── + +fracEq("COUNT", call("COUNT", range(1, 3, [[frac(1n), "x", frac(2n)]])), "2"); +fracEq("COUNTA", call("COUNTA", range(1, 3, [[frac(1n), "x", null]])), "2"); +fracEq( + "COUNTIF 조건", + call("COUNTIF", range(1, 4, [[frac(1n), frac(2n), frac(3n), frac(4n)]]), txt(">2")), + "2", +); +fracEq( + "SUMIF", + call( + "SUMIF", + range(1, 3, [["가", "나", "가"]]), + txt("가"), + range(1, 3, [[frac(10n), frac(20n), frac(30n)]]), + ), + "40", +); + +// ── 숫자 형식 ──────────────────────────────────────────────────────────── + +function fmt(value: Scalar, code: string): string { + return formatValue(value, code).글; +} +ok("0.00", fmt(frac(BigInt(150), 100n), "0.00") === "1.50", fmt(frac(150n, 100n), "0.00")); +ok("#,##0", fmt(frac(1234567n), "#,##0") === "1,234,567", fmt(frac(1234567n), "#,##0")); +ok("천 나눔 끝쉼표", fmt(frac(1234000n), "#,##0,") === "1,234", fmt(frac(1234000n), "#,##0,")); +ok("퍼센트", fmt(frac(50n, 100n), "0%") === "50%", fmt(frac(50n, 100n), "0%")); +ok( + "음수 회계형", + fmt(frac(-150n, 100n), "0.00_);[Red](0.00)") === "(1.50)", + fmt(frac(-150n, 100n), "0.00_);[Red](0.00)"), +); +ok("글자 형식 @", fmt("abc", "@@") === "abcabc", fmt("abc", "@@")); +ok( + "일반(General)", + fmt(frac(15n, 10n), undefined as unknown as string) === "1.5", + fmt(frac(15n, 10n), undefined as unknown as string), +); +ok( + "오류 값은 코드 글 그대로", + fmt({ error: "#DIV/0!" }, "0.00") === "#DIV/0!", + fmt({ error: "#DIV/0!" }, "0.00"), +); +ok("참거짓은 형식 무시", fmt(true, "0.00") === "TRUE", fmt(true, "0.00")); + +// ── 채우기 ────────────────────────────────────────────────────────────── + +function bookWith(cells: Record): Workbook { + return { + 종류: "통합문서", + 판: 1, + 열: "test", + 서식: [{}], + 시트: [{ id: "s1", 이름: "s1", 칸: cells }], + }; +} + +{ + const book = bookWith({ A1: { 값: 5 } }); + const out = fillCells(book, "s1", { r0: 0, c0: 0, r1: 0, c1: 0 }, { r0: 0, c0: 0, r1: 3, c1: 0 }); + ok( + "채우기 수 하나 = 복사", + out.A2?.값 === 5 && out.A3?.값 === 5 && out.A4?.값 === 5, + JSON.stringify(out), + ); +} +{ + const book = bookWith({ A1: { 값: 1 }, A2: { 값: 2 } }); + const out = fillCells(book, "s1", { r0: 0, c0: 0, r1: 1, c1: 0 }, { r0: 0, c0: 0, r1: 4, c1: 0 }); + ok( + "채우기 등차 연속", + out.A3?.값 === 3 && out.A4?.값 === 4 && out.A5?.값 === 5, + JSON.stringify(out), + ); +} +{ + const book = bookWith({ A1: { 값: "구-01" } }); + const out = fillCells(book, "s1", { r0: 0, c0: 0, r1: 0, c1: 0 }, { r0: 0, c0: 0, r1: 2, c1: 0 }); + ok("채우기 글 끝 번호", out.A2?.값 === "구-02" && out.A3?.값 === "구-03", JSON.stringify(out)); +} +{ + const book = bookWith({ A1: { 값: "고정글" } }); + const out = fillCells(book, "s1", { r0: 0, c0: 0, r1: 0, c1: 0 }, { r0: 0, c0: 0, r1: 2, c1: 0 }); + ok( + "채우기 끝번호 없는 글 = 복사", + out.A2?.값 === "고정글" && out.A3?.값 === "고정글", + JSON.stringify(out), + ); +} + +// ── 요약 ──────────────────────────────────────────────────────────────── + +console.log(JSON.stringify({ pass, fail, failures }, null, 2)); +if (fail > 0) process.exitCode = 1; diff --git a/resources/tester/test_spreadsheet_b.py b/resources/tester/test_spreadsheet_b.py new file mode 100644 index 00000000..14cd884e --- /dev/null +++ b/resources/tester/test_spreadsheet_b.py @@ -0,0 +1,56 @@ +"""산출근거 스프레드시트 — B(함수 · 숫자 형식 · 채우기) 자기 시험. + +엔진 A(파서 · eval 몸)가 아직 없어 `build:spreadsheet`(F)로 전체를 못 돌린다. +B 파일은 DOM 을 모르고 A00_Common/spreadsheet 안에서 서로만 참조하므로(단 `spreadsheet_fill.ts` +가 A 의 `spreadsheet_refshift.moveFormula` 를 참조 — 채우기 시험은 식 채우기를 안 씀), +`resources/tester/spreadsheet/spreadsheet_b_test_entry.ts` 를 vite `--ssr` 로 바로 번들해 +Node 로 실행한다(엔진 전체가 아니라 B 파일만 문다). +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import tempfile +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +ENTRY = ROOT / "resources" / "tester" / "spreadsheet" / "spreadsheet_b_test_entry.ts" +VITE = ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js" + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") + + +def _run_entry() -> dict: + with tempfile.TemporaryDirectory(prefix="spreadsheet_b_test_") as outdir: + entry_rel = "../" + str(ENTRY.relative_to(ROOT)).replace("\\", "/") + build = subprocess.run( + ["node", str(VITE), "build", "--configLoader", "runner", "--ssr", entry_rel, "--outDir", outdir], + cwd=str(ROOT), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=120, + ) + assert build.returncode == 0, f"vite ssr 번들 실패:\n{build.stdout}\n{build.stderr}" + bundle = Path(outdir) / "spreadsheet_b_test_entry.js" + run = subprocess.run( + ["node", str(bundle)], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=60, + ) + assert run.stdout.strip(), f"시험 진입이 아무 결과도 안 냄:\n{run.stderr}" + return json.loads(run.stdout) + + +def test_b_functions_numfmt_fill(): + result = _run_entry() + assert result["fail"] == 0, result["failures"] + assert result["pass"] > 60