feat(platform): harden telemetry pipeline and unify Semi UI workspaces
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import type { AlertEvent, Page } from '../../api/types';
|
||||
import type { AlertEvent, AlertRule, Page } from '../../api/types';
|
||||
import AlertsPage from './AlertsPage';
|
||||
import { ROUTER_FUTURE } from '../routing/routerConfig';
|
||||
|
||||
@@ -11,11 +11,14 @@ const mocks = vi.hoisted(() => ({
|
||||
alertRulesV2: vi.fn(), metricCatalog: vi.fn(), alertNotificationsV2: vi.fn(), readAlertNotificationsV2: vi.fn(),
|
||||
saveAlertRuleV2: vi.fn(), setAlertRuleEnabledV2: vi.fn()
|
||||
}));
|
||||
const layout = vi.hoisted(() => ({ mobile: false }));
|
||||
vi.mock('../../api/client', () => ({ api: mocks }));
|
||||
vi.mock('../auth/AuthGate', () => ({ usePlatformSession: () => ({ session: { role: 'admin' } }) }));
|
||||
vi.mock('../hooks/useMobileLayout', () => ({ useMobileLayout: () => layout.mobile }));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
layout.mobile = false;
|
||||
Object.values(mocks).forEach((mock) => mock.mockReset());
|
||||
});
|
||||
|
||||
@@ -27,6 +30,17 @@ function alertEvent(id: string, vin: string, plate: string): AlertEvent {
|
||||
};
|
||||
}
|
||||
|
||||
function alertRule(): AlertRule {
|
||||
return {
|
||||
id: 'speed-rule', name: '测试超速规则', description: '测试规则', severity: 'major', valueType: 'numeric',
|
||||
metric: 'speed_kmh', operator: 'gt', threshold: 80, thresholdHigh: 0, durationSec: 60,
|
||||
recoveryOperator: 'lte', recoveryThreshold: 75, repeatIntervalSec: 600,
|
||||
scopeProtocols: ['JT808'], scopeVins: [], scopeOems: [], scopeModels: [], scopeCompanies: [],
|
||||
notificationChannels: ['in_app'], enabled: true, version: 3,
|
||||
createdBy: 'admin', updatedBy: 'admin', createdAt: '', updatedAt: ''
|
||||
};
|
||||
}
|
||||
|
||||
test('preserves an unsubmitted event filter draft across alert tab URL changes', async () => {
|
||||
mocks.alertSummaryV2.mockResolvedValue({ active: 0, unprocessed: 0, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
|
||||
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 20, offset: 0 });
|
||||
@@ -38,9 +52,9 @@ test('preserves an unsubmitted event filter draft across alert tab URL changes',
|
||||
|
||||
const keyword = await screen.findByPlaceholderText('车牌 / VIN / 规则名称');
|
||||
fireEvent.change(keyword, { target: { value: '保留筛选草稿' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /站内通知/ }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: /站内通知/ }));
|
||||
expect(await screen.findByText('仅站内通道具备真实送达与已读状态')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /告警事件/ }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: /告警事件/ }));
|
||||
|
||||
expect(await screen.findByPlaceholderText('车牌 / VIN / 规则名称')).toHaveValue('保留筛选草稿');
|
||||
});
|
||||
@@ -63,9 +77,15 @@ test('loads only event dependencies on the default alert tab', async () => {
|
||||
test('loads one full notification query on a direct notifications entry', async () => {
|
||||
mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=notifications']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
|
||||
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=notifications']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
await screen.findByText('仅站内通道具备真实送达与已读状态');
|
||||
await screen.findByText('暂无站内通知');
|
||||
expect(view.container.querySelector('.v2-alert-notifications')).toHaveClass('semi-card');
|
||||
expect(screen.getByRole('heading', { level: 5, name: '站内通知' })).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-alert-notification-empty.semi-empty')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-alert-channel-card.semi-card')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-alert-channel-descriptions.semi-descriptions')).toBeInTheDocument();
|
||||
expect(mocks.alertRulesV2).not.toHaveBeenCalled();
|
||||
expect(mocks.metricCatalog).not.toHaveBeenCalled();
|
||||
expect(mocks.alertNotificationsV2).toHaveBeenCalledTimes(1);
|
||||
@@ -78,7 +98,10 @@ test('ends notification mutation feedback with a visible retryable error instead
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=notifications']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '标为已读' }));
|
||||
const markRead = await screen.findByRole('button', { name: '标为已读' });
|
||||
expect(markRead.closest('.v2-alert-notification-card.semi-card')).toBeInTheDocument();
|
||||
expect(markRead.closest('.v2-alert-notification-list')?.querySelector('article')).not.toBeInTheDocument();
|
||||
fireEvent.click(markRead);
|
||||
|
||||
expect(await screen.findByText('通知状态更新超时')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '标为已读' })).toBeEnabled();
|
||||
@@ -86,6 +109,7 @@ test('ends notification mutation feedback with a visible retryable error instead
|
||||
|
||||
test('clears old alert rows and inspector evidence when the event scope changes', async () => {
|
||||
const oldEvent = alertEvent('old-event', 'OLDVIN', '旧告警车牌');
|
||||
oldEvent.actions = [{ id: 1, action: 'detect', fromStatus: '', toStatus: 'unprocessed', actor: 'alert-evaluator', note: '规则首次命中', createdAt: '2026-07-16T04:00:00Z' }];
|
||||
const newEvent = alertEvent('new-event', 'NEWVIN', '新告警车牌');
|
||||
let resolveNew!: (value: Page<AlertEvent>) => void;
|
||||
mocks.alertSummaryV2.mockResolvedValue({ active: 1, unprocessed: 1, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
|
||||
@@ -96,9 +120,39 @@ test('clears old alert rows and inspector evidence when the event scope changes'
|
||||
mocks.alertRulesV2.mockResolvedValue([]);
|
||||
mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?keyword=OLDVIN']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
|
||||
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?keyword=OLDVIN']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
expect((await screen.findAllByText('旧告警车牌')).length).toBeGreaterThan(0);
|
||||
for (const className of ['v2-alert-filter-card', 'v2-alert-kpis-card', 'v2-alert-table-card']) {
|
||||
expect(view.container.querySelector(`.${className}.semi-card`)).toBeInTheDocument();
|
||||
}
|
||||
expect(view.container.querySelector('.v2-alert-inspector')).not.toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-alert-workspace')).not.toHaveClass('is-inspector-open');
|
||||
expect(view.container.querySelectorAll('.v2-alert-kpis .v2-metric-action')).toHaveLength(4);
|
||||
expect(view.container.querySelectorAll('.v2-alert-secondary-states .semi-button')).toHaveLength(3);
|
||||
expect(screen.getByRole('button', { name: '查看未读通知,共 0 条' })).toHaveClass('is-notice');
|
||||
expect(view.container.querySelector('.v2-alert-event-table.semi-table-wrapper')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-alert-table-scroll > table')).not.toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-alert-mobile-list')).not.toBeInTheDocument();
|
||||
const desktopAlertRow = screen.getByTestId('alert-row-old-event');
|
||||
expect(desktopAlertRow).toHaveAttribute('role', 'button');
|
||||
expect(desktopAlertRow).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.keyDown(desktopAlertRow, { key: 'Enter' });
|
||||
|
||||
expect(await screen.findByText('old-event')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-alert-inspector.semi-card')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-alert-workspace')).toHaveClass('is-inspector-open');
|
||||
expect(desktopAlertRow).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getByText('事件信息').closest('.semi-card')).toHaveClass('v2-alert-detail-card');
|
||||
expect(screen.getByText('证据对比').closest('.semi-card')).toHaveClass('v2-alert-evidence-card');
|
||||
expect(view.container.querySelector('.v2-alert-inspector-heading')).toHaveClass('v2-workspace-panel-header');
|
||||
expect(view.container.querySelectorAll('.v2-alert-descriptions.semi-descriptions')).toHaveLength(2);
|
||||
expect(screen.getByLabelText('告警处理进度')).toHaveClass('semi-timeline');
|
||||
expect(screen.getByText('规则首次命中')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭告警详情' }));
|
||||
expect(view.container.querySelector('.v2-alert-inspector')).not.toBeInTheDocument();
|
||||
expect(desktopAlertRow).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.click(desktopAlertRow);
|
||||
expect(await screen.findByText('old-event')).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByPlaceholderText('车牌 / VIN / 规则名称'), { target: { value: 'NEWVIN' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||
@@ -106,10 +160,100 @@ test('clears old alert rows and inspector evidence when the event scope changes'
|
||||
expect(await screen.findByText('正在更新事件…')).toBeInTheDocument();
|
||||
expect(screen.queryByText('旧告警车牌')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('old-event')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('选择告警事件')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-alert-inspector')).not.toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-alert-workspace')).not.toHaveClass('is-inspector-open');
|
||||
|
||||
await act(async () => resolveNew({ items: [newEvent], total: 1, limit: 20, offset: 0 }));
|
||||
expect((await screen.findAllByText('新告警车牌')).length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('new-event')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('alert-row-new-event'));
|
||||
expect(await screen.findByText('new-event')).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.queryByText('正在更新事件…')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
test('renders only selectable Semi alert cards on mobile', async () => {
|
||||
layout.mobile = true;
|
||||
const event = alertEvent('mobile-event', 'MOBILEVIN', '粤A移动01');
|
||||
mocks.alertSummaryV2.mockResolvedValue({ active: 1, unprocessed: 1, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
|
||||
mocks.alertEventsV2.mockResolvedValue({ items: [event], total: 1, limit: 20, offset: 0 });
|
||||
mocks.alertEventV2.mockResolvedValue(event);
|
||||
mocks.alertRulesV2.mockResolvedValue([]);
|
||||
mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
const action = await screen.findByRole('button', { name: '查看 粤A移动01 粤A移动01速度告警 告警详情' });
|
||||
expect(action).toHaveClass('semi-button', 'v2-alert-mobile-action');
|
||||
expect(action.closest('.semi-card')).toHaveClass('v2-alert-mobile-card');
|
||||
expect(view.container.querySelector('.v2-alert-event-table')).not.toBeInTheDocument();
|
||||
expect(action).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.click(action);
|
||||
expect(action).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(action).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(await screen.findByRole('dialog', { name: '告警事件详情' })).toBeInTheDocument();
|
||||
expect(await screen.findByText('mobile-event')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭告警详情' }));
|
||||
expect(screen.queryByText('mobile-event')).not.toBeInTheDocument();
|
||||
expect(action).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
test('keeps the primary alert query compact and applies advanced filters from a SideSheet', async () => {
|
||||
mocks.alertSummaryV2.mockResolvedValue({ active: 0, unprocessed: 0, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
|
||||
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 20, offset: 0 });
|
||||
mocks.alertRulesV2.mockResolvedValue([alertRule()]);
|
||||
mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
await screen.findByText('当前筛选条件没有告警事件');
|
||||
expect(view.container.querySelector('.v2-alert-filter-primary')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('协议')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /更多筛选/ }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '告警高级筛选' });
|
||||
fireEvent.click(within(dialog).getByRole('combobox', { name: '协议' }));
|
||||
fireEvent.click(await screen.findByText('JT808'));
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '应用筛选' }));
|
||||
|
||||
await waitFor(() => expect(mocks.alertEventsV2).toHaveBeenLastCalledWith(expect.objectContaining({ protocol: 'JT808' }), expect.anything()));
|
||||
expect(screen.getByRole('button', { name: /更多筛选 · 1/ })).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
test('creates auditable drafts from simple offline and hydrogen rule templates', async () => {
|
||||
mocks.alertRulesV2.mockResolvedValue([alertRule()]);
|
||||
mocks.metricCatalog.mockResolvedValue({
|
||||
metrics: [
|
||||
{ key: 'freshness_sec', label: '离线时长', unit: 's', category: 'quality', valueType: 'numeric', protocols: ['GB32960', 'JT808', 'YUTONG_MQTT'], sourceFields: {}, searchable: true, chartable: true, alertable: true },
|
||||
{ key: 'hydrogen_concentration_percent', label: '最高氢浓度', unit: '%', category: 'fuel-cell', valueType: 'numeric', protocols: ['GB32960'], sourceFields: {}, searchable: true, chartable: true, alertable: true }
|
||||
],
|
||||
asOf: ''
|
||||
});
|
||||
mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=rules']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
const ruleItem = await screen.findByRole('button', { name: /测试超速规则/ });
|
||||
expect(view.container.querySelector('.v2-alert-rule-list')).toHaveClass('semi-card');
|
||||
expect(view.container.querySelector('.v2-alert-rule-editor')).toHaveClass('semi-card');
|
||||
expect(view.container.querySelector('.v2-alert-rule-list-heading')).toHaveClass('v2-workspace-panel-header');
|
||||
expect(view.container.querySelector('.v2-alert-rule-editor-heading')).toHaveClass('v2-workspace-panel-header');
|
||||
expect(ruleItem).toHaveClass('semi-button', 'v2-alert-rule-item');
|
||||
await waitFor(() => expect(ruleItem).toHaveAttribute('aria-pressed', 'true'));
|
||||
expect(within(ruleItem).getByText('重要').closest('.semi-tag')).toBeTruthy();
|
||||
expect(within(ruleItem).getByText('已启用').closest('.semi-tag')).toBeTruthy();
|
||||
|
||||
const offlineTemplate = await screen.findByRole('button', { name: /离线超过 10 小时/ });
|
||||
expect(offlineTemplate).toHaveClass('semi-button', 'v2-alert-template-card');
|
||||
expect(within(offlineTemplate).getByText('GB32960 / JT808 / YUTONG_MQTT').closest('.semi-tag')).toBeTruthy();
|
||||
await waitFor(() => expect(offlineTemplate).toBeEnabled());
|
||||
fireEvent.click(offlineTemplate);
|
||||
expect(screen.getByDisplayValue('车辆离线超过 10 小时')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('36000')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('GB32960,JT808,YUTONG_MQTT')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /最高氢浓度/ }));
|
||||
expect(screen.getByDisplayValue('最高氢浓度超限')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('请按厂家标准填写百分比')).toBeRequired();
|
||||
expect(screen.getByPlaceholderText('请按厂家标准填写百分比')).toHaveValue(null);
|
||||
expect(screen.getByDisplayValue('GB32960')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user