feat(spreadsheet): 2단계 — 데이터 도구(텍스트 나누기·중복 제거·연속 채우기)
텍스트 나누기(구분 기호 탭·세미콜론·쉼표·공백·사용자 지정 또는 고정 폭 · 미리보기) · 중복된 항목 제거(비교할 열 고르기 · 머리글 포함 여부 · 지운 수를 토스트로 알림) · 채우기 연속 데이터(단계 값 · 끝 값 · 행/열 방향)를 새 파일로 짬. 셋 다 「칸」 명령 하나로만 문서를 바꿈. 순수 로직(나누기 · 중복 찾기 · 등차수열 짓기)은 DOM 없이도 시험 가능해 resources/tester/spreadsheet/test_stage2_data_tools.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,422 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_data_tools.ts (2단계 · 주인 sub_laptop_1)
|
||||
* 데이터 도구 셋 — ① 텍스트 나누기(구분 기호 · 고정 폭 · 미리보기) ② 중복된 항목 제거
|
||||
* (열 고르기 · 지운 수 알림) ③ 채우기 연속 데이터(단계 값 · 끝 값 · 행/열).
|
||||
* 문서를 바꾸는 길은 「칸」 명령 하나뿐 — 세 도구 모두 이것만 씀.
|
||||
*
|
||||
* 잇는 법(D · sub7) — 메뉴 [데이터] 아래 세 항목을 만들어 `openTextToColumnsDialog(ctx)` ·
|
||||
* `openRemoveDuplicatesDialog(ctx)` · `openFillSeriesDialog(ctx)` 를 각각 부름
|
||||
* (`spreadsheet_menu.ts` 우클릭 메뉴에도 [중복된 항목 제거]는 달아 둘 만함).
|
||||
*
|
||||
* 고정 폭 나누기는 실제 엑셀처럼 눈금자를 끌어 자르지 않고 자를 자리(문자 수)를
|
||||
* 쉼표로 적는 입력 한 줄로 단순화함(미리보기로 바로 확인 가능 — 자리 표시만 다름).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
|
||||
import { fracToString } from "@ui/sheet/ui_template_sheet_frac";
|
||||
import { colName, toA1 } from "./spreadsheet_address";
|
||||
import type { Cell, CellAddress, CellInput, CellRange, Scalar } from "./spreadsheet_types";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
const TEXT = {
|
||||
textToColumns: "텍스트 나누기",
|
||||
byDelimiter: "구분 기호로",
|
||||
byFixedWidth: "고정 폭으로",
|
||||
delimiters: "구분 기호",
|
||||
tab: "탭",
|
||||
semicolon: "세미콜론",
|
||||
comma: "쉼표",
|
||||
space: "공백",
|
||||
custom: "사용자 지정",
|
||||
breaks: "자를 자리(문자 수, 쉼표로 구분)",
|
||||
preview: "미리보기",
|
||||
run: "실행",
|
||||
cancel: "취소",
|
||||
removeDuplicates: "중복된 항목 제거",
|
||||
hasHeader: "머리글 포함",
|
||||
keyColumns: "비교할 열",
|
||||
removed: (n: number) => `중복 ${n}개 지움`,
|
||||
fillSeries: "연속 데이터 채우기",
|
||||
direction: "방향",
|
||||
down: "아래로(열)",
|
||||
right: "오른쪽으로(행)",
|
||||
step: "단계 값",
|
||||
stop: "끝 값(선택)",
|
||||
};
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// 공용 — 값 ↔ 글, 작은 대화상자 뼈대
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function scalarText(v: Scalar): string {
|
||||
if (v == null) return "";
|
||||
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
||||
if (typeof v === "string") return v;
|
||||
if (typeof v === "object" && "error" in v) return v.error;
|
||||
return fracToString(v);
|
||||
}
|
||||
|
||||
function cellInputFrom(text: string): CellInput {
|
||||
const n = Number(text);
|
||||
return text !== "" && !Number.isNaN(n) ? n : text;
|
||||
}
|
||||
|
||||
function row(label: string, ...controls: HTMLElement[]): HTMLElement {
|
||||
const r = el("div", {
|
||||
children: [el("span", { text: label }), ...controls],
|
||||
});
|
||||
r.style.cssText = "display:flex;align-items:center;gap:8px;margin-bottom:6px;";
|
||||
return r;
|
||||
}
|
||||
|
||||
/** `.ui-confirm` 배경 위에 제목 · 본문 · [실행]/[취소] 를 올린 작은 대화상자. */
|
||||
function modal(title: string, body: HTMLElement[], onRun: () => void, runLabel = TEXT.run): void {
|
||||
const overlay = el("div", { className: "ui-confirm" });
|
||||
const panel = el("div", { className: "ui-confirm__panel" });
|
||||
panel.style.minWidth = "360px";
|
||||
const close = (): void => overlay.remove();
|
||||
overlay.addEventListener("click", (ev) => {
|
||||
if (ev.target === overlay) close();
|
||||
});
|
||||
panel.append(el("h3", { text: title }), ...body);
|
||||
panel.append(
|
||||
el("div", {
|
||||
className: "ui-confirm__actions",
|
||||
children: [
|
||||
createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }),
|
||||
createButton({
|
||||
label: runLabel,
|
||||
variant: "filled",
|
||||
onClick: () => {
|
||||
onRun();
|
||||
close();
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
overlay.append(panel);
|
||||
document.body.append(overlay);
|
||||
}
|
||||
|
||||
function readRange(ctx: SpreadsheetContext): CellRange | null {
|
||||
return ctx.selection.범위[0] ?? null;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// 1. 텍스트 나누기
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export type DelimiterKind = "탭" | "세미콜론" | "쉼표" | "공백" | "사용자";
|
||||
|
||||
const DELIM_CHARS: Record<Exclude<DelimiterKind, "사용자">, string> = {
|
||||
탭: "\t",
|
||||
세미콜론: ";",
|
||||
쉼표: ",",
|
||||
공백: " ",
|
||||
};
|
||||
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/** 한 줄을 고른 구분 기호로 나눔 — 기호가 하나도 없으면 원문 그대로 한 칸. */
|
||||
export function splitDelimited(line: string, kinds: DelimiterKind[], custom: string): string[] {
|
||||
const chars = kinds
|
||||
.map((k) => (k === "사용자" ? custom : DELIM_CHARS[k]))
|
||||
.filter((c): c is string => !!c);
|
||||
if (chars.length === 0) return [line];
|
||||
return line.split(new RegExp(chars.map(escapeRegExp).join("|")));
|
||||
}
|
||||
|
||||
/** 한 줄을 자를 자리(문자 수, 오름차순)로 나눔. */
|
||||
export function splitFixedWidth(line: string, breaks: number[]): string[] {
|
||||
const points = [0, ...breaks.filter((b) => b > 0 && b < line.length), line.length];
|
||||
const sorted = [...new Set(points)].sort((a, b) => a - b);
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < sorted.length - 1; i++) out.push(line.slice(sorted[i], sorted[i + 1]));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 나뉜 줄들을 `anchor` 를 왼위로 삼아 칸에 씀(빈 글은 칸 지움). */
|
||||
export function applyTextToColumns(
|
||||
ctx: SpreadsheetContext,
|
||||
anchor: CellAddress,
|
||||
rows: string[][],
|
||||
): void {
|
||||
const cells: Record<string, Cell | null> = {};
|
||||
rows.forEach((cols, ri) => {
|
||||
cols.forEach((text, ci) => {
|
||||
cells[toA1(anchor.r + ri, anchor.c + ci)] = text === "" ? null : { 값: cellInputFrom(text) };
|
||||
});
|
||||
});
|
||||
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
|
||||
}
|
||||
|
||||
export function openTextToColumnsDialog(ctx: SpreadsheetContext): void {
|
||||
if (ctx.readOnly) return;
|
||||
const range = readRange(ctx);
|
||||
if (!range) return;
|
||||
const lines: string[] = [];
|
||||
for (let r = range.r0; r <= range.r1; r++) {
|
||||
lines.push(scalarText(ctx.engine.value(ctx.selection.시트, r, range.c0)));
|
||||
}
|
||||
|
||||
let mode: "구분" | "고정" = "구분";
|
||||
let kinds: DelimiterKind[] = ["쉼표"];
|
||||
let custom = "";
|
||||
let breaksText = "";
|
||||
|
||||
const modeSelect = el("select", {
|
||||
children: [
|
||||
el("option", { attrs: { value: "구분" }, text: TEXT.byDelimiter }),
|
||||
el("option", { attrs: { value: "고정" }, text: TEXT.byFixedWidth }),
|
||||
],
|
||||
}) as HTMLSelectElement;
|
||||
|
||||
const delimBox = el("div");
|
||||
const kindChecks = (["탭", "세미콜론", "쉼표", "공백", "사용자"] as DelimiterKind[]).map((k) => {
|
||||
const check = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
|
||||
check.checked = kinds.includes(k);
|
||||
check.addEventListener("change", () => {
|
||||
kinds = check.checked ? [...kinds, k] : kinds.filter((x) => x !== k);
|
||||
refreshPreview();
|
||||
});
|
||||
return row(
|
||||
k === "탭"
|
||||
? TEXT.tab
|
||||
: k === "세미콜론"
|
||||
? TEXT.semicolon
|
||||
: k === "쉼표"
|
||||
? TEXT.comma
|
||||
: k === "공백"
|
||||
? TEXT.space
|
||||
: TEXT.custom,
|
||||
check,
|
||||
);
|
||||
});
|
||||
const customInput = createInputField({
|
||||
label: TEXT.custom,
|
||||
onInput: (v) => {
|
||||
custom = v;
|
||||
refreshPreview();
|
||||
},
|
||||
});
|
||||
delimBox.append(...kindChecks, customInput.root);
|
||||
|
||||
const breaksField = createInputField({
|
||||
label: TEXT.breaks,
|
||||
placeholder: "5,10,20",
|
||||
onInput: (v) => {
|
||||
breaksText = v;
|
||||
refreshPreview();
|
||||
},
|
||||
});
|
||||
breaksField.root.hidden = true;
|
||||
|
||||
const previewBox = el("pre");
|
||||
previewBox.style.cssText =
|
||||
"max-height:160px;overflow:auto;background:var(--color-canvas);padding:6px;border-radius:4px;font-size:0.8rem;";
|
||||
|
||||
function splitAll(): string[][] {
|
||||
if (mode === "고정") {
|
||||
const breaks = breaksText
|
||||
.split(",")
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
return lines.map((l) => splitFixedWidth(l, breaks));
|
||||
}
|
||||
return lines.map((l) => splitDelimited(l, kinds, custom));
|
||||
}
|
||||
|
||||
function refreshPreview(): void {
|
||||
previewBox.textContent = splitAll()
|
||||
.slice(0, 8)
|
||||
.map((cols) => cols.join(" | "))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
modeSelect.addEventListener("change", () => {
|
||||
mode = modeSelect.value as "구분" | "고정";
|
||||
delimBox.hidden = mode !== "구분";
|
||||
breaksField.root.hidden = mode !== "고정";
|
||||
refreshPreview();
|
||||
});
|
||||
|
||||
refreshPreview();
|
||||
|
||||
modal(
|
||||
TEXT.textToColumns,
|
||||
[
|
||||
row(TEXT.byDelimiter + "/" + TEXT.byFixedWidth, modeSelect),
|
||||
delimBox,
|
||||
breaksField.root,
|
||||
el("div", { text: TEXT.preview }),
|
||||
previewBox,
|
||||
],
|
||||
() => applyTextToColumns(ctx, { r: range.r0, c: range.c0 }, splitAll()),
|
||||
);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// 2. 중복된 항목 제거
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** `keyCols`(범위 안 0 부터 상대 열)로 값이 같은 뒤쪽 행들의 자리(0 부터 상대 행)를 돌려줌. */
|
||||
export function findDuplicateRowIndexes(rows: string[][], keyCols: number[]): number[] {
|
||||
const seen = new Set<string>();
|
||||
const dup: number[] = [];
|
||||
rows.forEach((r, i) => {
|
||||
const key = keyCols.map((c) => r[c] ?? "").join("\u0001");
|
||||
if (seen.has(key)) dup.push(i);
|
||||
else seen.add(key);
|
||||
});
|
||||
return dup;
|
||||
}
|
||||
|
||||
/** 범위 안 값을 읽어 중복 행을 지우고 위로 당김(범위 밖 칸은 안 건드림) — 지운 행 수를 돌려줌. */
|
||||
export function removeDuplicateRows(
|
||||
ctx: SpreadsheetContext,
|
||||
range: CellRange,
|
||||
keyCols: number[],
|
||||
skipHeader: boolean,
|
||||
): number {
|
||||
const width = range.c1 - range.c0 + 1;
|
||||
const bodyR0 = range.r0 + (skipHeader ? 1 : 0);
|
||||
const rows: string[][] = [];
|
||||
for (let r = bodyR0; r <= range.r1; r++) {
|
||||
const line: string[] = [];
|
||||
for (let c = 0; c < width; c++)
|
||||
line.push(scalarText(ctx.engine.value(ctx.selection.시트, r, range.c0 + c)));
|
||||
rows.push(line);
|
||||
}
|
||||
const dupIdx = new Set(findDuplicateRowIndexes(rows, keyCols));
|
||||
const kept = rows.filter((_, i) => !dupIdx.has(i));
|
||||
|
||||
const cells: Record<string, Cell | null> = {};
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = bodyR0 + i;
|
||||
for (let c = 0; c < width; c++) {
|
||||
const text = i < kept.length ? kept[i][c] : "";
|
||||
cells[toA1(r, range.c0 + c)] = text === "" ? null : { 값: cellInputFrom(text) };
|
||||
}
|
||||
}
|
||||
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
|
||||
return dupIdx.size;
|
||||
}
|
||||
|
||||
export function openRemoveDuplicatesDialog(ctx: SpreadsheetContext): void {
|
||||
if (ctx.readOnly) return;
|
||||
const range = readRange(ctx);
|
||||
if (!range) return;
|
||||
const width = range.c1 - range.c0 + 1;
|
||||
|
||||
let hasHeader = width > 1 || range.r1 > range.r0;
|
||||
const headerCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
|
||||
headerCheck.checked = hasHeader;
|
||||
|
||||
const keyCols = new Set<number>(Array.from({ length: width }, (_, i) => i));
|
||||
const colChecks = Array.from({ length: width }, (_, i) => {
|
||||
const c = range.c0 + i;
|
||||
const label = hasHeader
|
||||
? scalarText(ctx.engine.value(ctx.selection.시트, range.r0, c)) || colName(c)
|
||||
: colName(c);
|
||||
const check = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
|
||||
check.checked = true;
|
||||
check.addEventListener("change", () => {
|
||||
if (check.checked) keyCols.add(i);
|
||||
else keyCols.delete(i);
|
||||
});
|
||||
return row(label, check);
|
||||
});
|
||||
|
||||
headerCheck.addEventListener("change", () => {
|
||||
hasHeader = headerCheck.checked;
|
||||
});
|
||||
|
||||
modal(
|
||||
TEXT.removeDuplicates,
|
||||
[row(TEXT.hasHeader, headerCheck), el("div", { text: TEXT.keyColumns }), ...colChecks],
|
||||
() => {
|
||||
const removed = removeDuplicateRows(ctx, range, [...keyCols], hasHeader);
|
||||
showToast(TEXT.removed(removed), "success");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// 3. 채우기 연속 데이터
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export type SeriesDirection = "행" | "열";
|
||||
|
||||
/** 시작값 · 단계 · 끝값(넘으면 멈춤, 없으면 count 까지) · 개수로 등차수열을 지음. */
|
||||
export function buildLinearSeries(
|
||||
start: number,
|
||||
step: number,
|
||||
stop: number | null,
|
||||
count: number,
|
||||
): number[] {
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const v = start + step * i;
|
||||
if (stop != null && ((step >= 0 && v > stop) || (step < 0 && v < stop))) break;
|
||||
out.push(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function applyFillSeries(
|
||||
ctx: SpreadsheetContext,
|
||||
anchor: CellAddress,
|
||||
direction: SeriesDirection,
|
||||
values: number[],
|
||||
): void {
|
||||
const cells: Record<string, Cell | null> = {};
|
||||
values.forEach((v, i) => {
|
||||
const r = direction === "열" ? anchor.r + i : anchor.r;
|
||||
const c = direction === "행" ? anchor.c + i : anchor.c;
|
||||
cells[toA1(r, c)] = { 값: v };
|
||||
});
|
||||
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
|
||||
}
|
||||
|
||||
export function openFillSeriesDialog(ctx: SpreadsheetContext): void {
|
||||
if (ctx.readOnly) return;
|
||||
const range = readRange(ctx);
|
||||
if (!range) return;
|
||||
const at = ctx.selection.활성;
|
||||
const startText = scalarText(ctx.engine.value(ctx.selection.시트, at.r, at.c));
|
||||
const start = Number(startText) || 0;
|
||||
|
||||
let direction: SeriesDirection = range.r1 > range.r0 ? "열" : "행";
|
||||
let step = 1;
|
||||
let stop: number | null = null;
|
||||
|
||||
const dirSelect = el("select", {
|
||||
children: [
|
||||
el("option", { attrs: { value: "열" }, text: TEXT.down }),
|
||||
el("option", { attrs: { value: "행" }, text: TEXT.right }),
|
||||
],
|
||||
}) as HTMLSelectElement;
|
||||
dirSelect.value = direction;
|
||||
dirSelect.addEventListener("change", () => (direction = dirSelect.value as SeriesDirection));
|
||||
|
||||
const stepField = createInputField({
|
||||
label: TEXT.step,
|
||||
type: "number",
|
||||
value: "1",
|
||||
onInput: (v) => (step = Number(v) || 0),
|
||||
});
|
||||
const stopField = createInputField({
|
||||
label: TEXT.stop,
|
||||
type: "number",
|
||||
onInput: (v) => (stop = v === "" ? null : Number(v)),
|
||||
});
|
||||
|
||||
modal(TEXT.fillSeries, [row(TEXT.direction, dirSelect), stepField.root, stopField.root], () => {
|
||||
const extent = direction === "열" ? range.r1 - range.r0 + 1 : range.c1 - range.c0 + 1;
|
||||
const values = buildLinearSeries(start, step, stop, extent);
|
||||
applyFillSeries(ctx, at, direction, values);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/* =============================================================================
|
||||
* test_stage2_data_tools.ts — spreadsheet_data_tools 시험(텍스트 나누기 · 중복 제거 · 연속 채우기).
|
||||
* 돌리기: npx tsx <이 파일> (`@ui/*` 별칭 때문에 plain node 안 됨)
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
applyFillSeries,
|
||||
applyTextToColumns,
|
||||
buildLinearSeries,
|
||||
findDuplicateRowIndexes,
|
||||
removeDuplicateRows,
|
||||
splitDelimited,
|
||||
splitFixedWidth,
|
||||
} from "../../../A00_Common/spreadsheet/spreadsheet_data_tools.ts";
|
||||
import { a1, assertEqual, makeBook, makeCtx, makeSheet, v } from "./test_stage2_helpers.ts";
|
||||
|
||||
// ── 텍스트 나누기 ──────────────────────────────────────────────
|
||||
assertEqual(splitDelimited("가,나,다", ["쉼표"], ""), ["가", "나", "다"], "쉼표 나누기");
|
||||
assertEqual(splitDelimited("가\t나", ["탭"], ""), ["가", "나"], "탭 나누기");
|
||||
assertEqual(
|
||||
splitDelimited("가;나,다", ["세미콜론", "쉼표"], ""),
|
||||
["가", "나", "다"],
|
||||
"복수 구분 기호",
|
||||
);
|
||||
assertEqual(splitDelimited("가|나", ["사용자"], "|"), ["가", "나"], "사용자 지정 구분 기호");
|
||||
assertEqual(splitDelimited("가나다", [], ""), ["가나다"], "구분 기호 없으면 한 칸 그대로");
|
||||
|
||||
assertEqual(splitFixedWidth("2026-09-27", [4, 7]), ["2026", "-09", "-27"], "고정 폭 나누기");
|
||||
assertEqual(splitFixedWidth("ABC", [10]), ["ABC"], "자리가 글보다 길면 한 칸");
|
||||
|
||||
{
|
||||
const book = makeBook([makeSheet("s1", {})]);
|
||||
const ctx = makeCtx(book, "s1");
|
||||
applyTextToColumns(ctx, { r: 0, c: 0 }, [
|
||||
["가", "나"],
|
||||
["1", "2"],
|
||||
]);
|
||||
assertEqual(book.시트[0].칸[a1(0, 0)], { 값: "가" }, "나누기 결과 A1");
|
||||
assertEqual(book.시트[0].칸[a1(0, 1)], { 값: "나" }, "나누기 결과 B1");
|
||||
assertEqual(book.시트[0].칸[a1(1, 0)], { 값: 1 }, "나누기 결과 숫자는 수로");
|
||||
assertEqual(book.시트[0].칸[a1(1, 1)], { 값: 2 }, "나누기 결과 B2");
|
||||
}
|
||||
|
||||
// ── 중복된 항목 제거 ──────────────────────────────────────────
|
||||
assertEqual(
|
||||
findDuplicateRowIndexes(
|
||||
[
|
||||
["가", "1"],
|
||||
["나", "2"],
|
||||
["가", "1"],
|
||||
["다", "3"],
|
||||
["나", "2"],
|
||||
],
|
||||
[0, 1],
|
||||
),
|
||||
[2, 4],
|
||||
"중복 자리 찾기(모든 열 비교)",
|
||||
);
|
||||
assertEqual(
|
||||
findDuplicateRowIndexes(
|
||||
[
|
||||
["가", "1"],
|
||||
["가", "9"],
|
||||
],
|
||||
[0],
|
||||
),
|
||||
[1],
|
||||
"일부 열만 비교",
|
||||
);
|
||||
|
||||
{
|
||||
const book = makeBook([
|
||||
makeSheet("s1", {
|
||||
[a1(0, 0)]: v("가"),
|
||||
[a1(1, 0)]: v("나"),
|
||||
[a1(2, 0)]: v("가"),
|
||||
[a1(3, 0)]: v("다"),
|
||||
}),
|
||||
]);
|
||||
const ctx = makeCtx(book, "s1", {
|
||||
s1: { A1: "가", A2: "나", A3: "가", A4: "다" },
|
||||
});
|
||||
const removed = removeDuplicateRows(ctx, { r0: 0, c0: 0, r1: 3, c1: 0 }, [0], false);
|
||||
assertEqual(removed, 1, "지운 중복 행 수");
|
||||
assertEqual(book.시트[0].칸[a1(0, 0)], { 값: "가" }, "중복 제거 뒤 A1 그대로");
|
||||
assertEqual(book.시트[0].칸[a1(1, 0)], { 값: "나" }, "중복 제거 뒤 A2 그대로");
|
||||
assertEqual(book.시트[0].칸[a1(2, 0)], { 값: "다" }, "중복 제거 뒤 위로 당김");
|
||||
assertEqual(book.시트[0].칸[a1(3, 0)], undefined, "중복 제거 뒤 마지막 칸 비움");
|
||||
}
|
||||
|
||||
// ── 채우기 연속 데이터 ──────────────────────────────────────────
|
||||
assertEqual(buildLinearSeries(1, 1, null, 5), [1, 2, 3, 4, 5], "단계 1 · 5개");
|
||||
assertEqual(buildLinearSeries(0, 2, 6, 100), [0, 2, 4, 6], "끝값에서 멈춤");
|
||||
assertEqual(buildLinearSeries(10, -2, 4, 100), [10, 8, 6, 4], "내림차순 끝값에서 멈춤");
|
||||
|
||||
{
|
||||
const book = makeBook([makeSheet("s1", {})]);
|
||||
const ctx = makeCtx(book, "s1");
|
||||
applyFillSeries(ctx, { r: 0, c: 0 }, "열", [1, 2, 3]);
|
||||
assertEqual(book.시트[0].칸[a1(0, 0)], { 값: 1 }, "세로 채우기 A1");
|
||||
assertEqual(book.시트[0].칸[a1(1, 0)], { 값: 2 }, "세로 채우기 A2");
|
||||
assertEqual(book.시트[0].칸[a1(2, 0)], { 값: 3 }, "세로 채우기 A3");
|
||||
}
|
||||
{
|
||||
const book = makeBook([makeSheet("s1", {})]);
|
||||
const ctx = makeCtx(book, "s1");
|
||||
applyFillSeries(ctx, { r: 0, c: 0 }, "행", [1, 2]);
|
||||
assertEqual(book.시트[0].칸[a1(0, 0)], { 값: 1 }, "가로 채우기 A1");
|
||||
assertEqual(book.시트[0].칸[a1(0, 1)], { 값: 2 }, "가로 채우기 B1");
|
||||
}
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
console.log("spreadsheet_data_tools 시험 끝");
|
||||
Reference in New Issue
Block a user