feat(web): rebuild the console around the fused control plane
Render the Hub feature set in the existing Animal Island styling: fleet messages, notification centre, log centre, organisation panel, per-instance device module panels and the settings backup, connection and maintenance surfaces.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ConnectionSettingsPanel } from './connection-settings-panel.js';
|
||||
import type { SystemMaintenanceDataSource } from './system-maintenance-api-data-source.js';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const defaults = { heartbeatSeconds: 30, offlineSeconds: 90 };
|
||||
|
||||
function source(overrides: Partial<SystemMaintenanceDataSource> = {}): SystemMaintenanceDataSource {
|
||||
return {
|
||||
connection: vi.fn(async () => defaults),
|
||||
updateConnection: vi.fn(async (settings) => settings),
|
||||
refreshHeartbeat: vi.fn(async () => ({
|
||||
probed: 7,
|
||||
failed: 2,
|
||||
startedAt: '2026-09-05T00:00:00.000Z',
|
||||
finishedAt: '2026-09-05T00:00:01.000Z',
|
||||
})),
|
||||
...overrides,
|
||||
} as unknown as SystemMaintenanceDataSource;
|
||||
}
|
||||
|
||||
async function mount(overrides: Partial<SystemMaintenanceDataSource> = {}) {
|
||||
const data = source(overrides);
|
||||
const view = render(<ConnectionSettingsPanel dataSource={data} />);
|
||||
const region = await screen.findByRole('region', { name: '连接设置' });
|
||||
return { ...view, data, region };
|
||||
}
|
||||
|
||||
function field(region: HTMLElement, label: string): HTMLInputElement {
|
||||
const node = within(region).getByLabelText(label) as HTMLInputElement;
|
||||
return node;
|
||||
}
|
||||
|
||||
describe('ConnectionSettingsPanel', () => {
|
||||
it('loads the saved cadence into the inputs', async () => {
|
||||
const { data, region } = await mount();
|
||||
expect(data.connection).toHaveBeenCalledTimes(1);
|
||||
expect(field(region, '心跳间隔(秒)').value).toBe('30');
|
||||
expect(field(region, '离线判定(秒)').value).toBe('90');
|
||||
});
|
||||
|
||||
it('keeps save disabled until a valid change is made', async () => {
|
||||
const { region } = await mount();
|
||||
const save = within(region).getByRole('button', { name: '保存设置' }) as HTMLButtonElement;
|
||||
expect(save.disabled).toBe(true);
|
||||
|
||||
await userEvent.clear(field(region, '心跳间隔(秒)'));
|
||||
await userEvent.type(field(region, '心跳间隔(秒)'), '4');
|
||||
expect(save.disabled).toBe(true);
|
||||
|
||||
await userEvent.clear(field(region, '心跳间隔(秒)'));
|
||||
await userEvent.type(field(region, '心跳间隔(秒)'), '45');
|
||||
expect(save.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('sends whole numbers and echoes the saved window', async () => {
|
||||
const { data, region } = await mount();
|
||||
await userEvent.clear(field(region, '心跳间隔(秒)'));
|
||||
await userEvent.type(field(region, '心跳间隔(秒)'), '45');
|
||||
await userEvent.clear(field(region, '离线判定(秒)'));
|
||||
await userEvent.type(field(region, '离线判定(秒)'), '180');
|
||||
await userEvent.click(within(region).getByRole('button', { name: '保存设置' }));
|
||||
|
||||
expect(data.updateConnection).toHaveBeenCalledWith({
|
||||
heartbeatSeconds: 45,
|
||||
offlineSeconds: 180,
|
||||
});
|
||||
const notice = await within(region).findByRole('status');
|
||||
expect(notice.textContent).toContain('连接设置已保存,心跳 45 秒,离线判定 180 秒。');
|
||||
});
|
||||
|
||||
it('blocks a save when the offline window drops below twice the heartbeat', async () => {
|
||||
const { data, region } = await mount();
|
||||
await userEvent.clear(field(region, '离线判定(秒)'));
|
||||
await userEvent.type(field(region, '离线判定(秒)'), '59');
|
||||
const save = within(region).getByRole('button', { name: '保存设置' }) as HTMLButtonElement;
|
||||
expect(save.disabled).toBe(true);
|
||||
expect(data.updateConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs an on-demand beat and reports the offline devices', async () => {
|
||||
const { data, region } = await mount();
|
||||
await userEvent.click(within(region).getByRole('button', { name: '立即刷新设备状态' }));
|
||||
expect(data.refreshHeartbeat).toHaveBeenCalledTimes(1);
|
||||
const notice = await within(region).findByRole('status');
|
||||
expect(notice.textContent).toContain('已检查 9 台设备,2 台无响应。');
|
||||
});
|
||||
|
||||
it('surfaces a failed save without losing the form', async () => {
|
||||
const { region } = await mount({
|
||||
updateConnection: vi.fn(async () => {
|
||||
throw new Error('offline');
|
||||
}),
|
||||
});
|
||||
await userEvent.clear(field(region, '心跳间隔(秒)'));
|
||||
await userEvent.type(field(region, '心跳间隔(秒)'), '60');
|
||||
await userEvent.clear(field(region, '离线判定(秒)'));
|
||||
await userEvent.type(field(region, '离线判定(秒)'), '150');
|
||||
await userEvent.click(within(region).getByRole('button', { name: '保存设置' }));
|
||||
const problem = await within(region).findByRole('alert');
|
||||
expect(problem.textContent).toContain('连接设置未保存,请检查心跳与离线判定的取值范围。');
|
||||
expect(field(region, '心跳间隔(秒)').value).toBe('60');
|
||||
});
|
||||
|
||||
it('explains itself when the cadence cannot be read', async () => {
|
||||
const { region } = await mount({
|
||||
connection: vi.fn(async () => {
|
||||
throw new Error('offline');
|
||||
}),
|
||||
});
|
||||
const problem = await within(region).findByRole('alert');
|
||||
expect(problem.textContent).toContain('无法读取连接设置,请刷新页面重试。');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user