import { describe, expect, it } from 'vitest'; import { buildFleetTableViewModel, fleetStatusKind, type FleetInstance, type FleetStatus, } from './fleet-table-view-model.js'; const instances: readonly FleetInstance[] = [ { id: 'charlie', name: 'Charlie', url: 'https://charlie.example', description: 'Warehouse router', tags: ['west'], }, { id: 'alpha', name: 'Alpha', url: 'https://alpha.example', tags: ['east'] }, { id: 'bravo', name: 'Bravo', url: 'https://bravo.example' }, { id: 'delta', name: '', url: 'https://delta.example' }, ]; const statuses: ReadonlyMap = new Map([ [ 'charlie', { reachable: true, authenticated: true, latencyMs: 55, summary: { carrier: 'Acme', version: '2.4.1', capabilities: ['sms', 'ussd'], freshness: 'stale', anomalies: ['latency spike'], }, }, ], ['alpha', { reachable: false, latencyMs: 8 }], ['bravo', { reachable: true, authenticated: false, latencyMs: 20 }], ]); describe('fleet table view model', () => { it('classifies missing, unreachable, authentication-required, and online status', () => { expect(fleetStatusKind(undefined)).toBe('unknown'); expect(fleetStatusKind({ reachable: false })).toBe('offline'); expect(fleetStatusKind({ reachable: true, authenticated: false })).toBe('auth'); expect(fleetStatusKind({ reachable: true, authenticated: true })).toBe('online'); }); it('treats a device the heartbeat has never reached as unknown, not offline', () => { expect(fleetStatusKind({ probed: false, reachable: false, authenticated: false })).toBe( 'unknown', ); expect(fleetStatusKind({ probed: true, reachable: false, authenticated: false })).toBe( 'offline', ); expect(fleetStatusKind({ probed: true, reachable: true, authenticated: true })).toBe('online'); }); it('builds stable rows and defaults to ascending display-name order', () => { const model = buildFleetTableViewModel(instances, statuses); expect(model.rows.map((row) => [row.id, row.displayName, row.statusKind])).toEqual([ ['alpha', 'Alpha', 'offline'], ['bravo', 'Bravo', 'auth'], ['charlie', 'Charlie', 'online'], ['delta', 'delta', 'unknown'], ]); expect(model.totalCount).toBe(4); expect(model.visibleCount).toBe(4); expect(model.emptyReason).toBeNull(); }); it('searches user-visible instance and status fields case-insensitively', () => { expect( buildFleetTableViewModel(instances, statuses, { query: ' ACME ' }).rows.map((row) => row.id), ).toEqual(['charlie']); expect( buildFleetTableViewModel(instances, statuses, { query: 'WEST' }).rows.map((row) => row.id), ).toEqual(['charlie']); }); it('filters by status and reports a useful empty reason', () => { const filtered = buildFleetTableViewModel(instances, statuses, { filter: 'online' }); expect(filtered.rows.map((row) => row.id)).toEqual(['charlie']); expect(buildFleetTableViewModel([], new Map()).emptyReason).toBe('config'); expect( buildFleetTableViewModel(instances, statuses, { query: 'not present' }).emptyReason, ).toBe('search'); expect( buildFleetTableViewModel(instances, statuses, { filter: 'online', query: 'Alpha' }) .emptyReason, ).toBe('search'); expect( buildFleetTableViewModel([instances[1]!], statuses, { filter: 'online' }).emptyReason, ).toBe('filter'); expect( buildFleetTableViewModel(instances, statuses, { filter: 'online', query: ' ' }).emptyReason, ).toBeNull(); expect( buildFleetTableViewModel(instances, statuses, { filter: 'offline', query: 'Bravo' }) .emptyReason, ).toBe('search'); }); it('sorts latency with missing latency last and status by operational priority', () => { expect( buildFleetTableViewModel(instances, statuses, { sort: { column: 'latency', direction: 'asc' }, }).rows.map((row) => row.id), ).toEqual(['alpha', 'bravo', 'charlie', 'delta']); expect( buildFleetTableViewModel(instances, statuses, { sort: { column: 'latency', direction: 'desc' }, }).rows.map((row) => row.id), ).toEqual(['charlie', 'bravo', 'alpha', 'delta']); expect( buildFleetTableViewModel(instances, statuses, { sort: { column: 'status', direction: 'asc' }, }).rows.map((row) => row.id), ).toEqual(['charlie', 'bravo', 'alpha', 'delta']); }); it('normalizes selection to known instances and exposes visible selection state', () => { const model = buildFleetTableViewModel(instances, statuses, { filter: 'online', selectedIds: new Set(['charlie', 'alpha', 'missing']), }); expect(model.rows[0]?.selected).toBe(true); expect(model.selectedIds).toEqual(['alpha', 'charlie']); expect(model.visibleSelection).toEqual({ checked: true, indeterminate: false, selectedCount: 1, rowCount: 1, }); }); it('does not mutate input arrays while sorting', () => { const originalOrder = instances.map((instance) => instance.id); buildFleetTableViewModel(instances, statuses, { sort: { column: 'name', direction: 'desc' } }); expect(instances.map((instance) => instance.id)).toEqual(originalOrder); }); it('derives optional operational metadata from status summary with safe defaults', () => { const model = buildFleetTableViewModel(instances, statuses); const charlie = model.rows.find((row) => row.id === 'charlie'); const alpha = model.rows.find((row) => row.id === 'alpha'); expect(charlie).toMatchObject({ version: '2.4.1', capabilities: ['sms', 'ussd'], freshness: 'stale', anomalies: ['latency spike'], tags: ['west'], }); expect(alpha).toMatchObject({ version: null, capabilities: [], freshness: 'unknown', anomalies: [], tags: ['east'], }); }); it('combines authentication, capability, version, and tag filters', () => { expect( buildFleetTableViewModel(instances, statuses, { auth: 'authenticated', capability: 'sms', version: '2.4.1', tag: 'west', }).rows.map((row) => row.id), ).toEqual(['charlie']); expect( buildFleetTableViewModel(instances, statuses, { auth: 'required' }).rows.map((row) => row.id), ).toEqual(['bravo']); }); it('sorts metadata columns, including scalar freshness, and paginates after filtering and sorting', () => { const model = buildFleetTableViewModel(instances, statuses, { sort: { column: 'version', direction: 'asc' }, page: 2, pageSize: 2, }); expect(model.rows.map((row) => row.id)).toEqual(['bravo', 'delta']); expect(model.filteredCount).toBe(4); expect(model.page).toBe(2); expect(model.pageCount).toBe(2); expect(model.visibleSelection.rowCount).toBe(2); expect( buildFleetTableViewModel(instances, statuses, { sort: { column: 'freshness', direction: 'asc' }, }).rows.map((row) => row.id), ).toEqual(['charlie', 'alpha', 'bravo', 'delta']); }); it('clamps invalid pages and exposes filter choices from available data', () => { const model = buildFleetTableViewModel(instances, statuses, { page: 99, pageSize: 3 }); expect(model.page).toBe(2); expect(model.rows.map((row) => row.id)).toEqual(['delta']); expect(model.facets).toEqual({ capabilities: ['sms', 'ussd'], versions: ['2.4.1'], tags: ['east', 'west'], groups: [], }); }); it('filters, sorts, and searches by device group', () => { const grouped: readonly FleetInstance[] = [ { id: 'one', name: 'One', url: 'https://one.example', groupId: 'group-field' }, { id: 'two', name: 'Two', url: 'https://two.example', groupId: 'group-office' }, { id: 'three', name: 'Three', url: 'https://three.example', groupId: null }, { id: 'four', name: 'Four', url: 'https://four.example' }, ]; const groupNames = new Map([ ['group-field', 'Field Ops'], ['group-office', 'Office'], ]); const model = buildFleetTableViewModel(grouped, new Map(), { groupNames }); expect(model.facets.groups).toEqual(['group-field', 'group-office']); expect(model.rows.map((row) => [row.id, row.groupId])).toEqual([ ['four', null], ['one', 'group-field'], ['three', null], ['two', 'group-office'], ]); expect( buildFleetTableViewModel(grouped, new Map(), { group: 'group-field', groupNames, }).rows.map((row) => row.id), ).toEqual(['one']); // Group names are searchable, not just the raw identifier. expect( buildFleetTableViewModel(grouped, new Map(), { query: 'office', groupNames }).rows.map( (row) => row.id, ), ).toEqual(['two']); expect( buildFleetTableViewModel(grouped, new Map(), { query: '外场', groupNames: new Map([['group-field', '外场']]), }).rows.map((row) => row.id), ).toEqual(['one']); expect( buildFleetTableViewModel(grouped, new Map(), { sort: { column: 'group', direction: 'asc' }, groupNames, }).rows.map((row) => row.id), ).toEqual(['one', 'two', 'four', 'three']); // Sorting follows the visible group name, never the opaque identifier. expect( buildFleetTableViewModel(grouped, new Map(), { sort: { column: 'group', direction: 'asc' }, groupNames: new Map([ ['group-field', 'Zeta'], ['group-office', 'Alpha'], ]), }).rows.map((row) => row.id), ).toEqual(['two', 'one', 'four', 'three']); expect( buildFleetTableViewModel(grouped, new Map(), { group: 'group-missing', groupNames, }).emptyReason, ).toBe('filter'); }); it('marks and searches the nodes the identity guard is holding', () => { const instances: FleetInstance[] = [ { id: 'held', name: 'Held', url: 'http://a', tags: [] }, { id: 'clear', name: 'Clear', url: 'http://b', tags: [] }, { id: 'blind', name: 'Blind', url: 'http://c', tags: [] }, ]; const statuses = new Map([ [ 'held', { reachable: true, identity: { tracked: true, status: 'pending', reasons: ['imei_claimed'] }, }, ], ['clear', { reachable: true, identity: { tracked: true, status: 'confirmed', reasons: [] } }], [ 'blind', { reachable: true, identity: { tracked: false, status: 'confirmed', reasons: [] } }, ], ]); const rows = buildFleetTableViewModel(instances, statuses).rows; expect(rows.map((row) => [row.id, row.identityPending])).toEqual([ ['blind', false], ['clear', false], ['held', true], ]); expect( buildFleetTableViewModel(instances, statuses, { query: '身份待确认' }).rows.map((r) => r.id), ).toEqual(['held']); }); it('filters and searches by derived access method and reported IMEI', () => { const scoped: readonly FleetInstance[] = [ { id: 'local', name: 'Local', url: 'http://127.0.0.1:3000', tags: [] }, { id: 'lan', name: 'Lan', url: 'http://192.168.1.3', tags: [] }, { id: 'wan', name: 'Wan', url: 'https://wan.example', tags: [] }, ]; const model = buildFleetTableViewModel(scoped, new Map(), { access: 'local' }); expect(model.rows.map((row) => row.id)).toEqual(['local']); expect(buildFleetTableViewModel(instances, statuses, { access: 'lan' }).emptyReason).toBe( 'filter', ); expect( buildFleetTableViewModel(scoped, new Map(), { query: 'lan' }).rows.map((r) => r.id), ).toEqual(['lan']); const identified = new Map([ [ 'alpha', { reachable: true, identity: { tracked: true, status: 'confirmed', reasons: [], imei: '860000000000001', }, }, ], ]); expect( buildFleetTableViewModel( [{ id: 'alpha', name: 'Alpha', url: 'http://alpha.example', tags: [] }], identified, { query: '860000000000001' }, ).rows.map((row) => row.id), ).toEqual(['alpha']); }); });