432 lines
14 KiB
TypeScript
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);
|
|
});
|
|
});
|