/** * table-response-mapping.test.ts — Step 17.5 * * Tests covering: * - normalizeTableRows: valid, empty, invalid shapes * - Runtime behavior: precedence, fallback, selection clearing * - Target capability: Table.rows accepted; other Table targets rejected * - Regression: static Table, Dropdown options, Label/JsonViewer value */ import { normalizeTableRows, isTargetPropertySupported, } from './bindingUtils'; import type { NormalizeTableRowsResult } from './bindingUtils'; // ── normalizeTableRows: valid shapes ───────────────────────────────────────── describe('normalizeTableRows — valid shapes', () => { test('valid array of objects is accepted', () => { const result = normalizeTableRows([ { hostname: 'host-one', environment: 'development', status: 'online' }, { hostname: 'host-two', environment: 'test', status: 'offline' }, ]); expect(result.ok).toBe(true); if (result.ok) expect(result.rows).toHaveLength(2); }); test('empty array is accepted and produces empty rows', () => { const result = normalizeTableRows([]); expect(result.ok).toBe(true); if (result.ok) expect(result.rows).toHaveLength(0); }); test('row order is preserved', () => { const raw = [ { order: 1, name: 'first' }, { order: 2, name: 'second' }, { order: 3, name: 'third' }, ]; const result = normalizeTableRows(raw); expect(result.ok).toBe(true); if (result.ok) { expect(result.rows[0].order).toBe(1); expect(result.rows[1].order).toBe(2); expect(result.rows[2].order).toBe(3); } }); test('creates shallow copies — no retained references to source objects', () => { const originalRow = { hostname: 'h1', status: 'online' }; const raw = [originalRow]; const result = normalizeTableRows(raw); expect(result.ok).toBe(true); if (result.ok) { // Shallow copy: different object identity expect(result.rows[0]).not.toBe(originalRow); // Shallow copy: same field values expect(result.rows[0]).toEqual(originalRow); } }); test('nested object cell value is retained in shallow copy', () => { const nested = { region: 'us-east-1' }; const raw = [{ hostname: 'h1', meta: nested }]; const result = normalizeTableRows(raw); expect(result.ok).toBe(true); if (result.ok) { // Shallow copy — the nested object itself is the same reference expect(result.rows[0].meta).toBe(nested); } }); test('nested array cell value is retained in shallow copy', () => { const tags = ['prod', 'web']; const raw = [{ hostname: 'h1', tags }]; const result = normalizeTableRows(raw); expect(result.ok).toBe(true); if (result.ok) { expect(result.rows[0].tags).toBe(tags); } }); test('row with mixed value types is accepted', () => { const raw = [{ str: 'text', num: 42, bool: true, nil: null, nested: { key: 'val' }, arr: [1, 2], }]; const result = normalizeTableRows(raw); expect(result.ok).toBe(true); if (result.ok) { expect(result.rows[0].str).toBe('text'); expect(result.rows[0].num).toBe(42); expect(result.rows[0].bool).toBe(true); expect(result.rows[0].nil).toBe(null); } }); }); // ── normalizeTableRows: invalid shapes ──────────────────────────────────────── describe('normalizeTableRows — invalid shapes', () => { test('non-array object is rejected', () => { const result = normalizeTableRows({ hostname: 'h1' }); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toContain('array'); }); test('string is rejected', () => { const result = normalizeTableRows('host-one'); expect(result.ok).toBe(false); }); test('number is rejected', () => { const result = normalizeTableRows(42); expect(result.ok).toBe(false); }); test('null is rejected', () => { const result = normalizeTableRows(null); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toContain('null'); }); test('undefined is rejected', () => { const result = normalizeTableRows(undefined); expect(result.ok).toBe(false); }); test('array of strings is rejected', () => { const result = normalizeTableRows(['host-one', 'host-two']); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toContain('index 0'); expect(result.reason).toContain('string'); } }); test('array of numbers is rejected', () => { const result = normalizeTableRows([1, 2, 3]); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toContain('index 0'); }); test('array of booleans is rejected', () => { const result = normalizeTableRows([true, false]); expect(result.ok).toBe(false); }); test('array containing null is rejected', () => { const result = normalizeTableRows([null]); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toContain('index 0'); expect(result.reason).toContain('null'); } }); test('array containing a nested array is rejected', () => { const result = normalizeTableRows([['nested']]); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toContain('index 0'); expect(result.reason).toContain('array'); } }); test('mixed object/primitive array is rejected at first bad element', () => { const result = normalizeTableRows([ { hostname: 'h1' }, 'invalid-string', ]); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toContain('index 1'); }); test('null at non-zero index is rejected with correct index', () => { const result = normalizeTableRows([ { hostname: 'h1' }, null, ]); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toContain('index 1'); }); test('entire array is rejected on invalid element — no partial rows returned', () => { const result: NormalizeTableRowsResult = normalizeTableRows([ { hostname: 'h1' }, null, { hostname: 'h3' }, ]); expect(result.ok).toBe(false); // ok:false has no rows property — confirm shape expect('rows' in result).toBe(false); }); }); // ── Runtime behavior ────────────────────────────────────────────────────────── describe('Table runtime rows precedence', () => { type RuntimeState = { rows?: Array>; selectedIndex?: number; selectedRow?: Record; }; const configuredRows = [{ hostname: 'fallback-host', status: 'configured' }]; function effectiveRows( runtimeState: RuntimeState | undefined, configured: Array>, ): Array> { // Mirrors the PreviewComponent logic exactly return runtimeState?.rows !== undefined ? runtimeState.rows : configured; } test('runtime rows override configured rows when defined', () => { const runtimeState: RuntimeState = { rows: [{ hostname: 'runtime-host', status: 'dynamic' }], }; const result = effectiveRows(runtimeState, configuredRows); expect(result).toHaveLength(1); expect(result[0].hostname).toBe('runtime-host'); }); test('empty runtime rows override configured rows — do not fall back', () => { const runtimeState: RuntimeState = { rows: [] }; const result = effectiveRows(runtimeState, configuredRows); expect(result).toHaveLength(0); }); test('configured rows are used when runtime rows are undefined', () => { const result = effectiveRows(undefined, configuredRows); expect(result).toBe(configuredRows); }); test('configured rows are used when runtimeState is undefined', () => { const result = effectiveRows(undefined, configuredRows); expect(result).toHaveLength(1); expect(result[0].hostname).toBe('fallback-host'); }); }); // ── Selection clearing when rows are replaced ───────────────────────────────── describe('Table selection clearing on rows replacement', () => { type TableRuntimeState = { rows?: Array>; selectedIndex?: number; selectedRow?: Record; loading?: boolean; error?: string; }; /** * Simulates the state update performed in applyResponseBindings for a .rows target. * Mirrors the Step 17.5 contract exactly. */ function applyRuntimeRows( existing: TableRuntimeState, newRows: Array>, ): TableRuntimeState { return { ...existing, rows: newRows, selectedIndex: undefined, selectedRow: undefined, loading: false, error: undefined, }; } test('replacing rows clears selectedIndex', () => { const existing: TableRuntimeState = { selectedIndex: 1, selectedRow: { hostname: 'h2' } }; const updated = applyRuntimeRows(existing, [{ hostname: 'new-host' }]); expect(updated.selectedIndex).toBeUndefined(); }); test('replacing rows clears selectedRow', () => { const existing: TableRuntimeState = { selectedIndex: 0, selectedRow: { hostname: 'h1' } }; const updated = applyRuntimeRows(existing, [{ hostname: 'new-host' }]); expect(updated.selectedRow).toBeUndefined(); }); test('first row is not auto-selected after replacement', () => { const existing: TableRuntimeState = {}; const updated = applyRuntimeRows(existing, [ { hostname: 'host-1' }, { hostname: 'host-2' }, ]); expect(updated.selectedIndex).toBeUndefined(); expect(updated.selectedRow).toBeUndefined(); }); test('replacing rows with empty array clears selection', () => { const existing: TableRuntimeState = { selectedIndex: 2, selectedRow: { hostname: 'h3' } }; const updated = applyRuntimeRows(existing, []); expect(updated.rows).toHaveLength(0); expect(updated.selectedIndex).toBeUndefined(); expect(updated.selectedRow).toBeUndefined(); }); test('unrelated runtime state is preserved when rows are replaced', () => { const existing: TableRuntimeState = { selectedIndex: 0, selectedRow: { hostname: 'old' }, loading: true, // will be cleared to false error: 'old error', // will be cleared }; const updated = applyRuntimeRows(existing, [{ hostname: 'new' }]); expect(updated.rows).toHaveLength(1); expect(updated.loading).toBe(false); expect(updated.error).toBeUndefined(); }); test('invalid mapping does not modify existing runtime rows', () => { // Simulate: normalizeTableRows fails → skip → existing state unchanged const existingRows = [{ hostname: 'preserved' }]; const existing: TableRuntimeState = { rows: existingRows }; const normalizeResult = normalizeTableRows(['invalid-string']); expect(normalizeResult.ok).toBe(false); // Because normalization failed, we do NOT call applyRuntimeRows // existing state is unchanged expect(existing.rows).toBe(existingRows); }); }); // ── Target capability: full model ───────────────────────────────────────────── describe('Target capability — Step 17.5 model', () => { // Accepted targets test('JsonViewer.value is accepted', () => { expect(isTargetPropertySupported('JsonViewer', 'value')).toBe(true); }); test('Label.value is accepted', () => { expect(isTargetPropertySupported('Label', 'value')).toBe(true); }); test('Dropdown.options is accepted', () => { expect(isTargetPropertySupported('Dropdown', 'options')).toBe(true); }); test('Table.rows is accepted', () => { expect(isTargetPropertySupported('Table', 'rows')).toBe(true); }); // Rejected Table targets test('Table.value is rejected', () => { expect(isTargetPropertySupported('Table', 'value')).toBe(false); }); test('Table.options is rejected', () => { expect(isTargetPropertySupported('Table', 'options')).toBe(false); }); test('Table.selectedRow is rejected', () => { expect(isTargetPropertySupported('Table', 'selectedRow')).toBe(false); }); test('Table.selectedIndex is rejected', () => { expect(isTargetPropertySupported('Table', 'selectedIndex')).toBe(false); }); // Rejected rows targets on other components test('JsonViewer.rows is rejected', () => { expect(isTargetPropertySupported('JsonViewer', 'rows')).toBe(false); }); test('Label.rows is rejected', () => { expect(isTargetPropertySupported('Label', 'rows')).toBe(false); }); test('Dropdown.rows is rejected', () => { expect(isTargetPropertySupported('Dropdown', 'rows')).toBe(false); }); test('Button.rows is rejected', () => { expect(isTargetPropertySupported('Button', 'rows')).toBe(false); }); test('TextInput.rows is rejected', () => { expect(isTargetPropertySupported('TextInput', 'rows')).toBe(false); }); // Regression: existing accepted targets still work test('Dropdown.value is rejected (not a response-binding target)', () => { expect(isTargetPropertySupported('Dropdown', 'value')).toBe(false); }); test('Label.options is rejected', () => { expect(isTargetPropertySupported('Label', 'options')).toBe(false); }); }); // ── Regression: static Table and Dropdown still work ───────────────────────── describe('Regression: static rendering and Dropdown', () => { test('normalizeTableRows accepts three-row inventory', () => { const result = normalizeTableRows([ { hostname: 'host-one', environment: 'development', status: 'online' }, { hostname: 'host-two', environment: 'test', status: 'offline' }, { hostname: 'host-three', environment: 'production', status: 'online' }, ]); expect(result.ok).toBe(true); if (result.ok) { expect(result.rows).toHaveLength(3); expect(result.rows[0].hostname).toBe('host-one'); expect(result.rows[2].status).toBe('online'); } }); test('Dropdown.options still accepted (regression)', () => { expect(isTargetPropertySupported('Dropdown', 'options')).toBe(true); }); test('JsonViewer.value still accepted (regression)', () => { expect(isTargetPropertySupported('JsonViewer', 'value')).toBe(true); }); test('Label.value still accepted (regression)', () => { expect(isTargetPropertySupported('Label', 'value')).toBe(true); }); });