/** * Broad behavioral test suite for the render component. * * The intent is wide coverage on a single file - SvGrid.svelte is ~3000 * lines and most of its branches are reachable only when the component is * mounted with a particular prop combination. Each test below exercises a * different feature set so the whole file's effect graph is traversed. * * Pattern (re-used across files): * 1. mount SvGrid in jsdom with the props that trigger the feature * 2. wait one microtask for $effects to flush * 3. assert on the api object delivered via onApiReady, or on visible DOM * 4. unmount */ import { describe, expect, it } from 'vitest' import { mount, unmount } from 'svelte' import SvGrid from './SvGrid.svelte' import { columnFilteringFeature, columnGroupingFeature, createCoreRowModel, createExpandedRowModel, createFilteredRowModel, createGroupedRowModel, createPaginatedRowModel, createSortedRowModel, rowExpandingFeature, rowPaginationFeature, rowSelectionFeature, rowSortingFeature, sortFns, tableFeatures, } from './index' import type { ColumnDef, SvGridApi } from './index' type Person = { id: number name: string team: string age: number active: boolean joined: string salary: number } const fullFeatures = tableFeatures({ columnFilteringFeature, columnGroupingFeature, rowExpandingFeature, rowPaginationFeature, rowSelectionFeature, rowSortingFeature, }) const people: Person[] = [ { id: 1, name: 'Ada Lovelace', team: 'Research', age: 38, active: true, joined: '2020-01-10', salary: 142_000 }, { id: 2, name: 'Grace Hopper', team: 'Compilers', age: 42, active: true, joined: '2018-05-22', salary: 158_000 }, { id: 3, name: 'Alan Turing', team: 'Research', age: 41, active: false, joined: '2021-09-01', salary: 138_000 }, { id: 4, name: 'Margaret Hamilton', team: 'Apollo', age: 35, active: true, joined: '2019-03-15', salary: 165_000 }, { id: 5, name: 'Linus Torvalds', team: 'Kernel', age: 54, active: true, joined: '2017-11-30', salary: 175_000 }, { id: 6, name: 'Donald Knuth', team: 'Research', age: 86, active: false, joined: '2010-06-06', salary: 120_000 }, { id: 7, name: 'Brian Kernighan', team: 'Compilers', age: 80, active: true, joined: '2012-02-02', salary: 160_000 }, { id: 8, name: 'Dennis Ritchie', team: 'Kernel', age: 70, active: false, joined: '2009-07-14', salary: 110_000 }, ] const personColumns: ColumnDef[] = [ { field: 'name', header: 'Name', width: 200 }, { field: 'team', header: 'Team', width: 160 }, { field: 'age', header: 'Age', width: 100, editorType: 'number', format: { type: 'number' }, }, { field: 'active', header: 'Active', width: 100, editorType: 'checkbox', }, { field: 'joined', header: 'Joined', width: 140, editorType: 'date', format: { type: 'number' }, }, { field: 'salary', header: 'Salary', width: 140, editorType: 'number', format: { type: 'currency', currency: 'USD' }, }, ] type MountResult = { api: SvGridApi target: HTMLElement destroy: () => void } function mountGrid(overrides: Record = {}): Promise { return new Promise((resolveApi, rejectApi) => { const target = document.createElement('div') target.style.width = '1200px' target.style.height = '600px' document.body.appendChild(target) let capturedApi: SvGridApi | null = null const app = mount(SvGrid, { target, props: { data: people, columns: personColumns, features: fullFeatures, _rowModels: { coreRowModel: createCoreRowModel(), filteredRowModel: createFilteredRowModel(), sortedRowModel: createSortedRowModel(sortFns), groupedRowModel: createGroupedRowModel(), expandedRowModel: createExpandedRowModel(), paginatedRowModel: createPaginatedRowModel(), }, rowHeight: 36, containerHeight: 480, virtualization: false, onApiReady(api: SvGridApi) { capturedApi = api resolveApi({ api, target, destroy: () => { unmount(app) target.remove() }, }) }, ...overrides, } as any, }) queueMicrotask(() => { if (!capturedApi) rejectApi(new Error('onApiReady never fired')) }) }) } const tick = () => Promise.resolve() describe('SvGrid - core render', () => { it('mounts and renders the column headers', async () => { const { target, destroy } = await mountGrid() try { const headers = target.querySelectorAll('[data-svgrid-header-col]') // jsdom reports 0 for offsetWidth so column virtualization may render // a subset. The point of this assertion is just that the header row // exists and is populated. expect(headers.length).toBeGreaterThan(0) } finally { destroy() } }) it('renders the rows in the body', async () => { const { target, destroy } = await mountGrid() try { await tick() const rows = target.querySelectorAll('[data-svgrid-row]') // Pagination defaults to off in our mount - all rows render. expect(rows.length).toBeGreaterThanOrEqual(people.length) } finally { destroy() } }) it('renders the empty message when data is empty', async () => { const { target, destroy } = await mountGrid({ data: [], emptyMessage: 'No rows yet' }) try { await tick() expect(target.textContent).toContain('No rows yet') } finally { destroy() } }) it('renders a loading state when `loading` is true', async () => { const { target, destroy } = await mountGrid({ loading: true }) try { await tick() const statusEls = target.querySelectorAll('[role="status"]') expect(statusEls.length).toBeGreaterThan(0) } finally { destroy() } }) it('renders an error state when `error` is set', async () => { const { target, destroy } = await mountGrid({ error: 'Something broke' }) try { await tick() expect(target.textContent).toContain('Something broke') } finally { destroy() } }) }) describe('SvGrid - sort', () => { it('setSort then clearSort updates filters via api', async () => { const { api, destroy } = await mountGrid() try { api.setSort('name', 'asc') await tick() api.setSort('name', 'desc') await tick() api.setSort('name', null) await tick() api.clearSort() await tick() // No throw + state consistent = pass. expect(api.getData().length).toBe(people.length) } finally { destroy() } }) }) describe('SvGrid - pagination', () => { it('shows fewer displayed rows than total when paginated', async () => { const { api, destroy } = await mountGrid({ showPagination: true, pageSize: 3 }) try { await tick() const displayed = api.getDisplayedRows() expect(displayed.length).toBeLessThanOrEqual(3) } finally { destroy() } }) }) describe('SvGrid - global filter', () => { it('showGlobalFilter renders the input', async () => { const { target, destroy } = await mountGrid({ showGlobalFilter: true }) try { await tick() const input = target.querySelector( '.sv-grid-global-filter input, input[type="text"]', ) expect(input).not.toBeNull() } finally { destroy() } }) }) describe('SvGrid - column filter row', () => { it('showColumnFilters renders the filter row inputs', async () => { const { target, destroy } = await mountGrid({ showColumnFilters: true }) try { await tick() const inputs = target.querySelectorAll('.sv-grid-column-filter') // One filter input per filterable column. expect(inputs.length).toBeGreaterThan(0) } finally { destroy() } }) }) describe('SvGrid - inline editing', () => { it('enables enableInlineEditing without throwing', async () => { const { api, destroy } = await mountGrid({ enableInlineEditing: true }) try { // setCellValue should mutate internal data. api.setCellValue(0, 'name', 'Renamed Person') await tick() const data = api.getData() expect((data[0] as Person).name).toBe('Renamed Person') } finally { destroy() } }) }) describe('SvGrid - row selection', () => { it('showRowSelection renders the checkbox column', async () => { const { target, destroy } = await mountGrid({ showRowSelection: true }) try { await tick() const checkboxes = target.querySelectorAll('.sv-grid-checkbox') // At least the header + one per row. expect(checkboxes.length).toBeGreaterThan(0) } finally { destroy() } }) }) describe('SvGrid - capability shortcuts', () => { it('sortable + filterable enable sorting/filtering UI with no features prop', async () => { const { target, destroy } = await mountGrid({ features: undefined, sortable: true, filterable: true, }) try { await tick() // rowSortingFeature injected -> headers expose aria-sort. expect( target.querySelectorAll('[role="columnheader"][aria-sort]').length, ).toBeGreaterThan(0) // columnFilteringFeature injected -> per-column filter button renders. expect(target.querySelector('.sv-grid-col-filter-btn')).toBeTruthy() } finally { destroy() } }) it('sortable={false} overrides a rowSortingFeature passed via features', async () => { // The harness default `fullFeatures` includes rowSortingFeature. const { target, destroy } = await mountGrid({ sortable: false }) try { await tick() expect( target.querySelector('[role="columnheader"][aria-sort]'), ).toBeNull() } finally { destroy() } }) it('pageable toggles the pagination footer', async () => { const off = await mountGrid({ pageable: false }) try { await tick() expect(off.target.querySelector('.sv-grid-pagination')).toBeNull() } finally { off.destroy() } const on = await mountGrid({ features: undefined, pageable: true }) try { await tick() expect(on.target.querySelector('.sv-grid-pagination')).toBeTruthy() } finally { on.destroy() } }) it('paginationPosition mounts + gates the bottom pager', async () => { // The top pager is gated on measurement (jsdom does not measure), so it's not // asserted here; the visible top pager is covered by the browser/e2e check. // This guards the mount path (a top footer rendered pre-measure used to crash). for (const paginationPosition of ['top', 'both', 'bottom'] as const) { const g = await mountGrid({ showPagination: true, paginationPosition }) try { await tick() expect(g.api).toBeTruthy() const bottoms = [...g.target.querySelectorAll('.sv-grid-pagination')].filter((el) => !el.classList.contains('sv-grid-pagination-top')) expect(bottoms.length).toBe(paginationPosition === 'top' ? 0 : 1) // no bottom pager when position is 'top' } finally { g.destroy() } } }) it('pageSizeOptions is accepted by the pager', async () => { const g = await mountGrid({ showPagination: true, pageSizeOptions: [5, 15, 45] }) try { await tick() expect(g.target.querySelector('.sv-grid-pagination')).toBeTruthy() } finally { g.destroy() } }) it('editable={false} blocks inline editing entry points', async () => { const { api, target, destroy } = await mountGrid({ editable: false }) try { await tick() // Double-clicking a body cell must not open an editor when editing // is disabled via the shortcut. const cell = target.querySelector('.sv-grid-cell') cell?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) await tick() expect(target.querySelector('.sv-grid-cell-editor')).toBeNull() expect(api.getData().length).toBe(people.length) } finally { destroy() } }) }) describe('SvGrid - cell selection', () => { it('enableCellSelection mounts without error', async () => { const { api, destroy } = await mountGrid({ enableCellSelection: true }) try { await tick() expect(api.getData().length).toBe(people.length) } finally { destroy() } }) }) describe('SvGrid - grouping + aggregation', () => { it('setGroupBy mounts + the codepath runs without throwing', async () => { const { api, target, destroy } = await mountGrid({ enableRowSummaries: true, showGroupingControls: true, }) try { api.setGroupBy(['team']) await tick() await tick() // Sanity: the grid is still mounted and the API responsive. const data = api.getData() expect(data.length).toBe(people.length) // Group rendering depends on row model + container dimensions in jsdom; // just verify the codepath does not throw. expect(target).toBeTruthy() } finally { destroy() } }) }) describe('SvGrid - column add / remove / visibility', () => { it('addColumn and removeColumn round-trip', async () => { const { api, destroy } = await mountGrid() try { api.addColumn({ id: 'id-col', field: 'id', header: 'ID', width: 60 } as any, 'left') await tick() api.addColumn( { id: 'extra-tag', field: 'team', header: 'Extra', width: 60 } as any, 'right', ) await tick() api.addColumn( { id: 'mid-col', field: 'name', header: 'Mid', width: 60 } as any, 1, ) await tick() api.removeColumn('id-col') await tick() // No throw means the codepath was exercised. expect(typeof api.getData).toBe('function') } finally { destroy() } }) it('setColumnVisible toggles visibility flag', async () => { const { api, destroy } = await mountGrid() try { expect(api.isColumnVisible('name')).toBe(true) api.setColumnVisible('name', false) await tick() expect(api.isColumnVisible('name')).toBe(false) api.setColumnVisible('name', true) await tick() expect(api.isColumnVisible('name')).toBe(true) } finally { destroy() } }) it('columns marked `visible: false` start hidden but stay listed', async () => { const columns: ColumnDef[] = personColumns.map( (c) => (c.field === 'team' ? { ...c, visible: false } : c), ) const { api, destroy } = await mountGrid({ columns }) try { // Starts hidden... expect(api.isColumnVisible('team')).toBe(false) // ...but is still listed in getColumns() (so a Choose Columns UI // can offer it), just with visible=false. const team = api.getColumns().find((c) => c.id === 'team') expect(team).toBeDefined() expect(team?.visible).toBe(false) // A later user toggle wins over the initial flag. api.setColumnVisible('team', true) await tick() expect(api.isColumnVisible('team')).toBe(true) } finally { destroy() } }) }) describe('SvGrid - row add / remove', () => { it('addRow at top / bottom / specific index', async () => { const { api, destroy } = await mountGrid() try { const before = api.getData().length api.addRow({ id: 99, name: 'Top', team: 'X', age: 20, active: true, joined: '2025', salary: 1 }, 'top') api.addRow({ id: 100, name: 'Bottom', team: 'X', age: 20, active: true, joined: '2025', salary: 1 }, 'bottom') api.addRow({ id: 101, name: 'Mid', team: 'X', age: 20, active: true, joined: '2025', salary: 1 }, 2) await tick() expect(api.getData().length).toBe(before + 3) } finally { destroy() } }) it('addRows / removeRow / removeRows', async () => { const { api, destroy } = await mountGrid() try { const before = api.getData().length api.addRows( [ { id: 90, name: 'A', team: 'X', age: 1, active: true, joined: '2025', salary: 1 }, { id: 91, name: 'B', team: 'X', age: 1, active: true, joined: '2025', salary: 1 }, ], 'bottom', ) await tick() api.removeRow(0) await tick() api.removeRows([0, 1]) await tick() expect(api.getData().length).toBe(before + 2 - 3) } finally { destroy() } }) }) describe('SvGrid - getCellValue', () => { it('returns the cell value for a known row+column', async () => { const { api, destroy } = await mountGrid() try { const value = api.getCellValue(0, 'name') expect(value).toBe(people[0]!.name) } finally { destroy() } }) it('returns undefined for unknown row or column', async () => { const { api, destroy } = await mountGrid() try { expect(api.getCellValue(999, 'name')).toBeUndefined() expect(api.getCellValue(0, 'no-such-col')).toBeUndefined() } finally { destroy() } }) }) describe('SvGrid - row number column', () => { it('showRowNumbers renders the leading row-number column', async () => { const { target, destroy } = await mountGrid({ showRowNumbers: true }) try { await tick() const rnCells = target.querySelectorAll('.sv-grid-row-number-cell') expect(rnCells.length).toBeGreaterThan(0) } finally { destroy() } }) }) describe('SvGrid - external sort / filter', () => { it('externalSort + onSortingChange fires without re-ordering rows', async () => { let captured: Array<{ id: string; desc: boolean }> | null = null const { api, destroy } = await mountGrid({ externalSort: true, onSortingChange: (s: any) => (captured = s), }) try { api.setSort('age', 'desc') await tick() expect(captured).not.toBeNull() } finally { destroy() } }) it('externalFilter + onFiltersChange fires when a filter is set', async () => { let calls = 0 const { api, destroy } = await mountGrid({ externalFilter: true, onFiltersChange: () => { calls += 1 }, }) try { api.setFilter('team', { operator: 'contains', value: 'Research' }) await tick() expect(calls).toBeGreaterThan(0) } finally { destroy() } }) }) describe('SvGrid - custom snippet cell', () => { it('accepts a custom cell renderer without throwing', async () => { // Build a snippet via raw object - the SvGrid component reads // `column.columnDef.cell` and pipes it through FlexRender. Returning a // plain string is the simplest renderer; tests only need to verify the // render path doesn't crash. const customCols: ColumnDef[] = personColumns.map((c) => c.field === 'name' ? { ...c, cell: (ctx: any) => `★ ${(ctx.row.original as Person).name}`, } : c, ) const { target, destroy } = await mountGrid({ columns: customCols }) try { await tick() expect(target.textContent).toContain('★') } finally { destroy() } }) }) describe('SvGrid - filterMode shortcuts', () => { it('filterMode="row" + global filter input wiring', async () => { const { destroy } = await mountGrid({ filterMode: 'row' }) try { await tick() // No assertion needed - exercising the codepath is enough. expect(true).toBe(true) } finally { destroy() } }) it('filterMode="global"', async () => { const { destroy } = await mountGrid({ filterMode: 'global' }) try { await tick() expect(true).toBe(true) } finally { destroy() } }) it('filterMode="none"', async () => { const { destroy } = await mountGrid({ filterMode: 'none' }) try { await tick() expect(true).toBe(true) } finally { destroy() } }) }) describe('SvGrid - selectionMode shortcuts', () => { it('selectionMode="row"', async () => { const { destroy } = await mountGrid({ selectionMode: 'row' }) try { await tick() expect(true).toBe(true) } finally { destroy() } }) it('selectionMode="cell"', async () => { const { destroy } = await mountGrid({ selectionMode: 'cell' }) try { await tick() expect(true).toBe(true) } finally { destroy() } }) it('selectionMode="both"', async () => { const { destroy } = await mountGrid({ selectionMode: 'both' }) try { await tick() expect(true).toBe(true) } finally { destroy() } }) it('selectionMode="none"', async () => { const { destroy } = await mountGrid({ selectionMode: 'none' }) try { await tick() expect(true).toBe(true) } finally { destroy() } }) }) describe('SvGrid - fitColumns', () => { it('mounts with fitColumns=true without throwing', async () => { const { destroy } = await mountGrid({ fitColumns: true }) try { await tick() expect(true).toBe(true) } finally { destroy() } }) })