/**
* Source-level smoke tests for the wrapper additions: `getRowId` prop,
* `cellClass` / `rowClass` rendering, the column-layout API surface
* (`setColumnWidth` / `getColumnWidths` / `setColumnPinning` /
* `getColumnPinning`), the `between` operator UI, and the multi-cell
* paste / cut keyboard plumbing.
*
* These are deliberately source-string assertions, not jsdom mounts -
* Svelte 5 mount inside vitest is brittle (the existing
* `svgrid.wrapper.test.ts` follows the same pattern). The behaviour
* itself is covered by integration probes in the gallery (see the
* pivot expansion + paste/cut probes that were run during build) and
* the upstream unit tests of the headless engine + Pro pivot.
*/
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
// The wrapper implementation spans SvGrid.svelte plus its extracted
// sibling modules (types + pure helpers). Concatenate them so these
// "the wrapper declares/exposes X" assertions hold wherever the code
// physically lives.
const source = [
'./SvGrid.svelte',
'./SvGrid.controller.svelte.ts',
'./GridMenus.svelte',
'./GridFooter.svelte',
'./SvGrid.types.ts',
'./SvGrid.helpers.ts',
'./filter-operators.ts',
'./facet-buckets.ts',
'./cell-values.ts',
'./clipboard.ts',
'./build-api.ts',
'./columns.ts',
'./selection.ts',
'./editing.ts',
'./cell-render.ts',
'./menus.ts',
'./summaries.ts',
'./keyboard-handlers.ts',
'./scroll-sync.ts',
'./features.ts',
]
.map((p) => readFileSync(resolve(__dirname, p), 'utf-8'))
.join('\n')
const typesPath = resolve(__dirname, './svgrid-wrapper.types.ts')
const types = readFileSync(typesPath, 'utf-8')
describe('SvGrid wrapper - getRowId prop', () => {
it('declares the getRowId prop on the Props type', () => {
expect(source).toMatch(/getRowId\?:\s*\(row:\s*TData,\s*index:\s*number\)\s*=>\s*string/)
})
it('forwards the prop into createSvGrid options', () => {
expect(source).toMatch(/get getRowId\(\)[\s\S]*?return props\.getRowId/)
})
})
describe('SvGrid wrapper - cellClass + rowClass', () => {
it('declares the rowClass prop on the Props type', () => {
expect(source).toMatch(/rowClass\?:\s*\(ctx:\s*\{/)
})
it('exposes a resolveClassList helper that normalises array / record / string forms', () => {
expect(source).toMatch(/function resolveClassList\(/)
expect(source).toMatch(/Array\.isArray\(value\)/)
})
it('computes the row-level class once per render row and threads it onto the
', () => {
expect(source).toMatch(/function computeRowClass/)
expect(source).toMatch(/userRowClass = computeRowClass\(row, rowIndex\)/)
expect(source).toMatch(/class=\{`sv-grid-row \$\{userRowClass\}/)
})
it('computes the cell-level class per column and threads it onto the | ', () => {
expect(source).toMatch(/function computeCellClass/)
expect(source).toMatch(/userCellClass = computeCellClass\(\s*row,\s*rendered\.column,?\s*\)/)
expect(source).toMatch(/class=\{`sv-grid-cell \$\{userCellClass\}`\}/)
})
})
describe('SvGrid wrapper - declarative cell validation (validate hook)', () => {
it('reads the column validate hook with the documented return union', () => {
expect(source).toMatch(/column\.columnDef\.validate/)
expect(source).toMatch(/=>\s*string\s*\|\s*boolean\s*\|\s*null\s*\|\s*undefined/)
})
it('computes per-cell validity in the render layer (runs for every rendered cell)', () => {
expect(source).toMatch(/function computeCellValidity/)
expect(source).toMatch(/columnDef\.validate/)
// Valid when null / undefined / true; invalid on false or a message.
expect(source).toMatch(/if \(out == null \|\| out === true\)/)
})
it('threads the invalid class + message tooltip onto both | render paths', () => {
const hits = source.match(/class:sv-grid-cell-invalid=\{cellValidity\.invalid\}/g) ?? []
expect(hits.length).toBeGreaterThanOrEqual(2)
// Message wins over the plain column tooltip when the cell is invalid.
expect(source).toMatch(/cellValidity\.invalid && cellValidity\.message/)
})
it('forwards computeCellValidity through the controller so the wrapper can read it', () => {
expect(source).toMatch(/get computeCellValidity\(\)\s*\{\s*return computeCellValidity;/)
expect(source).toMatch(/computeCellValidity\s*=\s*\$derived\(ctrl\.computeCellValidity\)/)
})
})
describe('SvGrid wrapper - column-layout API', () => {
it('declares setColumnWidth + getColumnWidths in the public type', () => {
expect(types).toContain('setColumnWidth(columnId: string, width: number): void')
expect(types).toContain('getColumnWidths(): Record')
})
it('declares setColumnPinning + getColumnPinning in the public type', () => {
expect(types).toMatch(/setColumnPinning\(pinning:\s*\{/)
expect(types).toContain('getColumnPinning(): { left: string[]; right: string[] }')
})
it('implements all four methods in the api object', () => {
expect(source).toMatch(/setColumnWidth\(columnId: string, width: number\)/)
expect(source).toMatch(/getColumnWidths\(\)/)
expect(source).toMatch(/setColumnPinning\(pinning\)/)
expect(source).toMatch(/getColumnPinning\(\)/)
})
it('clamps setColumnWidth to MIN_COLUMN_WIDTH', () => {
// Confirm the clamp is in the code path so a caller can't drop a
// column below the minimum by passing 0 or a negative number.
expect(source).toMatch(/Math\.max\(MIN_COLUMN_WIDTH/)
})
it('defensive-copies setColumnPinning inputs so callers cannot mutate state', () => {
expect(source).toMatch(/Array\.from\(new Set\(pinning\.left/)
expect(source).toMatch(/Array\.from\(new Set\(pinning\.right/)
})
})
describe('SvGrid wrapper - between filter operator', () => {
it("includes 'between' in the FilterOperator type", () => {
expect(source).toMatch(/"between"/)
})
it("includes 'between' in NUMBER_OPERATORS + DATE_OPERATORS but NOT in TEXT_OPERATORS", () => {
// The list literals appear in order, so we slice each block out
// separately and check membership.
const numOps = source.match(/const NUMBER_OPERATORS[^]*?\];/)?.[0] ?? ''
expect(numOps).toContain('"between"')
const dateOps = source.match(/const DATE_OPERATORS[^]*?\];/)?.[0] ?? ''
expect(dateOps).toContain('"between"')
const textOps = source.match(/const TEXT_OPERATORS[^]*?\];/)?.[0] ?? ''
expect(textOps).not.toContain('"between"')
})
it('renders a second input ("To") when the operator is between', () => {
expect(source).toMatch(/menuActiveOperator === "between"/)
expect(source).toMatch(/placeholder="To"/)
})
it('treats the filter as inactive until BOTH endpoints are non-empty', () => {
// The active-filters filter() should require value AND valueTo.
expect(source).toMatch(/if \(f\.operator === "between"\) \{[^}]*f\.value\.trim\(\)\.length > 0[^}]*f\.valueTo[^}]*\.trim\(\)\.length > 0/)
})
it('forwards valueTo through applyExcelFilter', () => {
expect(source).toMatch(/valueTo:\s*(filter\.operator|op) === "between" \? (filter\.valueTo|valueTo) : undefined/)
})
it('publishes valueTo in the onFiltersChange payload only for between clauses', () => {
expect(source).toMatch(/f\.operator === "between" && f\.valueTo\s*\?\s*\{ valueTo: f\.valueTo \}/)
})
it('exposes valueTo on the imperative setFilter input + getFilters output', () => {
expect(types).toMatch(/setFilter\(\s*columnId: string,[\s\S]*?valueTo\?: string/)
expect(types).toMatch(/getFilters\(\)[\s\S]*?valueTo\?: string/)
})
})
describe('SvGrid wrapper - multi-cell paste + cut + delete', () => {
it('fills the selection range when the clipboard payload is 1x1', () => {
expect(source).toMatch(/clipboardIsSingleCell\s*=\s*\n?\s*lines\.length === 1 && \(lines\[0\]\?\.split\("\\t"\)\.length \?\? 0\) === 1/)
expect(source).toMatch(/selectionIsRange = startRow !== endRow \|\| startCol !== endCol/)
expect(source).toMatch(/fillRange = clipboardIsSingleCell && selectionIsRange/)
})
it('walks the entire selection rect when filling', () => {
expect(source).toMatch(/const rowSpan = fillRange \? endRow - startRow \+ 1 : lines\.length/)
expect(source).toMatch(/const colSpan = fillRange/)
})
it('implements clearSelectedCells() that touches every editable cell in the range', () => {
expect(source).toMatch(/function clearSelectedCells\(\): boolean/)
expect(source).toMatch(/parseEditorValue\(editorType, ""\)/)
})
it('wires Ctrl/Cmd+X to cutSelectionToClipboard', () => {
expect(source).toMatch(/lower === "x"/)
expect(source).toMatch(/void (?:ctx\.)?cutSelectionToClipboard\(\)/)
expect(source).toMatch(/async function cutSelectionToClipboard/)
// Confirm both calls live inside the cutSelectionToClipboard body
// (copy first, then clear). A comment between them is fine.
const body = source.match(
/async function cutSelectionToClipboard\(\)[^{]*\{([\s\S]*?)^\s\s\}/m,
)?.[1] ?? ''
expect(body).toContain('copySelectionToClipboard();')
expect(body).toContain('clearSelectedCells();')
expect(body.indexOf('copySelectionToClipboard()')).toBeLessThan(
body.indexOf('clearSelectedCells()'),
)
})
it('wires Delete + Backspace to clearSelectedCells (without clipboard interaction)', () => {
expect(source).toMatch(/event\.key === "Delete" \|\| event\.key === "Backspace"/)
expect(source).toMatch(/if \((?:ctx\.)?clearSelectedCells\(\)\)\s*\{/)
})
// Regression: the controller's ctx is an object literal of getters, and the
// copy/cut/paste handlers in keyboard-handlers + menus reach the functions
// via `ctx.()`. A refactor once shipped the `ctx.cutSelectionToClipboard`
// / `ctx.pasteFromClipboard` CALLS but dropped their getters, so those threw
// "is not a function" at runtime - copy worked, cut + paste were dead. Assert
// every clipboard function called through ctx has a matching getter.
it('exposes a ctx getter for every clipboard command it calls through ctx', () => {
for (const fn of [
'copySelectionToClipboard',
'cutSelectionToClipboard',
'pasteFromClipboard',
'onGridPaste',
'clearSelectedCells',
]) {
expect(source).toMatch(new RegExp(`get ${fn}\\(\\)\\s*\\{\\s*return ${fn};?\\s*\\}`))
}
})
it('copy/cut fall back to execCommand when navigator.clipboard is absent (insecure context)', () => {
expect(source).toMatch(/function writeClipboardText\(/)
expect(source).toMatch(/document\.execCommand\("copy"\)/)
})
it('paste falls back to a native paste event when the async API is unavailable', () => {
// keydown only preventDefaults (and uses the async API) in a secure context;
// otherwise it lets the browser deliver a native paste event to onGridPaste.
// Accept either the positive branch or the inverted early-return guard
// (`if (!navigator.clipboard?.readText) return`) - both gate on the async API.
expect(source).toMatch(/if \(!?navigator\.clipboard\?\.readText\)/)
expect(source).toMatch(/function onGridPaste\(event: ClipboardEvent\)/)
expect(source).toMatch(/clipboardData\?\.getData\("text\/plain"\)/)
// The grid root wires the handler.
expect(source).toMatch(/onpaste=\{onGridPaste\}/)
})
})
|