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] = "

Start

"; 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( "

legacy
text

", ); const unsafe = updateExtraSheetCell(updated, 0, 0, "

safe

"); expect(normalizeExtraSheetRichText(unsafe).rows[0].cells[columnId]).toBe( "

safe

", ); }); 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: "

Shared

", backgroundColor: "#16A34A" }); const matrix = extraSheetToMatrix(updated); expect(matrix[0][0]).toMatchObject({ value: "

Shared

", backgroundColor: "#16A34A" }); expect(matrix[1][1]).toMatchObject({ value: "

Shared

", 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] = "

Alpha

"; sheet.rows[1].cells[secondColumn] = "

Beta

"; const styled = styleExtraSheetCells(sheet, [ { row: 0, column: 0 }, { row: 1, column: 1 }, ], { bold: true }); expect(styled.rows[0].cells[firstColumn]).toBe("

Alpha

"); expect(styled.rows[1].cells[secondColumn]).toBe("

Beta

"); 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] = '

Alpha

'; 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("

Alpha

"); 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] = "

Alpha
Beta

"; 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("

Alpha
Beta

"); expect(pasted.rows[2].cells[pasted.columns[8].id]).toBe("

Gamma

"); }); }); 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] = "

First

"; 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("

First

Last

"); 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("

First

"); 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: "

Text

", 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("

Text

"); const cleared = clearExtraSheetCellFormatting(sheet, [rectangle.end]); expect(cleared.rows[0].cells[column]).toBe("

Text

"); 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, "

Text

"); 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("

New

"); 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("

B

"); }); }); 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(); });