Merge remote-tracking branch 'origin/dev' into sub_laptop_1

This commit is contained in:
2026-09-27 21:18:42 +09:00
26 changed files with 2630 additions and 376 deletions
@@ -111,6 +111,9 @@ fracEq(
fracEq("ROUND", call("ROUND", n("1.155"), n(2)), "1.16"); // 분수 반올림 — 부동소수 함정 없음
fracEq("ROUNDUP", call("ROUNDUP", n("1.001"), n(2)), "1.01");
fracEq("ROUNDDOWN", call("ROUNDDOWN", n("1.999"), n(2)), "1.99");
fracEq("ROUND 음수 자릿수(십의 자리)", call("ROUND", n(1234), n(-2)), "1200");
fracEq("ROUNDUP 음수 자릿수", call("ROUNDUP", n(1234), n(-2)), "1300");
fracEq("ROUNDDOWN 음수 자릿수", call("ROUNDDOWN", n(1234), n(-2)), "1200");
fracEq("INT 양수", call("INT", n("8.9")), "8");
fracEq("INT 음수(floor)", call("INT", n("-8.9")), "-9");
fracEq("TRUNC", call("TRUNC", n("8.9")), "8");
@@ -142,6 +145,29 @@ errEq("AVERAGE 빈 값 없음", call("AVERAGE", range(1, 1, [["x"]])), "#DIV/0!"
errEq("MOD 0 나눔", call("MOD", n(1), n(0)), "#DIV/0!");
errEq("CEILING 부호 다름", call("CEILING", n(-2), n(1)), "#NUM!");
// 2단계 첫 묶음(2026-09-27)
fracEq("MROUND", call("MROUND", n(10), n(3)), "9");
fracEq("MROUND 소수", call("MROUND", n("1.3"), n("0.2")), "1.4");
errEq("MROUND 부호 다름", call("MROUND", n(10), n(-3)), "#NUM!");
fracEq("ODD 양수", call("ODD", n("1.5")), "3");
fracEq("ODD 짝수 정수", call("ODD", n(2)), "3");
fracEq("ODD 0", call("ODD", n(0)), "1");
fracEq("ODD 음수", call("ODD", n(-2)), "-3");
fracEq("EVEN 양수", call("EVEN", n("1.5")), "2");
fracEq("EVEN 홀수 정수", call("EVEN", n(3)), "4");
fracEq("EVEN 음수", call("EVEN", n(-1)), "-2");
fracEq("LOG 밑 지정", call("LOG", n(8), n(2)), "3");
fracEq("LOG10", call("LOG10", n(100)), "2");
fracEq("LN(1) = 0", call("LN", n(1)), "0");
ok(
"EXP(1) ≈ e",
(() => {
const v = call("EXP", n(1));
return isFrac(v) && fracToString(v).startsWith("2.71828182845905");
})(),
);
errEq("LOG10 0 이하", call("LOG10", n(0)), "#NUM!");
// ── 논리 · 글 ────────────────────────────────────────────────────────────
fracEq("IF 참 값", call("IF", bl(true), n(1), n(2)), "1");
@@ -161,6 +187,22 @@ textEq("RIGHT", call("RIGHT", txt("가나다"), n(2)), "나다");
textEq("MID", call("MID", txt("가나다라"), n(2), n(2)), "나다");
fracEq("VALUE", call("VALUE", txt("1,234.5")), "1234.5");
errEq("VALUE 실패", call("VALUE", txt("가나다")), "#VALUE!");
textEq(
"TEXTJOIN 빈칸 건너뜀",
call("TEXTJOIN", txt("-"), bl(true), txt("a"), txt(""), txt("b")),
"a-b",
);
textEq("TRIM", call("TRIM", txt(" 가 나 ")), "가 나");
textEq("UPPER", call("UPPER", txt("abc")), "ABC");
textEq("LOWER", call("LOWER", txt("ABC")), "abc");
textEq("SUBSTITUTE 전부", call("SUBSTITUTE", txt("abcabc"), txt("a"), txt("X")), "XbcXbc");
textEq("SUBSTITUTE 몇번째", call("SUBSTITUTE", txt("abcabc"), txt("a"), txt("X"), n(2)), "abcXbc");
textEq("REPLACE", call("REPLACE", txt("abcdef"), n(2), n(3), txt("XYZ")), "aXYZef");
fracEq("FIND", call("FIND", txt("c"), txt("abcabc")), "3");
errEq("FIND 대소문자 가림", call("FIND", txt("C"), txt("abcabc")), "#VALUE!");
fracEq("SEARCH 대소문자 안가림", call("SEARCH", txt("C"), txt("abcabc")), "3");
fracEq("SEARCH 자리표시(*)", call("SEARCH", txt("a*c"), txt("xxabcyy")), "3");
textEq("REPT", call("REPT", txt("ab"), n(3)), "ababab");
// ── 찾기 ────────────────────────────────────────────────────────────────
@@ -207,6 +249,51 @@ fracEq(
"40",
);
// 2단계 첫 묶음 — 여러 조건 · IFS 계열 · XLOOKUP
const NAMES4 = range(1, 4, [["가", "나", "가", "다"]]);
const AMOUNTS4 = range(1, 4, [[frac(10n), frac(20n), frac(30n), frac(40n)]]);
const AMOUNTS4B = range(1, 4, [[frac(1n), frac(2n), frac(3n), frac(4n)]]);
fracEq("SUMIFS 두 조건", call("SUMIFS", AMOUNTS4, NAMES4, txt("가"), AMOUNTS4B, txt(">1")), "30");
fracEq("COUNTIFS 두 조건", call("COUNTIFS", NAMES4, txt("가"), AMOUNTS4B, txt(">1")), "1");
fracEq("AVERAGEIF", call("AVERAGEIF", AMOUNTS4, txt(">15")), "30");
fracEq("AVERAGEIFS", call("AVERAGEIFS", AMOUNTS4, NAMES4, txt("가")), "20");
fracEq("MAXIFS", call("MAXIFS", AMOUNTS4, NAMES4, txt("가")), "30");
fracEq("MINIFS", call("MINIFS", AMOUNTS4, NAMES4, txt("가")), "10");
fracEq("IFS 처음 참", call("IFS", bl(false), n(1), bl(true), n(2), bl(true), n(3)), "2");
textEq(
"SWITCH 맞음",
call("SWITCH", n(2), n(1), txt("one"), n(2), txt("two"), n(3), txt("three")),
"two",
);
textEq("SWITCH 기본값", call("SWITCH", n(9), n(1), txt("one"), txt("default")), "default");
errEq("SWITCH 안 맞음(기본값 없음)", call("SWITCH", n(9), n(1), txt("one")), "#N/A");
const XNAMES = range(1, 3, [["가", "나", "다"]]);
const XAMOUNTS = range(1, 3, [[frac(10n), frac(20n), frac(30n)]]);
fracEq("XLOOKUP", call("XLOOKUP", txt("나"), XNAMES, XAMOUNTS), "20");
textEq("XLOOKUP 못찾음 대체값", call("XLOOKUP", txt("없음"), XNAMES, XAMOUNTS, txt("no")), "no");
errEq("XLOOKUP 못찾음 기본", call("XLOOKUP", txt("없음"), XNAMES, XAMOUNTS), "#N/A");
// 정보 · 날짜
boolEq("ISNUMBER 참", call("ISNUMBER", n(5)) as boolean, true);
boolEq("ISNUMBER 거짓(글)", call("ISNUMBER", txt("x")) as boolean, false);
boolEq("ISTEXT 참", call("ISTEXT", txt("x")) as boolean, true);
boolEq("ISBLANK 참", call("ISBLANK", lit(null)) as boolean, true);
boolEq("ISBLANK 거짓(0)", call("ISBLANK", n(0)) as boolean, false);
boolEq("ISERROR 참", call("ISERROR", lit({ error: "#DIV/0!" } as ErrorValue)) as boolean, true);
boolEq("ISERROR 거짓", call("ISERROR", n(1)) as boolean, false);
errEq("NA", call("NA"), "#N/A");
{
const serial = call("DATE", n(2026), n(1), n(15));
fracEq("YEAR(DATE(2026,1,15))", call("YEAR", lit(serial)), "2026");
fracEq("MONTH(DATE(2026,1,15))", call("MONTH", lit(serial)), "1");
fracEq("DAY(DATE(2026,1,15))", call("DAY", lit(serial)), "15");
}
{
const todaySerial = call("TODAY");
const wantYear = new Date().getUTCFullYear();
fracEq("YEAR(TODAY()) = 지금 연도", call("YEAR", lit(todaySerial)), String(wantYear));
}
// ── 숫자 형식 ────────────────────────────────────────────────────────────
function fmt(value: Scalar, code: string): string {
@@ -249,6 +336,16 @@ ok(
fmt(frac(-150n, 100n), "0.00_);[Red](0.00)") === "(1.50)",
fmt(frac(-150n, 100n), "0.00_);[Red](0.00)"),
);
// 분수 형식
ok("분수 가분수(자동 분모)", fmt(frac(1n, 2n), "0/0") === "1/2", fmt(frac(1n, 2n), "0/0"));
ok(
"분수 정수부+분자(자동 분모)",
fmt(frac(5n, 2n), "# ?/?") === "2 1/2",
fmt(frac(5n, 2n), "# ?/?"),
);
ok("분수 분모 고정", fmt(frac(4n, 10n), "# ?/8") === " 3/8", fmt(frac(4n, 10n), "# ?/8"));
ok("분수 정확히 나눠떨어짐(정수만)", fmt(frac(3n), "# ?/?") === "3", fmt(frac(3n), "# ?/?"));
ok("글자 형식 @", fmt("abc", "@@") === "abcabc", fmt("abc", "@@"));
ok(
"일반(General)",
@@ -1,6 +1,10 @@
// 스프레드시트 크기 재기 — vite 로 줄여 묶은 뒤 gzip 크기(바이트). `test_spreadsheet_size.py` 가 부름.
// node spreadsheet_size.mjs <저장소 뿌리> → {"engine": {js, css}, "total": {js, css}}
// 앱 번들에 이미 있는 `@ui/ui_template_elements*` 는 빼고 잼(스프레드시트가 새로 더하는 몫만).
// 스프레드시트 크기 재기 — gzip 크기(바이트). `test_spreadsheet_size.py` 가 부름.
// node spreadsheet_size.mjs <저장소 뿌리> → {"engine": {js, css}, "total": {js, css}, "static": [...]}
// engine = 엔진 파일만 줄여 묶은 크기 · total = 실제 앱 빌드에서 스프레드시트를 열 때 받는 조각
// (스프레드시트 조각 + 끌어오는 공용 조각 + css · 첫 화면 `index.html` 조각은 뺌).
// static = 스프레드시트 조각을 정적으로 끌어오는 조각(비어야 함 — 늦게만 받음).
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { gzipSync } from "node:zlib";
@@ -25,7 +29,7 @@ const ENGINE = [
"spreadsheet_fill",
];
async function measure(entries) {
async function engineSize() {
const out = await build({
configFile: false,
logLevel: "silent",
@@ -35,22 +39,60 @@ async function measure(entries) {
write: false,
minify: true,
target: "es2022",
cssCodeSplit: false,
lib: { entry: entries, formats: ["es"] },
rollupOptions: { external: [/ui_template_elements/] },
lib: { entry: ENGINE.map((name) => path.join(dir, `${name}.ts`)), formats: ["es"] },
},
});
const outputs = (Array.isArray(out) ? out : [out]).flatMap((o) => o.output);
const size = { js: 0, css: 0 };
for (const o of outputs) {
const body = o.type === "chunk" ? o.code : o.source;
const gz = gzipSync(typeof body === "string" ? body : Buffer.from(body)).length;
if (o.fileName.endsWith(".css")) size.css += gz;
else if (o.fileName.endsWith(".js")) size.js += gz;
let js = 0;
for (const o of (Array.isArray(out) ? out : [out]).flatMap((o) => o.output)) {
if (o.type === "chunk") js += gzipSync(o.code).length;
}
return size;
return { js, css: 0 };
}
const engine = await measure(ENGINE.map((name) => path.join(dir, `${name}.ts`)));
const total = await measure([path.join(dir, "spreadsheet.ts")]);
console.log(JSON.stringify({ engine, total }));
async function appSize() {
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "aislo-sheet-size-"));
try {
await build({
configLoader: "runner",
configFile: path.join(root, "vite.config.ts"),
logLevel: "silent",
build: { outDir, emptyOutDir: true, manifest: true },
});
const manifest = JSON.parse(fs.readFileSync(path.join(outDir, ".vite/manifest.json"), "utf8"));
const key = Object.keys(manifest).find((k) => k.endsWith("spreadsheet/spreadsheet.ts"));
const gz = (file) => gzipSync(fs.readFileSync(path.join(outDir, file))).length;
// M02 가 이미 받은 조각은 새 몫이 아님 — 첫 화면 · M02 페이지 · M02 표(ui_template_sheet 등)
// 의 정적 import 사슬. 산출근거 카드는 집계표를 연 뒤에만 열림.
const first = new Set();
const mark = (k) => {
if (first.has(k)) return;
first.add(k);
for (const i of manifest[k].imports ?? []) mark(i);
};
const page = Object.keys(manifest).find((k) => k.endsWith("M02_MasterTemplete_UI_Page.ts"));
mark("index.html");
mark(page);
for (const d of manifest[page].dynamicImports ?? []) if (d !== key) mark(d);
const size = { js: 0, css: 0 };
const seen = new Set();
const walk = (k) => {
if (seen.has(k) || first.has(k)) return;
seen.add(k);
const e = manifest[k];
size.js += gz(e.file);
for (const c of e.css ?? []) size.css += gz(c);
for (const i of e.imports ?? []) walk(i); // 그 밖 공용 조각은 셈(넉넉히)
};
walk(key);
const statics = Object.entries(manifest)
.filter(([, v]) => (v.imports ?? []).includes(key))
.map(([k]) => k);
return { total: size, static: statics };
} finally {
fs.rmSync(outDir, { recursive: true, force: true });
}
}
const engine = await engineSize();
const { total, static: statics } = await appSize();
console.log(JSON.stringify({ engine, total, static: statics }));
@@ -1,7 +1,8 @@
"""F — 스프레드시트 크기 예산 · M02 만 늦게 불러옴(계약 `spreadsheet_types.ts` 머리).
예산(압축 gzip 뒤) — 엔진 A+B 22 KB · 화면 C+D+E 28 KB · css 4 KB · 합 54 KB.
엔진은 파일마다 따로 묶어 더하므로 실제보다 조금 크게 잼(넘치면 넘친 것).
예산(압축 gzip 뒤) — 엔진 A+B 32 KB · 화면 C+D+E 28 KB · css 4 KB · 합 64 KB.
엔진 = 엔진 파일만 따로 묶은 크기 · 합 = 실제 앱 빌드에서 스프레드시트를 열 때 받는 조각
(공용 조각까지 넉넉히 셈) · 화면 = 합 − 엔진.
"""
import json
@@ -15,7 +16,7 @@ import pytest
ROOT = Path(__file__).resolve().parents[3]
SCRIPT = Path(__file__).with_name("spreadsheet_size.mjs")
KB = 1024
BUDGET = {"engine": 22 * KB, "view": 28 * KB, "css": 4 * KB, "total": 54 * KB}
BUDGET = {"engine": 32 * KB, "view": 28 * KB, "css": 4 * KB, "total": 64 * KB}
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
@@ -36,6 +37,7 @@ def test_크기_예산():
print(
f"gzip — 엔진 {engine} · 화면 {view} · css {total['css']} · 합 {total['js'] + total['css']}"
)
assert size["static"] == [], f"스프레드시트 조각을 정적으로 끌어옴 — {size['static']}"
assert engine <= BUDGET["engine"], f"엔진 {engine} B > {BUDGET['engine']}"
assert view <= BUDGET["view"], f"화면 {view} B > {BUDGET['view']}"
assert total["css"] <= BUDGET["css"], f"css {total['css']} B > {BUDGET['css']}"
@@ -0,0 +1,157 @@
/* =============================================================================
* test_stage3_condfmt.ts — spreadsheet_condfmt 시험. 돌리기: npx tsx <이 파일>.
* ========================================================================== */
import {
addCondFmtRule,
moveCondFmtRule,
remapCondFmtRanges,
removeCondFmtRule,
resolveCondFmtStyle,
} from "../../../A00_Common/spreadsheet/spreadsheet_condfmt.ts";
import { assertEqual, makeBook, makeCtx, makeSheet, v } from "./test_stage2_helpers.ts";
import type { Sheet } from "../../../A00_Common/spreadsheet/spreadsheet_types.ts";
const range = { r0: 0, c0: 0, r1: 0, c1: 0 };
const sheet = makeSheet(
"s1",
{ A1: v(120) },
{
조건부서식: [
{
id: "cf1",
범위: [range],
조건: { 종류: "비교", 연산: "보다큼", 값: 100 },
스타일: { 채움: "#ffcccc" },
},
],
},
) as Sheet;
const book = makeBook([sheet]);
const env = { value: () => 120 };
assertEqual(
resolveCondFmtStyle(sheet, 0, 0, env),
{ 채움: "#ffcccc" },
"값 100 넘으면 스타일 먹임",
);
assertEqual(resolveCondFmtStyle(sheet, 5, 5, env), null, "범위 밖은 안 먹임");
const envLow = { value: () => 50 };
assertEqual(resolveCondFmtStyle(sheet, 0, 0, envLow), null, "조건 안 맞으면 null");
// 「사이」 · 「글포함」 · 「수식」 조건
const between = {
id: "b",
범위: [range],
조건: { 종류: "사이" as const, 아래: 10, 위: 20 },
스타일: {},
};
assertEqual(
resolveCondFmtStyle({ ...sheet, 조건부서식: [between] }, 0, 0, { value: () => 15 }),
{},
"사이 조건 참",
);
const contains = {
id: "c",
범위: [range],
조건: { 종류: "글포함" as const, 글: "돌" },
스타일: {},
};
assertEqual(
resolveCondFmtStyle({ ...sheet, 조건부서식: [contains] }, 0, 0, { value: () => "돌뒷길이" }),
{},
"글포함 조건 참",
);
const formula = { id: "f", 범위: [range], 조건: { 종류: "수식" as const, 식: "A1>0" }, 스타일: {} };
assertEqual(
resolveCondFmtStyle({ ...sheet, 조건부서식: [formula] }, 0, 0, {
value: () => 1,
evalFormula: (식) => 식 === "A1>0",
}),
{},
"수식 조건 — evalFormula 콜백으로 참",
);
assertEqual(
resolveCondFmtStyle({ ...sheet, 조건부서식: [formula] }, 0, 0, { value: () => 1 }),
null,
"evalFormula 없으면 수식 조건은 늘 거짓",
);
// 우선순위 — 배열 앞쪽이 이김
const two = makeSheet(
"s2",
{ A1: v(1) },
{
조건부서식: [
{
id: "first",
범위: [range],
조건: { 종류: "비교" as const, 연산: "같음" as const, 값: 1 },
스타일: { 채움: "red" },
},
{
id: "second",
범위: [range],
조건: { 종류: "비교" as const, 연산: "같음" as const, 값: 1 },
스타일: { 채움: "blue" },
},
],
},
) as Sheet;
assertEqual(
resolveCondFmtStyle(two, 0, 0, { value: () => 1 }),
{ 채움: "red" },
"먼저 나온 규칙이 이김",
);
// CRUD — 시트통째로 나감
const ctx = makeCtx(book, "s1");
addCondFmtRule(ctx, { 범위: [range], 조건: { 종류: "글포함", 글: "x" }, 스타일: { 굵게: true } });
assertEqual(ctx.applied.at(-1)?.종류, "시트통째", "addCondFmtRule 은 시트통째로 나감");
assertEqual(book.시트[0].조건부서식?.length, 2, "규칙이 더해짐");
const addedId = book.시트[0].조건부서식![1].id;
moveCondFmtRule(ctx, addedId, -1);
assertEqual(book.시트[0].조건부서식![0].id, addedId, "우선순위 위로 옮김");
removeCondFmtRule(ctx, "cf1");
assertEqual(
book.시트[0].조건부서식?.map((r) => r.id),
[addedId],
"규칙 지움",
);
// 행 · 열 넣기 뒤 범위 옮김
const shifted = remapCondFmtRanges(
[
{
id: "a",
범위: [{ r0: 0, c0: 0, r1: 0, c1: 0 }],
조건: { 종류: "글포함", 글: "x" },
스타일: {},
},
],
(r) => ({ ...r, r0: r.r0 + 1, r1: r.r1 + 1 }),
);
assertEqual(
shifted[0].범위[0],
{ r0: 1, c0: 0, r1: 1, c1: 0 },
"remapCondFmtRanges — 행 하나 밀림",
);
const dropped = remapCondFmtRanges(
[
{
id: "a",
범위: [{ r0: 0, c0: 0, r1: 0, c1: 0 }],
조건: { 종류: "글포함", 글: "x" },
스타일: {},
},
],
() => null,
);
assertEqual(dropped, [], "remapCondFmtRanges — 범위를 다 잃으면 규칙째 버림");
if (process.exitCode) process.exit(process.exitCode);
console.log("spreadsheet_condfmt 시험 끝");
@@ -0,0 +1,71 @@
/* =============================================================================
* test_stage3_names.ts — spreadsheet_names 시험. 돌리기: npx tsx <이 파일>.
* ========================================================================== */
import {
defineName,
deleteName,
invalidNameReason,
listNames,
renameName,
resolveName,
} from "../../../A00_Common/spreadsheet/spreadsheet_names.ts";
import { assertEqual, makeBook, makeCtx, makeSheet, f, v } from "./test_stage2_helpers.ts";
assertEqual(invalidNameReason("단가", []), null, "정상 이름은 통과");
assertEqual(invalidNameReason("", []), "이름이 비었음", "빈 이름 거절");
assertEqual(invalidNameReason("A1", []), "칸 주소처럼 보이는 이름은 못 씀", "칸 주소 꼴 거절");
assertEqual(
invalidNameReason("1단가", []),
"글자 · 숫자 · 밑줄만(첫 글자는 글자 · 밑줄)",
"숫자로 시작 거절",
);
assertEqual(invalidNameReason("TRUE", []), "TRUE · FALSE 는 못 씀", "예약어 거절");
assertEqual(
invalidNameReason("단가", ["단가"]),
"이미 있는 이름",
"겹치는 이름 거절(대소문자 안 가림)",
);
const range = { r0: 0, c0: 0, r1: 0, c1: 0 };
const s1 = makeSheet("s1", { A1: v(100) });
const s2 = makeSheet("s2", { B2: f("A1*2") });
const book = makeBook([s1, s2]);
const ctx = makeCtx(book, "s1");
assertEqual(defineName(ctx, "단가", range), null, "defineName 성공 — 까닭 없음");
assertEqual(ctx.applied.at(-1)?.종류, "시트통째", "defineName 은 시트통째로 나감");
assertEqual(listNames(book), [{ 이름: "단가", 시트: "s1", 범위: range }], "listNames 로 보임");
assertEqual(defineName(ctx, "단가", range), "이미 있는 이름", "같은 이름 두 번은 거절");
assertEqual(
resolveName(book, "s1", "단가"),
{ 시트: "s1", 범위: range },
"resolveName 제 시트에서 찾음",
);
assertEqual(
resolveName(book, "s2", "단가"),
{ 시트: "s1", 범위: range },
"resolveName 다른 시트에서도 느슨히 찾음",
);
assertEqual(resolveName(book, "s1", "없는이름"), null, "없는 이름은 null");
// 이름 바꾸기 — 정의 + 다른 시트 식 글 속 이름도 같이
const s2f = makeSheet("s2b", { B2: f("단가*2") });
const book2 = makeBook([s1, s2f]);
const ctx2 = makeCtx(book2, "s1");
defineName(ctx2, "단가", range);
const reason = renameName(ctx2, "단가", "단가2");
assertEqual(reason, null, "renameName 성공");
assertEqual(book2.시트[0].이름정의?.[0].이름, "단가2", "정의 이름 바뀜");
assertEqual(book2.시트[1].칸.B2.식, "단가2*2", "다른 시트 식 글 속 이름도 바뀜");
assertEqual(renameName(ctx2, "단가2", "TRUE"), "TRUE · FALSE 는 못 씀", "바꿀 이름도 규칙을 지킴");
deleteName(ctx2, "단가2");
assertEqual(book2.시트[0].이름정의, [], "deleteName 으로 지움");
assertEqual(book2.시트[1].칸.B2.식, "단가2*2", "지워도 식 글은 그대로 남음(#NAME? 은 풀이 때)");
if (process.exitCode) process.exit(process.exitCode);
console.log("spreadsheet_names 시험 끝");
@@ -0,0 +1,77 @@
/* =============================================================================
* test_stage3_validation.ts — spreadsheet_validation 시험. 돌리기: npx tsx <이 파일>.
* ========================================================================== */
import {
addValidationRule,
checkValue,
findValidation,
remapValidationRanges,
removeValidationRule,
} from "../../../A00_Common/spreadsheet/spreadsheet_validation.ts";
import { assertEqual, makeBook, makeCtx, makeSheet, v } from "./test_stage2_helpers.ts";
import type { Sheet } from "../../../A00_Common/spreadsheet/spreadsheet_types.ts";
const range = { r0: 0, c0: 0, r1: 0, c1: 0 };
const sheet = makeSheet(
"s1",
{ A1: v("사과") },
{
유효성: [
{ id: "v1", 범위: [range], 규칙: { 종류: "목록", 값들: ["사과", "배"] }, 어기면: "막음" },
],
},
) as Sheet;
const book = makeBook([sheet]);
assertEqual(findValidation(sheet, 0, 0)?.id, "v1", "findValidation 있음");
assertEqual(findValidation(sheet, 9, 9), undefined, "범위 밖은 없음");
const rule = sheet.유효성![0];
assertEqual(checkValue(rule, "배"), { ok: true, 메시지: "" }, "목록 안 값은 통과");
assertEqual(checkValue(rule, "감").ok, false, "목록 밖 값은 막힘");
assertEqual(checkValue(rule, "감").어기면, "막음", "어기면 = 막음 그대로 옴");
const rangeRule = {
id: "v2",
범위: [range],
규칙: { 종류: "수범위" as const, 아래: 0, 위: 100 },
어기면: "경고" as const,
메시지: "0~100 사이만",
};
assertEqual(checkValue(rangeRule, 50), { ok: true, 메시지: "" }, "수범위 안 값은 통과");
assertEqual(
checkValue(rangeRule, 150),
{ ok: false, 어기면: "경고", 메시지: "0~100 사이만" },
"수범위 밖 · 안내 글 그대로",
);
assertEqual(checkValue(rangeRule, "글").ok, false, "글은 수범위 규칙에서 늘 거짓");
// CRUD — 시트통째로 나감
const ctx = makeCtx(book, "s1");
addValidationRule(ctx, { 범위: [range], 규칙: { 종류: "수범위", 위: 10 }, 어기면: "경고" });
assertEqual(ctx.applied.at(-1)?.종류, "시트통째", "addValidationRule 은 시트통째로 나감");
assertEqual(book.시트[0].유효성?.length, 2, "규칙이 더해짐");
removeValidationRule(ctx, "v1");
assertEqual(book.시트[0].유효성?.map((r) => r.id).includes("v1"), false, "지운 규칙은 없음");
// 행 · 열 넣기 뒤 범위 옮김
const shifted = remapValidationRanges(
[{ id: "a", 범위: [{ r0: 0, c0: 0, r1: 0, c1: 0 }], 규칙: { 종류: "수범위" }, 어기면: "막음" }],
(r) => ({ ...r, c0: r.c0 + 1, c1: r.c1 + 1 }),
);
assertEqual(
shifted[0].범위[0],
{ r0: 0, c0: 1, r1: 0, c1: 1 },
"remapValidationRanges — 열 하나 밀림",
);
const dropped = remapValidationRanges(
[{ id: "a", 범위: [{ r0: 0, c0: 0, r1: 0, c1: 0 }], 규칙: { 종류: "수범위" }, 어기면: "막음" }],
() => null,
);
assertEqual(dropped, [], "remapValidationRanges — 범위를 다 잃으면 규칙째 버림");
if (process.exitCode) process.exit(process.exitCode);
console.log("spreadsheet_validation 시험 끝");