feat(spreadsheet): 스프레드시트 B(함수 · 숫자 형식 · 채우기) 1단계 구현
수학 26 · 논리/글/찾기/세기 22 함수 · 엑셀 숫자 형식 코드 해석기 · 채우기 핸들(복사 · 등차 연속 · 글 끝번호 · 식 상대이동) 구현. 자기 시험 69건(resources/tester/test_spreadsheet_b.py, vite ssr 번들 → node) 전부 통과. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
This commit is contained in:
@@ -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<string, Cell | null> {
|
||||
return todo();
|
||||
const sheetObj = book.시트.find((s) => s.id === sheet);
|
||||
const out: Record<string, Cell | null> = {};
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<string, SheetFunction> = { ...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();
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>);
|
||||
}
|
||||
|
||||
export function isFrac(v: unknown): v is Frac {
|
||||
return typeof v === "object" && v !== null && "n" in (v as Record<string, unknown>);
|
||||
}
|
||||
|
||||
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<string, SheetFunction> = {
|
||||
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)));
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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<string, SheetFunction> = {
|
||||
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;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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<string, unknown>)
|
||||
);
|
||||
}
|
||||
|
||||
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<Tok, { k: "digit" }> => t.k === "digit");
|
||||
const fracDigitToks = fracRun.filter((t): t is Extract<Tok, { k: "digit" }> => 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<FormattedText> = {};
|
||||
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<FormattedText> = {};
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>);
|
||||
}
|
||||
function isErr(v: unknown): v is ErrorValue {
|
||||
return typeof v === "object" && v !== null && "error" in (v as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function fracEq(name: string, got: EvalResult, expect: string): void {
|
||||
ok(
|
||||
name,
|
||||
isFrac(got) && fracToString(got) === expect,
|
||||
`got=${isFrac(got) ? fracToString(got) : JSON.stringify(got)} want=${expect}`,
|
||||
);
|
||||
}
|
||||
function textEq(name: string, got: EvalResult, expect: string): void {
|
||||
ok(name, got === expect, `got=${JSON.stringify(got)} want=${expect}`);
|
||||
}
|
||||
function errEq(name: string, got: EvalResult, expect: string): void {
|
||||
ok(name, isErr(got) && got.error === expect, `got=${JSON.stringify(got)} want=${expect}`);
|
||||
}
|
||||
function boolEq(name: string, got: EvalResult, expect: boolean): void {
|
||||
ok(name, got === expect, `got=${JSON.stringify(got)} want=${expect}`);
|
||||
}
|
||||
|
||||
// ── 함수 표 자체 ─────────────────────────────────────────────────────────
|
||||
|
||||
ok("함수 이름 45+ 개", functionNames().length >= 45, `count=${functionNames().length}`);
|
||||
ok("모르는 함수 = undefined", getFunction("없는함수123") === undefined);
|
||||
|
||||
// ── 수학 · 끝수 (경계값은 엑셀 실측) ─────────────────────────────────────
|
||||
|
||||
fracEq("SUM", call("SUM", n(1), n(2), n(3)), "6");
|
||||
fracEq("SUM 범위", call("SUM", range(2, 1, [[frac(1n)], [frac(2n)]])), "3");
|
||||
fracEq("SUM 범위+글자 건너뜀", call("SUM", range(1, 2, [["x", frac(5n)]])), "5");
|
||||
fracEq("PRODUCT", call("PRODUCT", n(2), n(3), n(4)), "24");
|
||||
fracEq(
|
||||
"SUMPRODUCT",
|
||||
call("SUMPRODUCT", range(1, 2, [[frac(2n), frac(3n)]]), range(1, 2, [[frac(4n), frac(5n)]])),
|
||||
"23",
|
||||
); // 2*4+3*5
|
||||
fracEq("ROUND", call("ROUND", n("1.155"), n(2)), "1.16"); // 분수 반올림 — 부동소수 함정 없음
|
||||
fracEq("ROUNDUP", call("ROUNDUP", n("1.001"), n(2)), "1.01");
|
||||
fracEq("ROUNDDOWN", call("ROUNDDOWN", n("1.999"), n(2)), "1.99");
|
||||
fracEq("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<string, Cell>): Workbook {
|
||||
return {
|
||||
종류: "통합문서",
|
||||
판: 1,
|
||||
열: "test",
|
||||
서식: [{}],
|
||||
시트: [{ id: "s1", 이름: "s1", 칸: cells }],
|
||||
};
|
||||
}
|
||||
|
||||
{
|
||||
const book = bookWith({ A1: { 값: 5 } });
|
||||
const out = fillCells(book, "s1", { r0: 0, c0: 0, r1: 0, c1: 0 }, { r0: 0, c0: 0, r1: 3, c1: 0 });
|
||||
ok(
|
||||
"채우기 수 하나 = 복사",
|
||||
out.A2?.값 === 5 && out.A3?.값 === 5 && out.A4?.값 === 5,
|
||||
JSON.stringify(out),
|
||||
);
|
||||
}
|
||||
{
|
||||
const book = bookWith({ A1: { 값: 1 }, A2: { 값: 2 } });
|
||||
const out = fillCells(book, "s1", { r0: 0, c0: 0, r1: 1, c1: 0 }, { r0: 0, c0: 0, r1: 4, c1: 0 });
|
||||
ok(
|
||||
"채우기 등차 연속",
|
||||
out.A3?.값 === 3 && out.A4?.값 === 4 && out.A5?.값 === 5,
|
||||
JSON.stringify(out),
|
||||
);
|
||||
}
|
||||
{
|
||||
const book = bookWith({ A1: { 값: "구-01" } });
|
||||
const out = fillCells(book, "s1", { r0: 0, c0: 0, r1: 0, c1: 0 }, { r0: 0, c0: 0, r1: 2, c1: 0 });
|
||||
ok("채우기 글 끝 번호", out.A2?.값 === "구-02" && out.A3?.값 === "구-03", JSON.stringify(out));
|
||||
}
|
||||
{
|
||||
const book = bookWith({ A1: { 값: "고정글" } });
|
||||
const out = fillCells(book, "s1", { r0: 0, c0: 0, r1: 0, c1: 0 }, { r0: 0, c0: 0, r1: 2, c1: 0 });
|
||||
ok(
|
||||
"채우기 끝번호 없는 글 = 복사",
|
||||
out.A2?.값 === "고정글" && out.A3?.값 === "고정글",
|
||||
JSON.stringify(out),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 요약 ────────────────────────────────────────────────────────────────
|
||||
|
||||
console.log(JSON.stringify({ pass, fail, failures }, null, 2));
|
||||
if (fail > 0) process.exitCode = 1;
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user