conductor/frontend/src/components/Preview/table-response-mapping.test.ts
2026-07-18 10:23:13 -04:00

432 lines
14 KiB
TypeScript

/**
* 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<Record<string, unknown>>;
selectedIndex?: number;
selectedRow?: Record<string, unknown>;
};
const configuredRows = [{ hostname: 'fallback-host', status: 'configured' }];
function effectiveRows(
runtimeState: RuntimeState | undefined,
configured: Array<Record<string, unknown>>,
): Array<Record<string, unknown>> {
// 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<Record<string, unknown>>;
selectedIndex?: number;
selectedRow?: Record<string, unknown>;
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<Record<string, unknown>>,
): 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);
});
});