- 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.
351 lines
12 KiB
TypeScript
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']);
|
|
});
|
|
});
|