From a4ad54571c4dedaa0003f1d850ad88e9227dbe41 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 20:56:28 +0900 Subject: [PATCH] =?UTF-8?q?feat(spreadsheet):=20=EC=A0=95=EB=A0=AC=20?= =?UTF-8?q?=E2=80=94=20=EC=97=91=EC=85=80=20=EC=B0=A8=EB=A1=80=20=C2=B7=20?= =?UTF-8?q?=EC=97=AC=EB=9F=AC=20=EA=B8=B0=EC=A4=80=20=C2=B7=20=EB=A8=B8?= =?UTF-8?q?=EB=A6=AC=20=ED=96=89=20=C2=B7=20=EC=8B=9D=20=EB=B3=B5=EC=82=AC?= =?UTF-8?q?=EC=8B=9D=20=EC=98=AE=EA=B9=80=20=C2=B7=20=EB=8C=80=ED=99=94=20?= =?UTF-8?q?=EC=83=81=EC=9E=90=20=E2=80=94=20=EA=B3=84=EC=95=BD=20=EB=8D=94?= =?UTF-8?q?=ED=95=A8(=EC=A0=95=EB=A0=AC=20=C2=B7=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EB=AA=85=EB=A0=B9=20=C2=B7=20Sheet.=ED=95=84=ED=84=B0=20=C2=B7?= =?UTF-8?q?=20RowInfo.=EA=B1=B8=EB=9F=AC=EC=A7=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 계약 더함: Command 「정렬」(차례를 박음 → 다시 하기가 같음) · 「필터」(걸러짐 행을 박음) · AutoFilter · Sheet.필터 · RowInfo.걸러짐 · 파일 목록 - spreadsheet_sortfilter.ts(A 엔진) — 수 < 글 < 참거짓 < 오류 · 빈 칸 늘 맨 뒤 · 같은 값 원래 차례 · 대소문자 옵션 · 현재 영역 · 정렬 옮김 = 범위 열만 · 식은 상대 참조만 복사처럼 · 범위 밖 식 그대로 · 서식 · 메모 따라감 · 걸러진 행 제자리 · 병합 걸치면 막음 · 자동 필터 엔진(값 목록 · 걸러짐 셈 · 적용 · 행열 넣기 · 지우기 때 필터 따라감) · 되돌림 짝 = 시트통째 - spreadsheet_sort_dialog.ts — 정렬 대화 상자(범위 · 머리 행 짐작 · 기준 더하기 · 오름 · 내림) · 빠른 정렬 sortSelection - spreadsheet_commands_sheet.ts — 시트 명령을 떼어 냄(commands 700 줄 안쪽) - 시험 test_spreadsheet_sortfilter.py 엑셀 규칙표 4 묶음 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF --- .../spreadsheet/spreadsheet_commands.ts | 86 ++---- .../spreadsheet/spreadsheet_commands_sheet.ts | 69 +++++ .../spreadsheet/spreadsheet_sort_dialog.ts | 180 +++++++++++ .../spreadsheet/spreadsheet_sortfilter.css | 128 ++++++++ .../spreadsheet/spreadsheet_sortfilter.ts | 289 ++++++++++++++++++ A00_Common/spreadsheet/spreadsheet_types.ts | 20 ++ .../spreadsheet/helper_spreadsheet_engine.cjs | 2 +- .../helper_spreadsheet_sortfilter.cjs | 206 +++++++++++++ .../test_spreadsheet_sortfilter.py | 36 +++ 9 files changed, 947 insertions(+), 69 deletions(-) create mode 100644 A00_Common/spreadsheet/spreadsheet_commands_sheet.ts create mode 100644 A00_Common/spreadsheet/spreadsheet_sort_dialog.ts create mode 100644 A00_Common/spreadsheet/spreadsheet_sortfilter.css create mode 100644 A00_Common/spreadsheet/spreadsheet_sortfilter.ts create mode 100644 resources/tester/spreadsheet/helper_spreadsheet_sortfilter.cjs create mode 100644 resources/tester/spreadsheet/test_spreadsheet_sortfilter.py diff --git a/A00_Common/spreadsheet/spreadsheet_commands.ts b/A00_Common/spreadsheet/spreadsheet_commands.ts index 5eee4ddb..e89a56a8 100644 --- a/A00_Common/spreadsheet/spreadsheet_commands.ts +++ b/A00_Common/spreadsheet/spreadsheet_commands.ts @@ -18,7 +18,15 @@ import { rangeToA1, toA1, } from "./spreadsheet_address"; +import { + addSheet, + moveSheet, + removeSheet, + renameSheet, + replaceSheet, +} from "./spreadsheet_commands_sheet"; import { shiftForCommand, shiftSpan } from "./spreadsheet_refshift"; +import { applyFilter, applySort, shiftFilter } from "./spreadsheet_sortfilter"; import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types"; import type { Cell, @@ -34,11 +42,11 @@ import type { /** 막힌 명령(병합 가름 · 마지막 시트 지우기 · 같은 시트 이름 …) — 화면이 `message` 를 알림 */ export class CommandError extends Error {} -type Of = Extract; +export type Of = Extract; -const clone = (x: T): T => structuredClone(x); +export const clone = (x: T): T => structuredClone(x); -function sheetOf(book: Workbook, id: string): Sheet { +export function sheetOf(book: Workbook, id: string): Sheet { const s = book.시트.find((x) => x.id === id); if (!s) throw new CommandError(`없는 시트: ${id}`); return s; @@ -107,7 +115,7 @@ function setSheetDefault(s: Sheet, key: "서식" | "열폭" | "행높이", value const addr = (시트: string, r: number, c: number): SheetCellAddress => ({ 시트, r, c }); /** 바뀐 시트만 통째 되돌림 짝 */ -function restoreChanged(book: Workbook, old: Sheet[]): Command[] { +export function restoreChanged(book: Workbook, old: Sheet[]): Command[] { const out: Command[] = []; for (const o of old) { const now = book.시트.find((x) => x.id === o.id); @@ -117,20 +125,12 @@ function restoreChanged(book: Workbook, old: Sheet[]): Command[] { } /** 모든 식 글을 명령에 맞춰 고침 — 시트 이름을 바꾸기 전에 부름 */ -function rewriteAll(book: Workbook, command: Command) { +export function rewriteAll(book: Workbook, command: Command) { for (const s of book.시트) for (const cell of Object.values(s.칸)) if (cell.식 !== undefined) cell.식 = shiftForCommand(cell.식, s.id, command, book); } -function checkSheetName(book: Workbook, name: string, self?: string) { - if (!name.trim() || name.length > 31) throw new CommandError("시트 이름은 1~31 글자"); - if (/[[\]:*?/\\]/.test(name) || name.startsWith("'") || name.endsWith("'")) - throw new CommandError("시트 이름에 못 쓰는 글자: [ ] : * ? / \\ 와 앞뒤 '"); - if (book.시트.some((s) => s.id !== self && s.이름.toLowerCase() === name.toLowerCase())) - throw new CommandError(`같은 이름 시트가 있음: ${name}`); -} - // ── 칸 ──────────────────────────────────────────────────────────────────── function setCells(book: Workbook, cmd: Of<"칸">): CommandEffect { @@ -332,6 +332,7 @@ function insertDelete( } } + shiftFilter(s, rows, insert, at, n); const key = rows ? "행" : "열"; const frozen = s.틀고정?.[key]; if (frozen && at < frozen) @@ -484,61 +485,6 @@ function sheetSize(s: Sheet, axis: "행" | "열", value: number | null): Command return { undo: { 종류: "시트통째", 시트: before }, cells: [], rebuild: false }; } -// ── 시트 ──────────────────────────────────────────────────────────────────── - -function addSheet(book: Workbook, cmd: Of<"시트더하기">): CommandEffect { - if (book.시트.some((s) => s.id === cmd.시트.id)) - throw new CommandError(`같은 id 시트가 있음: ${cmd.시트.id}`); - checkSheetName(book, cmd.시트.이름); - book.시트.splice(Math.max(0, Math.min(cmd.자리, book.시트.length)), 0, clone(cmd.시트)); - return { undo: { 종류: "시트지우기", 시트: cmd.시트.id }, cells: [], rebuild: true }; -} - -function removeSheet(book: Workbook, cmd: Of<"시트지우기">): CommandEffect { - const s = sheetOf(book, cmd.시트); - if (book.시트.length === 1) throw new CommandError("마지막 시트는 못 지움"); - const at = book.시트.indexOf(s); - const old = book.시트.map(clone); - rewriteAll(book, cmd); - book.시트.splice(at, 1); - if (book.활성 === s.id) book.활성 = book.시트[Math.min(at, book.시트.length - 1)].id; - const undo: Command[] = [ - { 종류: "시트더하기", 시트: old[at], 자리: at }, - ...restoreChanged(book, old), - ]; - return { undo: { 종류: "묶음", 명령: undo }, cells: [], rebuild: true }; -} - -function renameSheet(book: Workbook, cmd: Of<"시트이름">): CommandEffect { - const s = sheetOf(book, cmd.시트); - checkSheetName(book, cmd.이름, s.id); - const prev = s.이름; - rewriteAll(book, cmd); - s.이름 = cmd.이름; - return { undo: { 종류: "시트이름", 시트: s.id, 이름: prev }, cells: [], rebuild: true }; -} - -function moveSheet(book: Workbook, cmd: Of<"시트옮기기">): CommandEffect { - const s = sheetOf(book, cmd.시트); - const from = book.시트.indexOf(s); - book.시트.splice(from, 1); - book.시트.splice(Math.max(0, Math.min(cmd.자리, book.시트.length)), 0, s); - return { undo: { 종류: "시트옮기기", 시트: s.id, 자리: from }, cells: [], rebuild: false }; -} - -function replaceSheet(book: Workbook, cmd: Of<"시트통째">): CommandEffect { - const at = book.시트.findIndex((x) => x.id === cmd.시트.id); - if (at < 0) throw new CommandError(`없는 시트: ${cmd.시트.id}`); - const prev = book.시트[at]; - book.시트[at] = clone(cmd.시트); - const undo: Of<"시트통째"> = { 종류: "시트통째", 시트: prev }; - if (cmd.서식) { - undo.서식 = book.서식; - book.서식 = clone(cmd.서식); - } - return { undo, cells: [], rebuild: true }; -} - function batch(book: Workbook, cmd: Of<"묶음">): CommandEffect { const done: CommandEffect[] = []; try { @@ -644,6 +590,10 @@ export function applyCommand(book: Workbook, command: Command): CommandEffect { return moveSheet(book, command); case "시트통째": return replaceSheet(book, command); + case "정렬": + return applySort(book, command); + case "필터": + return applyFilter(book, command); case "묶음": return batch(book, command); } diff --git a/A00_Common/spreadsheet/spreadsheet_commands_sheet.ts b/A00_Common/spreadsheet/spreadsheet_commands_sheet.ts new file mode 100644 index 00000000..e4ac3e5d --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_commands_sheet.ts @@ -0,0 +1,69 @@ +/* ============================================================================= + * spreadsheet_commands_sheet.ts (주인 A) + * 시트 명령 — 더하기 · 지우기 · 이름 · 옮기기 · 통째 바꿔 넣기. 들머리는 `applyCommand`(commands). + * ========================================================================== */ + +import { clone, CommandError, restoreChanged, rewriteAll, sheetOf } from "./spreadsheet_commands"; +import type { Of } from "./spreadsheet_commands"; +import type { Command, CommandEffect, Workbook } from "./spreadsheet_types"; + +function checkSheetName(book: Workbook, name: string, self?: string) { + if (!name.trim() || name.length > 31) throw new CommandError("시트 이름은 1~31 글자"); + if (/[[\]:*?/\\]/.test(name) || name.startsWith("'") || name.endsWith("'")) + throw new CommandError("시트 이름에 못 쓰는 글자: [ ] : * ? / \\ 와 앞뒤 '"); + if (book.시트.some((s) => s.id !== self && s.이름.toLowerCase() === name.toLowerCase())) + throw new CommandError(`같은 이름 시트가 있음: ${name}`); +} + +export function addSheet(book: Workbook, cmd: Of<"시트더하기">): CommandEffect { + if (book.시트.some((s) => s.id === cmd.시트.id)) + throw new CommandError(`같은 id 시트가 있음: ${cmd.시트.id}`); + checkSheetName(book, cmd.시트.이름); + book.시트.splice(Math.max(0, Math.min(cmd.자리, book.시트.length)), 0, clone(cmd.시트)); + return { undo: { 종류: "시트지우기", 시트: cmd.시트.id }, cells: [], rebuild: true }; +} + +export function removeSheet(book: Workbook, cmd: Of<"시트지우기">): CommandEffect { + const s = sheetOf(book, cmd.시트); + if (book.시트.length === 1) throw new CommandError("마지막 시트는 못 지움"); + const at = book.시트.indexOf(s); + const old = book.시트.map(clone); + rewriteAll(book, cmd); + book.시트.splice(at, 1); + if (book.활성 === s.id) book.활성 = book.시트[Math.min(at, book.시트.length - 1)].id; + const undo: Command[] = [ + { 종류: "시트더하기", 시트: old[at], 자리: at }, + ...restoreChanged(book, old), + ]; + return { undo: { 종류: "묶음", 명령: undo }, cells: [], rebuild: true }; +} + +export function renameSheet(book: Workbook, cmd: Of<"시트이름">): CommandEffect { + const s = sheetOf(book, cmd.시트); + checkSheetName(book, cmd.이름, s.id); + const prev = s.이름; + rewriteAll(book, cmd); + s.이름 = cmd.이름; + return { undo: { 종류: "시트이름", 시트: s.id, 이름: prev }, cells: [], rebuild: true }; +} + +export function moveSheet(book: Workbook, cmd: Of<"시트옮기기">): CommandEffect { + const s = sheetOf(book, cmd.시트); + const from = book.시트.indexOf(s); + book.시트.splice(from, 1); + book.시트.splice(Math.max(0, Math.min(cmd.자리, book.시트.length)), 0, s); + return { undo: { 종류: "시트옮기기", 시트: s.id, 자리: from }, cells: [], rebuild: false }; +} + +export function replaceSheet(book: Workbook, cmd: Of<"시트통째">): CommandEffect { + const at = book.시트.findIndex((x) => x.id === cmd.시트.id); + if (at < 0) throw new CommandError(`없는 시트: ${cmd.시트.id}`); + const prev = book.시트[at]; + book.시트[at] = clone(cmd.시트); + const undo: Of<"시트통째"> = { 종류: "시트통째", 시트: prev }; + if (cmd.서식) { + undo.서식 = book.서식; + book.서식 = clone(cmd.서식); + } + return { undo, cells: [], rebuild: true }; +} diff --git a/A00_Common/spreadsheet/spreadsheet_sort_dialog.ts b/A00_Common/spreadsheet/spreadsheet_sort_dialog.ts new file mode 100644 index 00000000..2c660248 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_sort_dialog.ts @@ -0,0 +1,180 @@ +/* ============================================================================= + * spreadsheet_sort_dialog.ts (주인 A · 화면) + * 정렬 대화 상자 · 빠른 정렬(오름 · 내림). 엔진은 `spreadsheet_sortfilter.ts`(명령 `정렬`). + * 범위 = 고른 범위 · 칸 하나만 골랐으면 이어진 값 덩어리(엑셀 「현재 영역」) · 머리 행은 짐작해 켬. + * + * 잇는 법(D · sub7): + * · 도구 모음 · 메뉴 「정렬…」 → `openSortDialog(ctx)` — 창을 `ctx.root` 에 띄우고 닫는 함수를 돌려줌. + * · 「오름차순」 「내림차순」 단추 → `sortSelection(ctx, 내림)` — 활성 칸 열을 기준으로 바로 정렬. + * · 막히면(병합 걸침 등) 토스트로 까닭을 보임 · 되돌리기는 `ctx.dispatch` 가 쌓음. + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { colName, rangeToA1 } from "./spreadsheet_address"; +import { CommandError } from "./spreadsheet_commands"; +import { currentRegion, displayText, sortCommand } from "./spreadsheet_sortfilter"; +import type { SortKey, SortOptions } from "./spreadsheet_sortfilter"; +import type { CellRange } from "./spreadsheet_types"; +import type { SpreadsheetContext } from "./spreadsheet_view_types"; +import "./spreadsheet_sortfilter.css"; + +const TEXT = { + Title: ["정렬", "Sort"], + Range: ["범위", "Range"], + Header: ["머리 행 있음", "My data has headers"], + Case: ["대소문자 구분", "Case sensitive"], + Column: ["열", "Column"], + Asc: ["오름차순", "Ascending"], + Desc: ["내림차순", "Descending"], + AddKey: ["기준 더하기", "Add level"], + RemoveKey: ["빼기", "Remove"], + Cancel: ["취소", "Cancel"], + Ok: ["정렬", "Sort"], +}; +const t = (key: keyof typeof TEXT) => TEXT[key][currentLanguageIndex] ?? TEXT[key][0]; + +/** 정렬 범위 — 칸 하나면 현재 영역 */ +function targetRange(ctx: SpreadsheetContext): CellRange { + const g = ctx.selection.범위[0]; + if (g.r0 === g.r1 && g.c0 === g.c1) + return currentRegion(ctx.book, ctx.selection.시트, g.r0, g.c0); + return g; +} + +/** 머리 행 짐작 — 첫 행이 모두 글이고 둘째 행에 글 아닌 값이 있으면 머리 */ +function guessHeader(ctx: SpreadsheetContext, rg: CellRange): boolean { + if (rg.r0 === rg.r1) return false; + const v = (r: number, c: number) => ctx.engine.value(ctx.selection.시트, r, c); + let allText = true; + let secondOther = false; + for (let c = rg.c0; c <= rg.c1; c++) { + const a = v(rg.r0, c); + const b = v(rg.r0 + 1, c); + if (a !== null && typeof a !== "string") allText = false; + if (b !== null && typeof b !== "string") secondOther = true; + } + return allText && secondOther; +} + +function run(ctx: SpreadsheetContext, rg: CellRange, opts: SortOptions): boolean { + try { + ctx.dispatch(sortCommand(ctx.book, ctx.engine, ctx.selection.시트, rg, opts)); + return true; + } catch (e) { + if (!(e instanceof CommandError)) throw e; + showToast(e.message, "error"); + return false; + } +} + +/** 활성 칸 열 기준 바로 정렬 */ +export function sortSelection(ctx: SpreadsheetContext, desc: boolean): void { + if (ctx.readOnly) return; + const rg = targetRange(ctx); + const c = Math.min(Math.max(ctx.selection.활성.c, rg.c0), rg.c1); + run(ctx, rg, { 머리: guessHeader(ctx, rg), 기준: [{ 열: c, 내림: desc }] }); +} + +/** 정렬 대화 상자 — 닫는 함수를 돌려줌 */ +export function openSortDialog(ctx: SpreadsheetContext): () => void { + const rg = targetRange(ctx); + const sheet = ctx.selection.시트; + const keys: SortKey[] = [{ 열: Math.min(Math.max(ctx.selection.활성.c, rg.c0), rg.c1) }]; + + const header = el("input", { attrs: { type: "checkbox" } }); + header.checked = guessHeader(ctx, rg); + const caseBox = el("input", { attrs: { type: "checkbox" } }); + const list = el("div", { className: "ss-sort__keys" }); + const box = el("div", { + className: "ss-sort__box", + attrs: { role: "dialog", "aria-label": t("Title") }, + }); + const shade = el("div", { className: "ss-sort", children: [box] }); + + const columnLabel = (c: number) => { + const head = header.checked ? displayText(ctx.book, ctx.engine, sheet, rg.r0, c) : ""; + return head ? `${colName(c)}: ${head}` : `${t("Column")} ${colName(c)}`; + }; + + function drawKeys() { + list.replaceChildren( + ...keys.map((k, i) => { + const col = el("select", { attrs: { "aria-label": t("Column") } }); + for (let c = rg.c0; c <= rg.c1; c++) + col.append(el("option", { text: columnLabel(c), attrs: { value: String(c) } })); + col.value = String(k.열); + col.addEventListener("change", () => (k.열 = Number(col.value))); + const dir = el("select", { + children: [ + el("option", { text: t("Asc"), attrs: { value: "asc" } }), + el("option", { text: t("Desc"), attrs: { value: "desc" } }), + ], + }); + dir.value = k.내림 ? "desc" : "asc"; + dir.addEventListener("change", () => (k.내림 = dir.value === "desc")); + const remove = createButton({ + label: t("RemoveKey"), + variant: "ghost", + disabled: keys.length === 1, + onClick: () => { + keys.splice(i, 1); + drawKeys(); + }, + }); + return el("div", { className: "ss-sort__row", children: [col, dir, remove] }); + }), + ); + } + + const close = () => { + shade.remove(); + document.removeEventListener("keydown", onKey, true); + }; + const ok = () => { + if (run(ctx, rg, { 머리: header.checked, 기준: keys, 대소문자: caseBox.checked })) close(); + }; + const onKey = (ev: KeyboardEvent) => { + if (ev.key === "Escape") close(); + else if (ev.key === "Enter" && !(ev.target instanceof HTMLSelectElement)) ok(); + else return; + ev.preventDefault(); + ev.stopPropagation(); + }; + + header.addEventListener("change", drawKeys); + box.append( + el("div", { className: "ss-sort__title", text: t("Title") }), + el("div", { className: "ss-sort__meta", text: `${t("Range")} ${rangeToA1(rg)}` }), + el("label", { className: "ss-sort__meta", children: [header, t("Header")] }), + el("label", { className: "ss-sort__meta", children: [caseBox, t("Case")] }), + list, + el("div", { + className: "ss-sort__row", + children: [ + createButton({ + label: t("AddKey"), + variant: "ghost", + onClick: () => { + keys.push({ 열: rg.c0 }); + drawKeys(); + }, + }), + ], + }), + el("div", { + className: "ss-sort__foot", + children: [ + createButton({ label: t("Cancel"), variant: "ghost", onClick: close }), + createButton({ label: t("Ok"), onClick: ok }), + ], + }), + ); + shade.addEventListener("mousedown", (ev) => { + if (ev.target === shade) close(); + }); + document.addEventListener("keydown", onKey, true); + drawKeys(); + ctx.root.append(shade); + return close; +} diff --git a/A00_Common/spreadsheet/spreadsheet_sortfilter.css b/A00_Common/spreadsheet/spreadsheet_sortfilter.css new file mode 100644 index 00000000..57d40347 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_sortfilter.css @@ -0,0 +1,128 @@ +/* spreadsheet_sortfilter.css (주인 A) — 정렬 대화 상자 · 자동 필터 단추 · 값 목록 창 */ + +.ss-sort { + position: absolute; + inset: 0; + z-index: var(--z-dropdown); + display: flex; + align-items: center; + justify-content: center; + background: rgb(0 0 0 / 18%); +} + +.ss-sort__box, +.ss-filter__panel { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-12); + background: var(--color-surface-raised); + color: var(--color-text-body); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + box-shadow: var(--shadow-lg); + font-size: 13px; +} + +.ss-sort__box { + min-width: 360px; + max-width: 90%; +} + +.ss-sort__title { + font-weight: 600; +} + +.ss-sort__meta, +.ss-sort__row, +.ss-sort__foot, +.ss-filter__foot { + display: flex; + align-items: center; + gap: var(--spacing-8); +} + +.ss-sort__meta { + color: var(--color-text-secondary); +} + +.ss-sort__row select { + flex: 1; + padding: var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); + color: inherit; +} + +.ss-sort__foot, +.ss-filter__foot { + justify-content: flex-end; +} + +/* 필터 단추 층 — 크기 0(격자 조작을 막지 않음) · 단추만 누름 */ +.ss-filter__layer { + position: absolute; + left: 0; + top: 0; + width: 0; + height: 0; + overflow: visible; +} + +.ss-filter__btn { + position: absolute; + width: 16px; + height: 16px; + padding: 0; + font-size: 9px; + line-height: 14px; + text-align: center; + cursor: pointer; + background: var(--color-surface); + color: var(--color-text-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); +} + +.ss-filter__btn.is-on { + color: var(--color-royal-amethyst); + border-color: var(--color-royal-amethyst); +} + +.ss-filter__panel { + position: absolute; + z-index: var(--z-dropdown); + width: 220px; +} + +.ss-filter__list { + display: flex; + flex-direction: column; + max-height: 220px; + overflow: auto; + padding: var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); +} + +.ss-filter__list label { + display: flex; + gap: var(--spacing-4); + align-items: center; + white-space: nowrap; +} + +.ss-filter__link { + padding: 2px var(--spacing-4); + text-align: left; + background: none; + border: none; + color: inherit; + cursor: pointer; +} + +.ss-filter__link:hover { + background: var(--color-surface-sunken); +} diff --git a/A00_Common/spreadsheet/spreadsheet_sortfilter.ts b/A00_Common/spreadsheet/spreadsheet_sortfilter.ts new file mode 100644 index 00000000..e83f75f4 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_sortfilter.ts @@ -0,0 +1,289 @@ +/* ============================================================================= + * spreadsheet_sortfilter.ts (주인 A) + * 정렬 · 자동 필터 엔진 — DOM 모름. 값 비교 · 보이는 글은 엔진(`CalcEngine`) · 숫자 형식(B)으로 셈. + * 명령은 결과(정렬 차례 · 걸러진 행)를 박아 둠 → 다시 하기가 같은 결과 · 적용은 `applyCommand` 가 여기로 넘김. + * + * 엑셀 규칙: + * 정렬 차례 — 오름: 수 < 글 < 참거짓(FALSE !!s.행?.[r + 1]?.걸러짐; + +const rank = (v: Scalar) => + isFrac(v) ? 0 : typeof v === "string" ? 1 : typeof v === "boolean" ? 2 : 3; + +const blank = (v: Scalar) => v === null || v === ""; + +/** 엑셀 정렬 비교 — 빈 칸은 오름 · 내림 모두 맨 뒤 */ +export function sortCompare(a: Scalar, b: Scalar, desc = false, caseSensitive = false): number { + if (blank(a) || blank(b)) return blank(a) === blank(b) ? 0 : blank(a) ? 1 : -1; + let d = rank(a) - rank(b); + if (d === 0 && !isError(a)) { + if (typeof a === "string" && caseSensitive) d = COLLATE_CASE.compare(a, b as string); + else if (typeof a === "string") d = COLLATE.compare(a, b as string); + else d = compareValues(a, b); + } + return desc ? -d : d; +} + +const COLLATE = new Intl.Collator("ko", { sensitivity: "accent" }); +const COLLATE_CASE = new Intl.Collator("ko", { sensitivity: "variant", caseFirst: "lower" }); + +/** 칸 하나에서 이어진 값 덩어리(엑셀 「현재 영역」) — 빈 칸만 둘러싼 테두리까지 늘림 */ +export function currentRegion(book: Workbook, sheetId: string, r: number, c: number): CellRange { + const s = sheetOf(book, sheetId); + const filled = new Set(); + for (const [a1, cell] of Object.entries(s.칸)) + if (cell.값 !== undefined || cell.식 !== undefined) filled.add(a1); + const has = (rr: number, cc: number) => + rr >= 0 && cc >= 0 && rr < MAX_ROWS && cc < MAX_COLS && filled.has(toA1(rr, cc)); + const g = { r0: r, c0: c, r1: r, c1: c }; + for (let grew = true; grew;) { + grew = false; + const rowHas = (rr: number) => { + for (let cc = g.c0 - 1; cc <= g.c1 + 1; cc++) if (has(rr, cc)) return true; + return false; + }; + const colHas = (cc: number) => { + for (let rr = g.r0 - 1; rr <= g.r1 + 1; rr++) if (has(rr, cc)) return true; + return false; + }; + if (g.r0 > 0 && rowHas(g.r0 - 1)) (g.r0--, (grew = true)); + if (g.r1 < MAX_ROWS - 1 && rowHas(g.r1 + 1)) (g.r1++, (grew = true)); + if (g.c0 > 0 && colHas(g.c0 - 1)) (g.c0--, (grew = true)); + if (g.c1 < MAX_COLS - 1 && colHas(g.c1 + 1)) (g.c1++, (grew = true)); + } + return g; +} + +function checkMerges(s: Sheet, range: CellRange) { + const hit = (s.병합 ?? []).some((m) => { + const rg = parseRange(m); + return rg && rangesOverlap(rg, range); + }); + if (hit) throw new CommandError("병합한 칸이 든 범위는 정렬하지 못함 — 병합을 먼저 풂"); +} + +// ── 정렬 ─────────────────────────────────────────────────────────────────── + +/** 정렬 명령 만들기 — 값은 엔진 계산값으로 견줌 · 걸러진 행은 제자리 */ +export function sortCommand( + book: Workbook, + engine: CalcEngine, + sheetId: string, + range: CellRange, + opts: SortOptions, +): Command { + const s = sheetOf(book, sheetId); + const data: CellRange = { ...range, r0: range.r0 + (opts.머리 ? 1 : 0) }; + if (data.r0 > data.r1) throw new CommandError("정렬할 행이 없음"); + if (!opts.기준.length) throw new CommandError("정렬 기준이 없음"); + for (const k of opts.기준) + if (k.열 < range.c0 || k.열 > range.c1) throw new CommandError("정렬 기준 열이 범위 밖"); + checkMerges(s, data); + const rows: number[] = []; + for (let r = data.r0; r <= data.r1; r++) if (!filtered(s, r)) rows.push(r); + const keys = new Map(rows.map((r) => [r, opts.기준.map((k) => engine.value(sheetId, r, k.열))])); + const sorted = [...rows].sort((a, b) => { + const va = keys.get(a)!; + const vb = keys.get(b)!; + for (let i = 0; i < opts.기준.length; i++) { + const d = sortCompare(va[i], vb[i], opts.기준[i].내림, opts.대소문자); + if (d) return d; + } + return a - b; + }); + const order: number[] = []; + let next = 0; + for (let r = data.r0; r <= data.r1; r++) order.push(filtered(s, r) ? r : sorted[next++]); + return { 종류: "정렬", 시트: sheetId, 범위: data, 차례: order }; +} + +export function applySort(book: Workbook, cmd: Of<"정렬">): CommandEffect { + const s = sheetOf(book, cmd.시트); + const { r0, r1, c0, c1 } = cmd.범위; + const n = r1 - r0 + 1; + if ( + cmd.차례.length !== n || + new Set(cmd.차례).size !== n || + cmd.차례.some((r) => r < r0 || r > r1) + ) + throw new CommandError("정렬 차례가 범위와 안 맞음"); + checkMerges(s, cmd.범위); + const before = clone(s); + const take = (table: Record | undefined) => { + const out = new Map(); + for (let r = r0; r <= r1; r++) + for (let c = c0; c <= c1; c++) { + const a1 = toA1(r, c); + if (table && a1 in table) { + out.set(a1, table[a1]); + delete table[a1]; + } + } + return out; + }; + const cells = take(s.칸); + const notes = take(s.comments); + cmd.차례.forEach((src, i) => { + const to = r0 + i; + for (let c = c0; c <= c1; c++) { + const cell = cells.get(toA1(src, c)); + if (cell) { + const moved = { ...cell }; + if (moved.식 !== undefined) moved.식 = moveFormula(moved.식, to - src, 0); + s.칸[toA1(to, c)] = moved; + } + const note = notes.get(toA1(src, c)); + if (note !== undefined) (s.comments ??= {})[toA1(to, c)] = note; + } + }); + if (s.comments && !Object.keys(s.comments).length) delete s.comments; + return { undo: { 종류: "시트통째", 시트: before }, cells: [], rebuild: true }; +} + +// ── 자동 필터 ────────────────────────────────────────────────────────────── + +/** 칸의 보이는 글(숫자 형식 적용) — 빈 칸 "" */ +export function displayText( + book: Workbook, + engine: CalcEngine, + sheetId: string, + r: number, + c: number, +) { + const s = sheetOf(book, sheetId); + const cell = s.칸[toA1(r, c)]; + if (!cell || (cell.값 === undefined && cell.식 === undefined)) return ""; + const idx = cell.서식 ?? s.행?.[r + 1]?.서식 ?? s.열?.[colName(c)]?.서식 ?? s.기본?.서식 ?? 0; + return formatValue(engine.value(sheetId, r, c), book.서식[idx]?.형식).글; +} + +/** 필터 단추 목록 — 열의 서로 다른 보이는 글(정렬 차례 · 빈 칸 맨 뒤) · 켬 = 지금 보이는 값 */ +export function filterItems( + book: Workbook, + engine: CalcEngine, + sheetId: string, + c: number, +): { 글: string; 켬: boolean }[] { + const s = sheetOf(book, sheetId); + const f = s.필터 && parseRange(s.필터.범위); + if (!f) return []; + const allowed = s.필터!.조건?.[colName(c)]; + const seen = new Map(); + for (let r = f.r0 + 1; r <= f.r1; r++) { + const text = displayText(book, engine, sheetId, r, c); + if (!seen.has(text)) seen.set(text, text === "" ? null : engine.value(sheetId, r, c)); + } + return [...seen] + .sort((a, b) => sortCompare(a[1], b[1]) || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) + .map(([글]) => ({ 글, 켬: !allowed || allowed.includes(글) })); +} + +/** 필터 명령 — 범위 · 조건으로 걸러질 행을 셈(null = 필터 끔) */ +export function filterCommand( + book: Workbook, + engine: CalcEngine, + sheetId: string, + filter: AutoFilter | null, +): Command { + if (!filter) return { 종류: "필터", 시트: sheetId, 필터: null, 걸러짐: [] }; + const f = parseRange(filter.범위); + if (!f) throw new CommandError(`필터 범위 틀림: ${filter.범위}`); + const hidden: number[] = []; + const conds = Object.entries(filter.조건 ?? {}).map( + ([col, list]) => [colIndex(col), new Set(list)] as const, + ); + for (let r = f.r0 + 1; r <= f.r1; r++) + if (conds.some(([c, ok]) => !ok.has(displayText(book, engine, sheetId, r, c)))) hidden.push(r); + return { 종류: "필터", 시트: sheetId, 필터: filter, 걸러짐: hidden }; +} + +export function applyFilter(book: Workbook, cmd: Of<"필터">): CommandEffect { + const s = sheetOf(book, cmd.시트); + const before = clone(s); + if (cmd.필터) s.필터 = clone(cmd.필터); + else delete s.필터; + const table = (s.행 ??= {}); + for (const [k, info] of Object.entries(table)) { + delete info.걸러짐; + if (!Object.keys(info).length) delete table[k]; + } + for (const r of cmd.걸러짐) (table[String(r + 1)] ??= {}).걸러짐 = true; + if (!Object.keys(table).length) delete s.행; + return { undo: { 종류: "시트통째", 시트: before }, cells: [], rebuild: false }; +} + +/** 행열 넣기 · 지우기 때 필터 범위 · 조건 열 따라감(머리 행 · 끝 열까지 지우면 필터 없어짐) */ +export function shiftFilter(s: Sheet, rows: boolean, insert: boolean, at: number, n: number) { + const f = s.필터 && parseRange(s.필터.범위); + if (!s.필터 || !f) return; + const sp = rows + ? shiftSpan(f.r0, f.r1, insert, at, n, MAX_ROWS) + : shiftSpan(f.c0, f.c1, insert, at, n, MAX_COLS); + const headGone = rows && !insert && f.r0 >= at && f.r0 < at + n; + if (!sp || headGone) { + delete s.필터; + return; + } + const next = rows ? { ...f, r0: sp[0], r1: sp[1] } : { ...f, c0: sp[0], c1: sp[1] }; + s.필터.범위 = rangeToA1(next); + if (rows || !s.필터.조건) return; + const conds: Record = {}; + for (const [col, list] of Object.entries(s.필터.조건)) { + const c = shiftSpan(colIndex(col), colIndex(col), insert, at, n, MAX_COLS); + if (c) conds[colName(c[0])] = list; + } + s.필터.조건 = conds; +} diff --git a/A00_Common/spreadsheet/spreadsheet_types.ts b/A00_Common/spreadsheet/spreadsheet_types.ts index 0612f6b1..28c1da72 100644 --- a/A00_Common/spreadsheet/spreadsheet_types.ts +++ b/A00_Common/spreadsheet/spreadsheet_types.ts @@ -27,6 +27,11 @@ * A spreadsheet_refshift.ts 넣기 · 지우기 · 옮기기 · 복사 때 참조 옮김 ~300 * A spreadsheet_commands.ts 명령 적용 → 되돌림 짝 · 빈 통합문서 ~550 * A spreadsheet_history.ts 되돌리기 · 다시 ~100 + * A spreadsheet_commands_sheet.ts 시트 명령(더하기 · 지우기 · 이름 · 옮기기 · 통째) ~100 + * A spreadsheet_sortfilter.ts 정렬 · 자동 필터 엔진(차례 · 걸러짐 셈 · 적용) ~300 + * A spreadsheet_sort_dialog.ts 정렬 대화 상자 · 빠른 정렬(화면) ~250 + * A spreadsheet_filter.ts 자동 필터 단추 · 값 목록 창(화면) ~300 + * A spreadsheet_sortfilter.css ~150 * A spreadsheet_recalc.ts 통합문서 한 벌 풀이(서버 Node · 시험 진입) ~80 * B spreadsheet_functions.ts 함수 표(이름 → 뜻) · 인자 수 검사 ~120 * B spreadsheet_functions_math.ts 수학 · 삼각 · 끝수 ~400 @@ -125,6 +130,14 @@ export interface RowInfo { 높이?: number; 숨김?: boolean; 서식?: number; + /** 자동 필터가 숨긴 행 — 사용자 `숨김` 과 따로(필터 풀면 보임) · 격자는 숨김처럼 그림. */ + 걸러짐?: boolean; +} + +/** 자동 필터 — `범위` 첫 행 = 머리(단추) · `조건` = 열 글자 → 보일 값(보이는 글 · 빈 칸은 ""). 조건 없는 열은 다 보임. */ +export interface AutoFilter { + 범위: string; + 조건?: Record; } export interface Sheet { @@ -147,6 +160,8 @@ export interface Sheet { 기본?: { 서식?: number; 열폭?: number; 행높이?: number }; /** 칸 메모 — A1 열쇠 → 메모 글. 행열 넣기 · 지우기 · 잘라 붙이기 때 칸과 같이 옮김(지운 칸 메모는 지움). */ comments?: Record; + /** 자동 필터(시트에 하나) */ + 필터?: AutoFilter; } /** 새 시트 기본값 — 값은 사용자 협의로 확정(9_엑셀검토 8장 · 임의 확정 금지). 없으면 A 의 임시값. */ @@ -353,6 +368,11 @@ export type Command = | { 종류: "시트옮기기"; 시트: string; 자리: number } /** 되돌림 짝 전용 — 시트 통째 바꿔 넣기(행열 지우기 되돌림 등) */ | { 종류: "시트통째"; 시트: Sheet; 서식?: CellStyle[] } + /** 정렬 — `차례[i]` = 범위 r0+i 자리에 올 원래 행(범위 열만 옮김 · 식은 복사처럼 상대 참조만 옮김). + * 차례는 `sortCommand`(값 비교는 엔진)가 만듦 — 다시 하기가 같은 결과가 되게 명령에 박음. */ + | { 종류: "정렬"; 시트: string; 범위: CellRange; 차례: number[] } + /** 자동 필터 켜기 · 조건 바꾸기 · 끄기(null) — `걸러짐` = 숨길 행(`filterCommand` 가 값으로 셈) */ + | { 종류: "필터"; 시트: string; 필터: AutoFilter | null; 걸러짐: number[] } /** 여러 명령 = 되돌리기 한 번 */ | { 종류: "묶음"; 명령: Command[] }; diff --git a/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs b/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs index 0747dd8a..144b6699 100644 --- a/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs +++ b/resources/tester/spreadsheet/helper_spreadsheet_engine.cjs @@ -99,7 +99,7 @@ const H = load("spreadsheet_history"); const RC = load("spreadsheet_recalc"); // 무게 시험(`bench_spreadsheet_engine.cjs`)이 같은 적재 · 가짜 함수를 씀 — 불려 올 때는 시험을 안 돌림 -module.exports = { A, C, E, F, G }; +module.exports = { A, C, E, F, G, load }; if (require.main !== module) return; const results = {}; diff --git a/resources/tester/spreadsheet/helper_spreadsheet_sortfilter.cjs b/resources/tester/spreadsheet/helper_spreadsheet_sortfilter.cjs new file mode 100644 index 00000000..5b4c9520 --- /dev/null +++ b/resources/tester/spreadsheet/helper_spreadsheet_sortfilter.cjs @@ -0,0 +1,206 @@ +/* 정렬 · 자동 필터 엔진 시험 헬퍼 — 엑셀 규칙표를 묶음마다 돌려 틀린 것 목록을 JSON 으로 냄. + * 적재 · 가짜 함수는 `helper_spreadsheet_engine.cjs` 것을 씀. + */ +const { A, C, E, F, G, load } = require("./helper_spreadsheet_engine.cjs"); +const SF = load("spreadsheet_sortfilter"); + +const results = {}; +let group = ""; +const eq = (label, got, want) => { + const g = JSON.stringify(got); + const w = JSON.stringify(want); + if (g !== w) results[group].push(`${label}: got ${g} want ${w}`); +}; +const section = (name, fn) => { + group = name; + results[name] = []; + try { + fn(); + } catch (e) { + results[name].push(`예외: ${e && e.stack ? e.stack : e}`); + } +}; +const canon = (v) => + Array.isArray(v) + ? v.map(canon) + : v && typeof v === "object" + ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, canon(v[k])])) + : v; + +function book(cells, extra = {}) { + const 칸 = {}; + for (const [k, v] of Object.entries(cells)) + 칸[k] = typeof v === "string" && v.startsWith("=") ? { 식: v.slice(1) } : { 값: v }; + return { 종류: "통합문서", 판: 1, 열: "t", 서식: [{}], 시트: [{ id: "s1", 이름: "본", 칸, ...extra }] }; +} +const show = (v) => (v === null ? null : E.isError(v) ? v.error : E.isFrac(v) ? F.fracToString(v) : v); +const col = (b, letter, r0, r1) => { + const e = G.createCalcEngine(b); + const out = []; + for (let r = r0; r <= r1; r++) out.push(show(e.value("s1", r - 1, A.colIndex(letter)))); + return out; +}; +const R = A.parseRange; + +/** 정렬 → 명령 적용 → 되돌림이 처음과 같은지 · 다시 하면 같은 결과인지 */ +function sortAndCheck(label, b, range, opts) { + const e = G.createCalcEngine(b); + const cmd = SF.sortCommand(b, e, "s1", R(range), opts); + const before = JSON.stringify(canon(b.시트)); + const fx = C.applyCommand(b, cmd); + const after = JSON.stringify(canon(b.시트)); + C.applyCommand(b, fx.undo); + eq(`${label} 되돌림`, JSON.stringify(canon(b.시트)) === before, true); + C.applyCommand(b, cmd); + eq(`${label} 다시`, JSON.stringify(canon(b.시트)) === after, true); + return cmd; +} + +// ── 1. 정렬 차례 ──────────────────────────────────────────────────────────── +section("sort_order", () => { + const mixed = () => + book({ + A1: "값", + B1: "번호", + A2: 3, B2: 1, + A3: "b", B3: 2, + A4: true, B4: 3, + A5: "A", B5: 4, + A6: 1, B6: 5, + B7: 6, // 빈 칸 + A8: "=1/0", B8: 7, + A9: false, B9: 8, + A10: "a", B10: 9, + A11: 2, B11: 10, + }); + let b = mixed(); + sortAndCheck("오름", b, "A1:B11", { 머리: true, 기준: [{ 열: 0 }] }); + eq("오름 = 수 < 글 < 참거짓 < 오류 < 빈 칸 · 같은 값은 원래 차례", col(b, "B", 2, 11), ["5", "10", "1", "4", "9", "2", "8", "3", "7", "6"]); + eq("머리 행 제자리", col(b, "A", 1, 1), ["값"]); + b = mixed(); + sortAndCheck("내림", b, "A1:B11", { 머리: true, 기준: [{ 열: 0, 내림: true }] }); + eq("내림 = 거꾸로 · 빈 칸은 그래도 맨 뒤", col(b, "B", 2, 11), ["7", "3", "8", "2", "4", "9", "1", "10", "5", "6"]); + b = mixed(); + sortAndCheck("대소문자", b, "A1:B11", { 머리: true, 기준: [{ 열: 0 }], 대소문자: true }); + eq("대소문자 가리면 소문자 먼저", col(b, "B", 5, 6), ["9", "4"]); + // 여러 기준 · 머리 없음 + b = book({ A1: "나", B1: 1, A2: "가", B2: 2, A3: "나", B3: 3, A4: "가", B4: 1, A5: 10, B5: 9, A6: 9, B6: 9 }); + sortAndCheck("여러 기준", b, "A1:B6", { 머리: false, 기준: [{ 열: 0 }, { 열: 1, 내림: true }] }); + eq("첫 기준 오름 · 둘째 내림", [col(b, "A", 1, 6), col(b, "B", 1, 6)], [ + ["9", "10", "가", "가", "나", "나"], + ["9", "9", "2", "1", "3", "1"], + ]); + eq("현재 영역", SF.currentRegion(mixed(), "s1", 4, 0), R("A1:B11")); + eq("현재 영역 빈 칸 너머는 안 감", SF.currentRegion(book({ A1: 1, A2: 2, C1: 3 }), "s1", 0, 0), R("A1:A2")); +}); + +// ── 2. 정렬 뒤 수식 · 서식 · 메모 (엑셀 규칙) ──────────────────────────────── +section("sort_formulas", () => { + const b = book( + { + A1: 3, B1: "=A1*10", C1: "=$A$1+A1", + A2: 1, B2: "=A2*10", C2: "=$A$1+A2", + A3: 2, B3: "=A3*10", C3: "=$A$1+A3", + E1: "=SUM(A1:A3)", E2: "=A1", + }, + { comments: { A1: "셋", A3: "둘" } }, + ); + b.시트[0].칸.A2.서식 = 0; + b.서식.push({ 굵게: true }); + b.시트[0].칸.A1.서식 = 1; + sortAndCheck("식 옮김", b, "A1:C3", { 머리: false, 기준: [{ 열: 0 }] }); + const s = b.시트[0].칸; + eq("상대 참조는 복사처럼 · 절대는 그대로", [s.B1.식, s.B3.식, s.C1.식, s.C3.식], ["A1*10", "A3*10", "$A$1+A1", "$A$1+A3"]); + eq("범위 밖 식은 그대로", [s.E1.식, s.E2.식], ["SUM(A1:A3)", "A1"]); + eq("값", col(b, "B", 1, 3), ["10", "20", "30"]); + eq("E2 는 새 A1 을 봄", col(b, "E", 2, 2), ["1"]); + eq("서식 · 메모 따라감", [s.A3.서식, b.시트[0].comments], [1, { A2: "둘", A3: "셋" }]); +}); + +// ── 3. 정렬 막힘 · 걸러진 행 제자리 ───────────────────────────────────────── +section("sort_rules", () => { + const b = book({ A1: 3, A2: 1, A3: 2 }, { 병합: ["B2:C2"] }); + const e = G.createCalcEngine(b); + let msg = null; + try { + SF.sortCommand(b, e, "s1", R("A1:C3"), { 머리: false, 기준: [{ 열: 0 }] }); + } catch (err) { + msg = err instanceof C.CommandError; + } + eq("병합 걸치면 막음", msg, true); + msg = null; + try { + SF.sortCommand(b, e, "s1", R("A1:A3"), { 머리: false, 기준: [{ 열: 3 }] }); + } catch (err) { + msg = err instanceof C.CommandError; + } + eq("기준 열이 범위 밖", msg, true); + msg = null; + try { + C.applyCommand(b, { 종류: "정렬", 시트: "s1", 범위: R("A1:A3"), 차례: [0, 0, 1] }); + } catch (err) { + msg = err instanceof C.CommandError; + } + eq("차례가 틀리면 막음", msg, true); + const f = book({ A1: 5, A2: 4, A3: 3, A4: 2, A5: 1 }, { 행: { 2: { 걸러짐: true }, 4: { 걸러짐: true, 높이: 30 } } }); + sortAndCheck("걸러진 행", f, "A1:A5", { 머리: false, 기준: [{ 열: 0 }] }); + eq("보이는 행끼리만 · 걸러진 행 · 높이 제자리", [col(f, "A", 1, 5), f.시트[0].행], [["1", "4", "3", "2", "5"], { 2: { 걸러짐: true }, 4: { 걸러짐: true, 높이: 30 } }]); +}); + +// ── 4. 자동 필터 ──────────────────────────────────────────────────────────── +section("filter", () => { + const make = () => { + const b = book({ + A1: "공종", B1: "수량", + A2: "터파기", B2: 1.5, + A3: "되메우기", B3: 0, + A4: "터파기", B4: 2, + B5: 3, + A6: "잡석", B6: "=B2", + D1: "=SUM(B2:B6)", + }, { 행: { 3: { 숨김: true } } }); + b.서식.push({ 형식: "0.00" }); + for (let r = 2; r <= 6; r++) b.시트[0].칸[`B${r}`].서식 = 1; + return b; + }; + const b = make(); + let e = G.createCalcEngine(b); + C.applyCommand(b, SF.filterCommand(b, e, "s1", { 범위: "A1:B6" })); + eq("켬 = 걸러짐 없음", [b.시트[0].필터, b.시트[0].행], [{ 범위: "A1:B6" }, { 3: { 숨김: true } }]); + eq("값 목록 · 가나다 · 빈 칸 맨 뒤", SF.filterItems(b, e, "s1", 0), [ + { 글: "되메우기", 켬: true }, + { 글: "잡석", 켬: true }, + { 글: "터파기", 켬: true }, + { 글: "", 켬: true }, + ]); + eq("수 열 = 보이는 글(형식) · 수 차례", SF.filterItems(b, e, "s1", 1).map((x) => x.글), ["0.00", "1.50", "2.00", "3.00"]); + const on = SF.filterCommand(b, e, "s1", { 범위: "A1:B6", 조건: { A: ["터파기", ""] } }); + eq("걸러질 행", on.걸러짐, [2, 5]); + const before = JSON.stringify(canon(b.시트)); + const fx = C.applyCommand(b, on); + eq("걸러짐 · 사용자 숨김은 따로", b.시트[0].행, { 3: { 숨김: true, 걸러짐: true }, 6: { 걸러짐: true } }); + eq("목록 켬 = 조건", SF.filterItems(b, e, "s1", 0).map((x) => x.켬), [false, false, true, true]); + eq("걸러도 값 · 합계 그대로", col(b, "D", 1, 1), ["8"]); + C.applyCommand(b, fx.undo); + eq("필터 조건 되돌림", JSON.stringify(canon(b.시트)) === before, true); + C.applyCommand(b, on); + const two = SF.filterCommand(b, e, "s1", { 범위: "A1:B6", 조건: { A: ["터파기", ""], B: ["2.00", "3.00"] } }); + eq("두 열 조건 = 모두 맞아야", two.걸러짐, [1, 2, 5]); + // 행 넣기 · 열 지우기 때 필터 따라감 + C.applyCommand(b, { 종류: "행넣기", 시트: "s1", at: 3, 수: 1 }); + eq("행 넣기 = 범위 늘어남 · 걸러짐 밀림", [b.시트[0].필터.범위, b.시트[0].행["7"]], ["A1:B7", { 걸러짐: true }]); + C.applyCommand(b, { 종류: "열지우기", 시트: "s1", at: 0, 수: 1 }); + eq("조건 열을 지우면 조건 빠짐", b.시트[0].필터, { 범위: "A1:A7", 조건: {} }); + C.applyCommand(b, { 종류: "행지우기", 시트: "s1", at: 0, 수: 1 }); + eq("머리 행 지우면 필터 없어짐", b.시트[0].필터, undefined); + // 끄기 — 걸러짐 풀리고 사용자 숨김은 남음 + const c = make(); + e = G.createCalcEngine(c); + C.applyCommand(c, SF.filterCommand(c, e, "s1", { 범위: "A1:B6", 조건: { A: ["잡석"] } })); + const off = C.applyCommand(c, SF.filterCommand(c, e, "s1", null)); + eq("끄기", [c.시트[0].필터, c.시트[0].행], [undefined, { 3: { 숨김: true } }]); + C.applyCommand(c, off.undo); + eq("끄기 되돌림", c.시트[0].행["2"], { 걸러짐: true }); +}); + +process.stdout.write(JSON.stringify(results)); diff --git a/resources/tester/spreadsheet/test_spreadsheet_sortfilter.py b/resources/tester/spreadsheet/test_spreadsheet_sortfilter.py new file mode 100644 index 00000000..3d82328d --- /dev/null +++ b/resources/tester/spreadsheet/test_spreadsheet_sortfilter.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +"""산출근거 스프레드시트 정렬 · 자동 필터 엔진 — 엑셀 규칙표. + +정렬 차례(수 < 글 < 참거짓 < 오류 · 빈 칸 늘 맨 뒤 · 같은 값 원래 차례 · 여러 기준 · 대소문자) · +정렬 뒤 식(복사처럼 상대 참조만 · 범위 밖 식은 그대로) · 서식 · 메모 · 걸러진 행 제자리 · 병합 막음 · +필터 값 목록(보이는 글 · 빈 칸 맨 뒤) · 걸러짐(사용자 숨김과 따로) · 행열 넣기 · 지우기 때 필터 따라감 · 되돌리기. +화면(대화 상자 · 필터 단추)은 harness_sortfilter.html 을 ORCA 로. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +GROUPS = ["sort_order", "sort_formulas", "sort_rules", "filter"] + + +@pytest.fixture(scope="module") +def results(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_spreadsheet_sortfilter.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +@pytest.mark.parametrize("group", GROUPS) +def test_묶음(results, group): + assert group in results + assert results[group] == []