1052 lines
36 KiB
TypeScript
1052 lines
36 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { utils, read, write } from "xlsx";
|
|
import {
|
|
RangeSelection,
|
|
PointRange,
|
|
EmptySelection,
|
|
EntireRowsSelection,
|
|
EntireColumnsSelection,
|
|
EntireWorksheetSelection,
|
|
type CellBase,
|
|
type Matrix,
|
|
} from "react-spreadsheet";
|
|
import {
|
|
fileExt,
|
|
isTextSpreadsheet,
|
|
columnLabel,
|
|
cellRef,
|
|
sheetToMatrix,
|
|
matrixToSheet,
|
|
matrixToCsv,
|
|
selectionStats,
|
|
} from "./spreadsheet-utils";
|
|
import { isSpreadsheetFile } from "./file-viewer";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// fileExt — determines file extension, used to choose save strategy
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("fileExt", () => {
|
|
it("extracts lowercase extension from a simple filename", () => {
|
|
expect(fileExt("report.csv")).toBe("csv");
|
|
});
|
|
|
|
it("lowercases mixed-case extensions (prevents case-sensitive misrouting)", () => {
|
|
expect(fileExt("Data.XLSX")).toBe("xlsx");
|
|
expect(fileExt("Sales.Csv")).toBe("csv");
|
|
});
|
|
|
|
it("returns last segment when filename has multiple dots", () => {
|
|
expect(fileExt("archive.2024.01.csv")).toBe("csv");
|
|
expect(fileExt("my.file.name.tsv")).toBe("tsv");
|
|
});
|
|
|
|
it("returns empty string for files with no extension (avoids undefined crash)", () => {
|
|
expect(fileExt("Makefile")).toBe("makefile");
|
|
});
|
|
|
|
it("returns empty string for empty input", () => {
|
|
expect(fileExt("")).toBe("");
|
|
});
|
|
|
|
it("handles dotfiles correctly", () => {
|
|
expect(fileExt(".gitignore")).toBe("gitignore");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// isTextSpreadsheet — decides CSV-text save path vs binary save path
|
|
// This is a critical routing decision: wrong answer = data corruption
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("isTextSpreadsheet", () => {
|
|
it("returns true for .csv (saves as text, not binary)", () => {
|
|
expect(isTextSpreadsheet("data.csv")).toBe(true);
|
|
});
|
|
|
|
it("returns true for .tsv (saves as text with tab separator)", () => {
|
|
expect(isTextSpreadsheet("report.tsv")).toBe(true);
|
|
});
|
|
|
|
it("returns false for .xlsx (must save as binary or data is destroyed)", () => {
|
|
expect(isTextSpreadsheet("workbook.xlsx")).toBe(false);
|
|
});
|
|
|
|
it("returns false for .xls (binary format)", () => {
|
|
expect(isTextSpreadsheet("legacy.xls")).toBe(false);
|
|
});
|
|
|
|
it("returns false for .ods (binary format)", () => {
|
|
expect(isTextSpreadsheet("libre.ods")).toBe(false);
|
|
});
|
|
|
|
it("returns false for .numbers (binary format)", () => {
|
|
expect(isTextSpreadsheet("apple.numbers")).toBe(false);
|
|
});
|
|
|
|
it("is case-insensitive (CSV and csv both route to text save)", () => {
|
|
expect(isTextSpreadsheet("DATA.CSV")).toBe(true);
|
|
expect(isTextSpreadsheet("FILE.TSV")).toBe(true);
|
|
});
|
|
|
|
it("returns false for non-spreadsheet files", () => {
|
|
expect(isTextSpreadsheet("readme.md")).toBe(false);
|
|
expect(isTextSpreadsheet("photo.png")).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// columnLabel — Excel-style column headers
|
|
// Off-by-one or overflow here would misalign every cell reference
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("columnLabel", () => {
|
|
it("maps 0 to A (first column)", () => {
|
|
expect(columnLabel(0)).toBe("A");
|
|
});
|
|
|
|
it("maps single-letter range correctly (0-25 → A-Z)", () => {
|
|
expect(columnLabel(1)).toBe("B");
|
|
expect(columnLabel(12)).toBe("M");
|
|
expect(columnLabel(25)).toBe("Z");
|
|
});
|
|
|
|
it("rolls over to two letters at index 26 (AA)", () => {
|
|
expect(columnLabel(26)).toBe("AA");
|
|
});
|
|
|
|
it("maps 27 to AB (not BA — verifies letter ordering)", () => {
|
|
expect(columnLabel(27)).toBe("AB");
|
|
});
|
|
|
|
it("maps 51 to AZ (end of AA..AZ range)", () => {
|
|
expect(columnLabel(51)).toBe("AZ");
|
|
});
|
|
|
|
it("maps 52 to BA (start of BA..BZ range)", () => {
|
|
expect(columnLabel(52)).toBe("BA");
|
|
});
|
|
|
|
it("maps 701 to ZZ (last two-letter column)", () => {
|
|
expect(columnLabel(701)).toBe("ZZ");
|
|
});
|
|
|
|
it("maps 702 to AAA (three-letter columns)", () => {
|
|
expect(columnLabel(702)).toBe("AAA");
|
|
});
|
|
|
|
it("handles large column indices without crashing", () => {
|
|
const label = columnLabel(16383);
|
|
expect(label).toBe("XFD");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// cellRef — formats cell coordinates for formula bar display
|
|
// Wrong output here means the formula bar shows the wrong cell
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("cellRef", () => {
|
|
it("formats origin cell as A1 (not A0 — 1-indexed rows)", () => {
|
|
expect(cellRef({ row: 0, column: 0 })).toBe("A1");
|
|
});
|
|
|
|
it("formats typical cell reference", () => {
|
|
expect(cellRef({ row: 6, column: 2 })).toBe("C7");
|
|
});
|
|
|
|
it("formats double-letter columns with correct row", () => {
|
|
expect(cellRef({ row: 99, column: 26 })).toBe("AA100");
|
|
});
|
|
|
|
it("handles row 0 column 25 as Z1", () => {
|
|
expect(cellRef({ row: 0, column: 25 })).toBe("Z1");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// sheetToMatrix — converts xlsx WorkSheet to react-spreadsheet format
|
|
// Incorrect conversion = data silently lost or misaligned
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("sheetToMatrix", () => {
|
|
it("converts a simple 2x2 sheet to a matrix with CellBase objects", () => {
|
|
const ws = utils.aoa_to_sheet([
|
|
["Name", "Age"],
|
|
["Alice", 30],
|
|
]);
|
|
const m = sheetToMatrix(ws);
|
|
expect(m).toHaveLength(2);
|
|
expect(m[0]).toHaveLength(2);
|
|
expect(m[0][0]!.value).toBe("Name");
|
|
expect(m[0][1]!.value).toBe("Age");
|
|
expect(m[1][0]!.value).toBe("Alice");
|
|
expect(m[1][1]!.value).toBe(30);
|
|
});
|
|
|
|
it("pads ragged rows to the widest column count (prevents misaligned cells)", () => {
|
|
const ws = utils.aoa_to_sheet([
|
|
["A", "B", "C"],
|
|
["X"],
|
|
]);
|
|
const m = sheetToMatrix(ws);
|
|
expect(m[0]).toHaveLength(3);
|
|
expect(m[1]).toHaveLength(3);
|
|
expect(m[1][1]!.value).toBe("");
|
|
expect(m[1][2]!.value).toBe("");
|
|
});
|
|
|
|
it("returns a 1x1 empty matrix for a completely empty sheet", () => {
|
|
const ws = utils.aoa_to_sheet([]);
|
|
const m = sheetToMatrix(ws);
|
|
expect(m.length).toBeGreaterThanOrEqual(1);
|
|
expect(m[0].length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it("converts null/undefined cell values to empty string (prevents crash on .value access)", () => {
|
|
const ws = utils.aoa_to_sheet([[null, undefined, "ok"]]);
|
|
const m = sheetToMatrix(ws);
|
|
expect(m[0][0]!.value).toBe("");
|
|
expect(m[0][1]!.value).toBe("");
|
|
expect(m[0][2]!.value).toBe("ok");
|
|
});
|
|
|
|
it("preserves numeric values as numbers (not strings)", () => {
|
|
const ws = utils.aoa_to_sheet([[42, 3.14, 0]]);
|
|
const m = sheetToMatrix(ws);
|
|
expect(m[0][0]!.value).toBe(42);
|
|
expect(m[0][1]!.value).toBe(3.14);
|
|
expect(m[0][2]!.value).toBe(0);
|
|
});
|
|
|
|
it("preserves boolean values", () => {
|
|
const ws = utils.aoa_to_sheet([[true, false]]);
|
|
const m = sheetToMatrix(ws);
|
|
expect(m[0][0]!.value).toBe(true);
|
|
expect(m[0][1]!.value).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// matrixToSheet — converts react-spreadsheet data back to xlsx WorkSheet
|
|
// This is the write path: errors here corrupt the saved file
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("matrixToSheet", () => {
|
|
it("converts a simple matrix back to a WorkSheet preserving values", () => {
|
|
const matrix: Matrix<CellBase> = [
|
|
[{ value: "A" }, { value: "B" }],
|
|
[{ value: 1 }, { value: 2 }],
|
|
];
|
|
const ws = matrixToSheet(matrix);
|
|
const rows: unknown[][] = utils.sheet_to_json(ws, { header: 1 });
|
|
expect(rows).toEqual([
|
|
["A", "B"],
|
|
[1, 2],
|
|
]);
|
|
});
|
|
|
|
it("treats undefined cells as empty strings (prevents null writes)", () => {
|
|
const matrix: Matrix<CellBase> = [
|
|
[{ value: "X" }, undefined],
|
|
[undefined, { value: "Y" }],
|
|
];
|
|
const ws = matrixToSheet(matrix);
|
|
const rows: unknown[][] = utils.sheet_to_json(ws, { header: 1, defval: "" });
|
|
expect(rows[0][0]).toBe("X");
|
|
expect(rows[0][1]).toBe("");
|
|
expect(rows[1][0]).toBe("");
|
|
expect(rows[1][1]).toBe("Y");
|
|
});
|
|
|
|
it("handles null/undefined rows without crashing", () => {
|
|
const matrix: Matrix<CellBase> = [
|
|
[{ value: "first" }],
|
|
undefined as unknown as (CellBase | undefined)[],
|
|
[{ value: "third" }],
|
|
];
|
|
const ws = matrixToSheet(matrix);
|
|
const rows: unknown[][] = utils.sheet_to_json(ws, { header: 1, defval: "" });
|
|
expect(rows[0][0]).toBe("first");
|
|
expect(rows[2][0]).toBe("third");
|
|
});
|
|
|
|
it("round-trips through sheetToMatrix and back", () => {
|
|
const original: Matrix<CellBase> = [
|
|
[{ value: "Name" }, { value: "Score" }],
|
|
[{ value: "Bob" }, { value: 95 }],
|
|
[{ value: "Eve" }, { value: 88 }],
|
|
];
|
|
const ws = matrixToSheet(original);
|
|
const roundTripped = sheetToMatrix(ws);
|
|
expect(roundTripped[0][0]!.value).toBe("Name");
|
|
expect(roundTripped[1][0]!.value).toBe("Bob");
|
|
expect(roundTripped[1][1]!.value).toBe(95);
|
|
expect(roundTripped[2][1]!.value).toBe(88);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// matrixToCsv — CSV serialization
|
|
// This is the text save path. Incorrect quoting/escaping = data loss
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("matrixToCsv", () => {
|
|
it("serializes a simple grid with comma separator", () => {
|
|
const data: Matrix<CellBase> = [
|
|
[{ value: "A" }, { value: "B" }],
|
|
[{ value: 1 }, { value: 2 }],
|
|
];
|
|
expect(matrixToCsv(data)).toBe("A,B\n1,2");
|
|
});
|
|
|
|
it("quotes values containing the separator (prevents column shift)", () => {
|
|
const data: Matrix<CellBase> = [[{ value: "hello, world" }, { value: "ok" }]];
|
|
expect(matrixToCsv(data)).toBe('"hello, world",ok');
|
|
});
|
|
|
|
it("escapes double quotes inside values (RFC 4180 compliance)", () => {
|
|
const data: Matrix<CellBase> = [[{ value: 'She said "hi"' }]];
|
|
expect(matrixToCsv(data)).toBe('"She said ""hi"""');
|
|
});
|
|
|
|
it("quotes values containing newlines (prevents row split)", () => {
|
|
const data: Matrix<CellBase> = [[{ value: "line1\nline2" }]];
|
|
expect(matrixToCsv(data)).toBe('"line1\nline2"');
|
|
});
|
|
|
|
it("uses tab separator for TSV output", () => {
|
|
const data: Matrix<CellBase> = [
|
|
[{ value: "A" }, { value: "B" }],
|
|
[{ value: 1 }, { value: 2 }],
|
|
];
|
|
expect(matrixToCsv(data, "\t")).toBe("A\tB\n1\t2");
|
|
});
|
|
|
|
it("quotes TSV values containing tabs (prevents column shift in TSV)", () => {
|
|
const data: Matrix<CellBase> = [[{ value: "has\ttab" }, { value: "ok" }]];
|
|
expect(matrixToCsv(data, "\t")).toBe('"has\ttab"\tok');
|
|
});
|
|
|
|
it("treats undefined cells as empty strings", () => {
|
|
const data: Matrix<CellBase> = [[{ value: "X" }, undefined, { value: "Z" }]];
|
|
expect(matrixToCsv(data)).toBe("X,,Z");
|
|
});
|
|
|
|
it("treats null rows as empty rows", () => {
|
|
const data: Matrix<CellBase> = [
|
|
[{ value: "a" }],
|
|
undefined as unknown as (CellBase | undefined)[],
|
|
[{ value: "c" }],
|
|
];
|
|
expect(matrixToCsv(data)).toBe("a\n\nc");
|
|
});
|
|
|
|
it("handles a value that is exactly a double quote", () => {
|
|
const data: Matrix<CellBase> = [[{ value: '"' }]];
|
|
expect(matrixToCsv(data)).toBe('""""');
|
|
});
|
|
|
|
it("handles empty matrix", () => {
|
|
expect(matrixToCsv([])).toBe("");
|
|
});
|
|
|
|
it("handles matrix with single empty-value cell", () => {
|
|
const data: Matrix<CellBase> = [[{ value: "" }]];
|
|
expect(matrixToCsv(data)).toBe("");
|
|
});
|
|
|
|
it("does not quote values that only contain letters/numbers (no false quoting)", () => {
|
|
const data: Matrix<CellBase> = [[{ value: "hello" }, { value: 42 }]];
|
|
const csv = matrixToCsv(data);
|
|
expect(csv).toBe("hello,42");
|
|
expect(csv).not.toContain('"');
|
|
});
|
|
|
|
it("handles value that contains only the separator character", () => {
|
|
const data: Matrix<CellBase> = [[{ value: "," }]];
|
|
expect(matrixToCsv(data)).toBe('","');
|
|
});
|
|
|
|
it("handles mixed types: number, boolean, string", () => {
|
|
const data: Matrix<CellBase> = [[{ value: 0 }, { value: true }, { value: "text" }]];
|
|
expect(matrixToCsv(data)).toBe("0,true,text");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// selectionStats — computes Count/Sum/Avg for status bar
|
|
// Wrong stats mislead users making data-driven decisions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("selectionStats", () => {
|
|
const data: Matrix<CellBase> = [
|
|
[{ value: 10 }, { value: 20 }, { value: "text" }],
|
|
[{ value: 30 }, { value: "" }, { value: 40 }],
|
|
[{ value: "N/A" }, { value: 50 }, { value: 0 }],
|
|
];
|
|
|
|
it("returns null when selection is null (no selection active)", () => {
|
|
expect(selectionStats(data, null)).toBeNull();
|
|
});
|
|
|
|
it("returns null for an empty selection", () => {
|
|
const sel = new EmptySelection();
|
|
expect(selectionStats(data, sel)).toBeNull();
|
|
});
|
|
|
|
it("returns null when only a single cell is selected (no aggregation needed)", () => {
|
|
const sel = new RangeSelection(
|
|
new PointRange({ row: 0, column: 0 }, { row: 0, column: 0 }),
|
|
);
|
|
expect(selectionStats(data, sel)).toBeNull();
|
|
});
|
|
|
|
it("computes correct sum and average for a numeric range", () => {
|
|
const sel = new RangeSelection(
|
|
new PointRange({ row: 0, column: 0 }, { row: 1, column: 0 }),
|
|
);
|
|
const stats = selectionStats(data, sel);
|
|
expect(stats).not.toBeNull();
|
|
expect(stats!.count).toBe(2);
|
|
expect(stats!.numericCount).toBe(2);
|
|
expect(stats!.sum).toBe(40);
|
|
expect(stats!.avg).toBe(20);
|
|
});
|
|
|
|
it("excludes non-numeric cells from sum/avg but includes them in count", () => {
|
|
const sel = new RangeSelection(
|
|
new PointRange({ row: 0, column: 0 }, { row: 0, column: 2 }),
|
|
);
|
|
const stats = selectionStats(data, sel);
|
|
expect(stats).not.toBeNull();
|
|
expect(stats!.count).toBe(3);
|
|
expect(stats!.numericCount).toBe(2);
|
|
expect(stats!.sum).toBe(30);
|
|
expect(stats!.avg).toBe(15);
|
|
});
|
|
|
|
it("excludes empty-string cells from numeric count (empty is not zero)", () => {
|
|
const sel = new RangeSelection(
|
|
new PointRange({ row: 1, column: 0 }, { row: 1, column: 2 }),
|
|
);
|
|
const stats = selectionStats(data, sel);
|
|
expect(stats).not.toBeNull();
|
|
expect(stats!.numericCount).toBe(2);
|
|
expect(stats!.sum).toBe(70);
|
|
});
|
|
|
|
it("counts zero as a numeric value (0 is not empty)", () => {
|
|
const sel = new RangeSelection(
|
|
new PointRange({ row: 2, column: 1 }, { row: 2, column: 2 }),
|
|
);
|
|
const stats = selectionStats(data, sel);
|
|
expect(stats).not.toBeNull();
|
|
expect(stats!.numericCount).toBe(2);
|
|
expect(stats!.sum).toBe(50);
|
|
expect(stats!.avg).toBe(25);
|
|
});
|
|
|
|
it("returns avg=0 when all selected cells are non-numeric text", () => {
|
|
const textData: Matrix<CellBase> = [
|
|
[{ value: "foo" }, { value: "bar" }],
|
|
];
|
|
const sel = new RangeSelection(
|
|
new PointRange({ row: 0, column: 0 }, { row: 0, column: 1 }),
|
|
);
|
|
const stats = selectionStats(textData, sel);
|
|
expect(stats).not.toBeNull();
|
|
expect(stats!.numericCount).toBe(0);
|
|
expect(stats!.sum).toBe(0);
|
|
expect(stats!.avg).toBe(0);
|
|
});
|
|
|
|
it("handles selection that spans all rows (entire-row selection)", () => {
|
|
const sel = new EntireRowsSelection(0, 2);
|
|
const stats = selectionStats(data, sel);
|
|
expect(stats).not.toBeNull();
|
|
// 3x3 grid, all 9 cells have defined CellBase objects (including {value: ""})
|
|
expect(stats!.count).toBe(9);
|
|
});
|
|
|
|
it("skips undefined cells in sparse matrices", () => {
|
|
const sparse: Matrix<CellBase> = [
|
|
[{ value: 5 }, undefined, { value: 15 }],
|
|
];
|
|
const sel = new RangeSelection(
|
|
new PointRange({ row: 0, column: 0 }, { row: 0, column: 2 }),
|
|
);
|
|
const stats = selectionStats(sparse, sel);
|
|
expect(stats).not.toBeNull();
|
|
expect(stats!.count).toBe(2);
|
|
expect(stats!.numericCount).toBe(2);
|
|
expect(stats!.sum).toBe(20);
|
|
});
|
|
|
|
it("handles negative numbers correctly", () => {
|
|
const negData: Matrix<CellBase> = [
|
|
[{ value: -10 }, { value: 30 }],
|
|
];
|
|
const sel = new RangeSelection(
|
|
new PointRange({ row: 0, column: 0 }, { row: 0, column: 1 }),
|
|
);
|
|
const stats = selectionStats(negData, sel);
|
|
expect(stats).not.toBeNull();
|
|
expect(stats!.sum).toBe(20);
|
|
expect(stats!.avg).toBe(10);
|
|
});
|
|
|
|
it("treats string-encoded numbers as numeric (e.g. '42' from user input)", () => {
|
|
const strNum: Matrix<CellBase> = [
|
|
[{ value: "42" }, { value: "3.5" }],
|
|
];
|
|
const sel = new RangeSelection(
|
|
new PointRange({ row: 0, column: 0 }, { row: 0, column: 1 }),
|
|
);
|
|
const stats = selectionStats(strNum, sel);
|
|
expect(stats).not.toBeNull();
|
|
expect(stats!.numericCount).toBe(2);
|
|
expect(stats!.sum).toBe(45.5);
|
|
});
|
|
|
|
it("does not count 'NaN' string as numeric", () => {
|
|
const nanData: Matrix<CellBase> = [
|
|
[{ value: "NaN" }, { value: 10 }],
|
|
];
|
|
const sel = new RangeSelection(
|
|
new PointRange({ row: 0, column: 0 }, { row: 0, column: 1 }),
|
|
);
|
|
const stats = selectionStats(nanData, sel);
|
|
expect(stats).not.toBeNull();
|
|
expect(stats!.numericCount).toBe(1);
|
|
expect(stats!.sum).toBe(10);
|
|
});
|
|
|
|
it("handles entire-column selection correctly", () => {
|
|
const sel = new EntireColumnsSelection(0, 0);
|
|
const stats = selectionStats(data, sel);
|
|
expect(stats).not.toBeNull();
|
|
expect(stats!.count).toBe(3);
|
|
expect(stats!.numericCount).toBe(2);
|
|
expect(stats!.sum).toBe(40);
|
|
});
|
|
|
|
it("handles entire-worksheet selection", () => {
|
|
const sel = new EntireWorksheetSelection();
|
|
const stats = selectionStats(data, sel);
|
|
expect(stats).not.toBeNull();
|
|
expect(stats!.count).toBe(9);
|
|
});
|
|
|
|
it("handles floating-point precision without crashing (0.1 + 0.2 scenario)", () => {
|
|
const fpData: Matrix<CellBase> = [
|
|
[{ value: 0.1 }, { value: 0.2 }, { value: 0.3 }],
|
|
];
|
|
const sel = new RangeSelection(
|
|
new PointRange({ row: 0, column: 0 }, { row: 0, column: 2 }),
|
|
);
|
|
const stats = selectionStats(fpData, sel);
|
|
expect(stats).not.toBeNull();
|
|
expect(stats!.numericCount).toBe(3);
|
|
expect(stats!.sum).toBeCloseTo(0.6, 10);
|
|
expect(stats!.avg).toBeCloseTo(0.2, 10);
|
|
});
|
|
|
|
it("handles Infinity values as numeric", () => {
|
|
const infData: Matrix<CellBase> = [
|
|
[{ value: Infinity }, { value: 1 }],
|
|
];
|
|
const sel = new RangeSelection(
|
|
new PointRange({ row: 0, column: 0 }, { row: 0, column: 1 }),
|
|
);
|
|
const stats = selectionStats(infData, sel);
|
|
expect(stats).not.toBeNull();
|
|
expect(stats!.numericCount).toBe(2);
|
|
expect(stats!.sum).toBe(Infinity);
|
|
});
|
|
|
|
it("handles large multi-row selection correctly", () => {
|
|
const bigData: Matrix<CellBase> = Array.from({ length: 100 }, (_, i) => [
|
|
{ value: i + 1 },
|
|
]);
|
|
const sel = new RangeSelection(
|
|
new PointRange({ row: 0, column: 0 }, { row: 99, column: 0 }),
|
|
);
|
|
const stats = selectionStats(bigData, sel);
|
|
expect(stats).not.toBeNull();
|
|
expect(stats!.count).toBe(100);
|
|
expect(stats!.numericCount).toBe(100);
|
|
expect(stats!.sum).toBe(5050);
|
|
expect(stats!.avg).toBe(50.5);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// isSpreadsheetFile — gate function that routes files to spreadsheet viewer
|
|
// Missing extension = file opens in wrong viewer, user can't edit it
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("isSpreadsheetFile", () => {
|
|
it("recognizes all xlsx-family extensions", () => {
|
|
expect(isSpreadsheetFile("data.xlsx")).toBe(true);
|
|
expect(isSpreadsheetFile("legacy.xls")).toBe(true);
|
|
expect(isSpreadsheetFile("binary.xlsb")).toBe(true);
|
|
expect(isSpreadsheetFile("macro.xlsm")).toBe(true);
|
|
expect(isSpreadsheetFile("template.xltx")).toBe(true);
|
|
expect(isSpreadsheetFile("macrotemplate.xltm")).toBe(true);
|
|
});
|
|
|
|
it("recognizes OpenDocument formats", () => {
|
|
expect(isSpreadsheetFile("libre.ods")).toBe(true);
|
|
expect(isSpreadsheetFile("flat.fods")).toBe(true);
|
|
});
|
|
|
|
it("recognizes text-based spreadsheet formats", () => {
|
|
expect(isSpreadsheetFile("data.csv")).toBe(true);
|
|
expect(isSpreadsheetFile("data.tsv")).toBe(true);
|
|
});
|
|
|
|
it("recognizes Apple Numbers format", () => {
|
|
expect(isSpreadsheetFile("budget.numbers")).toBe(true);
|
|
});
|
|
|
|
it("is case-insensitive (XLSX and xlsx both detected)", () => {
|
|
expect(isSpreadsheetFile("DATA.XLSX")).toBe(true);
|
|
expect(isSpreadsheetFile("Report.CSV")).toBe(true);
|
|
expect(isSpreadsheetFile("budget.Numbers")).toBe(true);
|
|
expect(isSpreadsheetFile("LEGACY.XLS")).toBe(true);
|
|
});
|
|
|
|
it("rejects non-spreadsheet files (prevents wrong viewer)", () => {
|
|
expect(isSpreadsheetFile("readme.md")).toBe(false);
|
|
expect(isSpreadsheetFile("image.png")).toBe(false);
|
|
expect(isSpreadsheetFile("script.py")).toBe(false);
|
|
expect(isSpreadsheetFile("data.json")).toBe(false);
|
|
expect(isSpreadsheetFile("styles.css")).toBe(false);
|
|
expect(isSpreadsheetFile("document.pdf")).toBe(false);
|
|
expect(isSpreadsheetFile("archive.zip")).toBe(false);
|
|
});
|
|
|
|
it("handles files with multiple dots in the name", () => {
|
|
expect(isSpreadsheetFile("report.2024.01.xlsx")).toBe(true);
|
|
expect(isSpreadsheetFile("data.backup.csv")).toBe(true);
|
|
});
|
|
|
|
it("rejects files with no extension", () => {
|
|
expect(isSpreadsheetFile("Makefile")).toBe(false);
|
|
expect(isSpreadsheetFile("LICENSE")).toBe(false);
|
|
});
|
|
|
|
it("rejects dotfiles", () => {
|
|
expect(isSpreadsheetFile(".gitignore")).toBe(false);
|
|
expect(isSpreadsheetFile(".env")).toBe(false);
|
|
});
|
|
|
|
it("rejects empty filename", () => {
|
|
expect(isSpreadsheetFile("")).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// isSpreadsheetFile / isTextSpreadsheet consistency
|
|
// If a file passes isTextSpreadsheet but not isSpreadsheetFile, the save
|
|
// path runs but the file was never routed to the spreadsheet viewer.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("isSpreadsheetFile and isTextSpreadsheet consistency", () => {
|
|
const textSpreadsheetFiles = ["data.csv", "report.tsv", "DATA.CSV", "REPORT.TSV"];
|
|
const binarySpreadsheetFiles = [
|
|
"data.xlsx", "legacy.xls", "binary.xlsb", "macro.xlsm",
|
|
"template.xltx", "macrotemplate.xltm", "libre.ods", "flat.fods",
|
|
"budget.numbers",
|
|
];
|
|
|
|
it("every text spreadsheet is also detected as a spreadsheet file (prevents unroutable save)", () => {
|
|
for (const f of textSpreadsheetFiles) {
|
|
expect(isSpreadsheetFile(f)).toBe(true);
|
|
expect(isTextSpreadsheet(f)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("every binary spreadsheet is detected as spreadsheet but NOT text (prevents text-encoding binary)", () => {
|
|
for (const f of binarySpreadsheetFiles) {
|
|
expect(isSpreadsheetFile(f)).toBe(true);
|
|
expect(isTextSpreadsheet(f)).toBe(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Full xlsx binary round-trip
|
|
// The most critical data integrity invariant: data survives the full
|
|
// load → convert → modify → convert back → save → re-load pipeline
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("full xlsx binary round-trip", () => {
|
|
function createTestWorkbook(sheets: Record<string, unknown[][]>): ArrayBuffer {
|
|
const wb = utils.book_new();
|
|
for (const [name, aoa] of Object.entries(sheets)) {
|
|
utils.book_append_sheet(wb, utils.aoa_to_sheet(aoa), name);
|
|
}
|
|
return write(wb, { type: "array", bookType: "xlsx" }) as ArrayBuffer;
|
|
}
|
|
|
|
it("preserves a simple sheet through the full pipeline (load → edit → save → reload)", () => {
|
|
const original = [
|
|
["Name", "Age", "City"],
|
|
["Alice", 30, "NYC"],
|
|
["Bob", 25, "SF"],
|
|
];
|
|
const buf = createTestWorkbook({ Sheet1: original });
|
|
|
|
const wb1 = read(buf, { type: "array" });
|
|
const matrix = sheetToMatrix(wb1.Sheets["Sheet1"]);
|
|
|
|
expect(matrix[0][0]!.value).toBe("Name");
|
|
expect(matrix[1][0]!.value).toBe("Alice");
|
|
expect(matrix[1][1]!.value).toBe(30);
|
|
|
|
matrix[1][1] = { value: 31 };
|
|
|
|
const wb2 = utils.book_new();
|
|
utils.book_append_sheet(wb2, matrixToSheet(matrix), "Sheet1");
|
|
const buf2 = write(wb2, { type: "array", bookType: "xlsx" }) as ArrayBuffer;
|
|
|
|
const wb3 = read(buf2, { type: "array" });
|
|
const reloaded = sheetToMatrix(wb3.Sheets["Sheet1"]);
|
|
|
|
expect(reloaded[0][0]!.value).toBe("Name");
|
|
expect(reloaded[1][0]!.value).toBe("Alice");
|
|
expect(reloaded[1][1]!.value).toBe(31);
|
|
expect(reloaded[1][2]!.value).toBe("NYC");
|
|
expect(reloaded[2][0]!.value).toBe("Bob");
|
|
});
|
|
|
|
it("preserves multi-sheet workbook through full pipeline (sheet names and data)", () => {
|
|
const buf = createTestWorkbook({
|
|
Revenue: [["Q1", "Q2"], [100, 200]],
|
|
Expenses: [["Rent", "Salary"], [1000, 5000]],
|
|
Summary: [["Net"], [-600]],
|
|
});
|
|
|
|
const wb1 = read(buf, { type: "array" });
|
|
expect(wb1.SheetNames).toEqual(["Revenue", "Expenses", "Summary"]);
|
|
|
|
const sheets = wb1.SheetNames.map((name) => ({
|
|
name,
|
|
data: sheetToMatrix(wb1.Sheets[name]),
|
|
}));
|
|
|
|
const wb2 = utils.book_new();
|
|
for (const s of sheets) {
|
|
utils.book_append_sheet(wb2, matrixToSheet(s.data), s.name);
|
|
}
|
|
const buf2 = write(wb2, { type: "array", bookType: "xlsx" }) as ArrayBuffer;
|
|
|
|
const wb3 = read(buf2, { type: "array" });
|
|
expect(wb3.SheetNames).toEqual(["Revenue", "Expenses", "Summary"]);
|
|
|
|
const revenue = sheetToMatrix(wb3.Sheets["Revenue"]);
|
|
expect(revenue[1][0]!.value).toBe(100);
|
|
expect(revenue[1][1]!.value).toBe(200);
|
|
|
|
const summary = sheetToMatrix(wb3.Sheets["Summary"]);
|
|
expect(summary[1][0]!.value).toBe(-600);
|
|
});
|
|
|
|
it("preserves mixed data types through binary round-trip (strings, numbers, booleans)", () => {
|
|
const buf = createTestWorkbook({
|
|
Types: [
|
|
["string", 42, true, false, 0, 3.14, ""],
|
|
],
|
|
});
|
|
|
|
const wb1 = read(buf, { type: "array" });
|
|
const matrix = sheetToMatrix(wb1.Sheets["Types"]);
|
|
const ws2 = matrixToSheet(matrix);
|
|
const wb2 = utils.book_new();
|
|
utils.book_append_sheet(wb2, ws2, "Types");
|
|
const buf2 = write(wb2, { type: "array", bookType: "xlsx" }) as ArrayBuffer;
|
|
|
|
const wb3 = read(buf2, { type: "array" });
|
|
const reloaded = sheetToMatrix(wb3.Sheets["Types"]);
|
|
|
|
expect(reloaded[0][0]!.value).toBe("string");
|
|
expect(reloaded[0][1]!.value).toBe(42);
|
|
expect(reloaded[0][2]!.value).toBe(true);
|
|
expect(reloaded[0][3]!.value).toBe(false);
|
|
expect(reloaded[0][4]!.value).toBe(0);
|
|
expect(reloaded[0][5]!.value).toBeCloseTo(3.14);
|
|
expect(reloaded[0][6]!.value).toBe("");
|
|
});
|
|
|
|
it("handles large sheets (500 rows x 20 cols) without data loss", () => {
|
|
const rows: unknown[][] = [];
|
|
for (let r = 0; r < 500; r++) {
|
|
const row: unknown[] = [];
|
|
for (let c = 0; c < 20; c++) {
|
|
row.push(r * 20 + c);
|
|
}
|
|
rows.push(row);
|
|
}
|
|
const buf = createTestWorkbook({ Big: rows });
|
|
const wb1 = read(buf, { type: "array" });
|
|
const matrix = sheetToMatrix(wb1.Sheets["Big"]);
|
|
|
|
expect(matrix).toHaveLength(500);
|
|
expect(matrix[0]).toHaveLength(20);
|
|
expect(matrix[0][0]!.value).toBe(0);
|
|
expect(matrix[499][19]!.value).toBe(9999);
|
|
|
|
const ws2 = matrixToSheet(matrix);
|
|
const wb2 = utils.book_new();
|
|
utils.book_append_sheet(wb2, ws2, "Big");
|
|
const buf2 = write(wb2, { type: "array", bookType: "xlsx" }) as ArrayBuffer;
|
|
|
|
const wb3 = read(buf2, { type: "array" });
|
|
const reloaded = sheetToMatrix(wb3.Sheets["Big"]);
|
|
expect(reloaded).toHaveLength(500);
|
|
expect(reloaded[499][19]!.value).toBe(9999);
|
|
expect(reloaded[250][10]!.value).toBe(5010);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CSV round-trip
|
|
// Serialize to CSV, parse back, verify data survives quoting/escaping
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("CSV round-trip", () => {
|
|
it("simple data survives CSV serialization and re-parse", () => {
|
|
const matrix: Matrix<CellBase> = [
|
|
[{ value: "Name" }, { value: "Score" }],
|
|
[{ value: "Alice" }, { value: 95 }],
|
|
[{ value: "Bob" }, { value: 88 }],
|
|
];
|
|
const csv = matrixToCsv(matrix);
|
|
const ws = utils.aoa_to_sheet(
|
|
csv.split("\n").map((line) => line.split(",")),
|
|
);
|
|
const parsed = sheetToMatrix(ws);
|
|
|
|
expect(parsed[0][0]!.value).toBe("Name");
|
|
expect(parsed[0][1]!.value).toBe("Score");
|
|
expect(parsed[1][0]!.value).toBe("Alice");
|
|
expect(parsed[2][0]!.value).toBe("Bob");
|
|
});
|
|
|
|
it("cells with commas, quotes, and newlines all combined in one row", () => {
|
|
const matrix: Matrix<CellBase> = [[
|
|
{ value: 'She said "hi, there"' },
|
|
{ value: "line1\nline2" },
|
|
{ value: "plain" },
|
|
{ value: "has,comma" },
|
|
]];
|
|
const csv = matrixToCsv(matrix);
|
|
|
|
expect(csv).toContain('""');
|
|
|
|
// RFC 4180 parse: correctly handle quoted fields with embedded newlines
|
|
const parsed: string[] = [];
|
|
let current = "";
|
|
let inQuotes = false;
|
|
for (let i = 0; i < csv.length; i++) {
|
|
const ch = csv[i];
|
|
if (inQuotes) {
|
|
if (ch === '"') {
|
|
if (i + 1 < csv.length && csv[i + 1] === '"') {
|
|
current += '"';
|
|
i++;
|
|
} else {
|
|
inQuotes = false;
|
|
}
|
|
} else {
|
|
current += ch;
|
|
}
|
|
} else if (ch === '"') {
|
|
inQuotes = true;
|
|
} else if (ch === ",") {
|
|
parsed.push(current);
|
|
current = "";
|
|
} else if (ch === "\n") {
|
|
parsed.push(current);
|
|
current = "";
|
|
} else {
|
|
current += ch;
|
|
}
|
|
}
|
|
parsed.push(current);
|
|
|
|
expect(parsed[0]).toBe('She said "hi, there"');
|
|
expect(parsed[1]).toBe("line1\nline2");
|
|
expect(parsed[2]).toBe("plain");
|
|
expect(parsed[3]).toBe("has,comma");
|
|
});
|
|
|
|
it("TSV round-trip preserves data with embedded tabs", () => {
|
|
const matrix: Matrix<CellBase> = [
|
|
[{ value: "A" }, { value: "B\twith tab" }],
|
|
];
|
|
const tsv = matrixToCsv(matrix, "\t");
|
|
expect(tsv).toContain("A\t");
|
|
expect(tsv).toContain('"B\twith tab"');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// matrixToCsv — additional combined edge cases
|
|
// Real-world CSV files often have multiple edge cases in a single cell
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("matrixToCsv — combined edge cases", () => {
|
|
it("cell with comma + quote + newline all at once", () => {
|
|
const data: Matrix<CellBase> = [[{ value: 'a "b", c\nd' }]];
|
|
const csv = matrixToCsv(data);
|
|
expect(csv).toBe('"a ""b"", c\nd"');
|
|
});
|
|
|
|
it("consecutive empty cells produce correct number of separators", () => {
|
|
const data: Matrix<CellBase> = [
|
|
[{ value: "" }, { value: "" }, { value: "" }, { value: "end" }],
|
|
];
|
|
expect(matrixToCsv(data)).toBe(",,,end");
|
|
});
|
|
|
|
it("trailing empty cells are preserved (not truncated)", () => {
|
|
const data: Matrix<CellBase> = [
|
|
[{ value: "start" }, { value: "" }, { value: "" }],
|
|
];
|
|
expect(matrixToCsv(data)).toBe("start,,");
|
|
});
|
|
|
|
it("handles unicode content including emoji and CJK characters", () => {
|
|
const data: Matrix<CellBase> = [
|
|
[{ value: "Tokyo" }, { value: "\u6771\u4EAC" }, { value: "\u{1F4B4}" }],
|
|
];
|
|
const csv = matrixToCsv(data);
|
|
expect(csv).toBe("Tokyo,\u6771\u4EAC,\u{1F4B4}");
|
|
});
|
|
|
|
it("preserves leading/trailing whitespace in cells", () => {
|
|
const data: Matrix<CellBase> = [
|
|
[{ value: " padded " }, { value: "normal" }],
|
|
];
|
|
expect(matrixToCsv(data)).toBe(" padded ,normal");
|
|
});
|
|
|
|
it("handles very long cell value without truncation", () => {
|
|
const longVal = "x".repeat(10000);
|
|
const data: Matrix<CellBase> = [[{ value: longVal }]];
|
|
const csv = matrixToCsv(data);
|
|
expect(csv).toBe(longVal);
|
|
expect(csv).toHaveLength(10000);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// sheetToMatrix — additional edge cases
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("sheetToMatrix — additional edge cases", () => {
|
|
it("handles single-cell sheet", () => {
|
|
const ws = utils.aoa_to_sheet([["only"]]);
|
|
const m = sheetToMatrix(ws);
|
|
expect(m).toHaveLength(1);
|
|
expect(m[0]).toHaveLength(1);
|
|
expect(m[0][0]!.value).toBe("only");
|
|
});
|
|
|
|
it("handles sheet with only one column and many rows", () => {
|
|
const aoa = Array.from({ length: 50 }, (_, i) => [i]);
|
|
const ws = utils.aoa_to_sheet(aoa);
|
|
const m = sheetToMatrix(ws);
|
|
expect(m).toHaveLength(50);
|
|
expect(m[0]).toHaveLength(1);
|
|
expect(m[49][0]!.value).toBe(49);
|
|
});
|
|
|
|
it("handles sheet with only one row and many columns", () => {
|
|
const aoa = [Array.from({ length: 50 }, (_, i) => `col${i}`)];
|
|
const ws = utils.aoa_to_sheet(aoa);
|
|
const m = sheetToMatrix(ws);
|
|
expect(m).toHaveLength(1);
|
|
expect(m[0]).toHaveLength(50);
|
|
expect(m[0][49]!.value).toBe("col49");
|
|
});
|
|
|
|
it("never produces a cell with null/undefined value (null guard safety net)", () => {
|
|
const ws = utils.aoa_to_sheet([
|
|
[1, null, 3],
|
|
[null, null, null],
|
|
]);
|
|
const m = sheetToMatrix(ws);
|
|
for (const row of m) {
|
|
for (const cell of row ?? []) {
|
|
if (cell) {
|
|
expect(cell.value).not.toBeNull();
|
|
expect(cell.value).not.toBeUndefined();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it("preserves string that looks like a number (e.g. zip code '02134')", () => {
|
|
const ws = utils.aoa_to_sheet([["02134"]]);
|
|
const m = sheetToMatrix(ws);
|
|
expect(m[0][0]!.value).toBe("02134");
|
|
});
|
|
|
|
it("handles special string values (empty string, whitespace-only)", () => {
|
|
const ws = utils.aoa_to_sheet([["", " ", "\t"]]);
|
|
const m = sheetToMatrix(ws);
|
|
expect(m[0][0]!.value).toBe("");
|
|
expect(m[0][1]!.value).toBe(" ");
|
|
expect(m[0][2]!.value).toBe("\t");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// matrixToSheet — additional edge cases for the write path
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("matrixToSheet — additional edge cases", () => {
|
|
it("handles empty matrix (no rows)", () => {
|
|
const ws = matrixToSheet([]);
|
|
const rows: unknown[][] = utils.sheet_to_json(ws, { header: 1, defval: "" });
|
|
expect(rows).toEqual([]);
|
|
});
|
|
|
|
it("handles matrix with single empty-value cell", () => {
|
|
const ws = matrixToSheet([[{ value: "" }]]);
|
|
const rows: unknown[][] = utils.sheet_to_json(ws, { header: 1, defval: "" });
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0][0]).toBe("");
|
|
});
|
|
|
|
it("preserves row ordering (data is not scrambled)", () => {
|
|
const matrix: Matrix<CellBase> = Array.from({ length: 26 }, (_, i) => [
|
|
{ value: String.fromCharCode(65 + i) },
|
|
]);
|
|
const ws = matrixToSheet(matrix);
|
|
const rows: unknown[][] = utils.sheet_to_json(ws, { header: 1 });
|
|
for (let i = 0; i < 26; i++) {
|
|
expect(rows[i][0]).toBe(String.fromCharCode(65 + i));
|
|
}
|
|
});
|
|
|
|
it("handles ragged matrix (rows of different lengths)", () => {
|
|
const matrix: Matrix<CellBase> = [
|
|
[{ value: 1 }, { value: 2 }, { value: 3 }],
|
|
[{ value: 4 }],
|
|
[{ value: 5 }, { value: 6 }],
|
|
];
|
|
const ws = matrixToSheet(matrix);
|
|
const rows: unknown[][] = utils.sheet_to_json(ws, { header: 1, defval: "" });
|
|
expect(rows[0]).toEqual([1, 2, 3]);
|
|
expect(rows[1][0]).toBe(4);
|
|
// xlsx pads shorter rows to the widest column when using defval
|
|
expect(rows[2][0]).toBe(5);
|
|
expect(rows[2][1]).toBe(6);
|
|
});
|
|
});
|