feat(spreadsheet): 2단계 다섯 — 찾기 · 붙여넣기 골라서 · 서식 붓 · 폭 자동 맞춤 · 함수 도움말
PLAN 11-8 스프레드시트 2단계. 0 계약(spreadsheet_types.ts · spreadsheet_view_types.ts) 타입만 보고 새 파일 다섯 짬 — 잇기는 1단계 끝에 D(sub7)가 파일 머리 주석대로: - spreadsheet_find.ts 찾기 · 바꾸기(대소문자 · 전체칸 · 수식 안) - spreadsheet_paste_special.ts 붙여넣기 골라서(값만 · 서식만 · 수식만) - spreadsheet_format_painter.ts 서식 붓(칸→행→열→기본 차례로 서식을 찾음) - spreadsheet_autofit.ts 열 머리 두 번 눌러 폭 자동 맞춤(어림 재기 · 실제 재기 자리 열어 둠) - spreadsheet_func_help.ts 함수 자동 완성 · 인자 도움말(1단계 함수 48 개) 여러 범위 · 확대 · 눈금선 끄기는 이미 계약에 있어 새 파일 없이 붙일 자리만 확인(autofit.ts 머리). 시험 = resources/tester/spreadsheet/test_stage2_*.ts(공용 견본 · 가짜 컨텍스트는 test_stage2_helpers.ts). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_autofit.ts (2단계 · 주인 sub_laptop_1)
|
||||
* 열 머리 경계를 두 번 눌러 폭 자동 맞춤 — 그 열 안 글자 수로 어림(엑셀 「글자 단위」).
|
||||
* 진짜 픽셀 값은 C(격자)가 쓰는 글꼴로 재야 더 정확 — `measure` 자리에 C 의 실제 재기 함수를
|
||||
* 나중에 꽂을 수 있게 열어 둠(안 주면 아래 어림 규칙을 씀).
|
||||
*
|
||||
* 잇는 법(D · sub7 · C 격자 머리) — 열 머리 경계에서 두 번 누르면(dblclick)
|
||||
* `autofitColumn(ctx, col)` 을 부름. C 가 완성되면 `measure` 자리에 캔버스 `measureText` 를 넘겨 더 정밀하게.
|
||||
*
|
||||
* ── 이미 계약에 있어 새 파일이 필요 없는 것(붙일 자리만 확인) ──────────────────────
|
||||
* · 여러 범위(Ctrl+누르기) — `spreadsheet_view_types.ts` `Selection.범위` 가 이미 배열(D `spreadsheet_selection.ts` 가 씀).
|
||||
* · 눈금선 끄기 — `Command`(`종류:"눈금선"`) · `Sheet.보기?.눈금선` 이미 있음(A 가 처리).
|
||||
* · 확대 · 축소 — 값 자체를 저장하지 않는 화면 전용 CSS 배율(C 격자 뿌리에 transform) · 계약 밖.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { Workbook } from "./spreadsheet_types";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
export type TextMeasurer = (text: string) => number;
|
||||
|
||||
const PADDING = 2;
|
||||
const MIN_WIDTH = 4;
|
||||
|
||||
/** 글꼴을 모를 때 어림 — 한글 · 한자 · 전각은 1.9 배 폭 · 나머지는 1 배. */
|
||||
const defaultMeasure: TextMeasurer = (text) => {
|
||||
let width = 0;
|
||||
for (const ch of text) width += ch.codePointAt(0)! >= 0x1100 ? 1.9 : 1;
|
||||
return width;
|
||||
};
|
||||
|
||||
const colFromKey = (key: string): number => {
|
||||
const m = /^([A-Z]+)\d+$/.exec(key);
|
||||
if (!m) return -1;
|
||||
let col = 0;
|
||||
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
|
||||
return col - 1;
|
||||
};
|
||||
|
||||
/** 그 열에 든 칸 글자(식 칸은 식 글로 어림 — 계산값은 여기서 모름) 중 가장 넓은 것 + 여백. */
|
||||
export function estimateColumnWidth(
|
||||
book: Workbook,
|
||||
sheetId: string,
|
||||
col: number,
|
||||
measure: TextMeasurer = defaultMeasure,
|
||||
): number {
|
||||
const sheet = book.시트.find((s) => s.id === sheetId);
|
||||
if (!sheet) return MIN_WIDTH;
|
||||
let widest = 0;
|
||||
for (const [key, cell] of Object.entries(sheet.칸)) {
|
||||
if (colFromKey(key) !== col) continue;
|
||||
const text = cell.식 !== undefined ? cell.식 : cell.값 !== undefined ? String(cell.값) : "";
|
||||
if (text) widest = Math.max(widest, measure(text));
|
||||
}
|
||||
return Math.max(MIN_WIDTH, Math.ceil(widest + PADDING));
|
||||
}
|
||||
|
||||
/** 폭을 자동 맞춤 값으로 바꿈(열넓이 명령 하나). */
|
||||
export function autofitColumn(ctx: SpreadsheetContext, col: number, measure?: TextMeasurer): void {
|
||||
const width = estimateColumnWidth(ctx.book, ctx.selection.시트, col, measure);
|
||||
ctx.dispatch({ 종류: "열폭", 시트: ctx.selection.시트, 열: [col], 폭: width });
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_find.ts (2단계 · 주인 sub_laptop_1)
|
||||
* 찾기 · 바꾸기 — 대소문자 구분 · 전체 칸 일치 · 「수식 안」(식 글 속 글자도 뒤짐) 옵션.
|
||||
* 값 칸은 `값`(수 · 참거짓은 글로 바꿔 견줌) · 식 칸은 기본으로 식 글(`=` 뺀 것)을 뒤짐(계산값은
|
||||
* 이 계층에서 모름 — CalcEngine 은 C·D 쪽에만 있음). 바꾸기는 `dispatch({종류:"칸", …})` 하나로만.
|
||||
*
|
||||
* 잇는 법(D · sub7) — 1단계 격자 · 편집기가 서면:
|
||||
* 1) 도구 모음이나 Ctrl+F 단축키로 작은 패널을 띄우고 `createFindPanel(ctx)` 를 그 안에 붙임.
|
||||
* 2) 패널의 「다음 찾기」 는 `findNext` 결과로 `ctx.select` + `ctx.grid.reveal` 호출.
|
||||
* 3) 「모두 바꾸기」 버튼은 `replaceAll` 을 부르고 끝나면 바뀐 수를 토스트로.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CellInput, SheetCellAddress, Workbook } from "./spreadsheet_types";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
export interface FindOptions {
|
||||
대소문자?: boolean;
|
||||
전체칸?: boolean;
|
||||
/** 식 칸은 식 글(수식) 속에서도 찾음 — 꺼지면 값 칸만 뒤짐(식 칸은 건너뜀) */
|
||||
수식안?: boolean;
|
||||
}
|
||||
|
||||
const norm = (text: string, opts: FindOptions | undefined): string =>
|
||||
opts?.대소문자 ? text : text.toLowerCase();
|
||||
|
||||
/** 칸 하나를 글로 — 식 칸은 `수식안` 이 있어야 식 글, 없으면 후보에서 빠짐(null). */
|
||||
function cellText(
|
||||
book: Workbook,
|
||||
sheetId: string,
|
||||
r: number,
|
||||
c: number,
|
||||
opts?: FindOptions,
|
||||
): string | null {
|
||||
const sheet = book.시트.find((s) => s.id === sheetId);
|
||||
const cell = sheet?.칸[a1(r, c)];
|
||||
if (!cell) return null;
|
||||
if (cell.식 !== undefined) return opts?.수식안 ? cell.식 : null;
|
||||
const v = cell.값;
|
||||
if (v === undefined) return null;
|
||||
return typeof v === "boolean" ? (v ? "TRUE" : "FALSE") : String(v);
|
||||
}
|
||||
|
||||
function a1(r: number, c: number): string {
|
||||
let col = c + 1;
|
||||
let letters = "";
|
||||
while (col > 0) {
|
||||
const rem = (col - 1) % 26;
|
||||
letters = String.fromCharCode(65 + rem) + letters;
|
||||
col = Math.floor((col - 1) / 26);
|
||||
}
|
||||
return `${letters}${r + 1}`;
|
||||
}
|
||||
|
||||
const matches = (haystack: string, query: string, opts: FindOptions | undefined): boolean => {
|
||||
const h = norm(haystack, opts);
|
||||
const q = norm(query, opts);
|
||||
return opts?.전체칸 ? h === q : h.includes(q);
|
||||
};
|
||||
|
||||
/** 시트 하나 안 찾는 순서 — 왼위부터 오른아래로(행 우선). */
|
||||
function* cellsInOrder(book: Workbook, sheetId: string): Generator<SheetCellAddress> {
|
||||
const sheet = book.시트.find((s) => s.id === sheetId);
|
||||
if (!sheet) return;
|
||||
const addrs = Object.keys(sheet.칸)
|
||||
.map((key) => parseA1(key))
|
||||
.sort((x, y) => x.r - y.r || x.c - y.c);
|
||||
for (const { r, c } of addrs) yield { 시트: sheetId, r, c };
|
||||
}
|
||||
|
||||
function parseA1(key: string): { r: number; c: number } {
|
||||
const m = /^([A-Z]+)(\d+)$/.exec(key);
|
||||
if (!m) return { r: 0, c: 0 };
|
||||
let col = 0;
|
||||
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
|
||||
return { r: Number(m[2]) - 1, c: col - 1 };
|
||||
}
|
||||
|
||||
/** 통합문서 전체(활성 시트 먼저 뒤지고 싶으면 `sheetOrder` 로 순서를 줌)에서 다 찾음. */
|
||||
export function findAll(
|
||||
book: Workbook,
|
||||
query: string,
|
||||
opts?: FindOptions,
|
||||
sheetOrder?: string[],
|
||||
): SheetCellAddress[] {
|
||||
if (!query) return [];
|
||||
const ids = sheetOrder ?? book.시트.map((s) => s.id);
|
||||
const out: SheetCellAddress[] = [];
|
||||
for (const id of ids) {
|
||||
for (const at of cellsInOrder(book, id)) {
|
||||
const text = cellText(book, at.시트, at.r, at.c, opts);
|
||||
if (text !== null && matches(text, query, opts)) out.push(at);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `from` 다음 칸부터 한 바퀴 돌아 처음 걸리는 것(순환) · 없으면 null. */
|
||||
export function findNext(
|
||||
book: Workbook,
|
||||
from: SheetCellAddress,
|
||||
query: string,
|
||||
opts?: FindOptions,
|
||||
): SheetCellAddress | null {
|
||||
const all = findAll(book, query, opts, [
|
||||
from.시트,
|
||||
...book.시트.map((s) => s.id).filter((id) => id !== from.시트),
|
||||
]);
|
||||
if (all.length === 0) return null;
|
||||
const idx = all.findIndex((m) => m.시트 === from.시트 && m.r === from.r && m.c === from.c);
|
||||
return all[(idx + 1) % all.length];
|
||||
}
|
||||
|
||||
/** 한 칸 바꾸기 — 식 칸(`수식안`)이면 식 글 속 글자를 바꿈 · 값 칸이면 값 전체(전체칸)나 부분(글만)을 바꿈. */
|
||||
export function replaceOne(
|
||||
ctx: SpreadsheetContext,
|
||||
at: SheetCellAddress,
|
||||
query: string,
|
||||
replacement: string,
|
||||
opts?: FindOptions,
|
||||
): void {
|
||||
const sheet = ctx.book.시트.find((s) => s.id === at.시트);
|
||||
const cell = sheet?.칸[a1(at.r, at.c)];
|
||||
if (!sheet || !cell) return;
|
||||
const next: { 값?: CellInput; 식?: string; 서식?: number } = { 서식: cell.서식 };
|
||||
if (cell.식 !== undefined && opts?.수식안) {
|
||||
next.식 = replaceText(cell.식, query, replacement, opts);
|
||||
} else if (cell.값 !== undefined) {
|
||||
const text = typeof cell.값 === "boolean" ? (cell.값 ? "TRUE" : "FALSE") : String(cell.값);
|
||||
next.값 = replaceText(text, query, replacement, opts);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
ctx.dispatch({ 종류: "칸", 시트: at.시트, 칸: { [a1(at.r, at.c)]: next } });
|
||||
}
|
||||
|
||||
function replaceText(
|
||||
text: string,
|
||||
query: string,
|
||||
replacement: string,
|
||||
opts: FindOptions | undefined,
|
||||
): string {
|
||||
if (opts?.전체칸) return matches(text, query, opts) ? replacement : text;
|
||||
if (opts?.대소문자) return text.split(query).join(replacement);
|
||||
const re = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
|
||||
return text.replace(re, replacement);
|
||||
}
|
||||
|
||||
/** 모두 바꾸기 — 한 번의 되돌리기로 묶어 적용 · 바뀐 칸 수를 돌려줌. */
|
||||
export function replaceAll(
|
||||
ctx: SpreadsheetContext,
|
||||
query: string,
|
||||
replacement: string,
|
||||
opts?: FindOptions,
|
||||
): number {
|
||||
const hits = findAll(ctx.book, query, opts);
|
||||
if (hits.length === 0) return 0;
|
||||
const bySheet = new Map<string, SheetCellAddress[]>();
|
||||
for (const at of hits) bySheet.set(at.시트, [...(bySheet.get(at.시트) ?? []), at]);
|
||||
const commands = [...bySheet.entries()].map(([sheetId, ats]) => {
|
||||
const sheet = ctx.book.시트.find((s) => s.id === sheetId)!;
|
||||
const 칸: Record<string, { 값?: CellInput; 식?: string; 서식?: number }> = {};
|
||||
for (const at of ats) {
|
||||
const cell = sheet.칸[a1(at.r, at.c)];
|
||||
if (!cell) continue;
|
||||
if (cell.식 !== undefined && opts?.수식안) {
|
||||
칸[a1(at.r, at.c)] = {
|
||||
식: replaceText(cell.식, query, replacement, opts),
|
||||
서식: cell.서식,
|
||||
};
|
||||
} else if (cell.값 !== undefined) {
|
||||
const text = typeof cell.값 === "boolean" ? (cell.값 ? "TRUE" : "FALSE") : String(cell.값);
|
||||
칸[a1(at.r, at.c)] = { 값: replaceText(text, query, replacement, opts), 서식: cell.서식 };
|
||||
}
|
||||
}
|
||||
return { 종류: "칸" as const, 시트: sheetId, 칸 };
|
||||
});
|
||||
ctx.dispatch(commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands });
|
||||
return hits.length;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_format_painter.ts (2단계 · 주인 sub_laptop_1)
|
||||
* 서식 붓 — 활성 칸의 서식을 묻혀 다른 범위에 바름. 한 번 누르면 한 번만 · 두 번 누르면(sticky)
|
||||
* 끌 때까지 계속. 서식 찾기는 칸 → 행 → 열 → 기본(0) 차례(`spreadsheet_types.ts` `Cell.서식` 규칙).
|
||||
*
|
||||
* 잇는 법(D · sub7 · E 도구 모음) — 도구 모음에 버튼을 두고 `createFormatPainter(ctx)` 하나를 붙임.
|
||||
* 한 번 누르면 `pick()` → 커서를 붓 모양으로 → 범위를 고르면(mouseup) `paint(range)`.
|
||||
* 두 번 누르면 `pick()` 뒤 `sticky=true` 로 두고 Esc 나 버튼을 다시 누르면 꺼짐(`active()` 로 표시).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CellRange, CellStyle, Workbook } from "./spreadsheet_types";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
const EMPTY_STYLE: CellStyle = {};
|
||||
|
||||
/** 칸 하나의 실제 서식 — 칸 자기 서식 없으면 행 서식 · 없으면 열 서식 · 없으면 기본(0). */
|
||||
export function resolveCellStyle(book: Workbook, sheetId: string, r: number, c: number): CellStyle {
|
||||
const sheet = book.시트.find((s) => s.id === sheetId);
|
||||
if (!sheet) return EMPTY_STYLE;
|
||||
const idx = cellStyleIndex(sheet, r, c);
|
||||
return book.서식[idx] ?? EMPTY_STYLE;
|
||||
}
|
||||
|
||||
function cellStyleIndex(
|
||||
sheet: {
|
||||
칸: Record<string, { 서식?: number }>;
|
||||
열?: Record<string, { 서식?: number }>;
|
||||
행?: Record<string, { 서식?: number }>;
|
||||
},
|
||||
r: number,
|
||||
c: number,
|
||||
): number {
|
||||
const key = a1(r, c);
|
||||
const cellIdx = sheet.칸[key]?.서식;
|
||||
if (cellIdx !== undefined) return cellIdx;
|
||||
const rowIdx = sheet.행?.[String(r + 1)]?.서식;
|
||||
if (rowIdx !== undefined) return rowIdx;
|
||||
const colIdx = sheet.열?.[colLetters(c)]?.서식;
|
||||
if (colIdx !== undefined) return colIdx;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function colLetters(c: number): string {
|
||||
let col = c + 1;
|
||||
let letters = "";
|
||||
while (col > 0) {
|
||||
const rem = (col - 1) % 26;
|
||||
letters = String.fromCharCode(65 + rem) + letters;
|
||||
col = Math.floor((col - 1) / 26);
|
||||
}
|
||||
return letters;
|
||||
}
|
||||
|
||||
const a1 = (r: number, c: number): string => `${colLetters(c)}${r + 1}`;
|
||||
|
||||
export interface FormatPainterHandle extends PartHandle {
|
||||
/** 지금 활성 칸의 서식을 붓에 묻힘 */
|
||||
pick(): void;
|
||||
/** 묻힌 서식이 있으면 범위에 바름 · `sticky` 가 아니면 한 번 쓰고 스스로 끔 */
|
||||
paint(range: CellRange, sticky?: boolean): void;
|
||||
active(): boolean;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
export function createFormatPainter(ctx: SpreadsheetContext): FormatPainterHandle {
|
||||
let picked: CellStyle | null = null;
|
||||
let sticky = false;
|
||||
|
||||
return {
|
||||
root: null,
|
||||
pick(): void {
|
||||
const at = ctx.selection.활성;
|
||||
picked = resolveCellStyle(ctx.book, ctx.selection.시트, at.r, at.c);
|
||||
},
|
||||
paint(range: CellRange, keepOn = false): void {
|
||||
if (!picked) return;
|
||||
sticky = keepOn;
|
||||
ctx.dispatch({ 종류: "서식", 시트: ctx.selection.시트, 범위: [range], 바꿀: { ...picked } });
|
||||
if (!sticky) picked = null;
|
||||
},
|
||||
active(): boolean {
|
||||
return picked !== null;
|
||||
},
|
||||
clear(): void {
|
||||
picked = null;
|
||||
sticky = false;
|
||||
},
|
||||
refresh(): void {
|
||||
/* 상태 없는 표시만 씀 — 버튼 눌림은 active() 로 E 가 그림 */
|
||||
},
|
||||
destroy(): void {
|
||||
picked = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_func_help.ts (2단계 · 주인 sub_laptop_1)
|
||||
* 함수 자동 완성 목록 · 인자 도움말 — 1단계 함수 48 개(`spreadsheet_types.ts` 머리 「1단계 함수」)
|
||||
* 이름 · 인자 모양 · 한 줄 뜻. B `spreadsheet_functions.ts`(실제 풀이)와는 따로 — 이 표는 화면 도움말 전용
|
||||
* 정적 자료라 B 가 아직 없어도 씀. B 가 완성돼도 이름 목록이 갈리지 않게 이 표를 그대로 둠.
|
||||
*
|
||||
* 잇는 법(D · sub7 · 편집기) — 식 입력 중 `=` 뒤 낱말을 `suggestFunctions` 로 목록을 보여주고
|
||||
* `(` 를 친 뒤엔 `funcHelp(name)` 로 인자 도움말 풍선을 씀(지금 몇 번째 인자인지는 D 가 콤마를 세어 넘김).
|
||||
* ========================================================================== */
|
||||
|
||||
export interface FuncHelp {
|
||||
이름: string;
|
||||
/** 인자 모양 — 대괄호는 생략 가능 */
|
||||
꼴: string;
|
||||
뜻: string;
|
||||
}
|
||||
|
||||
const TABLE: FuncHelp[] = [
|
||||
{ 이름: "SUM", 꼴: "SUM(수1, [수2], …)", 뜻: "수를 더함" },
|
||||
{ 이름: "PRODUCT", 꼴: "PRODUCT(수1, [수2], …)", 뜻: "수를 곱함" },
|
||||
{ 이름: "SUMPRODUCT", 꼴: "SUMPRODUCT(범위1, [범위2], …)", 뜻: "같은 자리끼리 곱한 뒤 더함" },
|
||||
{ 이름: "ROUND", 꼴: "ROUND(수, 자릿수)", 뜻: "반올림" },
|
||||
{ 이름: "ROUNDUP", 꼴: "ROUNDUP(수, 자릿수)", 뜻: "올림" },
|
||||
{ 이름: "ROUNDDOWN", 꼴: "ROUNDDOWN(수, 자릿수)", 뜻: "내림" },
|
||||
{ 이름: "INT", 꼴: "INT(수)", 뜻: "정수로 내림(음수는 작은 쪽)" },
|
||||
{ 이름: "TRUNC", 꼴: "TRUNC(수, [자릿수])", 뜻: "소수점을 버림(0 쪽으로)" },
|
||||
{ 이름: "CEILING", 꼴: "CEILING(수, 배수)", 뜻: "배수 단위로 올림" },
|
||||
{ 이름: "FLOOR", 꼴: "FLOOR(수, 배수)", 뜻: "배수 단위로 내림" },
|
||||
{ 이름: "ABS", 꼴: "ABS(수)", 뜻: "절댓값" },
|
||||
{ 이름: "MOD", 꼴: "MOD(수, 나눌수)", 뜻: "나눈 나머지" },
|
||||
{ 이름: "POWER", 꼴: "POWER(수, 지수)", 뜻: "거듭제곱" },
|
||||
{ 이름: "SQRT", 꼴: "SQRT(수)", 뜻: "제곱근" },
|
||||
{ 이름: "PI", 꼴: "PI()", 뜻: "원주율" },
|
||||
{ 이름: "SIN", 꼴: "SIN(각도값)", 뜻: "사인(라디안)" },
|
||||
{ 이름: "COS", 꼴: "COS(각도값)", 뜻: "코사인(라디안)" },
|
||||
{ 이름: "TAN", 꼴: "TAN(각도값)", 뜻: "탄젠트(라디안)" },
|
||||
{ 이름: "ASIN", 꼴: "ASIN(수)", 뜻: "아크사인(라디안)" },
|
||||
{ 이름: "ACOS", 꼴: "ACOS(수)", 뜻: "아크코사인(라디안)" },
|
||||
{ 이름: "ATAN", 꼴: "ATAN(수)", 뜻: "아크탄젠트(라디안)" },
|
||||
{ 이름: "RADIANS", 꼴: "RADIANS(각도)", 뜻: "도 → 라디안" },
|
||||
{ 이름: "DEGREES", 꼴: "DEGREES(라디안)", 뜻: "라디안 → 도" },
|
||||
{ 이름: "MIN", 꼴: "MIN(수1, [수2], …)", 뜻: "가장 작은 수" },
|
||||
{ 이름: "MAX", 꼴: "MAX(수1, [수2], …)", 뜻: "가장 큰 수" },
|
||||
{ 이름: "AVERAGE", 꼴: "AVERAGE(수1, [수2], …)", 뜻: "평균" },
|
||||
{ 이름: "IF", 꼴: "IF(조건, 참일때, [거짓일때])", 뜻: "조건에 따라 값을 고름" },
|
||||
{ 이름: "AND", 꼴: "AND(조건1, [조건2], …)", 뜻: "모두 참이면 참" },
|
||||
{ 이름: "OR", 꼴: "OR(조건1, [조건2], …)", 뜻: "하나라도 참이면 참" },
|
||||
{ 이름: "NOT", 꼴: "NOT(조건)", 뜻: "참 · 거짓을 뒤집음" },
|
||||
{ 이름: "IFERROR", 꼴: "IFERROR(식, 오류일때)", 뜻: "오류면 대신 값을 씀" },
|
||||
{ 이름: "CONCATENATE", 꼴: "CONCATENATE(글1, [글2], …)", 뜻: "글을 이어 붙임" },
|
||||
{ 이름: "FIXED", 꼴: "FIXED(수, [소수자리], [쉼표뺌])", 뜻: "소수 자리를 정해 글로 바꿈" },
|
||||
{ 이름: "TEXT", 꼴: "TEXT(값, 형식)", 뜻: "형식 코드로 값을 글로 바꿈" },
|
||||
{ 이름: "LEN", 꼴: "LEN(글)", 뜻: "글자 수" },
|
||||
{ 이름: "LEFT", 꼴: "LEFT(글, [글자수])", 뜻: "왼쪽부터 글자를 뗌" },
|
||||
{ 이름: "RIGHT", 꼴: "RIGHT(글, [글자수])", 뜻: "오른쪽부터 글자를 뗌" },
|
||||
{ 이름: "MID", 꼴: "MID(글, 시작, 글자수)", 뜻: "가운데 글자를 뗌" },
|
||||
{ 이름: "VALUE", 꼴: "VALUE(글)", 뜻: "숫자 모양 글을 수로 바꿈" },
|
||||
{
|
||||
이름: "VLOOKUP",
|
||||
꼴: "VLOOKUP(찾을값, 표범위, 열번호, [정확히])",
|
||||
뜻: "표 왼쪽에서 찾아 그 행의 값",
|
||||
},
|
||||
{
|
||||
이름: "HLOOKUP",
|
||||
꼴: "HLOOKUP(찾을값, 표범위, 행번호, [정확히])",
|
||||
뜻: "표 위쪽에서 찾아 그 열의 값",
|
||||
},
|
||||
{ 이름: "INDEX", 꼴: "INDEX(범위, 행번호, [열번호])", 뜻: "범위 안 자리 값" },
|
||||
{ 이름: "MATCH", 꼴: "MATCH(찾을값, 범위, [맞춤꼴])", 뜻: "범위 안 자리 번호" },
|
||||
{ 이름: "CHOOSE", 꼴: "CHOOSE(순번, 값1, [값2], …)", 뜻: "순번에 맞는 값을 고름" },
|
||||
{ 이름: "COUNT", 꼴: "COUNT(값1, [값2], …)", 뜻: "수가 든 칸 개수" },
|
||||
{ 이름: "COUNTA", 꼴: "COUNTA(값1, [값2], …)", 뜻: "비어 있지 않은 칸 개수" },
|
||||
{ 이름: "COUNTIF", 꼴: "COUNTIF(범위, 조건)", 뜻: "조건에 맞는 칸 개수" },
|
||||
{ 이름: "SUMIF", 꼴: "SUMIF(범위, 조건, [더할범위])", 뜻: "조건에 맞는 칸(또는 짝 칸)의 합" },
|
||||
];
|
||||
|
||||
const BY_NAME = new Map(TABLE.map((f) => [f.이름, f]));
|
||||
|
||||
/** `=` 뒤 지금 치는 낱말(대소문자 안 가림)로 시작하는 함수 이름 — 가나다(알파벳)순. */
|
||||
export function suggestFunctions(prefix: string): FuncHelp[] {
|
||||
const p = prefix.toUpperCase();
|
||||
if (!p) return [];
|
||||
return TABLE.filter((f) => f.이름.startsWith(p));
|
||||
}
|
||||
|
||||
/** 함수 하나의 도움말(없으면 null) — 이름은 대소문자 안 가림. */
|
||||
export function funcHelp(name: string): FuncHelp | null {
|
||||
return BY_NAME.get(name.toUpperCase()) ?? null;
|
||||
}
|
||||
|
||||
/** 1단계 함수 48 개 다 있는지(시험용) */
|
||||
export function knownFunctionCount(): number {
|
||||
return TABLE.length;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_paste_special.ts (2단계 · 주인 sub_laptop_1)
|
||||
* 붙여넣기 골라서 — 값만 · 서식만 · 수식만(Ctrl+Shift+V). `ClipBlock`(0 계약) 을 받아
|
||||
* 대상 왼위(`at`)에 맞게 명령을 만듦 — 서식은 `서식` 명령(칸마다 하나 · A 가 표 번호를 매김) ·
|
||||
* 값 · 식은 `칸` 명령(대상 칸의 지금 서식 번호는 그대로 둠). 참조 옮김은 「원점」 이 있을 때만
|
||||
* `$` 없는 A1 참조를 얕게 옮김(진짜 옮김은 A `spreadsheet_refshift` 몫 — 여긴 메뉴용 편의).
|
||||
*
|
||||
* 잇는 법(D · sub7) — 클립보드(E)가 만든 `ClipBlock` 을 들고:
|
||||
* 1) 우클릭 메뉴 · Ctrl+Shift+V 로 모드 고르는 작은 팝업을 띄움(값만 · 서식만 · 수식만).
|
||||
* 2) 고른 모드로 `pasteSpecial(ctx, block, ctx.selection.활성, mode, sourceOrigin?)` 호출.
|
||||
* 3) 「값만」 은 원본이 아직 화면에 있을 때만 계산값을 살릴 수 있음 — `sourceOrigin` 을 줌(없으면 식 칸은 건너뜀).
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
Cell,
|
||||
CellAddress,
|
||||
CellRange,
|
||||
CellStyle,
|
||||
ClipBlock,
|
||||
ClipCell,
|
||||
Command,
|
||||
Frac,
|
||||
Scalar,
|
||||
} from "./spreadsheet_types";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
import { fracToString } from "@ui/sheet/ui_template_sheet_frac";
|
||||
|
||||
export type PasteSpecialMode = "값" | "서식" | "수식";
|
||||
|
||||
const a1 = (r: number, c: number): string => {
|
||||
let col = c + 1;
|
||||
let letters = "";
|
||||
while (col > 0) {
|
||||
const rem = (col - 1) % 26;
|
||||
letters = String.fromCharCode(65 + rem) + letters;
|
||||
col = Math.floor((col - 1) / 26);
|
||||
}
|
||||
return `${letters}${r + 1}`;
|
||||
};
|
||||
|
||||
/** `$` 없는 A1 참조만 (dr, dc) 만큼 옮김 — 절대참조 · 다른 시트 이름은 손대지 않음(안전한 최소). */
|
||||
export function shiftRelativeRefs(formula: string, dr: number, dc: number): string {
|
||||
return formula.replace(
|
||||
/(\$?)([A-Z]{1,3})(\$?)(\d+)/g,
|
||||
(whole, absC, colLetters, absR, rowDigits) => {
|
||||
if (absC === "$" && absR === "$") return whole;
|
||||
let col = 0;
|
||||
for (const ch of colLetters) col = col * 26 + (ch.charCodeAt(0) - 64);
|
||||
const nextCol = absC === "$" ? col : col + dc;
|
||||
const nextRow = absR === "$" ? Number(rowDigits) : Number(rowDigits) + dr;
|
||||
if (nextCol < 1 || nextRow < 1) return "#REF!";
|
||||
let letters = "";
|
||||
let n = nextCol;
|
||||
while (n > 0) {
|
||||
const rem = (n - 1) % 26;
|
||||
letters = String.fromCharCode(65 + rem) + letters;
|
||||
n = Math.floor((n - 1) / 26);
|
||||
}
|
||||
return `${absC}${letters}${absR}${nextRow}`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export interface PasteSpecialSource {
|
||||
시트: string;
|
||||
r: number;
|
||||
c: number;
|
||||
}
|
||||
|
||||
/** `block` 을 `at` 에 골라 붙임. `sourceOrigin` 은 「값만」 때 식 칸을 계산값으로 굳히는 데 씀(없으면 식 칸은 건너뜀). */
|
||||
export function pasteSpecial(
|
||||
ctx: SpreadsheetContext,
|
||||
block: ClipBlock,
|
||||
at: CellAddress,
|
||||
mode: PasteSpecialMode,
|
||||
sourceOrigin?: PasteSpecialSource,
|
||||
): void {
|
||||
const sheetId = ctx.selection.시트;
|
||||
const sheet = ctx.sheet();
|
||||
const dr = at.r - (block.원점?.r ?? at.r);
|
||||
const dc = at.c - (block.원점?.c ?? at.c);
|
||||
const commands: Command[] = [];
|
||||
const 칸: Record<string, Cell | null> = {};
|
||||
|
||||
for (const [key, clip] of Object.entries(block.칸)) {
|
||||
const [rowStr, colStr] = key.split(",");
|
||||
const rr = at.r + Number(rowStr);
|
||||
const cc = at.c + Number(colStr);
|
||||
if (rr < 0 || cc < 0) continue;
|
||||
const target = a1(rr, cc);
|
||||
const existing = sheet.칸[target];
|
||||
|
||||
if (mode === "서식") {
|
||||
if (clip.서식) commands.push(styleCommand(sheetId, rr, cc, clip.서식));
|
||||
continue;
|
||||
}
|
||||
if (mode === "수식") {
|
||||
if (clip.식 === undefined) continue;
|
||||
const moved = block.원점 ? shiftRelativeRefs(clip.식, dr, dc) : clip.식;
|
||||
칸[target] = { 식: moved, 서식: existing?.서식 };
|
||||
continue;
|
||||
}
|
||||
// mode === "값"
|
||||
const value = resolveClipValue(clip, sourceOrigin, Number(rowStr), Number(colStr), ctx);
|
||||
if (value === undefined) continue;
|
||||
칸[target] = { 값: value, 서식: existing?.서식 };
|
||||
}
|
||||
|
||||
if (Object.keys(칸).length > 0) commands.push({ 종류: "칸", 시트: sheetId, 칸 });
|
||||
if (commands.length === 0) return;
|
||||
ctx.dispatch(commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands });
|
||||
}
|
||||
|
||||
function styleCommand(sheetId: string, r: number, c: number, style: CellStyle): Command {
|
||||
const 범위: CellRange = { r0: r, c0: c, r1: r, c1: c };
|
||||
return { 종류: "서식", 시트: sheetId, 범위: [범위], 바꿀: { ...style } };
|
||||
}
|
||||
|
||||
function resolveClipValue(
|
||||
clip: ClipCell,
|
||||
origin: PasteSpecialSource | undefined,
|
||||
dr: number,
|
||||
dc: number,
|
||||
ctx: SpreadsheetContext,
|
||||
): Cell["값"] | undefined {
|
||||
if (clip.식 !== undefined) {
|
||||
if (!origin) return undefined;
|
||||
const scalar = ctx.engine.value(origin.시트, origin.r + dr, origin.c + dc);
|
||||
return scalarFrom(scalar);
|
||||
}
|
||||
return clip.값;
|
||||
}
|
||||
|
||||
function scalarFrom(value: Scalar): Cell["값"] | undefined {
|
||||
if (value === null) return undefined;
|
||||
if (typeof value === "string" || typeof value === "boolean") return value;
|
||||
if (typeof value === "object" && "n" in value && "d" in value)
|
||||
return Number(fracToString(value as Frac));
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/* =============================================================================
|
||||
* test_stage2_autofit.ts — spreadsheet_autofit 시험. 돌리기: node --experimental-strip-types <이 파일>
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
autofitColumn,
|
||||
estimateColumnWidth,
|
||||
} from "../../../A00_Common/spreadsheet/spreadsheet_autofit.ts";
|
||||
import { assertEqual, makeBook, makeCtx, makeSheet, v } from "./test_stage2_helpers.ts";
|
||||
|
||||
const sheet = makeSheet("s1", {
|
||||
A1: v("짧음"),
|
||||
A2: v("이것은 조금 더 긴 글자"),
|
||||
B1: v(123),
|
||||
});
|
||||
const book = makeBook([sheet]);
|
||||
|
||||
const widthA = estimateColumnWidth(book, "s1", 0);
|
||||
const widthB = estimateColumnWidth(book, "s1", 1);
|
||||
assertEqual(widthA > widthB, true, "A 열이 B 열보다 글자가 길어 폭도 큼");
|
||||
assertEqual(estimateColumnWidth(book, "s1", 5), 4, "빈 열은 최소 폭");
|
||||
|
||||
const ctx = makeCtx(book, "s1");
|
||||
autofitColumn(ctx, 0);
|
||||
assertEqual(sheet.열?.A.폭, widthA, "autofitColumn 이 열폭 명령으로 반영");
|
||||
|
||||
// 재는 함수를 바꿔 끼울 수 있음(C 격자가 실제 글꼴로 잴 때 대비)
|
||||
const fixedWidth = estimateColumnWidth(book, "s1", 0, () => 7);
|
||||
assertEqual(fixedWidth, 9, "measure 를 넘기면 그걸로 어림(7 + 여백 2)");
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
console.log("spreadsheet_autofit 시험 끝");
|
||||
@@ -0,0 +1,60 @@
|
||||
/* =============================================================================
|
||||
* test_stage2_find.ts — spreadsheet_find 시험. 돌리기: node --experimental-strip-types <이 파일>
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
findAll,
|
||||
findNext,
|
||||
replaceAll,
|
||||
replaceOne,
|
||||
} from "../../../A00_Common/spreadsheet/spreadsheet_find.ts";
|
||||
import { assertEqual, makeBook, makeCtx, makeSheet, v, f } from "./test_stage2_helpers.ts";
|
||||
|
||||
const sheet = makeSheet("s1", {
|
||||
A1: v("돌뒷길이"),
|
||||
A2: v("돌뒷길이2"),
|
||||
B1: v(10),
|
||||
B2: f('A1&"-"'),
|
||||
});
|
||||
const book = makeBook([sheet]);
|
||||
|
||||
assertEqual(
|
||||
findAll(book, "돌뒷길이").map((m) => `${m.r},${m.c}`),
|
||||
["0,0", "1,0"],
|
||||
"findAll 기본(값 칸 둘)",
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
findAll(book, "돌뒷길이", { 전체칸: true }).map((m) => `${m.r},${m.c}`),
|
||||
["0,0"],
|
||||
"findAll 전체칸",
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
findAll(book, "A1", { 수식안: true }).map((m) => `${m.r},${m.c}`),
|
||||
["1,1"],
|
||||
"findAll 수식안(식 글 속)",
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
findAll(book, "돌뒷길이", { 대소문자: true }).length,
|
||||
2,
|
||||
"findAll 대소문자 켜도 완전 일치 글자는 그대로 찾음",
|
||||
);
|
||||
|
||||
const next = findNext(book, { 시트: "s1", r: 0, c: 0 }, "돌뒷길이");
|
||||
assertEqual(next, { 시트: "s1", r: 1, c: 0 }, "findNext 다음 칸으로");
|
||||
|
||||
const wrapped = findNext(book, { 시트: "s1", r: 1, c: 0 }, "돌뒷길이");
|
||||
assertEqual(wrapped, { 시트: "s1", r: 0, c: 0 }, "findNext 한 바퀴 돌아 처음으로");
|
||||
|
||||
const ctx = makeCtx(book, "s1");
|
||||
replaceOne(ctx, { 시트: "s1", r: 0, c: 0 }, "돌뒷길이", "돌앞길이");
|
||||
assertEqual(book.시트[0].칸.A1.값, "돌앞길이", "replaceOne 값 칸");
|
||||
|
||||
const n = replaceAll(ctx, "-", "_", { 수식안: true });
|
||||
assertEqual(n, 1, "replaceAll 바뀐 칸 수");
|
||||
assertEqual(book.시트[0].칸.B2.식, 'A1&"_"', "replaceAll 식 칸 속 글자");
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
console.log("spreadsheet_find 시험 끝");
|
||||
@@ -0,0 +1,46 @@
|
||||
/* =============================================================================
|
||||
* test_stage2_format_painter.ts — spreadsheet_format_painter 시험. 돌리기: node --experimental-strip-types <이 파일>
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createFormatPainter,
|
||||
resolveCellStyle,
|
||||
} from "../../../A00_Common/spreadsheet/spreadsheet_format_painter.ts";
|
||||
import { assertEqual, makeBook, makeCtx, makeSheet, v } from "./test_stage2_helpers.ts";
|
||||
|
||||
const sheet = makeSheet(
|
||||
"s1",
|
||||
{ A1: v(1, 1), B1: v(2) },
|
||||
{ 열: { B: { 서식: 2 } }, 행: { "1": { 서식: 3 } } },
|
||||
);
|
||||
const book = makeBook([sheet], [{}, { 굵게: true }, { 글자색: "#ff0000" }, { 밑줄: true }]);
|
||||
|
||||
assertEqual(resolveCellStyle(book, "s1", 0, 0), { 굵게: true }, "칸 자기 서식 우선");
|
||||
// Cell.서식 규칙(0 계약 주석) = 칸 → 행 → 열 → 기본. B1 은 칸 서식 없음 · 행 1 서식(3) · 열 B 서식(2) 둘 다 있음 → 행이 이김.
|
||||
assertEqual(
|
||||
resolveCellStyle(book, "s1", 0, 1),
|
||||
{ 밑줄: true },
|
||||
"칸 서식 없으면 행 서식(열보다 앞섬)",
|
||||
);
|
||||
|
||||
delete sheet.행!["1"];
|
||||
assertEqual(resolveCellStyle(book, "s1", 0, 1), { 글자색: "#ff0000" }, "행 서식도 없으면 열 서식");
|
||||
assertEqual(resolveCellStyle(book, "s1", 5, 5), {}, "아무 서식도 없으면 기본(0)");
|
||||
|
||||
const ctx = makeCtx(book, "s1");
|
||||
const painter = createFormatPainter(ctx);
|
||||
ctx.selection.활성 = { r: 0, c: 0 }; // A1 = 굵게
|
||||
painter.pick();
|
||||
assertEqual(painter.active(), true, "pick 뒤 active");
|
||||
painter.paint({ r0: 4, c0: 4, r1: 4, c1: 4 });
|
||||
assertEqual(resolveCellStyle(book, "s1", 4, 4), { 굵게: true }, "paint 로 서식이 옮음");
|
||||
assertEqual(painter.active(), false, "한 번 쓰면 꺼짐(sticky 아님)");
|
||||
|
||||
painter.pick();
|
||||
painter.paint({ r0: 6, c0: 6, r1: 6, c1: 6 }, true);
|
||||
assertEqual(painter.active(), true, "sticky 는 계속 켜짐");
|
||||
painter.clear();
|
||||
assertEqual(painter.active(), false, "clear 로 끔");
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
console.log("spreadsheet_format_painter 시험 끝");
|
||||
@@ -0,0 +1,48 @@
|
||||
/* =============================================================================
|
||||
* test_stage2_func_help.ts — spreadsheet_func_help 시험. 돌리기: node --experimental-strip-types <이 파일>
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
funcHelp,
|
||||
knownFunctionCount,
|
||||
suggestFunctions,
|
||||
} from "../../../A00_Common/spreadsheet/spreadsheet_func_help.ts";
|
||||
import { assertEqual } from "./test_stage2_helpers.ts";
|
||||
|
||||
assertEqual(knownFunctionCount(), 48, "1단계 함수 48 개 다 있음");
|
||||
|
||||
assertEqual(
|
||||
suggestFunctions("ROU").map((f) => f.이름),
|
||||
["ROUND", "ROUNDUP", "ROUNDDOWN"],
|
||||
"접두사로 자동 완성 후보",
|
||||
);
|
||||
|
||||
assertEqual(suggestFunctions("rou").map((f) => f.이름).length, 3, "대소문자 안 가림");
|
||||
assertEqual(suggestFunctions("").length, 0, "빈 접두사는 후보 없음");
|
||||
assertEqual(suggestFunctions("ZZZ").length, 0, "안 맞으면 없음");
|
||||
|
||||
const help = funcHelp("vlookup");
|
||||
assertEqual(help?.이름, "VLOOKUP", "이름은 대소문자 안 가림");
|
||||
assertEqual(help?.꼴.includes("("), true, "꼴에 괄호가 있음");
|
||||
assertEqual(funcHelp("없는함수"), null, "없는 함수는 null");
|
||||
|
||||
// 실무 13 가지가 모두 표에 있는지(9_엑셀검토 1장)
|
||||
const 실무13 = [
|
||||
"ROUND",
|
||||
"SUM",
|
||||
"ROUNDDOWN",
|
||||
"INT",
|
||||
"SQRT",
|
||||
"IF",
|
||||
"FIXED",
|
||||
"VLOOKUP",
|
||||
"ROUNDUP",
|
||||
"CONCATENATE",
|
||||
"TAN",
|
||||
"PI",
|
||||
"COUNTA",
|
||||
];
|
||||
for (const name of 실무13) assertEqual(funcHelp(name) !== null, true, `실무 함수 ${name} 있음`);
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
console.log("spreadsheet_func_help 시험 끝");
|
||||
@@ -0,0 +1,166 @@
|
||||
/* =============================================================================
|
||||
* test_stage2_helpers.ts
|
||||
* PLAN 11-8 2단계 시험 공용 — 최소 통합문서 견본 · 가짜 SpreadsheetContext(dispatch 가 「칸」·
|
||||
* 「서식」·「묶음」·「열폭」 명령만 그대로 적용 — A(spreadsheet_commands)가 아직 없어도 2단계
|
||||
* 파일 하나하나를 검증하기 위한 최소한). 돌리기 — `node --experimental-strip-types <시험 파일>`.
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
Cell,
|
||||
CellStyle,
|
||||
Command,
|
||||
Frac,
|
||||
Scalar,
|
||||
Sheet,
|
||||
Workbook,
|
||||
} from "../../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import type { SpreadsheetContext } from "../../../A00_Common/spreadsheet/spreadsheet_view_types";
|
||||
|
||||
export function assertEqual<T>(actual: T, expected: T, label: string): void {
|
||||
const a = JSON.stringify(actual);
|
||||
const b = JSON.stringify(expected);
|
||||
if (a !== b) {
|
||||
console.error(`FAIL ${label}\n 실제: ${a}\n 기대: ${b}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(`ok ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function makeSheet(id: string, 칸: Record<string, Cell>, extra: Partial<Sheet> = {}): Sheet {
|
||||
return { id, 이름: id, 칸, ...extra };
|
||||
}
|
||||
|
||||
export function makeBook(sheets: Sheet[], 서식: CellStyle[] = [{}]): Workbook {
|
||||
return { 종류: "통합문서", 판: 1, 열: "test", 서식, 시트: sheets };
|
||||
}
|
||||
|
||||
/** 값 칸 하나 짜기(수는 그대로 넣음 — Frac 아님, 이 시험 계층에선 안 씀). */
|
||||
export const v = (값: Cell["값"], 서식?: number): Cell =>
|
||||
서식 === undefined ? { 값 } : { 값, 서식 };
|
||||
export const f = (식: string, 서식?: number): Cell => (서식 === undefined ? { 식 } : { 식, 서식 });
|
||||
|
||||
export interface FakeEngineValues {
|
||||
[sheetId: string]: { [a1: string]: Scalar };
|
||||
}
|
||||
|
||||
function toFrac(n: number): Frac {
|
||||
return { n: BigInt(Math.round(n * 1_000_000)), d: 1_000_000n } as unknown as Frac;
|
||||
}
|
||||
|
||||
export function makeCtx(
|
||||
book: Workbook,
|
||||
sheetId: string,
|
||||
values: FakeEngineValues = {},
|
||||
): SpreadsheetContext & { applied: Command[] } {
|
||||
const applied: Command[] = [];
|
||||
|
||||
const apply = (cmd: Command): void => {
|
||||
applied.push(cmd);
|
||||
if (cmd.종류 === "묶음") {
|
||||
for (const c of cmd.명령) apply(c);
|
||||
return;
|
||||
}
|
||||
if (cmd.종류 === "칸") {
|
||||
const sheet = book.시트.find((s) => s.id === cmd.시트);
|
||||
if (!sheet) return;
|
||||
for (const [key, cell] of Object.entries(cmd.칸)) {
|
||||
if (cell === null) delete sheet.칸[key];
|
||||
else sheet.칸[key] = cell;
|
||||
}
|
||||
} else if (cmd.종류 === "서식") {
|
||||
const sheet = book.시트.find((s) => s.id === cmd.시트);
|
||||
if (!sheet) return;
|
||||
let idx = book.서식.findIndex((s) => JSON.stringify(s) === JSON.stringify(cmd.바꿀));
|
||||
if (idx < 0) {
|
||||
idx = book.서식.length;
|
||||
book.서식.push(cmd.바꿀 as CellStyle);
|
||||
}
|
||||
for (const 범위 of cmd.범위) {
|
||||
for (let r = 범위.r0; r <= 범위.r1; r++) {
|
||||
for (let c = 범위.c0; c <= 범위.c1; c++) {
|
||||
const key = a1(r, c);
|
||||
sheet.칸[key] = { ...sheet.칸[key], 서식: idx };
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (cmd.종류 === "열폭") {
|
||||
const sheet = book.시트.find((s) => s.id === cmd.시트);
|
||||
if (!sheet) return;
|
||||
sheet.열 = sheet.열 ?? {};
|
||||
for (const c of cmd.열)
|
||||
sheet.열[colLetters(c)] = { ...sheet.열[colLetters(c)], 폭: cmd.폭 ?? undefined };
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
root: {} as HTMLElement,
|
||||
book,
|
||||
readOnly: false,
|
||||
engine: {
|
||||
update: () => [],
|
||||
rebuild: () => {},
|
||||
value: (sheet: string, r: number, c: number): Scalar => values[sheet]?.[a1(r, c)] ?? null,
|
||||
precedents: () => [],
|
||||
snapshot: () => ({}),
|
||||
},
|
||||
history: {
|
||||
push: () => {},
|
||||
undo: () => null,
|
||||
redo: () => null,
|
||||
canUndo: () => false,
|
||||
canRedo: () => false,
|
||||
clear: () => {},
|
||||
},
|
||||
selection: {
|
||||
시트: sheetId,
|
||||
범위: [{ r0: 0, c0: 0, r1: 0, c1: 0 }],
|
||||
활성: { r: 0, c: 0 },
|
||||
기준: { r: 0, c: 0 },
|
||||
},
|
||||
sheet: () => book.시트.find((s) => s.id === sheetId)!,
|
||||
dispatch: (cmd: Command) => apply(cmd),
|
||||
undo: () => {},
|
||||
redo: () => {},
|
||||
select: () => {},
|
||||
showSheet: () => {},
|
||||
grid: {
|
||||
root: {} as HTMLElement,
|
||||
render: () => {},
|
||||
invalidate: () => {},
|
||||
renderSelection: () => {},
|
||||
setRefHighlights: () => {},
|
||||
cellBox: () => ({ x: 0, y: 0, w: 0, h: 0 }),
|
||||
hitTest: () => null,
|
||||
reveal: () => {},
|
||||
visibleRange: () => ({ r0: 0, c0: 0, r1: 0, c1: 0 }),
|
||||
editorSlot: () => ({ layer: {} as HTMLElement, box: { x: 0, y: 0, w: 0, h: 0 } }),
|
||||
destroy: () => {},
|
||||
},
|
||||
editor: {
|
||||
editing: () => false,
|
||||
begin: () => {},
|
||||
commit: () => {},
|
||||
cancel: () => {},
|
||||
sync: () => {},
|
||||
},
|
||||
applied,
|
||||
};
|
||||
}
|
||||
|
||||
export function a1(r: number, c: number): string {
|
||||
let col = c + 1;
|
||||
let letters = "";
|
||||
while (col > 0) {
|
||||
const rem = (col - 1) % 26;
|
||||
letters = String.fromCharCode(65 + rem) + letters;
|
||||
col = Math.floor((col - 1) / 26);
|
||||
}
|
||||
return `${letters}${r + 1}`;
|
||||
}
|
||||
|
||||
function colLetters(c: number): string {
|
||||
return a1(0, c).replace(/\d+$/, "");
|
||||
}
|
||||
|
||||
export const fracLiteral = toFrac;
|
||||
@@ -0,0 +1,51 @@
|
||||
/* =============================================================================
|
||||
* test_stage2_paste_special.ts — spreadsheet_paste_special 시험.
|
||||
* 돌리기: npx tsx <이 파일> (`@ui/...` 별칭을 씀 — node 만으로는 안 풀림. F 가 진짜 시험
|
||||
* 틀을 놓으면 그걸로 옮김.)
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
pasteSpecial,
|
||||
shiftRelativeRefs,
|
||||
} from "../../../A00_Common/spreadsheet/spreadsheet_paste_special.ts";
|
||||
import { assertEqual, makeBook, makeCtx, makeSheet, v, f } from "./test_stage2_helpers.ts";
|
||||
import type { ClipBlock } from "../../../A00_Common/spreadsheet/spreadsheet_types.ts";
|
||||
|
||||
assertEqual(shiftRelativeRefs("A1+B2", 1, 1), "B2+C3", "옮김 기본");
|
||||
assertEqual(shiftRelativeRefs("$A$1+B2", 1, 1), "$A$1+C3", "절대참조는 안 옮김");
|
||||
assertEqual(shiftRelativeRefs("A1", -5, 0), "#REF!", "행 밖(1 보다 앞)으로 나가면 #REF!");
|
||||
|
||||
const sheet = makeSheet("s1", { A1: v(10, 1), B1: v("글") });
|
||||
const book = makeBook([sheet], [{}, { 굵게: true }]);
|
||||
const ctx = makeCtx(book, "s1");
|
||||
|
||||
const block: ClipBlock = {
|
||||
rows: 1,
|
||||
cols: 1,
|
||||
칸: { "0,0": { 값: 99, 서식: { 밑줄: true } } },
|
||||
병합: [],
|
||||
원점: { r: 0, c: 0 },
|
||||
출처: "aislo",
|
||||
};
|
||||
|
||||
pasteSpecial(ctx, block, { r: 4, c: 4 }, "값");
|
||||
assertEqual(sheet.칸.E5?.값, 99, "값만 — 값 붙음");
|
||||
assertEqual(sheet.칸.E5?.서식, undefined, "값만 — 서식은 안 옮음(대상 것 그대로)");
|
||||
|
||||
pasteSpecial(ctx, block, { r: 6, c: 6 }, "서식");
|
||||
assertEqual(book.서식[sheet.칸.G7!.서식!], { 밑줄: true }, "서식만 — 서식이 옮음");
|
||||
assertEqual(sheet.칸.G7?.값, undefined, "서식만 — 값은 안 옮음");
|
||||
|
||||
const formulaBlock: ClipBlock = {
|
||||
rows: 1,
|
||||
cols: 1,
|
||||
칸: { "0,0": { 식: "A1+1" } },
|
||||
병합: [],
|
||||
원점: { r: 0, c: 0 },
|
||||
출처: "aislo",
|
||||
};
|
||||
pasteSpecial(ctx, formulaBlock, { r: 2, c: 2 }, "수식");
|
||||
assertEqual(sheet.칸.C3?.식, "C3+1", "수식만 — 상대참조를 옮겨 붙임(원점 0,0 → 대상 2,2 만큼)");
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
console.log("spreadsheet_paste_special 시험 끝");
|
||||
Reference in New Issue
Block a user