feat(spreadsheet): 정렬 — 엑셀 차례 · 여러 기준 · 머리 행 · 식 복사식 옮김 · 대화 상자 — 계약 더함(정렬 · 필터 명령 · Sheet.필터 · RowInfo.걸러짐)

- 계약 더함: 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
This commit is contained in:
2026-09-27 20:56:28 +09:00
co-authored by Claude Opus 5.5
parent 4f1510bb48
commit a4ad54571c
9 changed files with 947 additions and 69 deletions
+18 -68
View File
@@ -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<K extends Command["종류"]> = Extract<Command, { 종류: K }>;
export type Of<K extends Command["종류"]> = Extract<Command, { 종류: K }>;
const clone = <T>(x: T): T => structuredClone(x);
export const clone = <T>(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);
}
@@ -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 };
}
@@ -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;
}
@@ -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);
}
@@ -0,0 +1,289 @@
/* =============================================================================
* spreadsheet_sortfilter.ts (주인 A)
* 정렬 · 자동 필터 엔진 — DOM 모름. 값 비교 · 보이는 글은 엔진(`CalcEngine`) · 숫자 형식(B)으로 셈.
* 명령은 결과(정렬 차례 · 걸러진 행)를 박아 둠 → 다시 하기가 같은 결과 · 적용은 `applyCommand` 가 여기로 넘김.
*
* 엑셀 규칙:
* 정렬 차례 — 오름: 수 < 글 < 참거짓(FALSE<TRUE) < 오류 · 내림은 거꾸로 · 빈 칸은 늘 맨 뒤 · 같은 값은 원래 차례.
* 글은 대소문자 가리지 않음(옵션으로 가림 = 소문자 먼저). 값은 계산값(식 칸은 푼 값).
* 정렬 옮김 — 범위 열만 행째 옮김 · 식은 복사처럼 상대 참조만 옮김(범위 밖에서 범위를 가리키던 식은 그대로) ·
* 서식 · 메모도 같이 · 행 높이는 제자리 · 걸러진 행은 제자리(보이는 행끼리만 정렬) · 병합이 걸치면 막음.
* 필터 — 첫 행 = 머리 · 값 목록 = 보이는 글(숫자 형식 적용) · 빈 칸 = "" · 조건 안 맞는 행은 `걸러짐`
* (사용자 숨김과 따로) · 행열을 넣고 지우면 필터 범위 · 조건 열도 따라감 · 끄면 걸러짐 모두 풂.
*
* 잇는 법(D · sub7 · C · sub6):
* · 정렬: 대화 상자 `openSortDialog(ctx)`(spreadsheet_sort_dialog.ts) · 도구 단추 `sortSelection(ctx, 내림)`.
* · 필터: `attachFilter(ctx)`(spreadsheet_filter.ts) 를 부품 목록에 붙이고 grid.render() 뒤 `refresh()` ·
* 도구 단추 · Ctrl+Shift+L = `toggleFilter(ctx)`.
* · 격자(C): 행 `걸러짐` 도 `숨김` 처럼 높이 0 — 머리의 숨김 표시는 사용자 `숨김` 만.
* ========================================================================== */
import { formatValue } from "./spreadsheet_numfmt";
import {
colIndex,
colName,
parseRange,
rangesOverlap,
rangeToA1,
toA1,
} from "./spreadsheet_address";
import { clone, CommandError, sheetOf } from "./spreadsheet_commands";
import type { Of } from "./spreadsheet_commands";
import { compareValues, isError, isFrac } from "./spreadsheet_eval";
import { moveFormula, shiftSpan } from "./spreadsheet_refshift";
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
import type {
AutoFilter,
CalcEngine,
Cell,
CellRange,
Command,
CommandEffect,
Scalar,
Sheet,
Workbook,
} from "./spreadsheet_types";
export interface SortKey {
/** 기준 열(0 부터 · 범위 안) */
열: number;
내림?: boolean;
}
export interface SortOptions {
/** 첫 행 = 머리(정렬 안 함) */
머리: boolean;
기준: SortKey[];
/** 글 대소문자 가림 */
대소문자?: boolean;
}
const filtered = (s: Sheet, r: number) => !!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<string>();
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 = <T>(table: Record<string, T> | undefined) => {
const out = new Map<string, T>();
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<Cell>(s.칸);
const notes = take<string>(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<string, Scalar>();
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<string, string[]> = {};
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;
}
@@ -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<string, string[]>;
}
export interface Sheet {
@@ -147,6 +160,8 @@ export interface Sheet {
기본?: { 서식?: number; 열폭?: number; 행높이?: number };
/** 칸 메모 — A1 열쇠 → 메모 글. 행열 넣기 · 지우기 · 잘라 붙이기 때 칸과 같이 옮김(지운 칸 메모는 지움). */
comments?: Record<string, string>;
/** 자동 필터(시트에 하나) */
필터?: 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[] };
@@ -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 = {};
@@ -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));
@@ -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] == []