From 5718aa7f9d83f4dd34bc8cbf1967d8e1dc2dfac3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 21:00:12 +0900 Subject: [PATCH 1/3] =?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 시험 끝"); From 8606272f5c8cc4149c01304679d20809acec99ea Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 21:00:23 +0900 Subject: [PATCH 2/3] auto: 2026-09-27 21:00 (ESD_LAPTOP) --- A00_Common/spreadsheet/spreadsheet_names.ts | 201 ++++++++++++++++ .../spreadsheet/spreadsheet_validation.ts | 219 ++++++++++++++++++ .../tester/spreadsheet/test_stage3_names.ts | 71 ++++++ .../spreadsheet/test_stage3_validation.ts | 77 ++++++ 4 files changed, 568 insertions(+) create mode 100644 A00_Common/spreadsheet/spreadsheet_names.ts create mode 100644 A00_Common/spreadsheet/spreadsheet_validation.ts create mode 100644 resources/tester/spreadsheet/test_stage3_names.ts create mode 100644 resources/tester/spreadsheet/test_stage3_validation.ts diff --git a/A00_Common/spreadsheet/spreadsheet_names.ts b/A00_Common/spreadsheet/spreadsheet_names.ts new file mode 100644 index 00000000..af2a36f9 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_names.ts @@ -0,0 +1,201 @@ +/* ============================================================================= + * spreadsheet_names.ts (3단계 · 주인 sub_laptop_4) + * 이름 정의 — 범위에 이름을 붙이고 수식에서 `=단가*수량` 처럼 씀. 저장 자리 `Sheet.이름정의`(0 계약에 + * 방금 더함) — 이 이름을 정의한 시트 안 범위만 가리킴(다른 시트 참조는 3단계 밖). + * + * 수식 나무는 이미 `{type:"name", name}` 갈래를 가짐(계약 `spreadsheet_types.ts` 3절 · 정의 없으면 + * `#NAME?`). 이 파일은 이름을 저장 · 관리만 하고 실제로 식을 풀 때 이름을 찾는 일은 A 몫. + * + * 잇는 법(A · D · sub7 — 지금은 하나도 안 이어짐): + * 1) `spreadsheet_eval.ts` 가 `{type:"name"}` 나무를 만나면 `resolveName(book, 식이든시트, 이름)` 을 + * 불러 범위를 얻고, 범위가 칸 하나면 그 칸 값 · 여러 칸이면 `RangeValue` 로 바꿔 씀(없으면 지금처럼 `#NAME?`). + * 2) 편집기 이름 상자(주소 상자)에서 범위를 고른 채 이름을 치고 Enter 하면 `defineName(ctx, 이름, 범위)`. + * 3) 도구 모음 · 메뉴에 [이름 관리] 항목을 두고 `createNameManagerPanel(ctx)` 를 띄움. + * ========================================================================== */ + +import { createButton, createInputField, el } from "@ui/ui_template_elements"; +import { rangeToA1 } from "./spreadsheet_address"; +import type { CellRange, NamedRange, Sheet, Workbook } from "./spreadsheet_types"; +import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; + +// css 는 아직 안 이어짐(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 한 줄 더함. + +export type { NamedRange }; + +const CELL_LIKE = /^[A-Za-z]{1,3}\d+$/; +const RESERVED = /^(TRUE|FALSE)$/i; +const NAME_RE = /^[\p{L}_][\p{L}\p{N}_.]*$/u; + +/** 이름 규칙 — 글자 · 밑줄로 시작 · 칸 주소처럼 안 보임 · 겹치지 않음. 어기면 까닭 글 · 되면 null. */ +export function invalidNameReason(name: string, existing: string[]): string | null { + if (!name) return "이름이 비었음"; + if (!NAME_RE.test(name)) return "글자 · 숫자 · 밑줄만(첫 글자는 글자 · 밑줄)"; + if (CELL_LIKE.test(name)) return "칸 주소처럼 보이는 이름은 못 씀"; + if (RESERVED.test(name)) return "TRUE · FALSE 는 못 씀"; + if (existing.some((n) => n.toLowerCase() === name.toLowerCase())) return "이미 있는 이름"; + return null; +} + +export function listNames(book: Workbook): { 이름: string; 시트: string; 범위: CellRange }[] { + return book.시트.flatMap((sheet) => + (sheet.이름정의 ?? []).map((n) => ({ 이름: n.이름, 시트: sheet.id, 범위: n.범위 })), + ); +} + +/** 식이 든 시트의 이름부터 찾고(자기 시트 우선) · 없으면 다른 시트 이름도(느슨한 전역 대체). */ +export function resolveName( + book: Workbook, + sheetId: string, + name: string, +): { 시트: string; 범위: CellRange } | null { + const lower = name.toLowerCase(); + const own = book.시트.find((s) => s.id === sheetId); + const local = own?.이름정의?.find((n) => n.이름.toLowerCase() === lower); + if (local) return { 시트: sheetId, 범위: local.범위 }; + for (const sheet of book.시트) { + const found = sheet.이름정의?.find((n) => n.이름.toLowerCase() === lower); + if (found) return { 시트: sheet.id, 범위: found.범위 }; + } + return null; +} + +function applySheet(ctx: SpreadsheetContext, sheetId: string, next: Sheet): void { + ctx.dispatch({ 종류: "시트통째", 시트: { ...next, id: sheetId } }); +} + +/** 지금 활성 시트에 이름을 더함 — 규칙 어기면 아무 일도 안 하고 까닭을 돌려줌(호출부가 토스트로). */ +export function defineName(ctx: SpreadsheetContext, name: string, 범위: CellRange): string | null { + const reason = invalidNameReason( + name, + listNames(ctx.book).map((n) => n.이름), + ); + if (reason) return reason; + const sheet = ctx.sheet(); + applySheet(ctx, sheet.id, { + ...sheet, + 이름정의: [...(sheet.이름정의 ?? []), { 이름: name, 범위 }], + }); + return null; +} + +/** 이름을 지움 — 그 이름을 쓰던 식은 그대로 남아 다음 풀이 때 `#NAME?`(계약대로). */ +export function deleteName(ctx: SpreadsheetContext, name: string): void { + const lower = name.toLowerCase(); + for (const sheet of ctx.book.시트) { + if (!sheet.이름정의?.some((n) => n.이름.toLowerCase() === lower)) continue; + applySheet(ctx, sheet.id, { + ...sheet, + 이름정의: sheet.이름정의!.filter((n) => n.이름.toLowerCase() !== lower), + }); + return; + } +} + +/** 이름을 바꿈 — 정의뿐 아니라 모든 시트 식 글 속 이름 글자도 같이 바꿈(시트이름 바꾸기와 같은 규칙). */ +export function renameName( + ctx: SpreadsheetContext, + oldName: string, + newName: string, +): string | null { + const reason = invalidNameReason( + newName, + listNames(ctx.book) + .map((n) => n.이름) + .filter((n) => n.toLowerCase() !== oldName.toLowerCase()), + ); + if (reason) return reason; + const commands = ctx.book.시트 + .map((sheet) => renamedSheet(sheet, oldName, newName)) + .filter((s): s is Sheet => s !== null) + .map((sheet) => ({ 종류: "시트통째" as const, 시트: sheet })); + if (!commands.length) return null; + ctx.dispatch(commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands }); + return null; +} + +function renamedSheet(sheet: Sheet, oldName: string, newName: string): Sheet | null { + // 한글 이름은 `\w` 밖이라 `\b` 가 안 먹음 — 글자 · 숫자 · 밑줄이 아닌 자리를 경계로 직접 잡음. + const re = new RegExp(`(? + n.이름.toLowerCase() === oldName.toLowerCase() ? { ...n, 이름: newName } : n, + ); + if (이름정의 && JSON.stringify(이름정의) !== JSON.stringify(sheet.이름정의)) changed = true; + return changed ? { ...sheet, 칸, ...(이름정의 ? { 이름정의 } : {}) } : null; +} + +function escapeReg(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** 이름 관리 창 — 통합문서 전체 이름 목록 + 고른 범위에 새 이름. */ +export function createNameManagerPanel(ctx: SpreadsheetContext): PartHandle { + const root = el("div", { className: "ss-names" }); + const list = el("ul", { className: "ss-names__list" }); + const nameInput = createInputField({ label: "새 이름", placeholder: "단가" }); + const addBtn = createButton({ label: "이름 더하기" }); + let error: HTMLElement | null = null; + + const showError = (msg: string | null): void => { + error?.remove(); + error = null; + if (!msg) return; + error = el("p", { text: msg }); + error.style.color = "var(--color-danger, red)"; + root.append(error); + }; + + addBtn.addEventListener("click", () => { + const 범위 = ctx.selection.범위[0]; + if (!범위) return; + const reason = defineName(ctx, nameInput.input.value.trim(), 범위); + showError(reason); + if (!reason) { + nameInput.input.value = ""; + render(); + } + }); + + function render(): void { + list.replaceChildren( + ...listNames(ctx.book).map(({ 이름, 시트, 범위 }) => { + const li = el("li", { className: "ss-names__row" }); + const label = el("span", { text: `${이름} = ${시트}!${rangeToA1(범위)}` }); + const rename = createButton({ label: "이름 바꾸기", variant: "ghost" }); + rename.addEventListener("click", () => { + const next = window.prompt("새 이름", 이름); + if (!next || next === 이름) return; + showError(renameName(ctx, 이름, next)); + render(); + }); + const del = createButton({ label: "지움", variant: "danger" }); + del.addEventListener("click", () => { + deleteName(ctx, 이름); + render(); + }); + li.append(label, rename, del); + return li; + }), + ); + } + + const form = el("div", { className: "ss-names__form", children: [nameInput.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_validation.ts b/A00_Common/spreadsheet/spreadsheet_validation.ts new file mode 100644 index 00000000..2f7579ce --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_validation.ts @@ -0,0 +1,219 @@ +/* ============================================================================= + * spreadsheet_validation.ts (3단계 · 주인 sub_laptop_4) + * 데이터 유효성 — 목록(드롭다운) · 수 범위. 어기면 막거나(칸에 못 들어감) 경고만(그래도 들어감). + * 저장 자리 `Sheet.유효성`(0 계약에 방금 더함). 규칙은 범위 하나에 하나만 먹임(엑셀과 같이 — 겹치면 + * 배열 앞쪽이 이김). + * + * 잇는 법(D · sub7 — 지금은 하나도 안 이어짐): + * 1) 편집기(`spreadsheet_editor.ts`)가 칸 편집을 커밋하기 전에 `checkValue(rule, candidate)` 를 + * 불러 `어기면 === "막음"` 이면 `dispatch` 를 안 부르고(입력 취소) 메시지를 토스트로 · `"경고"` 면 + * 토스트만 띄우고 그대로 진행. + * 2) 칸 편집이 시작될 때(`editor.begin`) `findValidation(sheet, r, c)` 가 「목록」 규칙이면 + * `createValidationDropdown(ctx, r, c, rule)` 을 칸 옆에 띄우고 고르면 그 값을 칸에 넣음. + * 3) 행 · 열 넣기 · 지우기 뒤 `remapValidationRanges` 를 그 시트 `유효성` 에 적용(조건부 서식과 같은 + * 자리 · A 몫 · 지금 안 이어짐). + * 4) 도구 모음 · 메뉴에 [데이터 유효성] 항목을 두고 `createValidationPanel(ctx)` 를 띄움. + * ========================================================================== */ + +import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; +import { inRange, rangeToA1 } from "./spreadsheet_address"; +import type { + CellInput, + CellRange, + DataValidationKind, + DataValidationRule, + Sheet, +} from "./spreadsheet_types"; +import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; + +// css 는 아직 안 이어짐(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 한 줄 더함. + +export type { DataValidationKind, DataValidationRule }; + +/** 칸이 든 범위를 덮는 첫 규칙(배열 앞쪽 우선) — 없으면 undefined. */ +export function findValidation(sheet: Sheet, r: number, c: number): DataValidationRule | undefined { + return (sheet.유효성 ?? []).find((rule) => rule.범위.some((range) => inRange(range, r, c))); +} + +export interface ValidationCheck { + ok: boolean; + 어기면?: "막음" | "경고"; + 메시지: string; +} + +function defaultMessage(kind: DataValidationKind): string { + if (kind.종류 === "목록") return `목록 안 값만 됨(${kind.값들.join(", ")})`; + const 아래 = kind.아래 ?? -Infinity; + const 위 = kind.위 ?? Infinity; + return `${아래 === -Infinity ? "" : 아래}${위 === Infinity ? " 이상" : ` ~ ${위}`} 수만 됨`; +} + +function matchesKind(kind: DataValidationKind, candidate: CellInput): boolean { + if (kind.종류 === "목록") + return typeof candidate !== "boolean" && kind.값들.includes(String(candidate)); + if (typeof candidate !== "number") return false; + if (kind.아래 !== undefined && candidate < kind.아래) return false; + if (kind.위 !== undefined && candidate > kind.위) return false; + return true; +} + +/** 후보 값이 규칙을 지키는지 — 어기면 `어기면`(막음 · 경고)과 안내 글을 돌려줌. */ +export function checkValue(rule: DataValidationRule, candidate: CellInput): ValidationCheck { + if (matchesKind(rule.규칙, candidate)) return { ok: true, 메시지: "" }; + return { ok: false, 어기면: rule.어기면, 메시지: rule.메시지 || defaultMessage(rule.규칙) }; +} + +/** 행 · 열 넣기 · 지우기 뒤 범위 옮김 — `shift` 가 null 이면 그 범위는 버림(규칙이 범위를 다 잃으면 규칙째 버림). */ +export function remapValidationRanges( + rules: DataValidationRule[], + shift: (range: CellRange) => CellRange | null, +): DataValidationRule[] { + const out: DataValidationRule[] = []; + 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 => `dv${Date.now().toString(36)}${seq++}`; + +export function addValidationRule( + ctx: SpreadsheetContext, + rule: Omit, +): void { + const sheet = ctx.sheet(); + applySheet(ctx, { ...sheet, 유효성: [...(sheet.유효성 ?? []), { ...rule, id: newId() }] }); +} + +export function removeValidationRule(ctx: SpreadsheetContext, id: string): void { + const sheet = ctx.sheet(); + applySheet(ctx, { ...sheet, 유효성: (sheet.유효성 ?? []).filter((r) => r.id !== id) }); +} + +/** 목록 규칙일 때 칸 옆에 띄울 작은 드롭다운(고르면 `onPick` 이 불림 — 실제 `dispatch` 는 부른 쪽 몫). */ +export function createValidationDropdown( + ctx: SpreadsheetContext, + r: number, + c: number, + rule: DataValidationRule, + onPick: (value: string) => void, +): HTMLElement | null { + if (rule.규칙.종류 !== "목록") return null; + const box = ctx.grid.cellBox(r, c); + const dropdown = el("div", { className: "ss-validation__dropdown" }); + dropdown.style.left = `${box.x}px`; + dropdown.style.top = `${box.y + box.h}px`; + dropdown.style.minWidth = `${box.w}px`; + dropdown.replaceChildren( + ...rule.규칙.값들.map((value) => { + const item = el("div", { className: "ss-validation__dropdown-item", text: value }); + item.addEventListener("click", () => onPick(value)); + return item; + }), + ); + return dropdown; +} + +const kindText = (k: DataValidationKind): string => + k.종류 === "목록" ? `목록: ${k.값들.join(", ")}` : `수범위: ${k.아래 ?? "−∞"} ~ ${k.위 ?? "+∞"}`; + +/** 유효성 관리 창 — 지금 시트 규칙 목록 + 고른 범위에 규칙 더하기. */ +export function createValidationPanel(ctx: SpreadsheetContext): PartHandle { + const root = el("div", { className: "ss-validation" }); + const list = el("ul", { className: "ss-validation__list" }); + + const kindSelect = createSelectField({ + label: "규칙", + options: [ + { value: "목록", text: "목록(드롭다운)" }, + { value: "수범위", text: "수 범위" }, + ], + }); + const listValues = createInputField({ label: "목록 값(쉼표로 나눔)" }); + const min = createInputField({ label: "아래(수범위)" }); + const max = createInputField({ label: "위(수범위)" }); + const onBreak = createSelectField({ + label: "어기면", + options: [ + { value: "막음", text: "막음" }, + { value: "경고", text: "경고" }, + ], + }); + const message = createInputField({ label: "안내 글(생략 가능)" }); + const addBtn = createButton({ label: "규칙 더하기" }); + + addBtn.addEventListener("click", () => { + const 범위 = ctx.selection.범위; + if (!범위.length) return; + const 규칙 = build규칙(); + if (!규칙) return; + addValidationRule(ctx, { + 범위: [...범위], + 규칙, + 어기면: onBreak.select.value as "막음" | "경고", + 메시지: message.input.value || undefined, + }); + render(); + }); + + function build규칙(): DataValidationKind | null { + if (kindSelect.select.value === "목록") { + const 값들 = listValues.input.value + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + return 값들.length ? { 종류: "목록", 값들 } : null; + } + const 아래 = min.input.value ? Number(min.input.value) : undefined; + const 위 = max.input.value ? Number(max.input.value) : undefined; + return 아래 === undefined && 위 === undefined ? null : { 종류: "수범위", 아래, 위 }; + } + + function render(): void { + const rules = ctx.sheet().유효성 ?? []; + list.replaceChildren( + ...rules.map((rule) => { + const li = el("li", { className: "ss-validation__row" }); + const label = el("span", { + text: `${rule.범위.map(rangeToA1).join(", ")} — ${kindText(rule.규칙)}(${rule.어기면})`, + }); + const del = createButton({ label: "지움", variant: "danger" }); + del.addEventListener("click", () => { + removeValidationRule(ctx, rule.id); + render(); + }); + li.append(label, del); + return li; + }), + ); + } + + const form = el("div", { + className: "ss-validation__form", + children: [ + kindSelect.root, + listValues.root, + min.root, + max.root, + onBreak.root, + message.root, + addBtn, + ], + }); + root.append(el("h4", { text: "데이터 유효성 규칙" }), list, form); + render(); + + return { + root, + refresh: render, + destroy(): void { + root.remove(); + }, + }; +} diff --git a/resources/tester/spreadsheet/test_stage3_names.ts b/resources/tester/spreadsheet/test_stage3_names.ts new file mode 100644 index 00000000..45b86b11 --- /dev/null +++ b/resources/tester/spreadsheet/test_stage3_names.ts @@ -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 시험 끝"); diff --git a/resources/tester/spreadsheet/test_stage3_validation.ts b/resources/tester/spreadsheet/test_stage3_validation.ts new file mode 100644 index 00000000..a2a35e3b --- /dev/null +++ b/resources/tester/spreadsheet/test_stage3_validation.ts @@ -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 시험 끝"); From 1a4e24b8a923ea43df3f4e1f61566e76f8307612 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 21:02:28 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(spreadsheet):=202=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=E2=80=94=20=EC=97=91=EC=85=80=20=EB=8B=A8=EC=B6=95=ED=82=A4=20?= =?UTF-8?q?=EC=A0=84=EC=88=98=20=ED=91=9C(=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 키 53 개(이동·고르기·편집·복사붙이기·찾기·메모·서식·시트·범위밖) — 뜻·동작 이름·지금 상태(됨·안됨·범위밖)·자리를 적음. `spreadsheet_keys.ts`(D) 를 오늘 기준으로 읽고 매김. 안 된 것 19 개(Ctrl+B/I/U·Ctrl+1 은 오늘 대화상자로 됨 처리·F4 절대참조 돌리기·Alt+= 자동합계· Ctrl+-/Ctrl+Shift++ 칸 넣기지우기·Ctrl+9/0 숨김·Ctrl+;/Shift+; 날짜시간·Ctrl+PageUp/Down 시트 이동·Ctrl+클릭 여러범위·테두리 두 개·Shift+F11 새 시트) — 잇는 법은 파일 머리에. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr --- .../spreadsheet/spreadsheet_shortcuts.ts | 359 ++++++++++++++++++ .../spreadsheet/test_stage2_shortcuts.ts | 65 ++++ 2 files changed, 424 insertions(+) create mode 100644 A00_Common/spreadsheet/spreadsheet_shortcuts.ts create mode 100644 resources/tester/spreadsheet/test_stage2_shortcuts.ts diff --git a/A00_Common/spreadsheet/spreadsheet_shortcuts.ts b/A00_Common/spreadsheet/spreadsheet_shortcuts.ts new file mode 100644 index 00000000..99d8bb11 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_shortcuts.ts @@ -0,0 +1,359 @@ +/* ============================================================================= + * spreadsheet_shortcuts.ts (2단계 · 주인 sub_laptop_1) + * 엑셀 단축키 전수 표 — 데이터 파일(동작은 안 함). 키마다 뜻 · 이을 동작 이름 · 지금 상태(됨 · + * 안됨 · 범위밖) · 자리(됨이면 어디 있는지 · 안됨이면 어디 만들지 추천)를 적어 둠. + * 2026-09-27 `spreadsheet_keys.ts`(D · sub7) 를 읽고 「됨」 을 매김 — keys.ts 가 바뀌면 이 표도 다시 맞출 것. + * + * 잇는 법(D · sub7) — `spreadsheet_keys.ts` 는 지금 각 키를 switch 문에 직접 심어 둠(표를 돌며 찾지 + * 않음). 이 표는 ① 빠짐없이 다 있는지 볼 대조표 ② 「안됨」 항목을 새로 심을 때 이름표(동작 이름을 + * 그대로 함수 이름으로 씀) 로 씀. 새로 심으면 이 파일의 그 항목 `상태` 를 "됨" 으로 바꾸고 `자리` 를 채움. + * 개발 중 대조가 필요하면 `findShortcut("Ctrl+B")` 로 찾아 콘솔에 남기는 정도로만 씀(런타임 배선 아님). + * ========================================================================== */ + +export type ShortcutState = "됨" | "안됨" | "범위밖"; + +export interface ShortcutEntry { + 키: string; + 뜻: string; + /** 이을 함수 이름(식별자) — 새로 심을 때 이 이름을 그대로 씀 */ + 동작: string; + 상태: ShortcutState; + /** 「됨」이면 있는 자리 · 「안됨」이면 만들 자리 추천 · 「범위밖」이면 왜 안 하는지 */ + 자리: string; +} + +export const SHORTCUTS: ShortcutEntry[] = [ + // ── 이동 ────────────────────────────────────────────────────────────── + { 키: "방향키", 뜻: "한 칸 이동", 동작: "move", 상태: "됨", 자리: "keys.ts navKey" }, + { + 키: "Shift+방향키", + 뜻: "고름 늘리기", + 동작: "extendSelection", + 상태: "됨", + 자리: "keys.ts navKey(move extend)", + }, + { + 키: "Ctrl+방향키", + 뜻: "자료 끝으로 건너뜀", + 동작: "jumpToEdge", + 상태: "됨", + 자리: "keys.ts navKey(move jump)", + }, + { + 키: "Ctrl+Shift+방향키", + 뜻: "자료 끝까지 고름", + 동작: "extendToEdge", + 상태: "됨", + 자리: "keys.ts navKey(move extend+jump)", + }, + { 키: "Home", 뜻: "그 행 맨 앞 칸", 동작: "moveHome", 상태: "됨", 자리: "keys.ts navKey" }, + { + 키: "Ctrl+Home", + 뜻: "A1 로", + 동작: "moveToStart", + 상태: "됨", + 자리: "keys.ts navKey(Ctrl+Home)", + }, + { + 키: "Ctrl+End", + 뜻: "마지막으로 쓴 칸으로", + 동작: "moveToUsedEnd", + 상태: "됨", + 자리: "keys.ts navKey(Ctrl+End)", + }, + { + 키: "PageUp/PageDown", + 뜻: "화면 높이만큼 위 · 아래", + 동작: "movePage", + 상태: "됨", + 자리: "keys.ts navKey", + }, + { + 키: "Tab/Shift+Tab", + 뜻: "확정 뒤 옆 · 앞 칸", + 동작: "nextCell", + 상태: "됨", + 자리: "keys.ts editingKey · navKey", + }, + { + 키: "Enter/Shift+Enter", + 뜻: "확정 뒤 아래 · 위 칸", + 동작: "nextRow", + 상태: "됨", + 자리: "keys.ts editingKey · navKey", + }, + { + 키: "Ctrl+PageDown/PageUp", + 뜻: "다음 · 이전 시트 탭", + 동작: "nextSheet", + 상태: "안됨", + 자리: "spreadsheet_tabs.ts 에 만들고 keys.ts navKey 에 연결", + }, + + // ── 고르기 ──────────────────────────────────────────────────────────── + { + 키: "Ctrl+A", + 뜻: "시트 전체 고름", + 동작: "selectAllCells", + 상태: "됨", + 자리: "keys.ts navKey", + }, + { + 키: "Ctrl+Space", + 뜻: "그 열 전체 고름", + 동작: "selectColumn", + 상태: "됨", + 자리: "keys.ts navKey", + }, + { + 키: "Shift+Space", + 뜻: "그 행 전체 고름", + 동작: "selectRow", + 상태: "됨", + 자리: "keys.ts navKey", + }, + { + 키: "Ctrl+클릭", + 뜻: "범위를 하나 더 고름(여러 범위)", + 동작: "addRangeToSelection", + 상태: "안됨", + 자리: "spreadsheet_selection.ts 확장 — spreadsheet_autofit.ts 머리에도 자리 적어 둠", + }, + + // ── 편집 ────────────────────────────────────────────────────────────── + { + 키: "F2", + 뜻: "칸 편집 시작 · 편집 방식 바꿈", + 동작: "toggleEditMode", + 상태: "됨", + 자리: "keys.ts navKey/editingKey", + }, + { + 키: "Delete", + 뜻: "내용만 지움(서식은 남김)", + 동작: "clearContents", + 상태: "됨", + 자리: "keys.ts navKey", + }, + { + 키: "Backspace", + 뜻: "지우고 새로 침", + 동작: "clearAndType", + 상태: "됨", + 자리: "keys.ts navKey", + }, + { 키: "Esc", 뜻: "편집 취소", 동작: "cancelEdit", 상태: "됨", 자리: "keys.ts editingKey" }, + { + 키: "Alt+Enter", + 뜻: "칸 안에서 줄바꿈", + 동작: "insertLineBreak", + 상태: "됨", + 자리: "keys.ts editingKey", + }, + { + 키: "Ctrl+Enter", + 뜻: "고른 칸 전부에 같은 값", + 동작: "fillAllSelected", + 상태: "됨", + 자리: "keys.ts editingKey(commitAll)", + }, + { + 키: "Ctrl+D", + 뜻: "맨 위 칸 값을 아래로 채움", + 동작: "fillDown", + 상태: "됨", + 자리: "keys.ts navKey(fill)", + }, + { + 키: "Ctrl+R", + 뜻: "맨 왼 칸 값을 오른쪽으로 채움", + 동작: "fillRight", + 상태: "됨", + 자리: "keys.ts navKey(fill)", + }, + { + 키: "Ctrl+;", + 뜻: "오늘 날짜를 침", + 동작: "insertToday", + 상태: "안됨", + 자리: "날짜 형식이 1단계 밖이라 보류 — keys.ts navKey 에 새 case", + }, + { + 키: "Ctrl+Shift+;", + 뜻: "지금 시각을 침", + 동작: "insertNow", + 상태: "안됨", + 자리: "위와 같음(날짜 보류)", + }, + { 키: "Ctrl+Z", 뜻: "되돌리기", 동작: "undo", 상태: "됨", 자리: "keys.ts navKey" }, + { 키: "Ctrl+Y", 뜻: "다시", 동작: "redo", 상태: "됨", 자리: "keys.ts navKey" }, + { + 키: "Ctrl+-", + 뜻: "칸 · 행 · 열 지우기(당겨 채움)", + 동작: "deleteCells", + 상태: "안됨", + 자리: "대화상자(방향 고르기) 필요 — keys.ts navKey 에 새 case", + }, + { + 키: "Ctrl+Shift++", + 뜻: "칸 · 행 · 열 넣기(밀어냄)", + 동작: "insertCells", + 상태: "안됨", + 자리: "위와 같음(대화상자 필요)", + }, + { + 키: "Ctrl+9", + 뜻: "고른 행 숨김", + 동작: "hideRows", + 상태: "안됨", + 자리: "「숨김」 명령은 이미 있음 — keys.ts navKey 에 새 case만", + }, + { 키: "Ctrl+0", 뜻: "고른 열 숨김", 동작: "hideColumns", 상태: "안됨", 자리: "위와 같음" }, + { 키: "Ctrl+Shift+9", 뜻: "행 숨김 풀기", 동작: "unhideRows", 상태: "안됨", 자리: "위와 같음" }, + { + 키: "Ctrl+Shift+0", + 뜻: "열 숨김 풀기", + 동작: "unhideColumns", + 상태: "안됨", + 자리: "위와 같음", + }, + { + 키: "F4", + 뜻: "식 편집 중 참조를 상대 → 절대 → 혼합으로 돌림", + 동작: "cycleReferenceAbs", + 상태: "안됨", + 자리: "spreadsheet_editor.ts 편집 중 커서 위치 참조를 찾아 고침", + }, + { + 키: "Alt+=", + 뜻: "위(왼) 수를 모아 SUM 자동 완성", + 동작: "autoSum", + 상태: "안됨", + 자리: "keys.ts navKey — 위쪽 연속 수 범위를 찾아 `=SUM(...)` 를 침", + }, + + // ── 복사 · 붙여넣기 ────────────────────────────────────────────────── + { + 키: "Ctrl+C", + 뜻: "복사", + 동작: "copySelection", + 상태: "됨", + 자리: "spreadsheet_clipboard.ts(E)", + }, + { + 키: "Ctrl+X", + 뜻: "잘라내기", + 동작: "cutSelection", + 상태: "됨", + 자리: "spreadsheet_clipboard.ts(E)", + }, + { + 키: "Ctrl+V", + 뜻: "붙여넣기", + 동작: "pasteSelection", + 상태: "됨", + 자리: "spreadsheet_clipboard.ts(E)", + }, + { + 키: "Ctrl+Shift+V", + 뜻: "붙여넣기 골라서(값만 · 서식만 · 수식만)", + 동작: "pasteSpecialDialog", + 상태: "됨", + 자리: "keys.ts navKey hook → spreadsheet_paste_special.ts", + }, + + // ── 찾기 ────────────────────────────────────────────────────────────── + { + 키: "Ctrl+F", + 뜻: "찾기", + 동작: "openFind", + 상태: "됨", + 자리: "keys.ts navKey hook → spreadsheet_find_panel.ts", + }, + { + 키: "Ctrl+H", + 뜻: "바꾸기", + 동작: "openReplace", + 상태: "됨", + 자리: "keys.ts navKey hook → spreadsheet_find_panel.ts", + }, + { 키: "Shift+F5", 뜻: "찾기(엑셀 버릇)", 동작: "openFind", 상태: "됨", 자리: "keys.ts navKey" }, + + // ── 메모 ────────────────────────────────────────────────────────────── + { + 키: "Shift+F2", + 뜻: "메모 넣기 · 고치기", + 동작: "editComment", + 상태: "됨", + 자리: "keys.ts navKey hook → spreadsheet_comments.ts", + }, + + // ── 서식 ────────────────────────────────────────────────────────────── + { + 키: "Ctrl+1", + 뜻: "셀 서식 대화상자", + 동작: "openFormatDialog", + 상태: "됨", + 자리: "spreadsheet_format_dialog.ts(오늘 새로 · 2026-09-27) — keys.ts 에 hook 연결만 남음", + }, + { + 키: "Ctrl+B", + 뜻: "굵게 토글", + 동작: "toggleBold", + 상태: "안됨", + 자리: "keys.ts navKey 새 case — 서식 명령 { 굵게: !지금값 }", + }, + { 키: "Ctrl+I", 뜻: "기울임 토글", 동작: "toggleItalic", 상태: "안됨", 자리: "위와 같음" }, + { 키: "Ctrl+U", 뜻: "밑줄 토글", 동작: "toggleUnderline", 상태: "안됨", 자리: "위와 같음" }, + { 키: "Ctrl+5", 뜻: "취소선 토글", 동작: "toggleStrikethrough", 상태: "안됨", 자리: "위와 같음" }, + { + 키: "Ctrl+Shift+&", + 뜻: "바깥 테두리 넣기", + 동작: "addOutlineBorder", + 상태: "안됨", + 자리: "keys.ts navKey 새 case — 서식 명령 테두리 네 변", + }, + { + 키: "Ctrl+Shift+_", + 뜻: "테두리 없앰", + 동작: "clearBorders", + 상태: "안됨", + 자리: "keys.ts navKey 새 case", + }, + + // ── 시트 ────────────────────────────────────────────────────────────── + { + 키: "Shift+F11", + 뜻: "새 시트", + 동작: "newSheet", + 상태: "안됨", + 자리: "spreadsheet_tabs.ts 버튼은 있을 수 있음 — 단축키만 keys.ts 에 더함", + }, + + // ── 범위 밖(Aislo 는 다른 방식이라 안 함) ──────────────────────────── + { + 키: "Ctrl+S/O/N", + 뜻: "저장 · 열기 · 새 파일", + 동작: "—", + 상태: "범위밖", + 자리: "Aislo 는 [저장] 버튼 · 서버 파일이라 브라우저 단축키를 흉내 안 냄", + }, + { + 키: "F9", + 뜻: "손으로 다시 계산", + 동작: "—", + 상태: "범위밖", + 자리: "자동 재계산이라 손으로 다시 계산할 일이 없음", + }, +]; + +const norm = (key: string): string => key.replace(/\s+/g, "").toLowerCase(); + +export function findShortcut(키: string): ShortcutEntry | null { + const k = norm(키); + return SHORTCUTS.find((s) => norm(s.키) === k) ?? null; +} + +export function unimplemented(): ShortcutEntry[] { + return SHORTCUTS.filter((s) => s.상태 === "안됨"); +} diff --git a/resources/tester/spreadsheet/test_stage2_shortcuts.ts b/resources/tester/spreadsheet/test_stage2_shortcuts.ts new file mode 100644 index 00000000..027e5d59 --- /dev/null +++ b/resources/tester/spreadsheet/test_stage2_shortcuts.ts @@ -0,0 +1,65 @@ +/* ============================================================================= + * test_stage2_shortcuts.ts — spreadsheet_shortcuts 시험. 돌리기: node --experimental-strip-types <이 파일> + * ========================================================================== */ + +import { + findShortcut, + SHORTCUTS, + unimplemented, +} from "../../../A00_Common/spreadsheet/spreadsheet_shortcuts.ts"; +import { assertEqual } from "./test_stage2_helpers.ts"; + +assertEqual(SHORTCUTS.length > 40, true, "충분히 전수(40 개 넘음)"); + +// 브레인이 콕 집은 키가 다 있는지 +const 요청키 = [ + "Ctrl+B", + "Ctrl+I", + "Ctrl+U", + "Ctrl+1", + "Ctrl+D", + "Ctrl+R", + "Ctrl+;", + "Shift+;", // 표기는 Ctrl+Shift+; — 부분 문자열로도 찾아짐 확인 + "Alt+Enter", + "Ctrl+-", + "Ctrl+Shift++", + "Ctrl+Space", + "Shift+Space", + "F4", + "Ctrl+Z", + "Ctrl+Y", + "Ctrl+F", + "Ctrl+H", + "Home", + "Ctrl+Home", + "Ctrl+End", + "Alt+=", +]; +for (const k of 요청키) { + if (k === "Shift+;") continue; // 실제 표기는 Ctrl+Shift+; 하나로 묶임 — 아래서 따로 확인 + assertEqual(findShortcut(k) !== null, true, `요청 키 있음: ${k}`); +} +assertEqual(findShortcut("Ctrl+Shift+;") !== null, true, "Ctrl+Shift+; 있음"); + +assertEqual(findShortcut("Ctrl+B")?.동작, "toggleBold", "Ctrl+B 동작 이름"); +assertEqual(findShortcut("ctrl+b")?.동작, "toggleBold", "findShortcut 대소문자 안 가림"); +assertEqual(findShortcut("Ctrl+없는키"), null, "없는 키는 null"); + +const missing = unimplemented(); +assertEqual(missing.length > 0, true, "안 된 것 목록이 있음"); +assertEqual( + missing.every((s) => s.동작 !== "—" && s.자리.length > 0), + true, + "안 된 것도 동작 이름 · 만들 자리가 다 있음", +); +assertEqual( + SHORTCUTS.filter((s) => s.상태 === "범위밖").every((s) => s.자리.length > 0), + true, + "범위밖도 왜 안 하는지 적혀 있음", +); + +console.log(`안 된 것 ${missing.length} 개: ${missing.map((s) => s.키).join(" · ")}`); + +if (process.exitCode) process.exit(process.exitCode); +console.log("spreadsheet_shortcuts 시험 끝");