- 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.
733 lines
30 KiB
TypeScript
733 lines
30 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { accessMethod } from './fleet-table-view-model.js';
|
|
import { FleetPage, type FleetSnapshot } from './fleet-page.js';
|
|
|
|
const snapshot: FleetSnapshot = {
|
|
instances: [
|
|
{
|
|
id: 'alpha',
|
|
name: 'Alpha modem',
|
|
url: 'http://192.168.1.2',
|
|
tags: [],
|
|
revision: 3,
|
|
},
|
|
],
|
|
statuses: new Map([
|
|
[
|
|
'alpha',
|
|
{
|
|
reachable: true,
|
|
authenticated: true,
|
|
summary: {
|
|
version: '1.1.6',
|
|
freshness: 'fresh',
|
|
resources: {
|
|
cpuPercent: 24,
|
|
memoryPercent: 51,
|
|
maxTemperatureCelsius: 42,
|
|
phoneNumbers: ['+852 5550 0100'],
|
|
hardwareOnline: true,
|
|
controlOnline: true,
|
|
simPresent: true,
|
|
carrier: 'China Mobile',
|
|
cellularRegistration: 'registered_home',
|
|
accessTechnology: 'LTE',
|
|
cellularOnline: true,
|
|
signalPercent: 72,
|
|
},
|
|
},
|
|
},
|
|
],
|
|
]),
|
|
};
|
|
|
|
afterEach(cleanup);
|
|
|
|
describe('FleetPage card navigation', () => {
|
|
it('masks both phone locations and reveals them only for the selected card', async () => {
|
|
const privacySnapshot: FleetSnapshot = {
|
|
instances: [
|
|
...snapshot.instances,
|
|
{
|
|
id: 'beta',
|
|
name: 'Beta modem',
|
|
url: 'http://192.168.1.3',
|
|
tags: [],
|
|
revision: 1,
|
|
},
|
|
],
|
|
statuses: new Map([
|
|
...snapshot.statuses,
|
|
[
|
|
'beta',
|
|
{
|
|
reachable: true,
|
|
authenticated: true,
|
|
summary: {
|
|
version: '1.2.0',
|
|
freshness: 'fresh' as const,
|
|
resources: { phoneNumbers: ['13812345678'] },
|
|
},
|
|
},
|
|
],
|
|
]),
|
|
};
|
|
const messagesDataSource = {
|
|
load: vi.fn(async (instanceId: string) =>
|
|
instanceId === 'alpha'
|
|
? {
|
|
latest: {
|
|
id: 'sms-1',
|
|
direction: 'incoming',
|
|
phoneNumber: '13900139000',
|
|
content: 'status',
|
|
timestamp: '2026-07-19T08:30:00.000Z',
|
|
},
|
|
}
|
|
: {},
|
|
),
|
|
};
|
|
|
|
render(<FleetPage initialData={privacySnapshot} messagesDataSource={messagesDataSource} />);
|
|
|
|
const alpha = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
|
|
const beta = screen.getByRole('article', { name: 'Beta modem 实例概览' });
|
|
expect(within(alpha).getByText('+852 •••• 0100')).toBeTruthy();
|
|
expect(within(beta).getByText('138 •••• 5678')).toBeTruthy();
|
|
expect(await within(alpha).findByText('139 •••• 9000')).toBeTruthy();
|
|
expect(within(alpha).queryByText('+852 5550 0100')).toBeNull();
|
|
expect(within(alpha).queryByText('13900139000')).toBeNull();
|
|
|
|
const reveal = within(alpha).getByRole('button', { name: '显示 Alpha modem 手机号' });
|
|
expect(reveal.getAttribute('aria-pressed')).toBe('false');
|
|
fireEvent.click(reveal);
|
|
|
|
expect(within(alpha).getByText('+852 5550 0100')).toBeTruthy();
|
|
expect(within(alpha).getByText('13900139000')).toBeTruthy();
|
|
expect(within(beta).getByText('138 •••• 5678')).toBeTruthy();
|
|
expect(within(beta).queryByText('13812345678')).toBeNull();
|
|
expect(within(alpha).getByRole('button', { name: '隐藏 Alpha modem 手机号' })).toBeTruthy();
|
|
});
|
|
|
|
it('does not show a privacy toggle when neither phone location has a number', () => {
|
|
const noNumbers: FleetSnapshot = {
|
|
...snapshot,
|
|
statuses: new Map([
|
|
[
|
|
'alpha',
|
|
{
|
|
...snapshot.statuses.get('alpha')!,
|
|
summary: {
|
|
...snapshot.statuses.get('alpha')!.summary,
|
|
resources: { cpuPercent: 24, memoryPercent: 51 },
|
|
},
|
|
},
|
|
],
|
|
]),
|
|
};
|
|
|
|
render(<FleetPage initialData={noNumbers} />);
|
|
|
|
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
|
|
expect(within(card).getByText('暂未获取')).toBeTruthy();
|
|
expect(within(card).queryByRole('button', { name: /手机号/ })).toBeNull();
|
|
});
|
|
|
|
it('makes the whole instance card open its dashboard while ops stay separate and config stays out of the card', () => {
|
|
render(<FleetPage initialData={snapshot} />);
|
|
|
|
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
|
|
const entry = within(card).getByRole('link', { name: '打开 Alpha modem 实例仪表盘' });
|
|
expect(entry.getAttribute('href')).toBe('/instances/alpha/overview');
|
|
expect(entry.classList.contains('fleet-card-entry')).toBe(true);
|
|
expect(within(card).queryByRole('link', { name: /编辑/ })).toBeNull();
|
|
expect(within(card).queryByRole('button', { name: /删除/ })).toBeNull();
|
|
expect(within(card).queryByText('alpha')).toBeNull();
|
|
expect(within(card).queryByRole('link', { name: /查看.*短信/ })).toBeNull();
|
|
expect(screen.getByRole('region', { name: '实例状态摘要' }).textContent).toMatch(
|
|
/实例总数\s*1.*在线\s*1.*需处理\s*0/s,
|
|
);
|
|
expect(screen.queryByText('NODE DIRECTORY')).toBeNull();
|
|
expect(screen.queryByText(/LIVE NODE MATRIX/u)).toBeNull();
|
|
expect(screen.queryByText('已纳入统一管理')).toBeNull();
|
|
expect(screen.queryByText('连接与认证正常')).toBeNull();
|
|
expect(screen.queryByText('离线、认证或未知')).toBeNull();
|
|
expect(screen.queryByRole('region', { name: '节点资源健康' })).toBeNull();
|
|
expect(within(card).getByText('SimAdmin 1.1.6')).toBeTruthy();
|
|
expect(
|
|
within(card).getByRole('progressbar', { name: 'CPU 使用率' }).getAttribute('aria-valuenow'),
|
|
).toBe('24');
|
|
expect(
|
|
within(card).getByRole('progressbar', { name: '内存使用率' }).getAttribute('aria-valuenow'),
|
|
).toBe('51');
|
|
expect(within(card).getByText('进入仪表盘')).toBeTruthy();
|
|
const links = within(card).getByRole('region', { name: '链路状态' });
|
|
for (const label of ['硬件', '控制', 'SIM', '蜂窝']) {
|
|
expect(within(links).getByText(label)).toBeTruthy();
|
|
}
|
|
expect(links.textContent).toMatch(/硬件\s*正常.*控制\s*正常.*SIM\s*正常.*蜂窝\s*正常/s);
|
|
expect(within(card).getByText('China Mobile')).toBeTruthy();
|
|
expect(within(card).getByText('LTE · 已注册')).toBeTruthy();
|
|
expect(
|
|
within(card).getByRole('progressbar', { name: '蜂窝信号' }).getAttribute('aria-valuenow'),
|
|
).toBe('72');
|
|
const nodeEntry = within(card).getByRole('link', { name: '打开 Alpha modem 节点入口' });
|
|
expect(nodeEntry.getAttribute('href')).toBe('http://192.168.1.2');
|
|
expect(nodeEntry.getAttribute('target')).toBe('_blank');
|
|
expect(nodeEntry.getAttribute('rel')).toContain('noopener');
|
|
expect(within(card).queryByRole('group', { name: '实例运维操作' })).toBeNull();
|
|
expect(within(card).queryByRole('button', { name: '重启服务 Alpha modem' })).toBeNull();
|
|
const actionTrigger = within(card).getByRole('button', { name: '实例操作 Alpha modem' });
|
|
expect(actionTrigger.getAttribute('aria-expanded')).toBe('false');
|
|
fireEvent.click(actionTrigger);
|
|
expect(actionTrigger.getAttribute('aria-expanded')).toBe('true');
|
|
expect(within(card).getByRole('menuitem', { name: '重启服务 Alpha modem' })).toBeTruthy();
|
|
expect(within(card).getByRole('menuitem', { name: '系统重启 Alpha modem' })).toBeTruthy();
|
|
|
|
const metadata = card.querySelector('.fleet-card-metadata');
|
|
const hardware = within(card).getByRole('group', { name: '节点硬件信息' });
|
|
const telemetry = within(card).getByRole('region', { name: '资源遥测' });
|
|
const footer = within(card).getByRole('region', { name: '短信状态' });
|
|
expect(metadata).toBeTruthy();
|
|
expect(
|
|
Boolean(metadata!.compareDocumentPosition(hardware) & Node.DOCUMENT_POSITION_FOLLOWING),
|
|
).toBe(true);
|
|
expect(
|
|
Boolean(hardware.compareDocumentPosition(telemetry) & Node.DOCUMENT_POSITION_FOLLOWING),
|
|
).toBe(true);
|
|
expect(
|
|
Boolean(telemetry.compareDocumentPosition(footer) & Node.DOCUMENT_POSITION_FOLLOWING),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('opens the Hub-style device context drawer and keeps the dashboard one click away', async () => {
|
|
const messagesDataSource = {
|
|
load: vi.fn(async () => ({
|
|
latest: {
|
|
id: 'sms-1',
|
|
direction: 'incoming',
|
|
phoneNumber: '13900139000',
|
|
content: 'status',
|
|
timestamp: '2026-07-19T08:30:00.000Z',
|
|
},
|
|
})),
|
|
};
|
|
render(<FleetPage initialData={snapshot} messagesDataSource={messagesDataSource} />);
|
|
|
|
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
|
|
expect(screen.queryByRole('dialog', { name: 'Alpha modem 设备上下文' })).toBeNull();
|
|
fireEvent.click(within(card).getByRole('button', { name: '打开 Alpha modem 设备上下文' }));
|
|
|
|
const drawer = screen.getByRole('dialog', { name: 'Alpha modem 设备上下文' });
|
|
const focusables = Array.from(
|
|
drawer.querySelectorAll<HTMLElement>('a[href], button:not([disabled])'),
|
|
);
|
|
fireEvent.focus(focusables[0]!);
|
|
const dashboard = within(drawer).getByRole('link', { name: '打开 Alpha modem 实例仪表盘' });
|
|
expect(dashboard.getAttribute('href')).toBe('/instances/alpha/overview');
|
|
expect(
|
|
within(drawer).getByRole('link', { name: '打开 Alpha modem 节点入口' }).getAttribute('href'),
|
|
).toBe('http://192.168.1.2');
|
|
expect(within(drawer).getByText('局域网接入')).toBeTruthy();
|
|
expect(
|
|
within(drawer).getByRole('progressbar', { name: 'CPU 使用率' }).getAttribute('aria-valuenow'),
|
|
).toBe('24');
|
|
expect(await within(drawer).findByRole('region', { name: '短信状态' })).toBeTruthy();
|
|
|
|
fireEvent.click(within(drawer).getByRole('button', { name: '关闭设备上下文' }));
|
|
expect(screen.queryByRole('dialog', { name: 'Alpha modem 设备上下文' })).toBeNull();
|
|
expect(document.activeElement).toBe(
|
|
within(card).getByRole('button', { name: '打开 Alpha modem 设备上下文' }),
|
|
);
|
|
|
|
fireEvent.click(within(card).getByRole('button', { name: '打开 Alpha modem 设备上下文' }));
|
|
fireEvent.click(screen.getByRole('dialog', { name: 'Alpha modem 设备上下文' }));
|
|
expect(screen.getByRole('dialog', { name: 'Alpha modem 设备上下文' })).toBeTruthy();
|
|
fireEvent.click(
|
|
screen.getByRole('dialog', { name: 'Alpha modem 设备上下文' }).closest('.drawer-backdrop')!,
|
|
);
|
|
expect(screen.queryByRole('dialog', { name: 'Alpha modem 设备上下文' })).toBeNull();
|
|
});
|
|
|
|
it('shows an explicit fallback when the upstream SimAdmin version is unavailable', () => {
|
|
const withoutVersion: FleetSnapshot = {
|
|
...snapshot,
|
|
statuses: new Map([
|
|
[
|
|
'alpha',
|
|
{
|
|
...snapshot.statuses.get('alpha')!,
|
|
summary: {
|
|
freshness: 'fresh',
|
|
resources: { cpuPercent: 24, memoryPercent: 51 },
|
|
},
|
|
},
|
|
],
|
|
]),
|
|
};
|
|
|
|
render(<FleetPage initialData={withoutVersion} />);
|
|
|
|
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
|
|
expect(within(card).getByText('版本未知')).toBeTruthy();
|
|
});
|
|
|
|
it('supports the menu button keyboard model and dismisses the menu from outside', () => {
|
|
render(<FleetPage initialData={snapshot} />);
|
|
|
|
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
|
|
const trigger = within(card).getByRole('button', { name: '实例操作 Alpha modem' });
|
|
|
|
trigger.focus();
|
|
fireEvent.keyDown(trigger, { key: 'ArrowDown' });
|
|
|
|
const messagesLink = within(card).getByRole('menuitem', {
|
|
name: '打开 Alpha modem 短信中心',
|
|
});
|
|
const serviceRestart = within(card).getByRole('menuitem', {
|
|
name: '重启服务 Alpha modem',
|
|
});
|
|
const basebandRestart = within(card).getByRole('menuitem', {
|
|
name: '重启基带 Alpha modem',
|
|
});
|
|
const systemReboot = within(card).getByRole('menuitem', { name: '系统重启 Alpha modem' });
|
|
expect(document.activeElement).toBe(messagesLink);
|
|
|
|
fireEvent.keyDown(messagesLink, { key: 'ArrowDown' });
|
|
expect(document.activeElement).toBe(serviceRestart);
|
|
fireEvent.keyDown(serviceRestart, { key: 'ArrowDown' });
|
|
expect(document.activeElement).toBe(basebandRestart);
|
|
fireEvent.keyDown(basebandRestart, { key: 'ArrowDown' });
|
|
expect(document.activeElement).toBe(systemReboot);
|
|
fireEvent.keyDown(systemReboot, { key: 'ArrowDown' });
|
|
expect(document.activeElement).toBe(messagesLink);
|
|
fireEvent.keyDown(messagesLink, { key: 'ArrowUp' });
|
|
expect(document.activeElement).toBe(systemReboot);
|
|
|
|
fireEvent.keyDown(systemReboot, { key: 'Escape' });
|
|
expect(within(card).queryByRole('menu')).toBeNull();
|
|
expect(document.activeElement).toBe(trigger);
|
|
|
|
fireEvent.click(trigger);
|
|
expect(within(card).getByRole('menu')).toBeTruthy();
|
|
fireEvent.pointerDown(document.body);
|
|
expect(within(card).queryByRole('menu')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('FleetPage search and filter toolbar', () => {
|
|
it('filters by status chips and clears active search conditions', () => {
|
|
render(<FleetPage initialData={snapshot} />);
|
|
|
|
const search = screen.getByRole('search', { name: '实例搜索与筛选' });
|
|
expect(within(search).getByPlaceholderText('搜索名称、ID、标签、源地址…')).toBeTruthy();
|
|
expect(within(search).queryByText(/显示/)).toBeNull();
|
|
expect(document.querySelector('.fleet-result-count')?.textContent).toMatch(/显示\s*1\s*\/\s*1/);
|
|
|
|
fireEvent.click(within(search).getByRole('button', { name: /离线/ }));
|
|
expect(screen.queryByRole('article', { name: 'Alpha modem 实例概览' })).toBeNull();
|
|
expect(document.querySelector('.fleet-result-count')?.textContent).toMatch(/显示\s*0\s*\/\s*1/);
|
|
expect(within(search).getByRole('button', { name: /移除筛选 状态:离线/ })).toBeTruthy();
|
|
|
|
fireEvent.change(within(search).getByPlaceholderText('搜索名称、ID、标签、源地址…'), {
|
|
target: { value: 'Alpha' },
|
|
});
|
|
expect(within(search).getByRole('button', { name: /移除筛选 搜索:Alpha/ })).toBeTruthy();
|
|
|
|
fireEvent.click(within(search).getByRole('button', { name: '清除全部筛选' }));
|
|
expect(screen.getByRole('article', { name: 'Alpha modem 实例概览' })).toBeTruthy();
|
|
expect(document.querySelector('.fleet-result-count')?.textContent).toMatch(/显示\s*1\s*\/\s*1/);
|
|
});
|
|
|
|
it('filters devices by access method and reports the active condition', () => {
|
|
const accessSnapshot: FleetSnapshot = {
|
|
instances: [
|
|
...snapshot.instances,
|
|
{
|
|
id: 'wan',
|
|
name: 'Wan modem',
|
|
url: 'https://wan.example',
|
|
tags: [],
|
|
},
|
|
],
|
|
statuses: new Map([...snapshot.statuses, ['wan', { reachable: true, authenticated: true }]]),
|
|
};
|
|
|
|
render(<FleetPage initialData={accessSnapshot} />);
|
|
|
|
fireEvent.click(screen.getByText('更多筛选'));
|
|
fireEvent.change(screen.getByLabelText('接入方式'), {
|
|
target: { value: 'lan' },
|
|
});
|
|
|
|
expect(screen.getByRole('article', { name: 'Alpha modem 实例概览' })).toBeTruthy();
|
|
expect(screen.queryByRole('article', { name: 'Wan modem 实例概览' })).toBeNull();
|
|
expect(screen.getByRole('button', { name: '移除筛选 接入:局域网接入' })).toBeTruthy();
|
|
});
|
|
|
|
it('filters the matrix from the device group row without moving search out of the sidebar', () => {
|
|
const groupedSnapshot: FleetSnapshot = {
|
|
instances: [
|
|
{ ...snapshot.instances[0]!, groupId: 'group-core' },
|
|
{
|
|
id: 'beta',
|
|
name: 'Beta modem',
|
|
url: 'http://192.168.1.3',
|
|
tags: ['外场'],
|
|
groupId: 'group-field',
|
|
revision: 1,
|
|
},
|
|
],
|
|
statuses: new Map([
|
|
...snapshot.statuses,
|
|
['beta', { reachable: false, authenticated: false }],
|
|
]),
|
|
};
|
|
render(
|
|
<FleetPage
|
|
initialData={groupedSnapshot}
|
|
groups={[
|
|
{ id: 'group-core', name: '核心', description: '', deviceCount: 1 },
|
|
{ id: 'group-field', name: '外场', description: '', deviceCount: 1 },
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
const groups = screen.getByRole('group', { name: '节点分组' });
|
|
const betaCard = screen.getByRole('article', { name: 'Beta modem 实例概览' });
|
|
expect(screen.queryByRole('region', { name: '节点资源健康' })).toBeNull();
|
|
// CPU, 内存, 信号, 运行时长 and 最高温度 all fall back to a dash with no telemetry.
|
|
expect(within(betaCard).getAllByText('--')).toHaveLength(5);
|
|
// The group chip and the tag chip are separate rows even when they read the same.
|
|
expect(betaCard.querySelector('.fleet-instance-groups')?.textContent).toContain('外场');
|
|
expect(betaCard.querySelector('.fleet-instance-tags')?.textContent).toContain('外场');
|
|
expect(within(groups).getByRole('button', { name: '全部节点' })).toBeTruthy();
|
|
fireEvent.click(within(groups).getByRole('button', { name: /核心/ }));
|
|
|
|
expect(screen.getByRole('article', { name: 'Alpha modem 实例概览' })).toBeTruthy();
|
|
expect(screen.queryByRole('article', { name: 'Beta modem 实例概览' })).toBeNull();
|
|
expect(betaCard.isConnected).toBe(false);
|
|
expect(
|
|
screen
|
|
.getByRole('search', { name: '实例搜索与筛选' })
|
|
.closest('aside')
|
|
?.classList.contains('fleet-sidebar'),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('FleetPage batch and restart actions', () => {
|
|
it('keeps selection controls hidden until batch-selection mode is entered', () => {
|
|
render(<FleetPage initialData={snapshot} />);
|
|
expect(screen.queryByRole('checkbox', { name: /选择 Alpha modem/ })).toBeNull();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '批量选择' }));
|
|
const checkbox = screen.getByRole('checkbox', { name: /选择 Alpha modem/ });
|
|
expect(checkbox).toBeTruthy();
|
|
expect(checkbox.closest('.fleet-card-header-meta')).toBeTruthy();
|
|
expect(screen.getByRole('button', { name: '全选本页' })).toBeTruthy();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '退出批量选择' }));
|
|
expect(screen.queryByRole('checkbox', { name: /选择 Alpha modem/ })).toBeNull();
|
|
});
|
|
|
|
it('exposes card restart controls and batch restart entry for selected instances', async () => {
|
|
const prepare = vi.fn(async () => ({
|
|
id: 'prep-1',
|
|
status: 'prepared',
|
|
operationId: 'postServiceRestart',
|
|
risk: 'R3',
|
|
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
confirmationPrompt: 'Confirm restart',
|
|
targetCount: 1,
|
|
}));
|
|
const execute = vi.fn(async () => ({
|
|
id: 'job-1',
|
|
operationId: 'postServiceRestart',
|
|
status: 'succeeded',
|
|
rootJobId: 'job-1',
|
|
items: [{ id: 'item-1', targetId: 'alpha', state: 'succeeded' }],
|
|
attempts: [],
|
|
createdAt: '2029-01-01T00:00:00.000Z',
|
|
}));
|
|
const list = vi.fn(async () => ({
|
|
items: [
|
|
{
|
|
operationId: 'postServiceRestart',
|
|
title: 'Restart Service',
|
|
risk: 'R3',
|
|
capability: 'job',
|
|
batchable: false,
|
|
parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1',
|
|
},
|
|
{
|
|
operationId: 'postBasebandRestart',
|
|
title: 'Restart Baseband',
|
|
risk: 'R3',
|
|
capability: 'job',
|
|
batchable: false,
|
|
parameterSchemaId: 'simadmin.58e2204.postBasebandRestart.parameters.v1',
|
|
},
|
|
{
|
|
operationId: 'postSystemReboot',
|
|
title: 'Reboot System',
|
|
risk: 'R3',
|
|
capability: 'job',
|
|
batchable: false,
|
|
parameterSchemaId: 'simadmin.58e2204.postSystemReboot.parameters.v1',
|
|
},
|
|
],
|
|
page: { page: 1, pageSize: 100, total: 3 },
|
|
}));
|
|
vi.stubGlobal('confirm', () => true);
|
|
render(
|
|
<FleetPage initialData={snapshot} operationClient={{ list, prepare, execute } as never} />,
|
|
);
|
|
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
|
|
const cardMenuTrigger = within(card).getByRole('button', { name: '实例操作 Alpha modem' });
|
|
expect(cardMenuTrigger.getAttribute('aria-expanded')).toBe('false');
|
|
fireEvent.click(cardMenuTrigger);
|
|
const messages = within(card).getByRole('menuitem', {
|
|
name: '打开 Alpha modem 短信中心',
|
|
});
|
|
expect(messages.getAttribute('href')).toBe('/fleet/messages?device=alpha');
|
|
expect(within(card).getByRole('menuitem', { name: '重启服务 Alpha modem' })).toBeTruthy();
|
|
expect(within(card).getByRole('menuitem', { name: '重启基带 Alpha modem' })).toBeTruthy();
|
|
expect(within(card).getByRole('menuitem', { name: '系统重启 Alpha modem' })).toBeTruthy();
|
|
fireEvent.click(within(card).getByRole('menuitem', { name: '重启基带 Alpha modem' }));
|
|
await vi.waitFor(() =>
|
|
expect(prepare).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
operationId: 'postBasebandRestart',
|
|
parameters: expect.objectContaining({
|
|
parameterSchemaId: 'simadmin.58e2204.postBasebandRestart.parameters.v1',
|
|
fields: [],
|
|
}),
|
|
}),
|
|
),
|
|
);
|
|
expect(execute).toHaveBeenCalledWith('prep-1');
|
|
fireEvent.click(screen.getByRole('button', { name: '批量选择' }));
|
|
fireEvent.click(within(card).getByRole('checkbox', { name: '选择 Alpha modem' }));
|
|
expect(
|
|
screen.getAllByText(/已选择 1 项/).some((node) => /已选择 1 项/.test(node.textContent ?? '')),
|
|
).toBe(true);
|
|
const batch = await screen.findByRole('region', { name: '批量操作入口' });
|
|
expect(batch.textContent).toMatch(/已选择 1 项/);
|
|
fireEvent.click(screen.getByRole('button', { name: '批量重启服务' }));
|
|
await vi.waitFor(() => expect(prepare).toHaveBeenCalled());
|
|
expect(execute).toHaveBeenCalledWith('prep-1');
|
|
});
|
|
});
|
|
|
|
describe('FleetPage access method', () => {
|
|
it('classifies how the control plane reaches each node', () => {
|
|
expect(accessMethod('http://127.0.0.1:3000/admin')).toEqual({
|
|
label: '本机直连',
|
|
kind: 'local',
|
|
});
|
|
expect(accessMethod('http://localhost:3000')).toEqual({ label: '本机直连', kind: 'local' });
|
|
expect(accessMethod('http://[::1]:3000')).toEqual({ label: '本机直连', kind: 'local' });
|
|
expect(accessMethod('http://192.168.2.69:3000')).toEqual({ label: '局域网接入', kind: 'lan' });
|
|
expect(accessMethod('http://172.16.5.4')).toEqual({ label: '局域网接入', kind: 'lan' });
|
|
expect(accessMethod('http://172.32.5.4')).toEqual({ label: '公网地址', kind: 'wan' });
|
|
expect(accessMethod('http://100.64.0.9')).toEqual({ label: '局域网接入', kind: 'lan' });
|
|
expect(accessMethod('http://203.0.113.9')).toEqual({ label: '公网地址', kind: 'wan' });
|
|
expect(accessMethod('https://modem.example.com/admin')).toEqual({
|
|
label: '域名接入',
|
|
kind: 'wan',
|
|
});
|
|
expect(accessMethod('not-a-url')).toBeNull();
|
|
});
|
|
|
|
it('shows the derived access method on the card origin row', () => {
|
|
render(<FleetPage initialData={snapshot} />);
|
|
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
|
|
const chip = card.querySelector('.fleet-card-access');
|
|
expect(chip?.textContent).toBe('局域网接入');
|
|
expect(chip?.getAttribute('data-kind')).toBe('lan');
|
|
});
|
|
});
|
|
|
|
describe('FleetPage heartbeat reporting', () => {
|
|
const withConnection = (status: Record<string, unknown>): FleetSnapshot => ({
|
|
instances: snapshot.instances,
|
|
statuses: new Map([['alpha', { ...snapshot.statuses.get('alpha')!, ...status }]]),
|
|
});
|
|
|
|
it('renders the age of the last heartbeat the console received', () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-09-05T12:00:00.000Z'));
|
|
try {
|
|
render(
|
|
<FleetPage
|
|
initialData={withConnection({ probed: true, checkedAt: '2026-09-05T11:59:20.000Z' })}
|
|
/>,
|
|
);
|
|
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
|
|
const row = card.querySelector('.fleet-card-heartbeat');
|
|
const time = row?.querySelector('time');
|
|
expect(row?.getAttribute('data-stale')).toBe('false');
|
|
expect(time?.getAttribute('datetime')).toBe('2026-09-05T11:59:20.000Z');
|
|
expect(time?.textContent).toBe('40 秒前');
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('ages a long-dead heartbeat into days and flags the card stale', () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-09-05T12:00:00.000Z'));
|
|
try {
|
|
render(
|
|
<FleetPage
|
|
initialData={withConnection({
|
|
probed: true,
|
|
reachable: false,
|
|
authenticated: false,
|
|
checkedAt: '2026-09-02T12:00:00.000Z',
|
|
})}
|
|
/>,
|
|
);
|
|
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
|
|
const row = card.querySelector('.fleet-card-heartbeat');
|
|
expect(row?.getAttribute('data-stale')).toBe('true');
|
|
expect(row?.querySelector('time')?.textContent).toBe('3 天前');
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('renders the device uptime the node reports, down to seconds', () => {
|
|
const withUptime = (uptimeSeconds: number | undefined): FleetSnapshot => ({
|
|
instances: snapshot.instances,
|
|
statuses: new Map([
|
|
[
|
|
'alpha',
|
|
{
|
|
...snapshot.statuses.get('alpha')!,
|
|
summary: {
|
|
...snapshot.statuses.get('alpha')!.summary,
|
|
resources: {
|
|
...snapshot.statuses.get('alpha')!.summary?.resources,
|
|
...(uptimeSeconds === undefined ? {} : { uptimeSeconds }),
|
|
},
|
|
},
|
|
},
|
|
],
|
|
]),
|
|
});
|
|
const { unmount } = render(<FleetPage initialData={withUptime(93_784)} />);
|
|
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
|
|
const values = [...card.querySelectorAll<HTMLElement>('.fleet-card-hardware dd')];
|
|
expect(values.map((node) => node.textContent)).toContain('1天 2小时');
|
|
unmount();
|
|
|
|
render(<FleetPage initialData={withUptime(45)} />);
|
|
expect(
|
|
screen.getByRole('article', { name: 'Alpha modem 实例概览' }).textContent?.includes('45秒'),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('says 未上报 until the heartbeat has reached the node', () => {
|
|
render(<FleetPage initialData={withConnection({ probed: false, checkedAt: null })} />);
|
|
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
|
|
const row = card.querySelector('.fleet-card-heartbeat');
|
|
expect(row?.getAttribute('data-stale')).toBe('true');
|
|
expect(row?.querySelector('time')).toBeNull();
|
|
expect(row?.textContent).toContain('未上报');
|
|
});
|
|
|
|
const withIdentity = (identity: Record<string, unknown> | undefined): FleetSnapshot => ({
|
|
instances: snapshot.instances,
|
|
statuses: new Map([
|
|
[
|
|
'alpha',
|
|
{
|
|
...snapshot.statuses.get('alpha')!,
|
|
...(identity ? { identity } : {}),
|
|
},
|
|
],
|
|
]),
|
|
});
|
|
|
|
it('warns on the card that the identity guard is holding the node', () => {
|
|
render(
|
|
<FleetPage
|
|
initialData={withIdentity({ tracked: true, status: 'pending', reasons: ['imei_claimed'] })}
|
|
/>,
|
|
);
|
|
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
|
|
expect(card.querySelector('.fleet-card-identity')?.textContent).toContain('身份待确认');
|
|
expect(card.querySelector('.fleet-card-identity')?.textContent).toContain('控制操作已暂停');
|
|
});
|
|
|
|
it('stays quiet for a confirmed node and for one that never reported identity', () => {
|
|
render(
|
|
<FleetPage initialData={withIdentity({ tracked: true, status: 'confirmed', reasons: [] })} />,
|
|
);
|
|
expect(
|
|
screen
|
|
.getByRole('article', { name: 'Alpha modem 实例概览' })
|
|
.querySelector('.fleet-card-identity'),
|
|
).toBeNull();
|
|
cleanup();
|
|
|
|
render(<FleetPage initialData={withIdentity(undefined)} />);
|
|
expect(
|
|
screen
|
|
.getByRole('article', { name: 'Alpha modem 实例概览' })
|
|
.querySelector('.fleet-card-identity'),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('opens the identity guard from the node toolbar', async () => {
|
|
const identityDataSource = {
|
|
list: vi.fn().mockResolvedValue({ items: [], tracked: 0, pending: 0 }),
|
|
confirm: vi.fn(),
|
|
refresh: vi.fn(),
|
|
};
|
|
render(
|
|
<FleetPage initialData={withIdentity(undefined)} identityDataSource={identityDataSource} />,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '设备身份核对' }));
|
|
expect(await screen.findByRole('dialog', { name: '设备身份核对' })).toBeTruthy();
|
|
expect(identityDataSource.list).toHaveBeenCalled();
|
|
});
|
|
|
|
it('counts held nodes in the cross-health rail and opens the guard from there', async () => {
|
|
const identityDataSource = {
|
|
list: vi.fn().mockResolvedValue({ items: [], tracked: 0, pending: 0 }),
|
|
confirm: vi.fn(),
|
|
refresh: vi.fn(),
|
|
};
|
|
render(
|
|
<FleetPage
|
|
initialData={withIdentity({ tracked: true, status: 'pending', reasons: ['imei_claimed'] })}
|
|
identityDataSource={identityDataSource}
|
|
/>,
|
|
);
|
|
|
|
const rail = screen.getByRole('button', { name: '设备身份待确认节点' });
|
|
expect(rail.textContent).toContain('身份待确认');
|
|
expect(rail.querySelector('strong')?.textContent).toBe('1');
|
|
expect(rail.querySelector('strong')?.getAttribute('data-tone')).toBe('attention');
|
|
|
|
fireEvent.click(rail);
|
|
expect(await screen.findByRole('dialog', { name: '设备身份核对' })).toBeTruthy();
|
|
});
|
|
|
|
it('shows a clean identity rail when every node matches its record', () => {
|
|
render(
|
|
<FleetPage initialData={withIdentity({ tracked: true, status: 'confirmed', reasons: [] })} />,
|
|
);
|
|
const rail = screen.getByRole('button', { name: '设备身份待确认节点' });
|
|
expect(rail.querySelector('strong')?.textContent).toBe('0');
|
|
expect(rail.querySelector('strong')?.getAttribute('data-tone')).toBe('ok');
|
|
});
|
|
});
|