From 5718aa7f9d83f4dd34bc8cbf1967d8e1dc2dfac3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 21:00:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(spreadsheet):=203=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=E2=80=94=20=EC=A1=B0=EA=B1=B4=EB=B6=80=20=EC=84=9C=EC=8B=9D(?= =?UTF-8?q?=EA=B3=84=EC=95=BD=20=EB=8D=94=ED=95=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sheet.조건부서식·유효성·이름정의 계약 더함(0 계약 · git-sync 뒤 더함) · spreadsheet_condfmt.ts — 값 비교·사이·글 포함·수식 조건 · 채움·글자색 · 규칙 관리 창 · 우선순위 옮김 · 행열 넣기·지우기 범위 따라가기(remapCondFmtRanges). 엔진(A)·격자(C) 파일은 안 건드림 — 잇는 법을 머리 주석에 적어 둠(잇기는 sub7). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7 --- A00_Common/spreadsheet/spreadsheet_condfmt.ts | 272 ++++++++++++++++++ A00_Common/spreadsheet/spreadsheet_stage3.css | 87 ++++++ A00_Common/spreadsheet/spreadsheet_types.ts | 57 ++++ .../tester/spreadsheet/test_stage3_condfmt.ts | 157 ++++++++++ 4 files changed, 573 insertions(+) create mode 100644 A00_Common/spreadsheet/spreadsheet_condfmt.ts create mode 100644 A00_Common/spreadsheet/spreadsheet_stage3.css create mode 100644 resources/tester/spreadsheet/test_stage3_condfmt.ts diff --git a/A00_Common/spreadsheet/spreadsheet_condfmt.ts b/A00_Common/spreadsheet/spreadsheet_condfmt.ts new file mode 100644 index 00000000..e9168ecd --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_condfmt.ts @@ -0,0 +1,272 @@ +/* ============================================================================= + * spreadsheet_condfmt.ts (3단계 · 주인 sub_laptop_4) + * 조건부 서식 — 칸 값이 규칙에 맞으면 채움색 · 글자색을 덮어씀. 저장 자리 `Sheet.조건부서식` + * (0 계약 `spreadsheet_types.ts` 에 방금 더함 · 계약 더함). + * + * 규칙 차례 = 우선순위(배열 앞쪽이 먼저 이김 · 엑셀 「규칙 관리」 위쪽과 같음). 스타일은 `서식` 표 + * 번호를 안 씀 — 규칙마다 채움 · 글자색을 직접 가져 표 번호 조율(A 몫)이 필요 없음. 「수식이 참」 + * 조건은 임의 수식 글을 파싱·계산해야 해서(엔진 A) 이 파일은 `evalFormula` 콜백만 받고 엔진을 모름. + * + * 잇는 법(A · C · D · sub7 — 지금은 하나도 안 이어짐): + * 1) 격자(C)가 칸을 그릴 때 `resolveCondFmtStyle(sheet, r, c, env)` 로 덮어쓸 스타일을 얻어 + * 지금 셀 스타일 위에 얹음(env.value 는 `ctx.engine.value` 를 수 · 글 · 참거짓 · null 로 바꿔 줌). + * 2) `env.evalFormula(식, r, c)` 구현 — `spreadsheet_parser.parse(식)` 뒤 `spreadsheet_eval` 로 + * 풀어 참거짓으로(A 가 만들 자리 · 지금 없음). + * 3) 행 · 열 넣기 · 지우기 뒤 `remapCondFmtRanges` 를 그 시트 `조건부서식` 에 적용해 범위를 옮김 + * (`spreadsheet_commands.insertDelete` 가 지금 이 규칙을 모름 — refshift 참조 옮김과 같은 자리에 + * 걸어야 함 · A 가 채울 자리). + * 4) 도구 모음 · 우클릭 메뉴에 [조건부 서식] 항목을 두고 `createCondFmtPanel(ctx)` 를 띄움. + * ========================================================================== */ + +import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; +import { inRange, rangeToA1 } from "./spreadsheet_address"; +import type { + CellRange, + CondFmtCompareOp, + CondFmtCondition, + CondFmtRule, + CondFmtStyle, + Sheet, +} from "./spreadsheet_types"; +import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; + +// css 는 아직 안 이어짐(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 한 줄 더함. + +export type { CondFmtCompareOp, CondFmtCondition, CondFmtRule, CondFmtStyle }; + +/** 칸 값을 이미 수 · 글 · 참거짓 · 빈칸으로 바꿔 넘김(Frac → 수는 호출부 몫 — 이 파일은 엔진을 모름). */ +export type CondFmtValue = number | string | boolean | null; + +function testCondition( + condition: CondFmtCondition, + value: CondFmtValue, + evalFormula: (식: string) => boolean, +): boolean { + switch (condition.종류) { + case "비교": { + if (typeof value !== "number") return false; + switch (condition.연산) { + case "보다큼": + return value > condition.값; + case "보다작음": + return value < condition.값; + case "크거나같음": + return value >= condition.값; + case "작거나같음": + return value <= condition.값; + case "같음": + return value === condition.값; + case "다름": + return value !== condition.값; + } + break; + } + case "사이": + return typeof value === "number" && value >= condition.아래 && value <= condition.위; + case "글포함": + return typeof value === "string" && value.includes(condition.글); + case "수식": + return evalFormula(condition.식); + } + return false; +} + +export interface CondFmtEnv { + value(r: number, c: number): CondFmtValue; + /** 없으면 「수식이 참」 조건은 늘 거짓 */ + evalFormula?(formula: string, r: number, c: number): boolean; +} + +/** 칸 하나에 먹일 조건부 서식 — 처음 맞는 규칙(우선순위 위) · 없으면 null. */ +export function resolveCondFmtStyle( + sheet: Sheet, + r: number, + c: number, + env: CondFmtEnv, +): CondFmtStyle | null { + for (const rule of sheet.조건부서식 ?? []) { + if (!rule.범위.some((range) => inRange(range, r, c))) continue; + const value = env.value(r, c); + const hit = testCondition(rule.조건, value, (식) => env.evalFormula?.(식, r, c) ?? false); + if (hit) return rule.스타일; + } + return null; +} + +/** 행 · 열 넣기 · 지우기 뒤 범위 옮김 — `shift` 가 null 이면 그 범위는 버림(규칙이 범위를 다 잃으면 규칙째 버림). */ +export function remapCondFmtRanges( + rules: CondFmtRule[], + shift: (range: CellRange) => CellRange | null, +): CondFmtRule[] { + const out: CondFmtRule[] = []; + for (const rule of rules) { + const moved = rule.범위.map(shift).filter((r): r is CellRange => r !== null); + if (moved.length) out.push({ ...rule, 범위: moved }); + } + return out; +} + +function applySheet(ctx: SpreadsheetContext, next: Sheet): void { + ctx.dispatch({ 종류: "시트통째", 시트: next }); +} + +let seq = 0; +const newId = (): string => `cf${Date.now().toString(36)}${seq++}`; + +export function addCondFmtRule(ctx: SpreadsheetContext, rule: Omit): void { + const sheet = ctx.sheet(); + applySheet(ctx, { + ...sheet, + 조건부서식: [...(sheet.조건부서식 ?? []), { ...rule, id: newId() }], + }); +} + +export function removeCondFmtRule(ctx: SpreadsheetContext, id: string): void { + const sheet = ctx.sheet(); + applySheet(ctx, { ...sheet, 조건부서식: (sheet.조건부서식 ?? []).filter((r) => r.id !== id) }); +} + +/** 우선순위 한 칸 옮김(엑셀 「규칙 관리」 위 · 아래 화살표와 같음). */ +export function moveCondFmtRule(ctx: SpreadsheetContext, id: string, dir: -1 | 1): void { + const sheet = ctx.sheet(); + const list = [...(sheet.조건부서식 ?? [])]; + const idx = list.findIndex((r) => r.id === id); + const swapWith = idx + dir; + if (idx < 0 || swapWith < 0 || swapWith >= list.length) return; + [list[idx], list[swapWith]] = [list[swapWith], list[idx]]; + applySheet(ctx, { ...sheet, 조건부서식: list }); +} + +const conditionText = (c: CondFmtCondition): string => { + switch (c.종류) { + case "비교": + return `${c.연산} ${c.값}`; + case "사이": + return `${c.아래} ~ ${c.위} 사이`; + case "글포함": + return `「${c.글}」 포함`; + case "수식": + return `수식 =${c.식}`; + } +}; + +/** 규칙 관리 창 — 지금 시트 규칙 목록 + 고른 범위에 규칙 더하기. 잇는 법 머리 주석 참고. */ +export function createCondFmtPanel(ctx: SpreadsheetContext): PartHandle { + const root = el("div", { className: "ss-condfmt" }); + const list = el("ul", { className: "ss-condfmt__list" }); + + const compareOps: CondFmtCompareOp[] = [ + "보다큼", + "보다작음", + "크거나같음", + "작거나같음", + "같음", + "다름", + ]; + const kindSelect = createSelectField({ + label: "조건", + options: [ + { value: "비교", text: "값 비교" }, + { value: "사이", text: "사이" }, + { value: "글포함", text: "글 포함" }, + { value: "수식", text: "수식이 참" }, + ], + }); + const opSelect = createSelectField({ + label: "연산", + options: compareOps.map((op) => ({ value: op, text: op })), + }); + const val1 = createInputField({ label: "값(또는 아래 · 글 · 수식)" }); + const val2 = createInputField({ label: "위(사이일 때만)" }); + const fill = createInputField({ label: "채움색", type: "text", placeholder: "#RRGGBB" }); + const fontColor = createInputField({ label: "글자색", type: "text", placeholder: "#RRGGBB" }); + const addBtn = createButton({ label: "규칙 더하기" }); + + addBtn.addEventListener("click", () => { + const 범위 = ctx.selection.범위; + if (!범위.length) return; + const 조건 = buildCondition(kindSelect.select.value, opSelect.select.value, val1, val2); + if (!조건) return; + const 스타일: CondFmtStyle = {}; + if (fill.input.value) 스타일.채움 = fill.input.value; + if (fontColor.input.value) 스타일.글자색 = fontColor.input.value; + addCondFmtRule(ctx, { 범위: [...범위], 조건, 스타일 }); + render(); + }); + + function buildCondition( + kind: string, + op: string, + a: ReturnType, + b: ReturnType, + ): CondFmtCondition | null { + if (kind === "비교") { + const n = Number(a.input.value); + return Number.isFinite(n) ? { 종류: "비교", 연산: op as CondFmtCompareOp, 값: n } : null; + } + if (kind === "사이") { + const 아래 = Number(a.input.value); + const 위 = Number(b.input.value); + return Number.isFinite(아래) && Number.isFinite(위) ? { 종류: "사이", 아래, 위 } : null; + } + if (kind === "글포함") return a.input.value ? { 종류: "글포함", 글: a.input.value } : null; + if (kind === "수식") return a.input.value ? { 종류: "수식", 식: a.input.value } : null; + return null; + } + + function render(): void { + const rules = ctx.sheet().조건부서식 ?? []; + list.replaceChildren( + ...rules.map((rule, idx) => { + const li = el("li", { className: "ss-condfmt__row" }); + const swatch = el("span", { className: "ss-condfmt__swatch" }); + if (rule.스타일.채움) swatch.style.background = rule.스타일.채움; + const label = el("span", { + text: `${rule.범위.map(rangeToA1).join(", ")} — ${conditionText(rule.조건)}`, + }); + const up = createButton({ label: "↑", variant: "ghost" }); + up.disabled = idx === 0; + up.addEventListener("click", () => { + moveCondFmtRule(ctx, rule.id, -1); + render(); + }); + const down = createButton({ label: "↓", variant: "ghost" }); + down.disabled = idx === rules.length - 1; + down.addEventListener("click", () => { + moveCondFmtRule(ctx, rule.id, 1); + render(); + }); + const del = createButton({ label: "지움", variant: "danger" }); + del.addEventListener("click", () => { + removeCondFmtRule(ctx, rule.id); + render(); + }); + li.append(swatch, label, up, down, del); + return li; + }), + ); + } + + const form = el("div", { + className: "ss-condfmt__form", + children: [ + kindSelect.root, + opSelect.root, + val1.root, + val2.root, + fill.root, + fontColor.root, + addBtn, + ], + }); + root.append(el("h4", { text: "조건부 서식 규칙" }), list, form); + render(); + + return { + root, + refresh: render, + destroy(): void { + root.remove(); + }, + }; +} diff --git a/A00_Common/spreadsheet/spreadsheet_stage3.css b/A00_Common/spreadsheet/spreadsheet_stage3.css new file mode 100644 index 00000000..9f7d71cd --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_stage3.css @@ -0,0 +1,87 @@ +/* ============================================================================= + * spreadsheet_stage3.css (주인 sub_laptop_4) + * 조건부 서식 · 데이터 유효성 · 이름 정의 관리 창 — 화면 스타일 한 벌. 색 · 간격은 테마 토큰만. + * ========================================================================== */ + +.ss-condfmt, +.ss-validation, +.ss-names { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-8); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 8px; + min-width: 280px; +} + +.ss-condfmt__list, +.ss-validation__list, +.ss-names__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.ss-condfmt__row, +.ss-validation__row, +.ss-names__row { + display: flex; + align-items: center; + gap: var(--spacing-4); + padding: var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: 4px; + font-size: 0.85rem; +} + +.ss-condfmt__row > span:first-of-type, +.ss-validation__row > span:first-of-type, +.ss-names__row > span:first-of-type { + flex: 1; +} + +.ss-condfmt__swatch { + display: inline-block; + width: 14px; + height: 14px; + border: 1px solid var(--color-border); + border-radius: 2px; + flex-shrink: 0; +} + +.ss-condfmt__form, +.ss-validation__form, +.ss-names__form { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4); + align-items: flex-end; + border-top: 1px solid var(--color-border); + padding-top: var(--spacing-8); +} + +.ss-validation__dropdown { + position: absolute; + z-index: 30; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + max-height: 160px; + overflow-y: auto; +} + +.ss-validation__dropdown-item { + padding: 4px 8px; + cursor: pointer; + font-size: 0.85rem; +} + +.ss-validation__dropdown-item:hover { + background: var(--color-mist-violet); +} diff --git a/A00_Common/spreadsheet/spreadsheet_types.ts b/A00_Common/spreadsheet/spreadsheet_types.ts index 0612f6b1..2135de05 100644 --- a/A00_Common/spreadsheet/spreadsheet_types.ts +++ b/A00_Common/spreadsheet/spreadsheet_types.ts @@ -48,6 +48,10 @@ * E spreadsheet_menu.ts 우클릭 메뉴 ~300 * E spreadsheet_clipboard.ts 복사 · 잘라내기 · 붙여넣기(TSV · HTML) ~550 * E spreadsheet_toolbar.css ~150 + * 3 spreadsheet_condfmt.ts 조건부 서식 규칙 · 관리 창(주인 sub_laptop_4 · 안 이어짐) ~260 + * 3 spreadsheet_validation.ts 데이터 유효성 규칙 · 관리 창(주인 sub_laptop_4 · 안 이어짐) ~230 + * 3 spreadsheet_names.ts 이름 정의 · 이름 관리 창(주인 sub_laptop_4 · 안 이어짐) ~220 + * 3 spreadsheet_stage3.css 위 셋 화면 스타일 한 벌 ~90 * F common_util/common_util_spreadsheet_node.ts Node 진입(재계산 · 폴더 밖) ~40 * F common_util/common_util_spreadsheet.py 파이썬 껍데기(common_util_node_bundle) ~60 * F package.json `build:spreadsheet` · M02 `basis` 종류(Store · Layers) · 옛 산출근거 옮김 · @@ -147,6 +151,13 @@ export interface Sheet { 기본?: { 서식?: number; 열폭?: number; 행높이?: number }; /** 칸 메모 — A1 열쇠 → 메모 글. 행열 넣기 · 지우기 · 잘라 붙이기 때 칸과 같이 옮김(지운 칸 메모는 지움). */ comments?: Record; + /** 조건부 서식 — 배열 앞쪽이 우선순위 위(`spreadsheet_condfmt.ts`). */ + 조건부서식?: CondFmtRule[]; + /** 데이터 유효성(`spreadsheet_validation.ts`). */ + 유효성?: DataValidationRule[]; + /** 이 시트에서 정의한 이름 — 범위는 이 시트 안(`spreadsheet_names.ts`). 식 나무의 `{type:"name"}` 는 + * A 의 eval 이 `resolveName` 으로 풀어야 함(지금 안 이어짐 — 8-3 절 참고). */ + 이름정의?: NamedRange[]; } /** 새 시트 기본값 — 값은 사용자 협의로 확정(9_엑셀검토 8장 · 임의 확정 금지). 없으면 A 의 임시값. */ @@ -432,3 +443,49 @@ export interface ClipBlock { 원점: CellAddress | null; 출처: "aislo" | "google" | "excel" | "text"; } + +// ═════════════════════════════════════════════════════════════════════════════ +// 9. 3단계 — 조건부 서식 · 데이터 유효성 · 이름 정의 (Sheet 확장 · sub_laptop_4 계약 더함 · 안 이어짐) +// ═════════════════════════════════════════════════════════════════════════════ + +export type CondFmtCompareOp = + "보다큼" | "보다작음" | "크거나같음" | "작거나같음" | "같음" | "다름"; + +export type CondFmtCondition = + | { 종류: "비교"; 연산: CondFmtCompareOp; 값: number } + | { 종류: "사이"; 아래: number; 위: number } + | { 종류: "글포함"; 글: string } + /** 임의 수식 글 — A 의 파서 · eval 이 있어야 풀림(`spreadsheet_condfmt.ts` 머리 「잇는 법」). */ + | { 종류: "수식"; 식: string }; + +/** 조건부 서식이 먹이는 스타일 — `서식` 표 번호를 안 씀(규칙마다 직접 가짐). */ +export interface CondFmtStyle { + 채움?: string; + 글자색?: string; + 굵게?: boolean; +} + +export interface CondFmtRule { + id: string; + 범위: CellRange[]; + 조건: CondFmtCondition; + 스타일: CondFmtStyle; +} + +export type DataValidationKind = + { 종류: "목록"; 값들: string[] } | { 종류: "수범위"; 아래?: number; 위?: number }; + +export interface DataValidationRule { + id: string; + 범위: CellRange[]; + 규칙: DataValidationKind; + /** 어긴 값을 넣으려 하면 막을지 · 경고만 하고 넣을지(둘 다 D 편집기 잇기 몫 · 지금 안 이어짐). */ + 어기면: "막음" | "경고"; + 메시지?: string; +} + +/** 이름 정의 — 이 이름을 정의한 시트 안 범위만 가리킴(다른 시트를 가리키는 이름은 3단계 밖). */ +export interface NamedRange { + 이름: string; + 범위: CellRange; +} diff --git a/resources/tester/spreadsheet/test_stage3_condfmt.ts b/resources/tester/spreadsheet/test_stage3_condfmt.ts new file mode 100644 index 00000000..f3025786 --- /dev/null +++ b/resources/tester/spreadsheet/test_stage3_condfmt.ts @@ -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 시험 끝");