390 lines
18 KiB
TypeScript
390 lines
18 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
addExtraSheetColumn,
|
|
addExtraSheetRow,
|
|
clearExtraSheetCellFormatting,
|
|
canMergeExtraSheetRange,
|
|
expandExtraSheetRange,
|
|
extraSheetAnchor,
|
|
extraSheetMergeMap,
|
|
insertExtraSheetColumn,
|
|
insertExtraSheetRow,
|
|
mergeExtraSheetRange,
|
|
unmergeExtraSheetRange,
|
|
createExtraSheet,
|
|
deleteExtraSheetColumn,
|
|
deleteExtraSheetRow,
|
|
extraSheetSchema,
|
|
extraSheetRangeToTsv,
|
|
extraSheetToMatrix,
|
|
extraSheetUsedRange,
|
|
matrixToExtraSheet,
|
|
moveExtraSheetColumn,
|
|
moveExtraSheetRow,
|
|
nextExtraSheetPoint,
|
|
normalizeExtraSheetRichText,
|
|
parseExtraSheetTsv,
|
|
pasteExtraSheetText,
|
|
resizeExtraSheetColumn,
|
|
styleExtraSheetCells,
|
|
updateExtraSheetCell,
|
|
updateExtraSheetCells,
|
|
type ExtraSheet,
|
|
} from "@/lib/guides/extra-sheet";
|
|
|
|
function idFactory() {
|
|
let index = 0;
|
|
return (prefix: string) => `${prefix}-${++index}`;
|
|
}
|
|
|
|
describe("Extra sheet", () => {
|
|
it("creates a valid four-by-six sheet", () => {
|
|
const sheet = createExtraSheet(idFactory());
|
|
expect(extraSheetSchema.safeParse(sheet).success).toBe(true);
|
|
expect(sheet.columns.map((column) => column.label)).toEqual(
|
|
Array.from({ length: 6 }, (_, index) => `คอลัมน์ ${index + 1}`),
|
|
);
|
|
expect(extraSheetToMatrix(sheet)).toEqual(
|
|
Array.from({ length: 4 }, () =>
|
|
Array.from({ length: 6 }, () => ({ value: "", backgroundColor: undefined })),
|
|
),
|
|
);
|
|
});
|
|
|
|
it("preserves cell identity while rows and columns move", () => {
|
|
const createId = idFactory();
|
|
const sheet = createExtraSheet(createId);
|
|
const firstColumnId = sheet.columns[0].id;
|
|
const secondColumnId = sheet.columns[1].id;
|
|
sheet.rows[0].cells[firstColumnId] = "A";
|
|
sheet.rows[0].cells[secondColumnId] = "B";
|
|
sheet.rows[1].cells[firstColumnId] = "C";
|
|
|
|
const moved = moveExtraSheetRow(moveExtraSheetColumn(sheet, 0, 1), 0, 1);
|
|
const matrix = extraSheetToMatrix(moved);
|
|
expect(matrix).toHaveLength(4);
|
|
expect(matrix[0]).toHaveLength(6);
|
|
expect(matrix[0].slice(0, 2).map((cell) => cell?.value)).toEqual(["", "C"]);
|
|
expect(matrix[1].slice(0, 2).map((cell) => cell?.value)).toEqual(["B", "A"]);
|
|
});
|
|
|
|
it("adds and removes rows and columns without orphaned cells", () => {
|
|
const createId = idFactory();
|
|
const initial = createExtraSheet(createId);
|
|
const columnId = createId("column");
|
|
const expanded = addExtraSheetRow(
|
|
addExtraSheetColumn(initial, "Notes", columnId),
|
|
createId("row"),
|
|
);
|
|
expect(expanded.rows).toHaveLength(5);
|
|
expect(expanded.rows.every((row) => row.cells[columnId] === "")).toBe(true);
|
|
|
|
const reduced = deleteExtraSheetColumn(expanded, expanded.columns.length - 1);
|
|
expect(reduced.rows.every((row) => !(columnId in row.cells))).toBe(true);
|
|
const secondRowId = reduced.rows[1].id;
|
|
const withoutFirstRow = deleteExtraSheetRow(reduced, 0);
|
|
expect(withoutFirstRow.rows).toHaveLength(4);
|
|
expect(withoutFirstRow.rows[0].id).toBe(secondRowId);
|
|
});
|
|
|
|
it("expands rows and columns when a pasted matrix is larger", () => {
|
|
const createId = idFactory();
|
|
const initial = createExtraSheet(createId);
|
|
const expanded = matrixToExtraSheet(initial, [
|
|
[],
|
|
[],
|
|
[],
|
|
[],
|
|
Array.from({ length: 9 }, (_, index) => ({ value: index === 8 ? "=1+1" : "" })),
|
|
], createId);
|
|
|
|
expect(expanded.columns).toHaveLength(9);
|
|
expect(expanded.rows).toHaveLength(5);
|
|
expect(extraSheetToMatrix(expanded)[4][8]?.value).toBe("=1+1");
|
|
expect(extraSheetSchema.safeParse(expanded).success).toBe(true);
|
|
});
|
|
|
|
it("rejects duplicate labels and cells for missing columns", () => {
|
|
const sheet = createExtraSheet(idFactory());
|
|
sheet.columns[1].label = sheet.columns[0].label.toUpperCase();
|
|
sheet.rows[0].cells.missing = "orphan";
|
|
const result = extraSheetSchema.safeParse(sheet);
|
|
expect(result.success).toBe(false);
|
|
if (!result.success) {
|
|
expect(result.error.issues.map((issue) => issue.message)).toContain("ชื่อคอลัมน์ต้องไม่ซ้ำกัน");
|
|
expect(result.error.issues.map((issue) => issue.message)).toContain("ไม่พบคอลัมน์ของเซลล์นี้");
|
|
}
|
|
});
|
|
|
|
it("returns only the used display range", () => {
|
|
const sheet = createExtraSheet(idFactory());
|
|
expect(extraSheetUsedRange(sheet)).toBeNull();
|
|
|
|
sheet.rows[1].cells[sheet.columns[2].id] = "<p>Start</p>";
|
|
sheet.rows[3].backgrounds[sheet.columns[4].id] = "#123456";
|
|
const used = extraSheetUsedRange(sheet);
|
|
|
|
expect(used?.columns.map((column) => column.label)).toEqual([
|
|
"คอลัมน์ 3",
|
|
"คอลัมน์ 4",
|
|
"คอลัมน์ 5",
|
|
]);
|
|
expect(used?.rows).toHaveLength(3);
|
|
expect(used && extraSheetSchema.safeParse(used).success).toBe(true);
|
|
});
|
|
|
|
it("updates and sanitizes rich-text cells while preserving legacy text", () => {
|
|
const initial = createExtraSheet(idFactory());
|
|
const updated = updateExtraSheetCell(initial, 0, 0, "legacy\ntext");
|
|
const columnId = updated.columns[0].id;
|
|
expect(normalizeExtraSheetRichText(updated).rows[0].cells[columnId]).toBe(
|
|
"<p>legacy<br>text</p>",
|
|
);
|
|
const unsafe = updateExtraSheetCell(updated, 0, 0, "<p><strong>safe</strong><script>bad()</script></p>");
|
|
expect(normalizeExtraSheetRichText(unsafe).rows[0].cells[columnId]).toBe(
|
|
"<p><strong>safe</strong></p>",
|
|
);
|
|
});
|
|
|
|
it("updates rich text and backgrounds across a cell selection", () => {
|
|
const sheet = createExtraSheet(idFactory());
|
|
const updated = updateExtraSheetCells(sheet, [
|
|
{ row: 0, column: 0 },
|
|
{ row: 1, column: 1 },
|
|
], { value: "<p>Shared</p>", backgroundColor: "#16A34A" });
|
|
|
|
const matrix = extraSheetToMatrix(updated);
|
|
expect(matrix[0][0]).toMatchObject({ value: "<p>Shared</p>", backgroundColor: "#16A34A" });
|
|
expect(matrix[1][1]).toMatchObject({ value: "<p>Shared</p>", backgroundColor: "#16A34A" });
|
|
expect(matrix[0][1]).toMatchObject({ value: "", backgroundColor: undefined });
|
|
expect(matrix[1][0]).toMatchObject({ value: "", backgroundColor: undefined });
|
|
expect(extraSheetSchema.safeParse(updated).success).toBe(true);
|
|
expect(updateExtraSheetCells(updated, [{ row: 0, column: 0 }], { backgroundColor: null })
|
|
.rows[0].backgrounds).toEqual({});
|
|
});
|
|
|
|
it("styles selected cells without replacing their individual text", () => {
|
|
const sheet = createExtraSheet(idFactory());
|
|
const firstColumn = sheet.columns[0].id;
|
|
const secondColumn = sheet.columns[1].id;
|
|
sheet.rows[0].cells[firstColumn] = "<p>Alpha</p>";
|
|
sheet.rows[1].cells[secondColumn] = "<p><em>Beta</em></p>";
|
|
|
|
const styled = styleExtraSheetCells(sheet, [
|
|
{ row: 0, column: 0 },
|
|
{ row: 1, column: 1 },
|
|
], { bold: true });
|
|
|
|
expect(styled.rows[0].cells[firstColumn]).toBe("<p><strong>Alpha</strong></p>");
|
|
expect(styled.rows[1].cells[secondColumn]).toBe("<p><em><strong>Beta</strong></em></p>");
|
|
expect(styled.rows[0].cells[secondColumn]).toBe("");
|
|
});
|
|
|
|
it("renders legacy sheets that do not have background metadata yet", () => {
|
|
const sheet = createExtraSheet(idFactory());
|
|
const legacy = {
|
|
...sheet,
|
|
rows: sheet.rows.map((row) => ({ id: row.id, cells: row.cells })),
|
|
} as ExtraSheet;
|
|
|
|
expect(extraSheetToMatrix(legacy)[0][0]).toEqual({
|
|
value: "",
|
|
backgroundColor: undefined,
|
|
alignment: undefined,
|
|
verticalAlignment: undefined,
|
|
});
|
|
|
|
const parsed = extraSheetSchema.parse(legacy);
|
|
expect(parsed.columns[0].width).toBe(140);
|
|
expect(parsed.rows[0].alignments).toEqual({});
|
|
});
|
|
|
|
it("persists bounded column widths and cell alignment", () => {
|
|
const sheet = createExtraSheet(idFactory());
|
|
const resized = resizeExtraSheetColumn(sheet, 0, 999);
|
|
const aligned = updateExtraSheetCells(resized, [{ row: 0, column: 0 }], {
|
|
alignment: "center",
|
|
backgroundColor: "#123456",
|
|
});
|
|
|
|
expect(aligned.columns[0].width).toBe(480);
|
|
expect(extraSheetToMatrix(aligned)[0][0]).toMatchObject({
|
|
alignment: "center",
|
|
backgroundColor: "#123456",
|
|
});
|
|
expect(extraSheetSchema.safeParse(aligned).success).toBe(true);
|
|
});
|
|
|
|
it("clears visual formatting without clearing cell content", () => {
|
|
const sheet = createExtraSheet(idFactory());
|
|
const columnId = sheet.columns[0].id;
|
|
sheet.rows[0].cells[columnId] = '<p><strong><span style="color: #FF0000">Alpha</span></strong></p>';
|
|
sheet.rows[0].backgrounds[columnId] = "#123456";
|
|
sheet.rows[0].alignments[columnId] = "right";
|
|
|
|
const cleared = clearExtraSheetCellFormatting(sheet, [{ row: 0, column: 0 }]);
|
|
expect(cleared.rows[0].cells[columnId]).toBe("<p>Alpha</p>");
|
|
expect(cleared.rows[0].backgrounds).toEqual({});
|
|
expect(cleared.rows[0].alignments).toEqual({});
|
|
});
|
|
|
|
it("copies plain TSV and pastes multiline cells while expanding the grid", () => {
|
|
const createId = idFactory();
|
|
const sheet = createExtraSheet(createId);
|
|
const firstColumn = sheet.columns[0].id;
|
|
sheet.rows[0].cells[firstColumn] = "<p>Alpha<br>Beta</p>";
|
|
const copied = extraSheetRangeToTsv(sheet, { row: 0, column: 0 }, { row: 0, column: 0 });
|
|
|
|
expect(copied).toBe('"Alpha\nBeta"');
|
|
expect(parseExtraSheetTsv('"Alpha\nBeta"\tGamma')).toEqual([["Alpha\nBeta", "Gamma"]]);
|
|
|
|
const pasted = pasteExtraSheetText(sheet, { row: 2, column: 7 }, '"Alpha\nBeta"\tGamma', createId);
|
|
expect(pasted.rows).toHaveLength(4);
|
|
expect(pasted.columns).toHaveLength(9);
|
|
expect(pasted.rows[2].cells[pasted.columns[7].id]).toBe("<p>Alpha<br>Beta</p>");
|
|
expect(pasted.rows[2].cells[pasted.columns[8].id]).toBe("<p>Gamma</p>");
|
|
});
|
|
});
|
|
|
|
|
|
describe("Extra sheet merged cells", () => {
|
|
const rectangle = { start: { row: 0, column: 0 }, end: { row: 1, column: 1 } };
|
|
|
|
it.each([
|
|
{ start: { row: 0, column: 0 }, end: { row: 0, column: 2 } },
|
|
{ start: { row: 0, column: 0 }, end: { row: 2, column: 0 } },
|
|
rectangle,
|
|
])("merges rectangular ranges while preserving rich text in reading order", (range) => {
|
|
const sheet = createExtraSheet(idFactory());
|
|
const column = sheet.columns[0].id;
|
|
sheet.rows[0].cells[column] = "<p><strong>First</strong></p>";
|
|
sheet.rows[range.end.row].cells[sheet.columns[range.end.column].id] = "Last";
|
|
sheet.rows[0].backgrounds[column] = "#123456";
|
|
const merged = mergeExtraSheetRange(sheet, range);
|
|
expect(merged.rows[0].cells[column]).toBe("<p><strong>First</strong></p><p>Last</p>");
|
|
expect(merged.rows[range.end.row].cells[sheet.columns[range.end.column].id]).toBe("");
|
|
expect(merged.rows[0].backgrounds[column]).toBe("#123456");
|
|
expect(extraSheetAnchor(merged, range.end)).toEqual(range.start);
|
|
expect(extraSheetSchema.safeParse(merged).success).toBe(true);
|
|
expect(sheet.rows[0].cells[column]).toBe("<p><strong>First</strong></p>");
|
|
const unmerged = unmergeExtraSheetRange(merged, { start: range.end, end: range.end });
|
|
expect(unmerged.merges).toEqual([]);
|
|
expect(unmerged.rows[0].cells[column]).toBe(merged.rows[0].cells[column]);
|
|
});
|
|
|
|
it("rejects invalid ranges and partial overlaps, and combines contained merges", () => {
|
|
const sheet = createExtraSheet(idFactory());
|
|
const merged = mergeExtraSheetRange(sheet, rectangle);
|
|
expect(canMergeExtraSheetRange(merged, rectangle)).toBe(false);
|
|
expect(canMergeExtraSheetRange(merged, { start: { row: 1, column: 1 }, end: { row: 2, column: 2 } })).toBe(false);
|
|
expect(canMergeExtraSheetRange(sheet, { start: { row: 0, column: 0 }, end: { row: 99, column: 99 } })).toBe(false);
|
|
const larger = { start: { row: 0, column: 0 }, end: { row: 2, column: 2 } };
|
|
expect(mergeExtraSheetRange(merged, larger).merges).toHaveLength(1);
|
|
for (const merges of [
|
|
[...merged.merges!, ...merged.merges!],
|
|
[{ rowIds: [sheet.rows[0].id], columnIds: [sheet.columns[0].id] }],
|
|
[{ rowIds: [sheet.rows[0].id, sheet.rows[2].id], columnIds: [sheet.columns[0].id] }],
|
|
[{ rowIds: ["missing"], columnIds: [sheet.columns[0].id, sheet.columns[1].id] }],
|
|
[{ rowIds: [sheet.rows[0].id, sheet.rows[0].id], columnIds: [sheet.columns[0].id] }],
|
|
]) expect(extraSheetSchema.safeParse({ ...sheet, merges }).success).toBe(false);
|
|
});
|
|
|
|
it("rejects oversized combined content without changing cells", () => {
|
|
const sheet = createExtraSheet(idFactory());
|
|
sheet.rows[0].cells[sheet.columns[0].id] = "A".repeat(6000);
|
|
sheet.rows[0].cells[sheet.columns[1].id] = "B".repeat(6000);
|
|
const before = JSON.stringify(sheet);
|
|
expect(() => mergeExtraSheetRange(sheet, rectangle)).toThrow("10,000");
|
|
expect(JSON.stringify(sheet)).toBe(before);
|
|
});
|
|
|
|
it("expands selections transitively and includes empty merge extents in display cropping", () => {
|
|
let sheet = createExtraSheet(idFactory());
|
|
sheet = mergeExtraSheetRange(sheet, { start: { row: 1, column: 1 }, end: { row: 2, column: 2 } });
|
|
sheet = mergeExtraSheetRange(sheet, { start: { row: 0, column: 3 }, end: { row: 1, column: 4 } });
|
|
expect(expandExtraSheetRange(sheet, { row: 2, column: 2 }, { row: 2, column: 3 })).toEqual({
|
|
start: { row: 0, column: 1 }, end: { row: 2, column: 4 },
|
|
});
|
|
const used = extraSheetUsedRange(sheet)!;
|
|
expect(used.columns).toHaveLength(4);
|
|
expect(used.rows).toHaveLength(3);
|
|
expect(extraSheetSchema.safeParse(used).success).toBe(true);
|
|
expect(extraSheetMergeMap(used).get("2:1")?.start).toEqual({ row: 1, column: 0 });
|
|
});
|
|
|
|
it("preserves merge metadata through normalization and matrix conversion", () => {
|
|
const merged = mergeExtraSheetRange(createExtraSheet(idFactory()), rectangle);
|
|
expect(normalizeExtraSheetRichText(merged).merges).toEqual(merged.merges);
|
|
expect(matrixToExtraSheet(merged, extraSheetToMatrix(merged), idFactory()).merges).toEqual(merged.merges);
|
|
expect(extraSheetSchema.parse(JSON.parse(JSON.stringify(merged))).merges).toEqual(merged.merges);
|
|
expect(extraSheetSchema.parse(createExtraSheet(idFactory())).merges).toBeUndefined();
|
|
});
|
|
|
|
it("unmerges only affected regions on structural edits", () => {
|
|
const merged = mergeExtraSheetRange(createExtraSheet(idFactory()), rectangle);
|
|
const operations = [
|
|
insertExtraSheetRow(merged, 1, "inserted-row"),
|
|
insertExtraSheetColumn(merged, 1, "Inserted", "inserted-column"),
|
|
deleteExtraSheetRow(merged, 1), deleteExtraSheetColumn(merged, 1),
|
|
moveExtraSheetRow(merged, 0, 3), moveExtraSheetColumn(merged, 0, 4),
|
|
moveExtraSheetRow(merged, 3, 0), moveExtraSheetColumn(merged, 5, 0),
|
|
];
|
|
for (const sheet of operations) {
|
|
expect(sheet.merges).toEqual([]);
|
|
expect(extraSheetSchema.safeParse(sheet).success).toBe(true);
|
|
}
|
|
for (const sheet of [
|
|
insertExtraSheetRow(merged, 0, "before-row"), insertExtraSheetRow(merged, 2, "after-row"),
|
|
insertExtraSheetColumn(merged, 0, "Before", "before-column"),
|
|
insertExtraSheetColumn(merged, 2, "After", "after-column"),
|
|
moveExtraSheetRow(merged, 2, 3), moveExtraSheetColumn(merged, 3, 5),
|
|
deleteExtraSheetRow(merged, 3), deleteExtraSheetColumn(merged, 5),
|
|
]) {
|
|
expect(sheet.merges).toEqual(merged.merges);
|
|
expect(extraSheetSchema.safeParse(sheet).success).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("formats and edits merged anchors once, including vertical alignment", () => {
|
|
let sheet = mergeExtraSheetRange(createExtraSheet(idFactory()), rectangle);
|
|
const column = sheet.columns[0].id;
|
|
sheet = updateExtraSheetCells(sheet, [rectangle.start, rectangle.end], {
|
|
value: "<p>Text</p>", verticalAlignment: "middle", alignment: "center", backgroundColor: "#123456",
|
|
});
|
|
expect(sheet.rows[1].cells[sheet.columns[1].id]).toBe("");
|
|
expect(sheet.rows[0].verticalAlignments[column]).toBe("middle");
|
|
expect(extraSheetToMatrix(sheet)[0][0]?.verticalAlignment).toBe("middle");
|
|
expect(matrixToExtraSheet(sheet, extraSheetToMatrix(sheet), idFactory()).rows[0].verticalAlignments[column]).toBe("middle");
|
|
sheet = styleExtraSheetCells(sheet, [rectangle.start, rectangle.end], { bold: true });
|
|
expect(sheet.rows[0].cells[column]).toBe("<p><strong>Text</strong></p>");
|
|
const cleared = clearExtraSheetCellFormatting(sheet, [rectangle.end]);
|
|
expect(cleared.rows[0].cells[column]).toBe("<p>Text</p>");
|
|
expect(cleared.rows[0].verticalAlignments).toEqual({});
|
|
expect(cleared.merges).toEqual(sheet.merges);
|
|
});
|
|
|
|
it("copies anchor text and blanks, edits single-cell paste, and unmerges multi-cell paste", () => {
|
|
const createId = idFactory();
|
|
let sheet = mergeExtraSheetRange(createExtraSheet(createId), rectangle);
|
|
sheet = updateExtraSheetCell(sheet, 1, 1, "<p>Text</p>");
|
|
expect(extraSheetRangeToTsv(sheet, rectangle.end, rectangle.end)).toBe("Text\t\n\t");
|
|
const single = pasteExtraSheetText(sheet, rectangle.end, "New", createId);
|
|
expect(single.rows[0].cells[single.columns[0].id]).toBe("<p>New</p>");
|
|
expect(single.merges).toEqual(sheet.merges);
|
|
const multi = pasteExtraSheetText(sheet, rectangle.start, "A\tB", createId);
|
|
expect(multi.merges).toEqual([]);
|
|
expect(multi.rows[0].cells[multi.columns[1].id]).toBe("<p>B</p>");
|
|
});
|
|
});
|
|
|
|
|
|
it("skips merge coverage during directional navigation and Tab wrapping", () => {
|
|
let sheet = createExtraSheet(idFactory());
|
|
sheet = mergeExtraSheetRange(sheet, { start: { row: 0, column: 0 }, end: { row: 1, column: 5 } });
|
|
expect(nextExtraSheetPoint(sheet, { row: 0, column: 0 }, { row: 0, column: 1 }, true)).toEqual({ row: 2, column: 0 });
|
|
expect(nextExtraSheetPoint(sheet, { row: 0, column: 0 }, { row: 1, column: 0 })).toEqual({ row: 2, column: 0 });
|
|
expect(nextExtraSheetPoint(sheet, { row: 2, column: 3 }, { row: -1, column: 0 })).toEqual({ row: 0, column: 0 });
|
|
expect(nextExtraSheetPoint(sheet, { row: 0, column: 0 }, { row: 0, column: -1 }, true)).toBeUndefined();
|
|
});
|