diff --git a/docs/.vitepress/smartrow-signatures.json b/docs/.vitepress/smartrow-signatures.json index 126b690d..82488da1 100644 --- a/docs/.vitepress/smartrow-signatures.json +++ b/docs/.vitepress/smartrow-signatures.json @@ -34,6 +34,11 @@ "signature": "smartFill: (data: Partial | Record, options?: FillOptions) => Promise", "comment": "/**\n* Intelligently fills form fields in the row.\n* Automatically detects input types (text, select, checkbox, contenteditable).\n*\n* @param data - Column-value pairs to fill\n* @param options - Optional configuration\n* @param options.inputMappers - Custom input selectors per column\n* @example\n* // Auto-detection\n* await row.smartFill({ Name: 'John', Status: 'Active', Subscribe: true });\n*\n* // Custom input mappers\n* await row.smartFill(\n* { Name: 'John' },\n* { inputMappers: { Name: (cell) => cell.locator('.custom-input') } }\n* );\n*/" }, + { + "name": "getValue", + "signature": "getValue(column: string): Promise", + "comment": "/**\n* Get the resolved value of any column — real, override, or synthetic.\n* @param column - Column name (case-sensitive)\n* @returns The column value as a string\n*/" + }, { "name": "wasFound", "signature": "wasFound(): boolean", diff --git a/docs/.vitepress/tableconfig-signatures.json b/docs/.vitepress/tableconfig-signatures.json index e20a6fc3..7a572dcc 100644 --- a/docs/.vitepress/tableconfig-signatures.json +++ b/docs/.vitepress/tableconfig-signatures.json @@ -54,6 +54,11 @@ "signature": "columnOverrides?: Partial>>", "comment": "/**\n* Unified interface for reading and writing data to specific columns.\n* Overrides both default extraction (toJSON) and filling (smartFill) logic.\n*/" }, + { + "name": "syntheticColumns", + "signature": "syntheticColumns?: Record>", + "comment": "/**\n* Computed columns with no DOM presence. Each key becomes a virtual column name\n* available in `toJSON()`, `getValue()`, and `findRow()`/`findRows()` filters.\n* The `compute` function receives the full SmartRow and must only read real or\n* override columns (no chaining between synthetics).\n*/" + }, { "name": "emptyState", "signature": "emptyState?: Locator", diff --git a/src/engine/rowFinder.ts b/src/engine/rowFinder.ts index ff5fd72c..58066262 100644 --- a/src/engine/rowFinder.ts +++ b/src/engine/rowFinder.ts @@ -28,17 +28,21 @@ export class RowFinder { private splitFilters(filters: Record): { domFilters: Record; overrideFilters: Record; + syntheticFilters: Record; } { const domFilters: Record = {}; const overrideFilters: Record = {}; + const syntheticFilters: Record = {}; for (const [key, value] of Object.entries(filters)) { - if (this.config.columnOverrides?.[key as keyof T]?.read) { + if (this.config.syntheticColumns?.[key]) { + syntheticFilters[key] = value; + } else if (this.config.columnOverrides?.[key as keyof T]?.read) { overrideFilters[key] = value; } else { domFilters[key] = value; } } - return { domFilters, overrideFilters }; + return { domFilters, overrideFilters, syntheticFilters }; } static matchReadValue(readValue: string, filterValue: FilterValue, exact: boolean): boolean { @@ -58,7 +62,7 @@ export class RowFinder { return true; } - private async matchesOverrideFilters( + async matchesOverrideFilters( rowLocator: Locator, overrideFilters: Record, map: Map, @@ -86,6 +90,21 @@ export class RowFinder { return true; } + async matchesSyntheticFilters( + rowLocator: Locator, + syntheticFilters: Record, + map: Map, + exact: boolean + ): Promise { + const smartRow = this.makeSmartRow(rowLocator, map, undefined); + for (const [colName, filterValue] of Object.entries(syntheticFilters)) { + const def = this.config.syntheticColumns![colName]; + const computedValue = String(await def.compute(smartRow)); + if (!RowFinder.matchReadValue(computedValue, filterValue, exact)) return false; + } + return true; + } + public async findRow( filters: Record, options: { exact?: boolean, maxPages?: number } = {} @@ -129,8 +148,9 @@ export class RowFinder { const tracker = new ElementTracker('findRows'); try { - const { domFilters, overrideFilters } = this.splitFilters(filtersRecord); + const { domFilters, overrideFilters, syntheticFilters } = this.splitFilters(filtersRecord); const hasOverrideFilters = Object.keys(overrideFilters).length > 0; + const hasSyntheticFilters = Object.keys(syntheticFilters).length > 0; const collectMatches = async () => { let rowLocators = this.resolve(this.config.rowSelector, this.rootLocator); @@ -183,6 +203,11 @@ export class RowFinder { continue; } + if (hasSyntheticFilters && !await this.matchesSyntheticFilters(currentRows[idx], syntheticFilters, map, options?.exact ?? false)) { + barrier?.markFinished(); + continue; + } + allRows.push(smartRow); added++; } @@ -249,8 +274,8 @@ export class RowFinder { } const allRows = this.resolve(this.config.rowSelector, this.rootLocator); - const { domFilters, overrideFilters } = this.splitFilters(filters); - const hasOverrideFilters = Object.keys(overrideFilters).length > 0; + const { domFilters, overrideFilters, syntheticFilters } = this.splitFilters(filters); + const hasPostFilters = Object.keys(overrideFilters).length > 0 || Object.keys(syntheticFilters).length > 0; let matchedRows = allRows; if (Object.keys(domFilters).length > 0) { @@ -264,21 +289,25 @@ export class RowFinder { ); } - if (!hasOverrideFilters) { + if (!hasPostFilters) { const count = await matchedRows.count(); logDebug(this.config, 'verbose',`Page ${this.tableState.currentPageIndex}: Found ${count} matches.`); if (count > 1) await this.throwIfAmbiguous(await matchedRows.all(), filters, map); if (count === 1) return matchedRows.first(); } else { const candidates = await matchedRows.all(); - logDebug(this.config, 'verbose',`Page ${this.tableState.currentPageIndex}: ${candidates.length} DOM candidate(s), post-filtering with override columns`); + logDebug(this.config, 'verbose',`Page ${this.tableState.currentPageIndex}: ${candidates.length} DOM candidate(s), post-filtering with override/synthetic columns`); const results = await Promise.all( - candidates.map(c => this.matchesOverrideFilters(c, overrideFilters, map, options.exact || false)) + candidates.map(async c => { + if (Object.keys(overrideFilters).length > 0 && !await this.matchesOverrideFilters(c, overrideFilters, map, options.exact || false)) return false; + if (Object.keys(syntheticFilters).length > 0 && !await this.matchesSyntheticFilters(c, syntheticFilters, map, options.exact || false)) return false; + return true; + }) ); - const overrideMatches = candidates.filter((_, i) => results[i]); - logDebug(this.config, 'verbose',`Page ${this.tableState.currentPageIndex}: ${overrideMatches.length} match(es) after override filter`); - if (overrideMatches.length > 1) await this.throwIfAmbiguous(overrideMatches, filters, map); - if (overrideMatches.length === 1) return overrideMatches[0]; + const postFilterMatches = candidates.filter((_, i) => results[i]); + logDebug(this.config, 'verbose',`Page ${this.tableState.currentPageIndex}: ${postFilterMatches.length} match(es) after post-filter`); + if (postFilterMatches.length > 1) await this.throwIfAmbiguous(postFilterMatches, filters, map); + if (postFilterMatches.length === 1) return postFilterMatches[0]; } if (pagesScanned < effectiveMaxPages) { diff --git a/src/engine/tableMapper.ts b/src/engine/tableMapper.ts index 3b641f83..15458f76 100644 --- a/src/engine/tableMapper.ts +++ b/src/engine/tableMapper.ts @@ -68,8 +68,19 @@ export class TableMapper { const rawHeaders = await strategy(context); const entries = await this.processHeaders(rawHeaders); - // Success - this._headerMap = new Map(entries); + // Success — validate before caching so a collision doesn't leave stale state + const headerMap = new Map(entries); + + const syntheticNames = Object.keys(this.config.syntheticColumns ?? {}); + const collisions = syntheticNames.filter(name => headerMap.has(name)); + if (collisions.length > 0) { + throw new Error( + `Synthetic column name(s) collide with real header(s): ${collisions.join(', ')}. ` + + `Rename the synthetic column or use columnOverrides for columns that exist in the DOM.` + ); + } + + this._headerMap = headerMap; this.log(`Mapped ${entries.length} columns: ${JSON.stringify(entries.map(e => e[0]))}`); return this._headerMap; diff --git a/src/index.ts b/src/index.ts index 195a1cfe..4ab4607f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,9 @@ export type { GetCellLocatorFn, GetActiveCellFn, DebugConfig, + SyntheticColumnDef, + ColumnOverride, + ColumnOverrideReadContext, } from './types'; // Export namespace-like strategy collections diff --git a/src/smartRow.ts b/src/smartRow.ts index c9f13326..17b85bf2 100644 --- a/src/smartRow.ts +++ b/src/smartRow.ts @@ -375,9 +375,12 @@ const createSmartRow = ( // Attach Methods smart.getCell = (colName: string): SmartCell => { + if (config.syntheticColumns?.[colName]) { + throw new Error(`Column "${colName}" is synthetic (no DOM cell) — use getValue("${colName}") or toJSON() instead`); + } const idx = map.get(colName); if (idx === undefined) { - throw new Error(buildColumnNotFoundError(colName, Array.from(map.keys()))); + throw new Error(buildColumnNotFoundError(colName, [...map.keys(), ...Object.keys(config.syntheticColumns ?? {})])); } let baseLocator: Locator; @@ -438,6 +441,47 @@ const createSmartRow = ( return !(smart as any)[SENTINEL_ROW]; }; + smart.getValue = async (colName: string): Promise => { + const syntheticDef = config.syntheticColumns?.[colName]; + if (syntheticDef) { + const guardedRow = Object.create(smart) as typeof smart; + guardedRow.getValue = async (innerCol: string): Promise => { + if (config.syntheticColumns?.[innerCol]) { + throw new Error( + `[SmartTable] Synthetic column "${innerCol}" cannot be read from inside another synthetic column's compute(). ` + + `Synthetic columns may only reference real or override columns.` + ); + } + return smart.getValue(innerCol); + }; + return String(await syntheticDef.compute(guardedRow)); + } + + const idx = map.get(colName); + if (idx === undefined) { + throw new Error(buildColumnNotFoundError(colName, [...map.keys(), ...Object.keys(config.syntheticColumns ?? {})])); + } + + const columnOverride = config.columnOverrides?.[colName as keyof T]; + const cell = config.strategies.getCellLocator + ? config.strategies.getCellLocator({ + row: rowLocator, root: rootLocator, columnName: colName, + columnIndex: idx, rowIndex, page: rootLocator.page(), config, + }) + : resolve(config.cellSelector, rowLocator).nth(idx); + + if (columnOverride?.read) { + const getCell = (name: string): Locator => { + const ci = map.get(name); + if (ci === undefined) throw new Error(`Column "${name}" not found`); + return resolve(config.cellSelector, rowLocator).nth(ci); + }; + return String(await columnOverride.read(cell, { row: smart, columnName: colName, columnIndex: idx, getCell })); + } + + return (await cell.innerText()).trim(); + }; + smart.toJSON = async (options?: { columns?: string[]; atomic?: boolean }): Promise => { // Atomic mode: snapshot the row in a single evaluate, then apply column overrides // against a frozen reconstruction. Zero inter-column stagger — even in-place React @@ -527,6 +571,17 @@ const createSmartRow = ( result[col] = snapshot.cells[idx].text; } } + + for (const [name, def] of Object.entries(config.syntheticColumns ?? {})) { + if (options?.columns && !options.columns.includes(name)) continue; + const snapshotRow = Object.create(smart) as typeof smart; + snapshotRow.getValue = async (col: string): Promise => { + if (col in result) return String(result[col]); + return smart.getValue(col); + }; + result[name] = String(await def.compute(snapshotRow)); + } + return result as unknown as T; } finally { await cleanupSnapshot(); @@ -717,6 +772,17 @@ const createSmartRow = ( result[col] = (text || '').trim(); } } + + for (const [name, def] of Object.entries(config.syntheticColumns ?? {})) { + if (options?.columns && !options.columns.includes(name)) continue; + const snapshotRow = Object.create(smart) as typeof smart; + snapshotRow.getValue = async (col: string): Promise => { + if (col in result) return String(result[col]); + return smart.getValue(col); + }; + result[name] = String(await def.compute(snapshotRow)); + } + return result as unknown as T; }; @@ -726,9 +792,13 @@ const createSmartRow = ( for (const [colName, value] of Object.entries(data)) { if (value === undefined) continue; + if (config.syntheticColumns?.[colName]) { + throw new Error(`Cannot fill synthetic column "${colName}" — it has no DOM cell`); + } + const colIdx = map.get(colName); if (colIdx === undefined) { - throw new Error(buildColumnNotFoundError(colName, Array.from(map.keys()))); + throw new Error(buildColumnNotFoundError(colName, [...map.keys(), ...Object.keys(config.syntheticColumns ?? {})])); } await _navigateToCell({ diff --git a/src/typeContext.ts b/src/typeContext.ts index 960fb204..2c48185a 100644 --- a/src/typeContext.ts +++ b/src/typeContext.ts @@ -304,6 +304,13 @@ export type SmartRow = Locator & { */ smartFill: (data: Partial | Record, options?: FillOptions) => Promise; + /** + * Get the resolved value of any column — real, override, or synthetic. + * @param column - Column name (case-sensitive) + * @returns The column value as a string + */ + getValue(column: string): Promise; + /** * Returns whether the row exists in the DOM (i.e. is not a sentinel row). */ @@ -473,6 +480,10 @@ export interface ColumnOverrideReadContext { getCell: (columnName: string) => Locator; } +export interface SyntheticColumnDef { + compute: (row: SmartRow) => Promise | string | number; +} + export interface ColumnOverride { /** * How to extract the value from the cell. @@ -685,6 +696,14 @@ export interface TableConfig { */ columnOverrides?: Partial>>; + /** + * Computed columns with no DOM presence. Each key becomes a virtual column name + * available in \`toJSON()\`, \`getValue()\`, and \`findRow()\`/\`findRows()\` filters. + * The \`compute\` function receives the full SmartRow and must only read real or + * override columns (no chaining between synthetics). + */ + syntheticColumns?: Record>; + /** * Locator for an empty-state element that replaces the table when there are no results. * If header resolution fails during init() and this locator is visible, init() succeeds diff --git a/src/types.ts b/src/types.ts index 81d51641..a158d8b8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -301,6 +301,13 @@ export type SmartRow = Locator & { */ smartFill: (data: Partial | Record, options?: FillOptions) => Promise; + /** + * Get the resolved value of any column — real, override, or synthetic. + * @param column - Column name (case-sensitive) + * @returns The column value as a string + */ + getValue(column: string): Promise; + /** * Returns whether the row exists in the DOM (i.e. is not a sentinel row). */ @@ -470,6 +477,10 @@ export interface ColumnOverrideReadContext { getCell: (columnName: string) => Locator; } +export interface SyntheticColumnDef { + compute: (row: SmartRow) => Promise | string | number; +} + export interface ColumnOverride { /** * How to extract the value from the cell. @@ -685,6 +696,14 @@ export interface TableConfig { */ columnOverrides?: Partial>>; + /** + * Computed columns with no DOM presence. Each key becomes a virtual column name + * available in `toJSON()`, `getValue()`, and `findRow()`/`findRows()` filters. + * The `compute` function receives the full SmartRow and must only read real or + * override columns (no chaining between synthetics). + */ + syntheticColumns?: Record>; + /** * Locator for an empty-state element that replaces the table when there are no results. * If header resolution fails during init() and this locator is visible, init() succeeds diff --git a/src/useTable.ts b/src/useTable.ts index ee48f41c..bf619d1d 100644 --- a/src/useTable.ts +++ b/src/useTable.ts @@ -307,7 +307,7 @@ export const useTable = (rootLocator: Locator, configOptions: TableConf getHeaders: async () => { log("getHeaders: fetching available columns"); const map = await tableMapper.getMap(); - return Array.from(map.keys()); + return [...map.keys(), ...Object.keys(config.syntheticColumns ?? {})]; }, getHeaderCell: async (columnName: string) => { @@ -329,16 +329,19 @@ export const useTable = (rootLocator: Locator, configOptions: TableConf const domFilters: Record = {}; const overrideFilters: Record = {}; + const syntheticFilters: Record = {}; if (hasFilters) { for (const [key, value] of Object.entries(filtersRecord)) { - if (config.columnOverrides?.[key as keyof T]?.read) { + if (config.syntheticColumns?.[key]) { + syntheticFilters[key] = value; + } else if (config.columnOverrides?.[key as keyof T]?.read) { overrideFilters[key] = value; } else { domFilters[key] = value; } } } - const hasOverrideFilters = Object.keys(overrideFilters).length > 0; + const hasPostFilters = Object.keys(overrideFilters).length > 0 || Object.keys(syntheticFilters).length > 0; const resolveRows = () => { let rows = resolve(config.rowSelector, rootLocator); @@ -350,39 +353,28 @@ export const useTable = (rootLocator: Locator, configOptions: TableConf return rows; }; - const countOverrideMatches = async (candidates: import('@playwright/test').Locator[], indices: number[]): Promise => { - if (!hasOverrideFilters) return indices.length; + const countPostFilterMatches = async (candidates: import('@playwright/test').Locator[], indices: number[]): Promise => { + if (!hasPostFilters) return indices.length; const map = tableMapper.getMapSync(); if (!map) throw new Error('Initialization Error: Table map not available. Call "await table.init()" first.'); const exact = options?.exact ?? false; + const hasOverrides = Object.keys(overrideFilters).length > 0; + const hasSynthetics = Object.keys(syntheticFilters).length > 0; let count = 0; for (const idx of indices) { const row = candidates[idx]; - let match = true; - for (const [colName, filterValue] of Object.entries(overrideFilters)) { - const colIndex = map.get(colName); - if (colIndex === undefined) continue; - const override = config.columnOverrides![colName as keyof T]!; - const cell = resolve(config.cellSelector, row).nth(colIndex); - const getCell = (name: string) => { - const ci = map.get(name); - if (ci === undefined) throw new Error(`Column "${name}" not found`); - return resolve(config.cellSelector, row).nth(ci); - }; - const ctx = { row: _makeSmart(row, map, undefined), columnName: colName, columnIndex: colIndex, getCell }; - const readValue = String(await override.read!(cell, ctx)); - if (!RowFinder.matchReadValue(readValue, filterValue, exact)) { match = false; break; } - } - if (match) count++; + if (hasOverrides && !await rowFinder.matchesOverrideFilters(row, overrideFilters, map, exact)) continue; + if (hasSynthetics && !await rowFinder.matchesSyntheticFilters(row, syntheticFilters, map, exact)) continue; + count++; } return count; }; if (!hasPagination) { log(`countRows: counting rows in current viewport (no pagination)${hasFilters ? ` filters=${safeStringify(filtersRecord)}` : ''}`); - if (!hasOverrideFilters) return resolveRows().count(); + if (!hasPostFilters) return resolveRows().count(); const all = await resolveRows().all(); - return countOverrideMatches(all, all.map((_, i) => i)); + return countPostFilterMatches(all, all.map((_, i) => i)); } log(`countRows: paginating up to ${effectiveMaxPages} page(s)${hasFilters ? ` filters=${safeStringify(filtersRecord)}` : ''}`); @@ -395,7 +387,7 @@ export const useTable = (rootLocator: Locator, configOptions: TableConf const rowLocators = resolveRows(); const newIndices = await tracker.getUnseenIndices(rowLocators); const candidates = await rowLocators.all(); - const matched = await countOverrideMatches(candidates, newIndices); + const matched = await countPostFilterMatches(candidates, newIndices); total += matched; log(`countRows: page ${pagesScanned} — ${matched} row(s) (running total: ${total})`); if (pagesScanned >= effectiveMaxPages) break; @@ -479,6 +471,13 @@ export const useTable = (rootLocator: Locator, configOptions: TableConf getRow: (filters: Partial | Record, options: { exact?: boolean } = { exact: false }): SmartRowType => { log(`getRow: filters=${safeStringify(filters)} exact=${options.exact}`); + const syntheticKeys = Object.keys(filters).filter(k => config.syntheticColumns?.[k]); + if (syntheticKeys.length > 0) { + throw new Error( + `getRow() cannot filter by synthetic column(s): ${syntheticKeys.join(', ')}. ` + + `Use findRow() instead — synthetic columns require async evaluation.` + ); + } const map = tableMapper.getMapSync(); if (!map) throw new Error('Initialization Error: You attempted to access a row before the table structure was mapped. Please call "await table.init()" once before using synchronous row access.'); diff --git a/src/utils/mergeTableConfig.ts b/src/utils/mergeTableConfig.ts index de6460dc..36744cac 100644 --- a/src/utils/mergeTableConfig.ts +++ b/src/utils/mergeTableConfig.ts @@ -70,5 +70,13 @@ export function mergeTableConfig( } as TableConfig['columnOverrides']; } + // Deep-merge syntheticColumns (override keys replace, preset keys retained) + if (base.syntheticColumns || overrides.syntheticColumns) { + result.syntheticColumns = { + ...base.syntheticColumns, + ...overrides.syntheticColumns, + }; + } + return result; } diff --git a/tests/synthetic-columns.spec.ts b/tests/synthetic-columns.spec.ts new file mode 100644 index 00000000..7e521673 --- /dev/null +++ b/tests/synthetic-columns.spec.ts @@ -0,0 +1,162 @@ +import { test, expect, type Page } from '@playwright/test'; +import { useTable } from '../src/index'; + +const TABLE = ` + + + + + + + +
NamePriceQty
Widget105
Gadget254
Doohickey710
+`; + +test.describe('Synthetic Columns (#391)', () => { + const makeTable = (page: Page) => + useTable(page.locator('#t'), { + syntheticColumns: { + Total: { + compute: async (row) => { + const price = Number(await row.getValue('Price')); + const qty = Number(await row.getValue('Qty')); + return String(price * qty); + }, + }, + Label: { + compute: async (row) => { + const name = await row.getValue('Name'); + return `Item: ${name}`; + }, + }, + }, + }); + + test('toJSON includes synthetic columns', async ({ page }) => { + await page.setContent(TABLE); + const table = await makeTable(page).init(); + const row = await table.findRow({ Name: 'Widget' }); + const data = await row.toJSON() as Record; + + expect(data.Total).toBe('50'); + expect(data.Label).toBe('Item: Widget'); + expect(data.Name).toBe('Widget'); + expect(data.Price).toBe('10'); + expect(data.Qty).toBe('5'); + }); + + test('toJSON with columns option filters synthetics', async ({ page }) => { + await page.setContent(TABLE); + const table = await makeTable(page).init(); + const row = await table.findRow({ Name: 'Gadget' }); + const data = await row.toJSON({ columns: ['Name', 'Total'] }) as Record; + + expect(data.Name).toBe('Gadget'); + expect(data.Total).toBe('100'); + expect(data.Price).toBeUndefined(); + expect(data.Label).toBeUndefined(); + }); + + test('getValue works for real and synthetic columns', async ({ page }) => { + await page.setContent(TABLE); + const table = await makeTable(page).init(); + const row = await table.findRow({ Name: 'Doohickey' }); + + expect(await row.getValue('Name')).toBe('Doohickey'); + expect(await row.getValue('Price')).toBe('7'); + expect(await row.getValue('Total')).toBe('70'); + }); + + test('getCell throws for synthetic columns', async ({ page }) => { + await page.setContent(TABLE); + const table = await makeTable(page).init(); + const row = await table.findRow({ Name: 'Widget' }); + + expect(() => row.getCell('Total')).toThrow(/synthetic.*no DOM cell/); + }); + + test('smartFill throws for synthetic columns', async ({ page }) => { + await page.setContent(TABLE); + const table = await makeTable(page).init(); + const row = await table.findRow({ Name: 'Widget' }); + + await expect(row.smartFill({ Total: '999' } as any)).rejects.toThrow(/synthetic/); + }); + + test('findRow filters by synthetic column value', async ({ page }) => { + await page.setContent(TABLE); + const table = await makeTable(page).init(); + const row = await table.findRow({ Total: '100' }, { exact: true }); + const data = await row.toJSON() as Record; + + expect(data.Name).toBe('Gadget'); + }); + + test('findRow combines DOM + synthetic filters', async ({ page }) => { + await page.setContent(TABLE); + const table = await makeTable(page).init(); + const row = await table.findRow({ Name: 'Widget', Total: '50' }, { exact: true }); + const data = await row.toJSON() as Record; + + expect(data.Name).toBe('Widget'); + expect(data.Total).toBe('50'); + }); + + test('findRows filters by synthetic column', async ({ page }) => { + await page.setContent(TABLE); + const table = await makeTable(page).init(); + const rows = await table.findRows({ Total: '70' }, { exact: true }); + + expect(rows.length).toBe(1); + const data = await rows[0].toJSON() as Record; + expect(data.Name).toBe('Doohickey'); + }); + + test('countRows with synthetic filter', async ({ page }) => { + await page.setContent(TABLE); + const table = await makeTable(page).init(); + + expect(await table.countRows({ Total: '50' }, { exact: true })).toBe(1); + expect(await table.countRows({ Total: '999' }, { exact: true })).toBe(0); + }); + + test('getRow throws when filtering by synthetic column', async ({ page }) => { + await page.setContent(TABLE); + const table = await makeTable(page).init(); + + expect(() => table.getRow({ Total: '50' } as any)).toThrow(/synthetic.*findRow/); + }); + + test('getHeaders includes synthetic column names', async ({ page }) => { + await page.setContent(TABLE); + const table = await makeTable(page).init(); + const headers = await table.getHeaders(); + + expect(headers).toEqual(['Name', 'Price', 'Qty', 'Total', 'Label']); + }); + + test('collision between synthetic and real header throws at init', async ({ page }) => { + await page.setContent(TABLE); + const table = useTable(page.locator('#t'), { + syntheticColumns: { + Price: { compute: async () => '0' }, + }, + }); + + await expect(table.init()).rejects.toThrow(/collide.*Price/); + }); + + test('synthetic column cannot read another synthetic (no chaining)', async ({ page }) => { + await page.setContent(TABLE); + const table = useTable(page.locator('#t'), { + syntheticColumns: { + A: { compute: async (row) => row.getValue('Name') }, + B: { compute: async (row) => row.getValue('A') }, + }, + }); + await table.init(); + const row = await table.findRow({ Name: 'Widget' }); + + await expect(row.getValue('B')).rejects.toThrow(/cannot be read.*inside another synthetic/); + }); +}); diff --git a/tests/unit/syntheticColumns.test.ts b/tests/unit/syntheticColumns.test.ts new file mode 100644 index 00000000..2a268886 --- /dev/null +++ b/tests/unit/syntheticColumns.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi } from 'vitest'; +import { RowFinder } from '../../src/engine/rowFinder'; +import { FinalTableConfig, FilterValue } from '../../src/types'; + +describe('Synthetic Columns', () => { + describe('RowFinder.splitFilters', () => { + const makeFinder = (overrideCols: string[] = [], syntheticCols: string[] = []) => { + const columnOverrides: any = {}; + for (const col of overrideCols) { + columnOverrides[col] = { read: vi.fn() }; + } + const syntheticColumns: any = {}; + for (const col of syntheticCols) { + syntheticColumns[col] = { compute: vi.fn() }; + } + const config = { + columnOverrides, + syntheticColumns, + } as unknown as FinalTableConfig; + + const finder = new (RowFinder as any)( + {}, config, vi.fn(), {}, {}, vi.fn(), { currentPageIndex: 0 }, vi.fn() + ); + return finder; + }; + + it('routes synthetic filters to syntheticFilters bucket', () => { + const finder = makeFinder([], ['Total']); + const result = finder.splitFilters({ Name: 'Alice', Total: '100' }); + expect(result.domFilters).toEqual({ Name: 'Alice' }); + expect(result.overrideFilters).toEqual({}); + expect(result.syntheticFilters).toEqual({ Total: '100' }); + }); + + it('separates all three filter types', () => { + const finder = makeFinder(['Link'], ['Total']); + const result = finder.splitFilters({ Name: 'Alice', Link: '/foo', Total: '100' }); + expect(result.domFilters).toEqual({ Name: 'Alice' }); + expect(result.overrideFilters).toEqual({ Link: '/foo' }); + expect(result.syntheticFilters).toEqual({ Total: '100' }); + }); + + it('synthetic takes priority over override when a column is in both', () => { + const finder = makeFinder(['Total'], ['Total']); + const result = finder.splitFilters({ Total: '100' }); + expect(result.syntheticFilters).toEqual({ Total: '100' }); + expect(result.overrideFilters).toEqual({}); + }); + }); + + describe('RowFinder.matchReadValue', () => { + it('matches string exactly when exact=true', () => { + expect(RowFinder.matchReadValue('100', '100', true)).toBe(true); + expect(RowFinder.matchReadValue('1000', '100', true)).toBe(false); + }); + + it('matches string with includes when exact=false', () => { + expect(RowFinder.matchReadValue('1000', '100', false)).toBe(true); + }); + + it('matches RegExp', () => { + expect(RowFinder.matchReadValue('42', /^\d+$/, true)).toBe(true); + expect(RowFinder.matchReadValue('abc', /^\d+$/, true)).toBe(false); + }); + + it('matches number as string', () => { + expect(RowFinder.matchReadValue('42', 42, true)).toBe(true); + }); + + it('throws on function filter', () => { + expect(() => RowFinder.matchReadValue('42', (() => {}) as any, true)).toThrow('Function filters are not supported'); + }); + }); +});