/** * Behavioral tests for the SvGridApi extensions inspired by the jqxGrid API: * - Row selection read/write (getSelectedRows / getSelectedRowIds / * selectRows / selectAllRows / toggleRowSelected) * - Pagination control (getPageInfo / setPage / next/prev/first/last / * setPageSize) * - Navigation (scrollToRow / getActiveCell / setActiveCell) * - View state (getState / setState / refresh) * - Click / double-click / scroll-bottom events * * They mount the real in jsdom and exercise the api object handed * back from onApiReady. */ import { describe, expect, it } from 'vitest' import { mount, unmount } from 'svelte' import SvGrid from './SvGrid.svelte' import { columnFilteringFeature, getGridCellDomId, rowPaginationFeature, rowSelectionFeature, rowSortingFeature, tableFeatures, } from './index' import type { ColumnDef, SvGridApi } from './index' type Row = { id: number; name: string; team: string; salary: number } const sampleRows: 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 sampleColumns: ColumnDef[] = [ { field: 'name', header: 'Name', width: 200 }, { field: 'team', header: 'Team', width: 160 }, { field: 'salary', header: 'Salary', width: 140 }, ] // One microtask is enough for the grid's derived state (gridStateVersion) to // re-run after an imperative mutation - mirrors the existing api test. const flush = () => Promise.resolve() function mountGrid( extraProps: Record = {}, features: any = tableFeatures({ columnFilteringFeature, rowSortingFeature, rowSelectionFeature, }), ): 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: sampleRows, columns: sampleColumns, features, rowHeight: 36, containerHeight: 480, virtualization: false, 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 - row selection', () => { it('selectAllRows selects every row; getSelectedRows / Ids reflect it', async () => { const { api, destroy } = await mountGrid() try { api.selectAllRows() await flush() expect(api.getSelectedRows().length).toBe(5) expect(api.getSelectedRowIds().length).toBe(5) } finally { destroy() } }) it('selectRows replaces the selection by default and adds with additive', async () => { const { api, destroy } = await mountGrid() try { api.selectAllRows() await flush() const ids = api.getSelectedRowIds() expect(ids.length).toBe(5) api.selectRows([ids[0]!]) await flush() expect(api.getSelectedRowIds()).toEqual([ids[0]!]) api.selectRows([ids[1]!], true) await flush() expect(new Set(api.getSelectedRowIds())).toEqual(new Set([ids[0]!, ids[1]!])) } finally { destroy() } }) it('toggleRowSelected flips one row; clearRowSelection wipes all', async () => { const { api, destroy } = await mountGrid() try { api.selectAllRows() await flush() const id = api.getSelectedRowIds()[0]! api.toggleRowSelected(id) await flush() expect(api.getSelectedRowIds()).not.toContain(id) api.clearRowSelection() await flush() expect(api.getSelectedRowIds().length).toBe(0) } finally { destroy() } }) }) describe('SvGridApi - pagination', () => { const paged = () => mountGrid( { showPagination: true, pageSize: 2 }, tableFeatures({ rowPaginationFeature }), ) it('getPageInfo derives pageCount from the filtered total', async () => { const { api, destroy } = await paged() try { const info = api.getPageInfo() expect(info.total).toBe(5) expect(info.pageSize).toBe(2) expect(info.pageCount).toBe(3) // ceil(5 / 2) expect(info.pageIndex).toBe(0) } finally { destroy() } }) it('setPage clamps to [0, pageCount - 1]', async () => { const { api, destroy } = await paged() try { api.setPage(99) await flush() expect(api.getPageInfo().pageIndex).toBe(2) api.setPage(-5) await flush() expect(api.getPageInfo().pageIndex).toBe(0) } finally { destroy() } }) it('next/prev/first/last move through the pages', async () => { const { api, destroy } = await paged() try { api.nextPage() await flush() expect(api.getPageInfo().pageIndex).toBe(1) api.lastPage() await flush() expect(api.getPageInfo().pageIndex).toBe(2) api.prevPage() await flush() expect(api.getPageInfo().pageIndex).toBe(1) api.firstPage() await flush() expect(api.getPageInfo().pageIndex).toBe(0) } finally { destroy() } }) it('setPageSize updates the page size and recomputes pageCount', async () => { const { api, destroy } = await paged() try { api.setPageSize(5) await flush() const info = api.getPageInfo() expect(info.pageSize).toBe(5) expect(info.pageCount).toBe(1) } finally { destroy() } }) }) describe('SvGridApi - navigation', () => { it('setActiveCell + getActiveCell round-trip with the column id', async () => { const { api, destroy } = await mountGrid() try { api.setActiveCell(2, 1) await flush() expect(api.getActiveCell()).toEqual({ rowIndex: 2, colIndex: 1, columnId: 'team', }) } finally { destroy() } }) it('setActiveCell clamps out-of-range coordinates', async () => { const { api, destroy } = await mountGrid() try { api.setActiveCell(999, 999) await flush() const cell = api.getActiveCell() expect(cell?.rowIndex).toBe(4) // last row expect(cell?.colIndex).toBe(2) // last column } finally { destroy() } }) it('scrollToRow does not throw for valid or out-of-range indices', async () => { const { api, destroy } = await mountGrid() try { expect(() => api.scrollToRow(3)).not.toThrow() expect(() => api.scrollToRow(9999)).not.toThrow() } finally { destroy() } }) }) describe('SvGridApi - view state (getState / setState)', () => { it('getState captures sort + filters; setState restores them', async () => { const { api, destroy } = await mountGrid() try { api.setSort('salary', 'desc') api.setFilter('team', { operator: 'equals', value: 'Research' }) await flush() const snapshot = api.getState() expect(snapshot.sorting).toEqual([{ id: 'salary', desc: true }]) expect(snapshot.columnFilters.team).toEqual({ operator: 'equals', value: 'Research', }) // Wipe, then restore from the snapshot. api.clearSort() api.clearAllFilters() await flush() expect(api.getState().sorting).toEqual([]) expect(api.getFilters()).toEqual({}) api.setState(snapshot) await flush() expect(api.getState().sorting).toEqual([{ id: 'salary', desc: true }]) expect(api.getFilters().team).toEqual({ operator: 'equals', value: 'Research', }) } finally { destroy() } }) it('setState restores column width + visibility', async () => { const { api, destroy } = await mountGrid() try { api.setState({ columnWidths: { name: 320 }, hiddenColumns: ['team'] }) await flush() expect(api.getColumnWidths().name).toBe(320) expect(api.isColumnVisible('team')).toBe(false) expect(api.getState().hiddenColumns).toContain('team') } finally { destroy() } }) it('partial setState only touches the keys present', async () => { const { api, destroy } = await mountGrid() try { api.setSort('name', 'asc') await flush() // Restoring only filters must not clear the existing sort. api.setState({ globalFilter: '' }) await flush() expect(api.getState().sorting).toEqual([{ id: 'name', desc: false }]) } finally { destroy() } }) }) describe('SvGridApi - click / double-click / scroll events', () => { function clickCell(target: HTMLElement, rowIndex: number, colIndex: number, type: 'click' | 'dblclick') { // Cell ids are scoped per grid instance (#77), so resolve the base from the // grid under test rather than assuming the old global "svgrid" prefix. const anyCell = target.querySelector('[role="gridcell"][id]') const base = anyCell?.id.replace(/_cell_\d+_\d+$/, '') if (!base) throw new Error('no gridcell with an id found') const id = getGridCellDomId(base, rowIndex, colIndex) const el = target.querySelector(`[id="${id}"]`) if (!el) throw new Error(`cell ${id} not found`) el.dispatchEvent(new MouseEvent(type, { bubbles: true })) } it('onCellClick + onRowClick fire with the expected payload', async () => { const cellEvents: any[] = [] const rowEvents: any[] = [] const { target, destroy } = await mountGrid({ onCellClick: (e: unknown) => cellEvents.push(e), onRowClick: (e: unknown) => rowEvents.push(e), }) try { clickCell(target, 0, 0, 'click') await flush() expect(cellEvents.length).toBe(1) expect(cellEvents[0]).toMatchObject({ rowIndex: 0, colIndex: 0, columnId: 'name', value: 'Ada Lovelace', }) expect((cellEvents[0].row as Row).id).toBe(1) expect(rowEvents.length).toBe(1) expect(rowEvents[0]).toMatchObject({ rowIndex: 0, columnId: 'name' }) } finally { destroy() } }) it('onCellDoubleClick + onRowDoubleClick fire on dblclick', async () => { const cellEvents: any[] = [] const rowEvents: any[] = [] const { target, destroy } = await mountGrid({ enableInlineEditing: false, onCellDoubleClick: (e: unknown) => cellEvents.push(e), onRowDoubleClick: (e: unknown) => rowEvents.push(e), }) try { clickCell(target, 1, 1, 'dblclick') await flush() expect(cellEvents.length).toBe(1) expect(cellEvents[0]).toMatchObject({ rowIndex: 1, colIndex: 1, columnId: 'team' }) expect(rowEvents.length).toBe(1) } finally { destroy() } }) })