Files
multi-simadmin/apps/web/src/fleet/fleet-table-view-model.test.ts
T
chick 864e1bd90c feat(web): rebuild fleet page with Hub-style identity guard and context actions
- Surface device identity state, carrier details and upstream link health.

- Add fleet-level identity monitoring and restart/baseband context actions.

- Apply consistent notification scoping across fleet views.
2026-09-07 00:45:28 +08:00

351 lines
12 KiB
TypeScript

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<string, FleetStatus> = 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<string, FleetStatus>([
[
'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<string, FleetStatus>([
[
'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']);
});
});