feat(spreadsheet): 3단계 — 조건부 서식(계약 더함)

Sheet.조건부서식·유효성·이름정의 계약 더함(0 계약 · git-sync 뒤 더함) · spreadsheet_condfmt.ts —
값 비교·사이·글 포함·수식 조건 · 채움·글자색 · 규칙 관리 창 · 우선순위 옮김 · 행열 넣기·지우기 범위
따라가기(remapCondFmtRanges). 엔진(A)·격자(C) 파일은 안 건드림 — 잇는 법을 머리 주석에 적어 둠(잇기는 sub7).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
This commit is contained in:
2026-09-27 21:00:12 +09:00
co-authored by Claude Sonnet 5
parent a644b2617c
commit 5718aa7f9d
4 changed files with 573 additions and 0 deletions
@@ -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 시험 끝");