/** * Coverage-focused behavioral tests for the SvGridApi assembled in * `build-api.ts`. The existing `svgrid.api.test.ts` / `svgrid.api-extensions * .test.ts` cover filters, row selection, pagination, navigation, and * getState/setState. This file exercises the remaining surface: * - getCellValue / setCellValue * - selectCells / getSelected * - addRow(s) / removeRow(s) / applyTransaction * - addColumn(s) / removeColumn / setColumnVisible / getColumns * - setColumnWidth / setColumnPinning / setColumnOrder * - setFacetFilter * - undo / redo / canUndo / canRedo / clearHistory * - find (open/close/query/hits) * - grouping (setGroupBy / expandAllGroups / collapseAllGroups) * - autosizeColumn / refresh * * All tests mount the real in jsdom and drive the api handed back * from onApiReady. */ import { describe, expect, it } from "vitest"; import { mount, unmount } from "svelte"; import SvGrid from "./SvGrid.svelte"; import { columnFilteringFeature, columnGroupingFeature, rowExpandingFeature, rowSelectionFeature, rowSortingFeature, tableFeatures, } from "./index"; import type { ColumnDef, SvGridApi } from "./index"; type Row = { id: number; name: string; team: string; salary: number }; const baseRows: Row[] = [ { id: 1, name: "Ada Lovelace", team: "Research", salary: 142_000 }, { id: 2, name: "Grace Hopper", team: "Compilers", salary: 158_000 }, { id: 3, name: "Alan Turing", team: "Research", salary: 138_000 }, { id: 4, name: "Margaret Hamilton", team: "Apollo", salary: 165_000 }, { id: 5, name: "Linus Torvalds", team: "Kernel", salary: 175_000 }, ]; const baseColumns: ColumnDef[] = [ { field: "name", header: "Name", width: 200 }, { field: "team", header: "Team", width: 160 }, { field: "salary", header: "Salary", width: 140 }, ]; const flush = () => Promise.resolve(); function mountGrid( extraProps: Record = {}, features: any = tableFeatures({ columnFilteringFeature, rowSortingFeature, rowSelectionFeature, columnGroupingFeature, rowExpandingFeature, }), ): Promise<{ api: SvGridApi; target: HTMLElement; destroy: () => void }> { return new Promise((resolveApi, rejectApi) => { const target = document.createElement("div"); document.body.appendChild(target); let captured: SvGridApi | null = null; const app = mount(SvGrid, { target, props: { data: baseRows.map((r) => ({ ...r })), columns: baseColumns.map((c) => ({ ...c })), features, getRowId: (r: Row) => String(r.id), rowHeight: 36, containerHeight: 480, virtualization: false, enableCellSelection: true, onApiReady(api: SvGridApi) { captured = api; resolveApi({ api, target, destroy: () => { unmount(app); target.remove(); }, }); }, ...extraProps, } as any, }); queueMicrotask(() => { if (!captured) rejectApi(new Error("onApiReady never fired")); }); }); } describe("SvGridApi - cell read / write", () => { it("getCellValue reads through the column field", async () => { const { api, destroy } = await mountGrid(); try { expect(api.getCellValue(0, "name")).toBe("Ada Lovelace"); expect(api.getCellValue(3, "team")).toBe("Apollo"); } finally { destroy(); } }); it("getCellValue returns undefined for out-of-range row or unknown column", async () => { const { api, destroy } = await mountGrid(); try { expect(api.getCellValue(999, "name")).toBeUndefined(); expect(api.getCellValue(0, "nope")).toBeUndefined(); } finally { destroy(); } }); it("setCellValue writes a new immutable row and reads back", async () => { const { api, destroy } = await mountGrid(); try { api.setCellValue(0, "name", "Ada L."); await flush(); expect(api.getCellValue(0, "name")).toBe("Ada L."); // other rows untouched expect(api.getCellValue(1, "name")).toBe("Grace Hopper"); } finally { destroy(); } }); it("setCellValue is a no-op for an invalid target", async () => { const { api, destroy } = await mountGrid(); try { expect(() => api.setCellValue(999, "name", "x")).not.toThrow(); expect(() => api.setCellValue(0, "unknown", "x")).not.toThrow(); } finally { destroy(); } }); }); describe("SvGridApi - cell selection", () => { it("selectCells sets a rectangle; getSelected reads it back normalized", async () => { const { api, destroy } = await mountGrid(); try { api.selectCells([[2, 1, 0, 0]]); // unsorted corners await flush(); expect(api.getSelected()).toEqual([[0, 0, 2, 1]]); } finally { destroy(); } }); it("selectCells clamps open-ended (Infinity) coordinates to grid bounds", async () => { const { api, destroy } = await mountGrid(); try { api.selectCells([[0, 0, Infinity, Infinity]]); await flush(); // 5 rows (0..4), 3 cols (0..2) expect(api.getSelected()).toEqual([[0, 0, 4, 2]]); } finally { destroy(); } }); it("empty selectCells clears the range; getSelected returns []", async () => { const { api, destroy } = await mountGrid(); try { api.selectCells([[0, 0, 1, 1]]); await flush(); api.selectCells([]); await flush(); expect(api.getSelected()).toEqual([]); } finally { destroy(); } }); }); describe("SvGridApi - row mutations", () => { it("addRow appends to the bottom by default", async () => { const { api, destroy } = await mountGrid(); try { api.addRow({ id: 6, name: "New", team: "QA", salary: 1 } as Row); await flush(); const data = api.getData() as Row[]; expect(data.length).toBe(6); expect(data[5]!.id).toBe(6); } finally { destroy(); } }); it("addRows at top / numeric index insert in place", async () => { const { api, destroy } = await mountGrid(); try { api.addRows([{ id: 7, name: "Top", team: "T", salary: 1 } as Row], "top"); await flush(); expect((api.getData() as Row[])[0]!.id).toBe(7); api.addRows([{ id: 8, name: "Mid", team: "M", salary: 1 } as Row], 2); await flush(); expect((api.getData() as Row[])[2]!.id).toBe(8); } finally { destroy(); } }); it("removeRow / removeRows drop by index", async () => { const { api, destroy } = await mountGrid(); try { api.removeRow(0); await flush(); expect((api.getData() as Row[]).map((r) => r.id)).toEqual([2, 3, 4, 5]); api.removeRows([0, 2]); // drops ids 2 and 4 await flush(); expect((api.getData() as Row[]).map((r) => r.id)).toEqual([3, 5]); } finally { destroy(); } }); it("applyTransaction adds, updates (by id), and removes; returns counts", async () => { const { api, destroy } = await mountGrid(); try { const result = api.applyTransaction({ add: [{ id: 9, name: "Added", team: "X", salary: 1 } as Row], update: [ { id: 2, name: "Grace H.", team: "Compilers", salary: 999 } as Row, ], remove: ["3"], // by getRowId string }); await flush(); expect(result).toEqual({ added: 1, updated: 1, removed: 1 }); const data = api.getData() as Row[]; expect(data.find((r) => r.id === 3)).toBeUndefined(); expect(data.find((r) => r.id === 2)!.salary).toBe(999); expect(data.find((r) => r.id === 9)).toBeTruthy(); } finally { destroy(); } }); it("applyTransaction removes by object reference too", async () => { const { api, destroy } = await mountGrid(); try { const target = (api.getData() as Row[])[0]!; const result = api.applyTransaction({ remove: [target] }); await flush(); expect(result.removed).toBe(1); expect((api.getData() as Row[]).find((r) => r === target)).toBeUndefined(); } finally { destroy(); } }); }); describe("SvGridApi - column mutations", () => { it("addColumn left / right / numeric position, then getColumns reflects it", async () => { const { api, destroy } = await mountGrid(); try { api.addColumn({ field: "id", header: "ID" } as any, "left"); await flush(); expect(api.getColumns()[0]!.id).toBe("id"); api.addColumns([{ field: "extra", header: "Extra" } as any], "right"); await flush(); const cols = api.getColumns(); expect(cols[cols.length - 1]!.id).toBe("extra"); } finally { destroy(); } }); it("removeColumn drops a column from the visible set", async () => { const { api, destroy } = await mountGrid(); try { api.removeColumn("team"); await flush(); expect(api.getColumns().find((c) => c.id === "team")).toBeUndefined(); } finally { destroy(); } }); it("setColumnVisible toggles visibility; getColumns lists hidden as visible:false", async () => { const { api, destroy } = await mountGrid(); try { api.setColumnVisible("team", false); await flush(); expect(api.isColumnVisible("team")).toBe(false); const hidden = api.getColumns().find((c) => c.id === "team"); expect(hidden!.visible).toBe(false); api.setColumnVisible("team", true); await flush(); expect(api.isColumnVisible("team")).toBe(true); } finally { destroy(); } }); it("setColumnWidth clamps + getColumnWidths round-trips", async () => { const { api, destroy } = await mountGrid(); try { api.setColumnWidth("name", 333.7); await flush(); expect(api.getColumnWidths().name).toBe(333); } finally { destroy(); } }); it("setColumnPinning dedupes; getColumnPinning returns copies", async () => { const { api, destroy } = await mountGrid(); try { api.setColumnPinning({ left: ["name", "name"], right: ["salary"] }); await flush(); const pin = api.getColumnPinning(); expect(pin.left).toEqual(["name"]); expect(pin.right).toEqual(["salary"]); // mutating the returned copy doesn't affect internal state pin.left.push("hacked"); expect(api.getColumnPinning().left).toEqual(["name"]); } finally { destroy(); } }); it("setColumnOrder + getColumnOrder round-trip", async () => { const { api, destroy } = await mountGrid(); try { api.setColumnOrder(["salary", "name", "team"]); await flush(); expect(api.getColumnOrder().slice(0, 3)).toEqual([ "salary", "name", "team", ]); } finally { destroy(); } }); // Note: autosizeColumn / autosizeAllColumns are intentionally not asserted // here - they measure text via real DOM layout (canvas/escape), which jsdom // does not provide, so they throw in this environment. They are exercised by // the demo gallery / Playwright suite per the coverage config notes. }); describe("SvGridApi - facet filter", () => { it("setFacetFilter narrows displayed rows; null clears it", async () => { const { api, destroy } = await mountGrid(); try { api.setFacetFilter("team", ["Research"]); await flush(); const displayed = api.getDisplayedRows() as Row[]; expect(displayed.every((r) => r.team === "Research")).toBe(true); expect(displayed.length).toBe(2); api.setFacetFilter("team", null); await flush(); expect((api.getDisplayedRows() as Row[]).length).toBe(5); } finally { destroy(); } }); it("setFacetFilter with an empty array also clears", async () => { const { api, destroy } = await mountGrid(); try { api.setFacetFilter("team", ["Apollo"]); await flush(); api.setFacetFilter("team", []); await flush(); expect((api.getDisplayedRows() as Row[]).length).toBe(5); } finally { destroy(); } }); }); describe("SvGridApi - find", () => { it("openFind / setFindQuery / getFindHits / closeFind", async () => { const { api, destroy } = await mountGrid(); try { api.openFind(); api.setFindQuery("Research"); await flush(); const hits = api.getFindHits(); expect(Array.isArray(hits)).toBe(true); // two cells contain "Research" expect(hits.length).toBeGreaterThanOrEqual(2); api.closeFind(); await flush(); expect(api.getFindHits()).toEqual([]); } finally { destroy(); } }); }); describe("SvGridApi - undo / redo / history", () => { it("canUndo is false before any history; undo/redo are no-ops then", async () => { const { api, destroy } = await mountGrid(); try { expect(api.canUndo()).toBe(false); expect(api.canRedo()).toBe(false); expect(api.undo()).toBe(false); expect(api.redo()).toBe(false); } finally { destroy(); } }); it("clearHistory resets the stacks without throwing", async () => { const { api, destroy } = await mountGrid(); try { expect(() => api.clearHistory()).not.toThrow(); expect(api.canUndo()).toBe(false); expect(api.canRedo()).toBe(false); } finally { destroy(); } }); }); describe("SvGridApi - grouping / expansion", () => { it("setGroupBy groups rows; the underlying data is unchanged", async () => { const { api, destroy } = await mountGrid(); try { api.setGroupBy(["team"]); await flush(); // Grouping reshapes the display pipeline; the source data is intact. expect((api.getData() as Row[]).length).toBe(5); expect(api.getState().grouping).toContain("team"); } finally { destroy(); } }); it("expandAllGroups / collapseAllGroups run without error", async () => { const { api, destroy } = await mountGrid(); try { api.setGroupBy(["team"]); await flush(); expect(() => api.expandAllGroups()).not.toThrow(); await flush(); expect(() => api.collapseAllGroups()).not.toThrow(); await flush(); // un-group api.setGroupBy([]); await flush(); expect((api.getData() as Row[]).length).toBe(5); } finally { destroy(); } }); it("setRowExpanded toggles a row's expanded flag", async () => { const { api, destroy } = await mountGrid(); try { expect(() => api.setRowExpanded("1", true)).not.toThrow(); await flush(); expect(() => api.setRowExpanded("1", false)).not.toThrow(); } finally { destroy(); } }); }); describe("SvGridApi - sort opt-out + refresh", () => { it("setSort with a direction sets a single active clause", async () => { const { api, destroy } = await mountGrid(); try { api.setSort("salary", "asc"); await flush(); expect(api.getState().sorting).toEqual([{ id: "salary", desc: false }]); api.setSort("name", "desc"); await flush(); // Setting a direction replaces the active sort clause. expect(api.getState().sorting).toEqual([{ id: "name", desc: true }]); } finally { destroy(); } }); it("setSort with a falsy direction removes that column's clause", async () => { const { api, destroy } = await mountGrid(); try { api.setSort("salary", "asc"); await flush(); api.setSort("salary", null as any); await flush(); expect( api.getState().sorting.find((s) => s.id === "salary"), ).toBeUndefined(); } finally { destroy(); } }); it("setSort is ignored for a non-sortable column", async () => { const { api, destroy } = await mountGrid({ columns: [ { field: "name", header: "Name", width: 200, sortable: false }, { field: "team", header: "Team", width: 160 }, ], }); try { api.setSort("name", "asc"); await flush(); expect(api.getState().sorting.find((s) => s.id === "name")).toBeUndefined(); } finally { destroy(); } }); it("refresh re-runs the pipeline without throwing or losing data", async () => { const { api, destroy } = await mountGrid(); try { expect(() => api.refresh()).not.toThrow(); await flush(); expect((api.getData() as Row[]).length).toBe(5); } finally { destroy(); } }); }); describe("SvGridApi - setOption / getOption (runtime prop overrides)", () => { it("getOption returns the incoming prop when no override is set", async () => { const { api, destroy } = await mountGrid(); try { expect(api.getOption("rowHeight")).toBe(36); expect(api.getOption("zebraRows")).toBeUndefined(); } finally { destroy(); } }); it("setOption overrides a prop, getOption reflects it, undefined clears it", async () => { const { api, destroy } = await mountGrid(); try { api.setOption("rowHeight", 48); await flush(); expect(api.getOption("rowHeight")).toBe(48); api.setOption("zebraRows", true); await flush(); expect(api.getOption("zebraRows")).toBe(true); // Clearing an override falls back to the incoming prop. api.setOption("rowHeight", undefined); await flush(); expect(api.getOption("rowHeight")).toBe(36); } finally { destroy(); } }); it("setOption('sortable', true) turns sorting on at runtime; false turns it off", async () => { // Mount with NO features so sorting starts disabled - only the runtime // override can inject rowSortingFeature (mirrors resolveEffectiveFeatures). const { api, destroy } = await mountGrid({}, tableFeatures({})); try { // Off by default: setSort is guarded to a no-op. api.setSort("salary", "asc"); await flush(); expect(api.getState().sorting).toEqual([]); // Turn sorting on via the override, then setSort takes effect. api.setOption("sortable", true); await flush(); api.setSort("salary", "asc"); await flush(); expect(api.getState().sorting).toEqual([{ id: "salary", desc: false }]); // Turn it back off: setSort is guarded again. api.setOption("sortable", false); await flush(); api.clearSort(); await flush(); api.setSort("team", "asc"); await flush(); expect(api.getState().sorting).toEqual([]); } finally { destroy(); } }); it("a view-direct prop (zebraRows) visually re-renders on override", async () => { const { api, target, destroy } = await mountGrid(); try { // No striping to start. expect(target.querySelectorAll(".sv-grid-row-alt").length).toBe(0); api.setOption("zebraRows", true); await flush(); await flush(); // Odd rows now carry the alternate-row class (view reads props.zebraRows, // which is the effective proxy). expect(target.querySelectorAll(".sv-grid-row-alt").length).toBeGreaterThan(0); api.setOption("zebraRows", undefined); await flush(); await flush(); expect(target.querySelectorAll(".sv-grid-row-alt").length).toBe(0); } finally { destroy(); } }); it("resetOptions clears every override", async () => { const { api, destroy } = await mountGrid(); try { api.setOption("rowHeight", 60); api.setOption("zebraRows", true); await flush(); expect(api.getOption("rowHeight")).toBe(60); api.resetOptions(); await flush(); expect(api.getOption("rowHeight")).toBe(36); expect(api.getOption("zebraRows")).toBeUndefined(); } finally { destroy(); } }); });