Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.test.tsx
T

1362 lines
103 KiB
TypeScript

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { MemoryRouter, useLocation } from 'react-router-dom';
import type { AlertEvent, AlertRule, AlertRuleRevision, Page } from '../../api/types';
import AlertsPage, { alertAutomationRunReturnFromParams, alertAutomationViewFromParams, alertNotificationDeliveryState, alertNotificationViewFromParams, alertPagingFromParams, automationReleaseChanges, filterAlertNotifications } from './AlertsPage';
import { ROUTER_FUTURE } from '../routing/routerConfig';
import { withMonitorReturn } from '../routing/monitorContext';
import { buildVehicleDetailPath, withVehicleReturn } from '../routing/vehicleContext';
const mocks = vi.hoisted(() => ({
alertSummaryV2: vi.fn(), alertEventsV2: vi.fn(), alertEventV2: vi.fn(), actOnAlertV2: vi.fn(),
alertRulesV2: vi.fn(), alertRuleLibraryV2: vi.fn(), alertRuleRevisionsV2: vi.fn(), rollbackAlertRuleV2: vi.fn(), archiveAlertRuleV2: vi.fn(), restoreAlertRuleV2: vi.fn(), metricCatalog: vi.fn(), alertNotificationsV2: vi.fn(), readAlertNotificationsV2: vi.fn(), retryAlertNotificationV2: vi.fn(), alertNotificationRetryAuditsV2: vi.fn(),
saveAlertRuleV2: vi.fn(), setAlertRuleEnabledV2: vi.fn(), vehicles: 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 }));
vi.mock('../map/GeofenceMapEditor', () => ({
default: ({ value, onChange }: { value: { longitude: number; latitude: number; radiusM: number }; onChange: (value: { longitude: number; latitude: number; radiusM: number }) => void }) => <section aria-label="地图绘制电子围栏"><button type="button" onClick={() => onChange({ longitude: 121.91, latitude: 30.9, radiusM: 860 })}>开始绘制</button><output>{value.longitude},{value.latitude},{value.radiusM}</output></section>
}));
beforeEach(() => {
mocks.vehicles.mockResolvedValue({ items: [{ vin: 'VIN-QUICK-001', plate: '粤A10001', phone: '', oem: '羚牛', protocol: 'JT808', online: true, lastSeen: '', locationText: '', bindingScore: 100 }], total: 1, limit: 12, offset: 0 });
mocks.alertNotificationRetryAuditsV2.mockResolvedValue([]);
mocks.alertRuleLibraryV2.mockImplementation(async (params: URLSearchParams) => {
const source = await mocks.alertRulesV2();
const lifecycle = params.get('lifecycle') === 'archived' ? 'archived' : 'current';
const keyword = (params.get('keyword') ?? '').trim().toLocaleLowerCase();
const status = params.get('status') ?? 'all';
const protocol = params.get('protocol') ?? '';
const currentRules = (source as AlertRule[]).filter((rule) => !rule.archivedAt);
const archivedRules = (source as AlertRule[]).filter((rule) => Boolean(rule.archivedAt));
const pool = lifecycle === 'archived' ? archivedRules : currentRules;
const filtered = pool.filter((rule) => {
if (status === 'enabled' && !rule.enabled) return false;
if (status === 'disabled' && rule.enabled) return false;
if (protocol && !rule.scopeProtocols.includes(protocol)) return false;
return !keyword || [rule.name, rule.description, rule.id].join(' ').toLocaleLowerCase().includes(keyword);
});
const limit = Number(params.get('limit') ?? 10);
const offset = Number(params.get('offset') ?? 0);
return {
items: filtered.slice(offset, offset + limit),
total: filtered.length,
limit,
offset,
summary: {
current: currentRules.length,
enabled: currentRules.filter((rule) => rule.enabled).length,
disabled: currentRules.filter((rule) => !rule.enabled).length,
archived: archivedRules.length
}
};
});
});
afterEach(() => {
cleanup();
layout.mobile = false;
Object.values(mocks).forEach((mock) => mock.mockReset());
});
function alertEvent(id: string, vin: string, plate: string): AlertEvent {
return {
id, ruleId: 'speed-rule', ruleName: `${plate}速度告警`, ruleVersion: 1, severity: 'major', status: 'unprocessed',
vin, plate, protocol: 'JT808', metric: 'speed_kmh', operator: 'gt', triggerValue: 90, threshold: 80, thresholdHigh: 0,
unit: 'km/h', durationSec: 60, location: '测试位置', sourceEventId: `${id}-source`, eventAt: '2026-07-16T04:00:00Z', receivedAt: '2026-07-16T04:00:01Z', triggeredAt: '2026-07-16T04:00:00Z', recoveredAt: '', handler: '', version: 1, actions: []
};
}
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: ''
};
}
function alertRuleRevisions(): AlertRuleRevision[] {
const current = alertRule();
return [
{ ruleId: current.id, version: 3, actor: 'night-admin', action: 'update', createdAt: '2026-07-23T09:18:00+08:00', snapshot: current },
{ ruleId: current.id, version: 2, actor: 'admin', action: 'update', createdAt: '2026-07-20T10:00:00+08:00', snapshot: { ...current, version: 2, threshold: 70, durationSec: 30 } },
{ ruleId: current.id, version: 1, actor: 'admin', action: 'create', createdAt: '2026-07-12T09:00:00+08:00', snapshot: { ...current, version: 1, threshold: 60, durationSec: 0 } }
];
}
function RouteState() {
const location = useLocation();
return <output data-testid="alert-route-state">{location.pathname}{location.search}</output>;
}
test('normalizes shareable event pagination parameters', () => {
expect(alertPagingFromParams(new URLSearchParams('limit=50&offset=119'))).toEqual({ limit: 50, offset: 100 });
expect(alertPagingFromParams(new URLSearchParams('limit=999&offset=-20'))).toEqual({ limit: 20, offset: 0 });
});
test('normalizes shareable notification workspace parameters', () => {
expect(alertNotificationViewFromParams(new URLSearchParams('notifyLimit=50&notifyOffset=119&notifySearch=%E7%B2%A4A&notifyRead=unread&notifyDelivery=failed&notificationId=18'))).toEqual({
limit: 50, offset: 100, search: '粤A', readFilter: 'unread', deliveryFilter: 'failed', selectedID: 18
});
expect(alertNotificationViewFromParams(new URLSearchParams('notifyLimit=999&notifyOffset=-1&notificationId=bad'))).toEqual({
limit: 20, offset: 0, search: '', readFilter: 'all', deliveryFilter: 'all', selectedID: undefined
});
const failed = { id: 1, eventId: 'event-failed', title: '失败通知', content: '未送达', severity: 'major' as const, channel: 'in_app', deliveryStatus: 'failed', read: false, createdAt: '', readAt: '' };
const delivered = { ...failed, id: 2, title: '完成通知', deliveryStatus: 'delivered' };
expect(alertNotificationDeliveryState(failed)).toBe('failed');
expect(filterAlertNotifications([failed, delivered], { search: '', deliveryFilter: 'failed' })).toEqual([failed]);
expect(filterAlertNotifications([failed, delivered], { search: '完成', deliveryFilter: 'all' })).toEqual([delivered]);
});
test('normalizes shareable automation workspace parameters', () => {
expect(alertAutomationViewFromParams(new URLSearchParams('automationSearch=%E7%A6%BB%E7%BA%BF&automationStatus=disabled&automationProtocol=GB32960&automationId=offline-rule&automationLimit=20&automationOffset=39'))).toEqual({
search: '离线', statusFilter: 'disabled', protocolFilter: 'GB32960', selectedID: 'offline-rule', lifecycle: 'current', limit: 20, offset: 20
});
expect(alertAutomationViewFromParams(new URLSearchParams('automationStatus=unknown'))).toEqual({
search: '', statusFilter: 'all', protocolFilter: '', selectedID: '', lifecycle: 'current', limit: 10, offset: 0
});
expect(alertAutomationViewFromParams(new URLSearchParams('automationScope=archived'))).toEqual({
search: '', statusFilter: 'all', protocolFilter: '', selectedID: '', lifecycle: 'archived', limit: 10, offset: 0
});
expect(alertAutomationRunReturnFromParams(new URLSearchParams('runFromAutomationId=speed-rule&runFromAutomationName=%E8%B6%85%E9%80%9F%E8%87%AA%E5%8A%A8%E5%8C%96'))).toEqual({ ruleId: 'speed-rule', ruleName: '超速自动化' });
expect(alertAutomationRunReturnFromParams(new URLSearchParams())).toBeUndefined();
});
test('summarizes only material automation changes before release', () => {
const baseline = { ...alertRule(), triggerType: 'metric' as const };
const draft = { ...baseline, id: '', version: 0, name: '测试超速规则(副本)', enabled: false };
expect(automationReleaseChanges(baseline, draft, { speed_kmh: '速度' }, { speed_kmh: 'km/h' })).toEqual([
{ key: 'name', label: '规则名称', before: '测试超速规则', after: '测试超速规则(副本)' },
{ key: 'status', label: '发布状态', before: '启用', after: '停用' }
]);
expect(automationReleaseChanges(baseline, baseline)).toEqual([]);
const pressureBaseline = {
...baseline,
id: 'hydrogen-pressure-rule',
name: '氢系统压力过低',
metric: 'hydrogen_pressure_mpa',
operator: 'lt',
threshold: 2,
recoveryOperator: 'gt',
recoveryThreshold: 3,
description: ''
};
const pressureDraft = { ...pressureBaseline, recoveryThreshold: 3.5, description: '压力恢复后自动关闭事件' };
expect(automationReleaseChanges(pressureBaseline, pressureDraft, { hydrogen_pressure_mpa: '最高氢气压力' }, { hydrogen_pressure_mpa: 'MPa' })).toEqual([
{ key: 'recovery', label: '恢复条件', before: '> 3 MPa', after: '> 3.5 MPa' },
{ key: 'description', label: '规则说明', before: '未填写', after: '压力恢复后自动关闭事件' }
]);
});
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 });
mocks.alertRulesV2.mockResolvedValue([]);
mocks.metricCatalog.mockResolvedValue({ metrics: [], asOf: '' });
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']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
const keyword = await screen.findByPlaceholderText('搜索车辆、事件或 VIN');
fireEvent.change(keyword, { target: { value: '保留筛选草稿' } });
fireEvent.click(screen.getByRole('tab', { name: /自动化/ }));
expect(await screen.findByRole('tab', { name: /自动化/ })).toHaveAttribute('aria-selected', 'true');
fireEvent.click(screen.getByRole('tab', { name: /事件流/ }));
expect(await screen.findByPlaceholderText('搜索车辆、事件或 VIN')).toHaveValue('保留筛选草稿');
});
test('preserves the nearest vehicle detail return while filtering its events', 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([]);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const vehiclePath = buildVehicleDetailPath('VIN-QUICK-001', { directoryReturn: '/vehicles?vehicleSearch=%E7%B2%A4A&vehicleView=online&vehiclePage=2' });
const initialEntry = withVehicleReturn('/alerts?vin=VIN-QUICK-001', vehiclePath);
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={[initialEntry]}><AlertsPage /><RouteState /></MemoryRouter></QueryClientProvider>);
expect(await screen.findByRole('link', { name: /返回车辆档案/ })).toHaveAttribute('href', vehiclePath);
fireEvent.click(screen.getByRole('button', { name: /^待处理/ }));
expect(await screen.findByRole('link', { name: /返回车辆档案/ })).toHaveAttribute('href', vehiclePath);
expect(screen.getByTestId('alert-route-state')).toHaveTextContent('status=unprocessed');
expect(screen.getByTestId('alert-route-state')).toHaveTextContent('vehicleReturn=');
});
test('preserves the global monitor return while filtering vehicle events', 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([]);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const monitorPath = '/monitor?selectedVin=VIN-QUICK-001&detail=open&zoom=13';
const initialEntry = withMonitorReturn('/alerts?vin=VIN-QUICK-001', monitorPath);
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={[initialEntry]}><AlertsPage /><RouteState /></MemoryRouter></QueryClientProvider>);
expect(await screen.findByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', expect.stringContaining('/monitor?'));
fireEvent.click(screen.getByRole('button', { name: /^待处理/ }));
expect(await screen.findByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', expect.stringContaining('selectedVin=VIN-QUICK-001'));
expect(screen.getByTestId('alert-route-state')).toHaveTextContent('status=unprocessed');
expect(screen.getByTestId('alert-route-state')).toHaveTextContent('monitorReturn=');
});
test('keeps creation contextual to the automation workspace', 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([]);
mocks.metricCatalog.mockResolvedValue({ metrics: [], asOf: '' });
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']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
await screen.findByText('当前筛选条件没有事件');
expect(screen.queryByRole('button', { name: /创建自动化/ })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: /自动化/ }));
fireEvent.click(await screen.findByRole('button', { name: /创建自动化/ }));
const editor = await screen.findByRole('dialog', { name: '事件自动化编辑' });
expect(editor.closest('.v2-alert-rule-editor-dialog')).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /自动化/ })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('navigation', { name: '自动化编辑步骤' })).toHaveTextContent(/事件与条件.*车辆范围.*执行动作.*检查并发布/);
expect(screen.getByLabelText('自动化实时摘要')).toBeInTheDocument();
const nameInput = screen.getByLabelText('规则名称');
const thresholdInput = screen.getByLabelText('触发阈值');
expect(nameInput).toHaveValue('速度 > 80 km/h');
expect(within(editor).queryByRole('alert')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '下一步:车辆范围' })).toBeEnabled();
fireEvent.change(thresholdInput, { target: { value: '90' } });
await waitFor(() => expect(nameInput).toHaveValue('速度 > 90 km/h'));
fireEvent.change(nameInput, { target: { value: '高速超速提醒' } });
fireEvent.change(thresholdInput, { target: { value: '100' } });
expect(nameInput).toHaveValue('高速超速提醒');
fireEvent.click(screen.getByRole('button', { name: '恢复推荐名称' }));
await waitFor(() => expect(nameInput).toHaveValue('速度 > 100 km/h'));
});
test('loads only event dependencies on the default alert tab', 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([]);
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']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
await screen.findByText('当前筛选条件没有事件');
expect(mocks.alertRulesV2).toHaveBeenCalledTimes(1);
expect(mocks.metricCatalog).not.toHaveBeenCalled();
expect(mocks.alertNotificationsV2).not.toHaveBeenCalled();
for (const query of client.getQueryCache().findAll({ queryKey: ['alert-events-v2'] }).concat(client.getQueryCache().findAll({ queryKey: ['alert-summary-v2'] }))) {
const liveOptions = query?.options as { refetchInterval?: number; refetchIntervalInBackground?: boolean; refetchOnWindowFocus?: boolean };
expect(liveOptions.refetchInterval).toBe(5_000);
expect(liveOptions.refetchIntervalInBackground).toBe(false);
expect(liveOptions.refetchOnWindowFocus).toBe(true);
}
expect(screen.getByRole('button', { name: '刷新事件流' })).toBeInTheDocument();
});
test('restores event pagination from the URL and keeps page changes shareable', async () => {
mocks.alertSummaryV2.mockResolvedValue({ active: 120, unprocessed: 120, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 120, limit: 50, offset: 100 });
mocks.alertRulesV2.mockResolvedValue([]);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?keyword=PAGEVIN&limit=50&offset=100']}><AlertsPage /><RouteState /></MemoryRouter></QueryClientProvider>);
await waitFor(() => expect(mocks.alertEventsV2).toHaveBeenCalledWith(expect.objectContaining({ keyword: 'PAGEVIN', limit: 50, offset: 100 }), expect.anything()));
expect(await screen.findByText('共 120 条')).toBeInTheDocument();
expect(screen.getByTestId('alert-route-state')).toHaveTextContent('limit=50&offset=100');
fireEvent.click(screen.getByRole('button', { name: 'Previous' }));
await waitFor(() => expect(mocks.alertEventsV2).toHaveBeenLastCalledWith(expect.objectContaining({ limit: 50, offset: 50 }), expect.anything()));
expect(screen.getByTestId('alert-route-state')).toHaveTextContent('limit=50&offset=50');
});
test('repairs an event URL that points past the last available page', async () => {
mocks.alertSummaryV2.mockResolvedValue({ active: 40, unprocessed: 40, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 40, limit: 50, offset: 100 });
mocks.alertRulesV2.mockResolvedValue([]);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?keyword=SHRUNK&limit=50&offset=100']}><AlertsPage /><RouteState /></MemoryRouter></QueryClientProvider>);
await waitFor(() => expect(mocks.alertEventsV2).toHaveBeenLastCalledWith(expect.objectContaining({ limit: 50, offset: 0 }), expect.anything()));
expect(screen.getByTestId('alert-route-state')).toHaveTextContent('/alerts?keyword=SHRUNK&limit=50');
expect(screen.getByTestId('alert-route-state')).not.toHaveTextContent('offset=');
});
test('shows only the event error state before revealing an empty result after retry', async () => {
mocks.alertSummaryV2.mockResolvedValue({ active: 0, unprocessed: 0, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
mocks.alertEventsV2.mockRejectedValueOnce(new Error('事件服务暂时不可用')).mockResolvedValue({ items: [], total: 0, limit: 20, offset: 0 });
mocks.alertRulesV2.mockResolvedValue([]);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?keyword=VIN-ERROR']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
const failure = await screen.findByRole('alert');
expect(failure).toHaveTextContent('事件服务暂时不可用');
expect(screen.queryByText('当前筛选条件没有事件')).not.toBeInTheDocument();
fireEvent.click(within(failure).getByRole('button', { name: '重试' }));
expect(await screen.findByText('当前筛选条件没有事件')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '清空筛选' })).toBeInTheDocument();
});
test('restores a deep-linked event and carries its exact context into history evidence', async () => {
const selected = alertEvent('event-deep-link', 'VIN-DEEP-LINK', '粤A深链');
mocks.alertSummaryV2.mockResolvedValue({ active: 1, unprocessed: 1, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
mocks.alertEventsV2.mockResolvedValue({ items: [selected], total: 1, limit: 20, offset: 0 });
mocks.alertEventV2.mockResolvedValue(selected);
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=VIN-DEEP-LINK&protocol=JT808&eventId=event-deep-link']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
expect(await screen.findByText('event-deep-link')).toBeInTheDocument();
expect(mocks.alertEventV2).toHaveBeenCalledWith('event-deep-link', expect.any(AbortSignal));
const historyLink = screen.getByRole('link', { name: '核验历史证据' });
const historyURL = new URL(historyLink.getAttribute('href') || '', 'https://vehicle-platform.invalid');
expect(historyURL.pathname).toBe('/history');
expect(Object.fromEntries(historyURL.searchParams)).toEqual(expect.objectContaining({
keywords: 'VIN-DEEP-LINK', category: 'raw', protocol: 'JT808', eventId: 'event-deep-link', eventVin: 'VIN-DEEP-LINK'
}));
});
test('shows an honest loading state while a deep-linked event is being restored', async () => {
const selected = alertEvent('event-slow-link', 'VIN-SLOW-LINK', '粤A慢链');
let resolveDetail!: (value: AlertEvent) => void;
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.alertEventV2.mockImplementation(() => new Promise<AlertEvent>((resolve) => { resolveDetail = resolve; }));
mocks.alertRulesV2.mockResolvedValue([]);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?eventId=event-slow-link']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
expect(await screen.findByText('正在读取事件详情')).toBeInTheDocument();
expect(screen.queryByText('选择一个事件')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '关闭事件详情' })).toBeInTheDocument();
await act(async () => { resolveDetail(selected); });
expect(await screen.findByText('event-slow-link')).toBeInTheDocument();
expect(screen.queryByText('正在读取事件详情')).not.toBeInTheDocument();
});
test('offers recovery when an event deep link fails and succeeds on retry', async () => {
const selected = alertEvent('event-retry-link', 'VIN-RETRY-LINK', '粤A重试');
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.alertEventV2.mockRejectedValueOnce(new Error('事件服务请求超时')).mockResolvedValue(selected);
mocks.alertRulesV2.mockResolvedValue([]);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?eventId=event-retry-link']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
const failure = await screen.findByRole('alert');
expect(failure).toHaveTextContent('事件详情无法加载事件服务请求超时');
fireEvent.click(within(failure).getByRole('button', { name: '重新加载事件详情' }));
expect(await screen.findByText('event-retry-link')).toBeInTheDocument();
expect(mocks.alertEventV2).toHaveBeenCalledTimes(2);
});
test('allows immediate event handling from list evidence while fresh detail is still loading', async () => {
const selected = alertEvent('event-fast-action', 'VIN-FAST-ACTION', '粤A快速处置');
let resolveDetail!: (value: AlertEvent) => void;
mocks.alertSummaryV2.mockResolvedValue({ active: 1, unprocessed: 1, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
mocks.alertEventsV2.mockResolvedValue({ items: [selected], total: 1, limit: 20, offset: 0 });
mocks.alertEventV2.mockImplementation(() => new Promise<AlertEvent>((resolve) => { resolveDetail = resolve; }));
mocks.actOnAlertV2.mockResolvedValue({ ...selected, status: 'processing', version: 2 });
mocks.alertRulesV2.mockResolvedValue([]);
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
fireEvent.click(await screen.findByTestId('alert-row-event-fast-action'));
fireEvent.click(await screen.findByRole('button', { name: '确认处理' }));
await waitFor(() => expect(mocks.actOnAlertV2).toHaveBeenCalledWith('event-fast-action', { version: 1, action: 'acknowledge', note: '' }));
await act(async () => { resolveDetail(selected); });
});
test('keeps the selected event in the URL and clears it when the inspector closes', async () => {
const selected = alertEvent('event-route-state', 'VIN-ROUTE-STATE', '粤A路由');
mocks.alertSummaryV2.mockResolvedValue({ active: 1, unprocessed: 1, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
mocks.alertEventsV2.mockResolvedValue({ items: [selected], total: 1, limit: 20, offset: 0 });
mocks.alertEventV2.mockResolvedValue(selected);
mocks.alertRulesV2.mockResolvedValue([]);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?keyword=VIN-ROUTE-STATE&protocol=JT808']}><AlertsPage /><RouteState /></MemoryRouter></QueryClientProvider>);
fireEvent.click(await screen.findByTestId('alert-row-event-route-state'));
await screen.findByText('event-route-state');
expect(screen.getByTestId('alert-route-state')).toHaveTextContent('/alerts?keyword=VIN-ROUTE-STATE&protocol=JT808&eventId=event-route-state');
fireEvent.click(screen.getByRole('button', { name: '关闭事件详情' }));
expect(screen.getByTestId('alert-route-state')).toHaveTextContent('/alerts?keyword=VIN-ROUTE-STATE&protocol=JT808');
expect(screen.getByTestId('alert-route-state')).not.toHaveTextContent('eventId');
});
test('opens the exact originating event from notification audit instead of staying on the notification tab', async () => {
const selected = alertEvent('event-from-notification', 'VIN-NOTIFICATION', '粤A通知');
mocks.alertNotificationsV2.mockResolvedValue({
items: [{ id: 18, eventId: selected.id, title: '车辆速度告警', content: '车辆速度超过阈值', severity: 'major', channel: 'in_app', vehiclePlate: selected.plate, vehicleVin: selected.vin, protocol: selected.protocol, read: true, createdAt: selected.triggeredAt, readAt: selected.triggeredAt }],
total: 1, limit: 20, offset: 0
});
mocks.alertSummaryV2.mockResolvedValue({ active: 1, unprocessed: 1, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
mocks.alertEventsV2.mockResolvedValue({ items: [selected], total: 1, limit: 20, offset: 0 });
mocks.alertEventV2.mockResolvedValue(selected);
mocks.alertRulesV2.mockResolvedValue([]);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=notifications']}><AlertsPage /><RouteState /></MemoryRouter></QueryClientProvider>);
fireEvent.click(await screen.findByRole('button', { name: '查看 车辆速度告警 送达详情' }));
fireEvent.click(within(await screen.findByRole('complementary', { name: '通知送达详情' })).getByRole('link', { name: '查看事件' }));
expect(await screen.findByRole('tab', { name: /事件流/ })).toHaveAttribute('aria-selected', 'true');
expect(await screen.findByText('event-from-notification')).toBeInTheDocument();
expect(mocks.alertEventV2).toHaveBeenCalledWith('event-from-notification', expect.any(AbortSignal));
expect(screen.getByTestId('alert-route-state')).toHaveTextContent('/alerts?keyword=VIN-NOTIFICATION&eventId=event-from-notification&protocol=JT808');
});
test('loads a paginated notification page and an independent unread total on direct entry', async () => {
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=notifications']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
await screen.findByText('暂无通知记录');
expect(view.container.querySelector('.v2-alert-delivery-workspace')).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /通知记录/ })).toHaveAttribute('aria-selected', 'true');
expect(view.container.querySelector('.v2-alert-notification-empty.semi-empty')).toBeInTheDocument();
expect(view.container.querySelector('.v2-alert-notification-pagination')).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: '每页通知数' })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: '搜索通知记录' })).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: '阅读状态' })).toBeInTheDocument();
expect(view.container.querySelector('.v2-alert-delivery-inspector')).not.toBeInTheDocument();
expect(mocks.alertRulesV2).not.toHaveBeenCalled();
expect(mocks.metricCatalog).not.toHaveBeenCalled();
expect(mocks.alertNotificationsV2).toHaveBeenCalledTimes(1);
expect(mocks.alertNotificationsV2.mock.calls[0][0].toString()).toBe('limit=20&offset=0');
const notificationQuery = client.getQueryCache().find({ queryKey: ['alert-notifications-v2', 'all', 'all', '', 20, 0] });
const liveOptions = notificationQuery?.options as { refetchInterval?: number; refetchIntervalInBackground?: boolean; refetchOnWindowFocus?: boolean };
expect(liveOptions.refetchInterval).toBe(5_000);
expect(liveOptions.refetchIntervalInBackground).toBe(false);
expect(liveOptions.refetchOnWindowFocus).toBe(true);
});
test('restores notification paging and detail from the URL and keeps changes shareable', async () => {
mocks.alertNotificationsV2.mockResolvedValue({
items: [{ id: 18, eventId: 'event-notify-route', title: '共享通知', content: '可恢复的通知详情', severity: 'major', channel: 'in_app', vehiclePlate: '粤A共享', vehicleVin: 'VIN-NOTIFY-ROUTE', recipient: '运营值班组', protocol: 'JT808', read: true, createdAt: '2026-07-16T04:00:00Z', readAt: '2026-07-16T04:01:00Z' }],
total: 120, limit: 50, offset: 50
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=notifications&notifyLimit=50&notifyOffset=50&notificationId=18']}><AlertsPage /><RouteState /></MemoryRouter></QueryClientProvider>);
const inspector = await screen.findByRole('complementary', { name: '通知送达详情' });
expect(await within(inspector).findByText('可恢复的通知详情')).toBeInTheDocument();
expect(mocks.alertNotificationsV2.mock.calls[0][0].toString()).toBe('limit=50&offset=50');
expect(screen.getByTestId('alert-route-state')).toHaveTextContent('notifyLimit=50&notifyOffset=50&notificationId=18');
fireEvent.click(within(inspector).getByRole('button', { name: '关闭通知送达详情' }));
await waitFor(() => expect(screen.getByTestId('alert-route-state')).not.toHaveTextContent('notificationId='));
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
await waitFor(() => expect(mocks.alertNotificationsV2.mock.calls[mocks.alertNotificationsV2.mock.calls.length - 1][0].toString()).toBe('limit=50&offset=100'));
expect(screen.getByTestId('alert-route-state')).toHaveTextContent('notifyLimit=50&notifyOffset=100');
});
test('matches notification search against vehicle and recipient and clears hidden detail', async () => {
const notification = { id: 19, eventId: 'generic-event', title: '通用提醒', content: '请及时处理', severity: 'major' as const, channel: 'in_app', vehiclePlate: '沪A·检索', vehicleVin: 'VIN-SEARCH-019', recipient: '夜班负责人', protocol: 'JT808', read: false, createdAt: '2026-07-16T04:00:00Z', readAt: '' };
mocks.alertNotificationsV2.mockImplementation(async (params: URLSearchParams) => {
const search = params.get('search') || '';
const matches = !search || ['沪A·检索', '夜班负责人'].some((value) => value.includes(search));
return { items: matches ? [notification] : [], total: matches ? 1 : 0, limit: 20, offset: 0 };
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=notifications']}><AlertsPage /><RouteState /></MemoryRouter></QueryClientProvider>);
const search = await screen.findByRole('textbox', { name: '搜索通知记录' });
fireEvent.change(search, { target: { value: '沪A·检索' } });
expect(await screen.findByRole('button', { name: '查看 通用提醒 送达详情' })).toBeInTheDocument();
fireEvent.change(search, { target: { value: '夜班负责人' } });
const row = await screen.findByRole('button', { name: '查看 通用提醒 送达详情' });
fireEvent.click(row);
expect(screen.getByTestId('alert-route-state')).toHaveTextContent('notificationId=19');
fireEvent.change(search, { target: { value: '完全不匹配' } });
expect(await screen.findByText('没有匹配的通知记录')).toBeInTheDocument();
await waitFor(() => {
const calls = mocks.alertNotificationsV2.mock.calls;
expect((calls[calls.length - 1]?.[0] as URLSearchParams).get('search')).toBe('完全不匹配');
});
expect(screen.queryByRole('complementary', { name: '通知送达详情' })).not.toBeInTheDocument();
expect(screen.getByTestId('alert-route-state')).not.toHaveTextContent('notificationId=');
});
test('filters failed deliveries from the URL and never presents them as delivered', async () => {
const failed = { id: 21, eventId: 'event-delivery-failed', title: '夜班超速通知', content: '短信服务连接超时', severity: 'major' as const, channel: 'sms', vehiclePlate: '浙A·失败', vehicleVin: 'VIN-DELIVERY-FAILED', recipient: '夜班负责人', protocol: 'JT808', deliveryStatus: 'failed', attemptCount: 3, read: false, createdAt: '2026-07-16T04:00:00Z', readAt: '' };
const delivered = { ...failed, id: 22, eventId: 'event-delivery-ok', title: '已送达通知', deliveryStatus: 'delivered', attemptCount: 1, read: true };
mocks.alertNotificationsV2.mockImplementation(async (params: URLSearchParams) => {
const filtered = params.get('deliveryStatus') === 'failed' ? [failed] : [failed, delivered];
return { items: filtered, total: filtered.length, limit: 20, offset: 0 };
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=notifications&notifyDelivery=failed&notificationId=21']}><AlertsPage /><RouteState /></MemoryRouter></QueryClientProvider>);
expect(await screen.findByRole('button', { name: '查看 夜班超速通知 送达详情' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '查看 已送达通知 送达详情' })).not.toBeInTheDocument();
expect(screen.getByRole('combobox', { name: '送达状态' })).toBeInTheDocument();
expect(screen.getByTestId('alert-route-state')).toHaveTextContent('notifyDelivery=failed&notificationId=21');
expect((mocks.alertNotificationsV2.mock.calls[0][0] as URLSearchParams).get('deliveryStatus')).toBe('failed');
const inspector = await screen.findByRole('complementary', { name: '通知送达详情' });
expect(within(inspector).getByText('已达到投递上限,需要人工处置')).toBeInTheDocument();
expect(within(inspector).getByText(/系统不会继续自动尝试/)).toBeInTheDocument();
expect(within(inspector).getByRole('link', { name: '查看事件' })).toBeInTheDocument();
expect(within(inspector).queryByRole('button', { name: /安全重试失败通知/ })).not.toBeInTheDocument();
expect(within(inspector).getByText('未返回')).toBeInTheDocument();
const timeline = within(inspector).getByRole('region', { name: '送达时间线' });
expect(within(timeline).getByText('发送失败')).toBeInTheDocument();
expect(within(timeline).getByText('第 3 次尝试未完成,未标记为已送达')).toBeInTheDocument();
expect(within(timeline).queryByText('已发送')).not.toBeInTheDocument();
expect(within(timeline).queryByText('已送达')).not.toBeInTheDocument();
fireEvent.click(within(inspector).getByRole('button', { name: '刷新状态' }));
await waitFor(() => expect(mocks.alertNotificationsV2).toHaveBeenCalledTimes(2));
});
test('confirms an idempotent notification retry and preserves a visible audit receipt', async () => {
const failed = { id: 23, eventId: 'event-delivery-retry', title: 'SOC 低电量短信通知', content: '短信网关连接超时', severity: 'major' as const, channel: 'sms', vehiclePlate: '粤B·重试', vehicleVin: 'VIN-DELIVERY-RETRY', recipient: '夜班负责人', protocol: 'GB32960', deliveryStatus: 'failed', attemptCount: 2, maxAttempts: 3, retryAvailable: true, lastError: 'provider_timeout', read: false, createdAt: '2026-07-16T04:00:00Z', readAt: '' };
mocks.alertNotificationsV2.mockResolvedValue({ items: [failed], total: 1, limit: 20, offset: 0 });
mocks.alertNotificationRetryAuditsV2.mockResolvedValue([{ id: 7, notificationId: 23, actor: 'operator-a', reason: '首次人工复核', previousStatus: 'failed', nextStatus: 'reserved', attemptCount: 2, requestedAt: '2026-07-16T04:02:00Z' }]);
mocks.retryAlertNotificationV2.mockResolvedValue({
notification: { ...failed, deliveryStatus: 'queued', attemptCount: 3, retryAvailable: false, retryRequestedBy: 'admin', retryRequestedAt: '2026-07-16T04:05:00Z' },
receipt: { id: 8, notificationId: 23, actor: 'admin', reason: '已确认短信网关恢复', previousStatus: 'failed', nextStatus: 'reserved', attemptCount: 3, requestedAt: '2026-07-16T04:05:00Z' },
idempotent: false
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=notifications&notifyDelivery=failed&notificationId=23']}><AlertsPage /><RouteState /></MemoryRouter></QueryClientProvider>);
const inspector = await screen.findByRole('complementary', { name: '通知送达详情' });
expect(await within(inspector).findByText('第 2 次 · 回执 #7')).toBeInTheDocument();
fireEvent.click(within(inspector).getByRole('button', { name: '安全重试失败通知:SOC 低电量短信通知' }));
const confirmation = await screen.findByRole('dialog', { name: '确认安全重试失败通知' });
expect(confirmation).toHaveTextContent('相同请求不会重复入队');
expect(confirmation).toHaveTextContent('2 → 3');
const confirm = within(confirmation).getByRole('button', { name: '确认并安全重试' });
expect(confirm).toBeDisabled();
fireEvent.change(within(confirmation).getByRole('textbox', { name: '通知重试原因' }), { target: { value: '已确认短信网关恢复' } });
expect(confirm).toBeEnabled();
fireEvent.click(confirm);
await waitFor(() => expect(mocks.retryAlertNotificationV2).toHaveBeenCalledTimes(1));
expect(mocks.retryAlertNotificationV2).toHaveBeenCalledWith(23, expect.objectContaining({
expectedAttemptCount: 2,
reason: '已确认短信网关恢复',
idempotencyKey: expect.stringMatching(/^alert-notification:23:3:/)
}));
const receipt = await screen.findByRole('status', { name: '通知重试回执' });
expect(receipt).toHaveTextContent('通知重试已进入发送队列');
expect(receipt).toHaveTextContent('第 3 次尝试 · 回执 #8 · admin');
await waitFor(() => expect(screen.getByTestId('alert-route-state')).not.toHaveTextContent('notificationId='));
});
test('uses a server-wide delivery filter so empty totals and paging stay trustworthy', async () => {
mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 20, offset: 0 });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=notifications&notifyDelivery=failed']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
expect(await screen.findByText('当前范围没有发送失败通知')).toBeInTheDocument();
expect(screen.getByText('已检查完整通知范围;可清除搜索或切换送达状态。')).toBeInTheDocument();
expect(screen.getByText('共 0 条 · 本页 0 条')).toBeInTheDocument();
expect((mocks.alertNotificationsV2.mock.calls[0][0] as URLSearchParams).get('deliveryStatus')).toBe('failed');
expect(screen.getByRole('button', { name: '查看全部通知' })).toBeInTheDocument();
});
test('explains and clears a stale notification detail link', async () => {
mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 20, offset: 0 });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=notifications&notificationId=999']}><AlertsPage /><RouteState /></MemoryRouter></QueryClientProvider>);
const inspector = await screen.findByRole('complementary', { name: '通知送达详情' });
expect(await within(inspector).findByText('当前页未找到指定通知')).toBeInTheDocument();
fireEvent.click(within(inspector).getByRole('button', { name: '清除失效链接' }));
await waitFor(() => expect(screen.getByTestId('alert-route-state')).not.toHaveTextContent('notificationId='));
expect(screen.queryByRole('complementary', { name: '通知送达详情' })).not.toBeInTheDocument();
});
test('ends notification mutation feedback with a visible retryable error instead of a silent pending state', async () => {
mocks.alertNotificationsV2.mockResolvedValue({ items: [{ id: 7, eventId: 'event-7', title: '车辆告警', content: '测试告警', severity: 'major', read: false, createdAt: '2026-07-16T04:00:00Z' }], total: 1, limit: 100, offset: 0 });
mocks.readAlertNotificationsV2.mockRejectedValue(new Error('通知状态更新超时'));
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>);
const row = await screen.findByRole('button', { name: '查看 车辆告警 送达详情' });
fireEvent.click(row);
const inspector = await screen.findByRole('complementary', { name: '通知送达详情' });
const markRead = within(inspector).getByRole('button', { name: '标为已读' });
fireEvent.click(markRead);
expect(await screen.findByText('通知状态更新超时')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '标为已读' })).toBeEnabled();
});
test('keeps the mobile notification title, count, and compact bulk action visible together', async () => {
layout.mobile = true;
mocks.alertNotificationsV2.mockResolvedValue({ items: [{ id: 8, eventId: 'event-8', title: '车辆告警', content: '移动端告警', severity: 'major', read: false, createdAt: '2026-07-16T04:00:00Z' }], total: 1, 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>);
expect(await screen.findByRole('button', { name: /车辆告警/ })).toHaveClass('v2-alert-delivery-mobile-row');
expect(screen.getByRole('button', { name: '本页站内信已读' })).toBeInTheDocument();
expect(document.querySelector('.v2-alert-notification-pagination')).toHaveTextContent('共 1 条');
});
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: '' });
mocks.alertEventsV2.mockImplementation((query: { keyword?: string }) => query.keyword === 'OLDVIN'
? Promise.resolve({ items: [oldEvent], total: 1, limit: 20, offset: 0 })
: new Promise<Page<AlertEvent>>((resolve) => { resolveNew = resolve; }));
mocks.alertEventV2.mockImplementation((id: string) => Promise.resolve(id === oldEvent.id ? oldEvent : newEvent));
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?keyword=OLDVIN']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
expect((await screen.findAllByText('旧告警车牌')).length).toBeGreaterThan(0);
for (const className of ['v2-alert-filter-card', 'v2-alert-table-card']) {
expect(view.container.querySelector(`.${className}.semi-card`)).toBeInTheDocument();
}
expect(view.container.querySelector('.v2-alert-navigation')).toBeInTheDocument();
expect(screen.getByLabelText('事件中心工作区')).toHaveClass('v2-alert-navigation');
expect(screen.getByLabelText('事件中心工作区')).toHaveTextContent('事件流');
expect(screen.queryByRole('heading', { level: 2, name: '事件中心' })).not.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-event-status-tabs .semi-button')).toHaveLength(6);
expect(view.container.querySelector('.v2-alert-event-table.semi-table-wrapper')).toBeInTheDocument();
expect(view.container.querySelector('.v2-alert-event-table.semi-table-wrapper')).not.toHaveClass('is-compact');
expect(screen.getByRole('button', { name: '刷新事件流' })).toBeInTheDocument();
expect(screen.queryByRole('columnheader', { name: '事件证据' })).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-alert-event-table .v2-alert-protocol-tag.semi-tag')).toHaveTextContent('JT/T 808');
expect(view.container.querySelector('.v2-alert-event-table input[type="radio"]')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-alert-table-scroll > table')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-alert-mobile-list')).not.toBeInTheDocument();
expect(view.container.querySelector('time[datetime="2026-07-16T04:00:00Z"]')).toHaveTextContent('07-16 12:00:00');
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(view.container.querySelector('.v2-alert-event-table.semi-table-wrapper')).toHaveClass('is-compact');
expect(desktopAlertRow).toHaveClass('is-severity-major', 'is-selected');
expect(screen.queryByRole('columnheader', { name: '事件证据' })).not.toBeInTheDocument();
expect(desktopAlertRow).toHaveAttribute('aria-expanded', 'true');
expect(view.container.querySelector('.v2-alert-focus-card[aria-label="事件摘要"]')).toHaveClass('v2-alert-detail-card');
expect(screen.getByLabelText('事件上下文')).toHaveTextContent('协议来源JT/T 808发生时间07-16 12:00:00匹配自动化v1');
expect(screen.getByLabelText('事件证据')).toHaveTextContent('观测值90 km/h匹配条件> 80 km/h,持续 60 秒变化幅度+10 km/h');
expect(screen.getAllByText('待处理').some((node) => Boolean(node.closest('.semi-tag')))).toBe(true);
const dispositionCard = view.container.querySelector('.v2-alert-disposition-card');
const progressCard = view.container.querySelector('.v2-alert-progress-card');
expect(dispositionCard).toHaveAttribute('aria-label', '事件处置操作');
expect(dispositionCard?.compareDocumentPosition(progressCard!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(view.container.querySelector('.v2-alert-technical-collapse.semi-collapse')).toBeInTheDocument();
expect(screen.getByText('原始事件字段')).toBeInTheDocument();
expect(view.container.querySelector('.v2-alert-inspector-heading')).toHaveClass('v2-workspace-panel-header');
expect(view.container.querySelectorAll('.v2-alert-descriptions.semi-descriptions')).toHaveLength(1);
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(view.container.querySelector('.v2-alert-event-table.semi-table-wrapper')).not.toHaveClass('is-compact');
expect(screen.queryByRole('columnheader', { name: '事件证据' })).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: '查询' }));
expect(await screen.findByText('正在更新事件…')).toBeInTheDocument();
expect(screen.queryByText('旧告警车牌')).not.toBeInTheDocument();
expect(screen.queryByText('old-event')).not.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('keeps status shortcuts comparable while the event list is status-filtered', async () => {
mocks.alertSummaryV2.mockResolvedValue({ active: 3, unprocessed: 2, processing: 1, recovered: 4, closed: 1, ignored: 0, unreadNotifications: 0, asOf: '' });
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 2, limit: 20, offset: 0 });
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=TESTVIN&status=unprocessed']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
await screen.findByText('当前筛选条件没有事件');
const summaryScope = mocks.alertSummaryV2.mock.calls[0][0];
expect(summaryScope).toEqual(expect.objectContaining({ keyword: 'TESTVIN' }));
expect(summaryScope).not.toHaveProperty('status');
expect(screen.getByRole('button', { name: /待处理.*2/ })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: /已恢复.*4/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /已完成.*1/ })).toBeInTheDocument();
const allStatus = within(document.querySelector('.v2-event-status-tabs')!).getByRole('button', { name: /全部事件/ });
fireEvent.click(allStatus);
await waitFor(() => expect(mocks.alertEventsV2).toHaveBeenLastCalledWith(expect.not.objectContaining({ status: expect.anything() }), expect.anything()));
expect(allStatus).toHaveAttribute('aria-pressed', 'true');
});
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-table-scroll.is-mobile-scroll')).toHaveAttribute('tabindex', '0');
expect(view.container.querySelector('.v2-alert-table-scroll.is-mobile-scroll')).toHaveAttribute('aria-label', '事件列表,可上下滚动');
expect(view.container.querySelector('.v2-alert-event-table')).not.toBeInTheDocument();
expect(view.container.querySelectorAll('.v2-event-status-tabs .semi-button')).toHaveLength(6);
expect(screen.getByRole('button', { name: /待处理.*1/ })).toHaveAttribute('aria-pressed', 'false');
expect(view.container.querySelector('.v2-alert-metric-rail')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /查看未读通知/ })).not.toBeInTheDocument();
expect(screen.getByRole('tab', { name: /通知记录/ })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /自动化/ })).toBeInTheDocument();
expect(action).toHaveAttribute('aria-expanded', 'false');
expect(action).toHaveTextContent('匹配条件> 80 km/h,持续 60 秒');
expect(action.querySelector('.v2-alert-protocol-tag.semi-tag')).toHaveTextContent('JT/T 808');
expect(action.querySelector('.v2-alert-mobile-facts')).toHaveTextContent('07-16 12:00:00');
fireEvent.click(action);
expect(action).toHaveAttribute('aria-pressed', 'true');
expect(action).toHaveAttribute('aria-expanded', 'true');
expect(await screen.findByRole('dialog', { name: '事件详情' })).toBeInTheDocument();
expect(document.querySelector('.v2-alert-detail-sidesheet')).toHaveClass('semi-sidesheet-bottom', 'v2-workspace-detail-sidesheet');
expect(document.querySelector('.v2-alert-detail-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(90dvh, 800px)' });
expect(document.querySelector('.v2-alert-detail-sidesheet .v2-workspace-config-title > .semi-tag')).toHaveTextContent('待处理');
expect(document.querySelector('.v2-alert-detail-sidesheet .v2-alert-inspector-heading')).not.toBeInTheDocument();
const mobileDisposition = document.querySelector('.v2-alert-detail-sidesheet .v2-alert-disposition-card');
const mobileProgress = document.querySelector('.v2-alert-detail-sidesheet .v2-alert-progress-card');
expect(mobileDisposition).toHaveAttribute('aria-label', '事件处置操作');
expect(mobileDisposition?.compareDocumentPosition(mobileProgress!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(screen.getByLabelText('事件证据')).toHaveTextContent('变化幅度+10 km/h');
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.getByRole('combobox', { name: '协议来源' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /更多筛选/ }));
const dialog = await screen.findByRole('dialog', { name: '事件高级筛选' });
expect(within(dialog).getByRole('group', { name: '事件快捷时间范围' })).toHaveClass('semi-button-group');
expect(within(dialog).getByPlaceholderText('开始时间')).toBeInTheDocument();
expect(within(dialog).getByPlaceholderText('结束时间')).toBeInTheDocument();
expect(within(dialog).queryByRole('textbox', { name: '事件起始时间' })).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('combobox', { name: '关注级别' }));
const criticalOption = (await screen.findAllByText('紧急')).find((item) => item.closest('.semi-select-option'))!;
fireEvent.click(criticalOption);
fireEvent.click(within(dialog).getByRole('button', { name: '应用筛选' }));
await waitFor(() => expect(mocks.alertEventsV2).toHaveBeenLastCalledWith(expect.objectContaining({ severity: 'critical' }), expect.anything()));
expect(screen.getByRole('button', { name: /更多筛选 · 1/ })).toHaveAttribute('aria-expanded', 'false');
});
test('uses one focused Semi bottom SideSheet for the complete mobile event filter', async () => {
layout.mobile = true;
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 } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
await screen.findByText('当前筛选条件没有事件');
const trigger = screen.getByRole('button', { name: /筛选/ });
expect(trigger).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(trigger);
const dialog = await screen.findByRole('dialog', { name: '事件筛选' });
expect(document.querySelector('.v2-alert-mobile-filter-sidesheet')).toHaveClass('semi-sidesheet-bottom');
expect(document.querySelector('.v2-alert-mobile-filter-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(82dvh, 690px)' });
expect(within(dialog).getByRole('textbox', { name: /搜索/ })).toBeInTheDocument();
expect(within(dialog).getByRole('combobox', { name: '协议来源' })).toBeInTheDocument();
expect(within(dialog).getByPlaceholderText('开始时间')).toBeInTheDocument();
expect(within(dialog).getByPlaceholderText('结束时间')).toBeInTheDocument();
expect(within(dialog).getByRole('group', { name: '事件快捷时间范围' })).toHaveClass('semi-button-group');
expect(within(dialog).queryByRole('textbox', { name: '事件起始时间' })).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('button', { name: '重置条件' }));
expect(screen.getByRole('dialog', { name: '事件筛选' })).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('combobox', { name: '协议来源' }));
const jtOption = (await screen.findAllByText('JT/T 808')).find((item) => item.closest('.semi-select-option'))!;
fireEvent.click(jtOption);
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('restores automation filters and the selected rule from a shareable URL', async () => {
const speedRule = alertRule();
const offlineRule = { ...alertRule(), id: 'offline-rule', name: '车辆离线自动化', description: '车辆持续离线后通知夜班负责人', triggerType: 'offline' as const, metric: 'freshness_sec', enabled: false, scopeProtocols: ['GB32960'] };
mocks.alertRulesV2.mockResolvedValue([speedRule, offlineRule]);
mocks.metricCatalog.mockResolvedValue({ metrics: [], asOf: '' });
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 6, offset: 0 });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=rules&automationSearch=%E7%A6%BB%E7%BA%BF&automationStatus=disabled&automationProtocol=GB32960&automationId=offline-rule']}><AlertsPage /><RouteState /></MemoryRouter></QueryClientProvider>);
const selected = await screen.findByRole('button', { name: /^车辆离线自动化/ });
expect(selected).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('textbox', { name: '搜索自动化' })).toHaveValue('离线');
expect(within(screen.getByRole('tablist', { name: '自动化状态' })).getByRole('tab', { name: /已停用/ })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('combobox', { name: '协议类型' })).toHaveTextContent('GB/T 32960');
expect(screen.getByTestId('alert-route-state')).toHaveTextContent('automationId=offline-rule');
expect(screen.getByLabelText('车辆离线自动化 自动化流程')).toBeInTheDocument();
});
test('queries run history for the selected automation and opens its complete event stream', async () => {
const speedRule = alertRule();
const run = alertEvent('run-speed-1', 'VIN-RUN-SCOPE-001', '粤A运行01');
mocks.alertRulesV2.mockResolvedValue([speedRule]);
mocks.metricCatalog.mockResolvedValue({ metrics: [], asOf: '' });
mocks.alertSummaryV2.mockResolvedValue({ active: 1, unprocessed: 1, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
mocks.alertEventsV2.mockImplementation((query: { ruleId?: string; limit?: number; offset?: number }) => Promise.resolve(
query.ruleId === speedRule.id
? { items: [run], total: 9, limit: query.limit ?? 20, offset: query.offset ?? 0 }
: { items: [], total: 0, limit: query.limit ?? 20, offset: query.offset ?? 0 }
));
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=rules&automationId=speed-rule']}><AlertsPage /><RouteState /></MemoryRouter></QueryClientProvider>);
await waitFor(() => expect(mocks.alertEventsV2).toHaveBeenCalledWith({ ruleId: 'speed-rule', limit: 6, offset: 0 }, expect.anything()));
expect(await screen.findByText('当前自动化最近 1 / 9 条,30 秒更新。')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '查看 粤A运行01 的 粤A运行01速度告警' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /查看全部/ }));
await waitFor(() => expect(screen.getByTestId('alert-route-state')).toHaveTextContent('ruleId=speed-rule'));
expect(screen.getByTestId('alert-route-state')).toHaveTextContent('runFromAutomationId=speed-rule');
expect(screen.getByTestId('alert-route-state')).toHaveTextContent('runFromAutomationName=%E6%B5%8B%E8%AF%95%E8%B6%85%E9%80%9F%E8%A7%84%E5%88%99');
expect(await screen.findByText('来自自动化运行记录')).toBeInTheDocument();
});
test('repairs a hidden automation selection and removes ghost detail when no rule matches', async () => {
const speedRule = alertRule();
const offlineRule = { ...alertRule(), id: 'offline-rule', name: '车辆离线自动化', description: '车辆持续离线后通知夜班负责人', triggerType: 'offline' as const, metric: 'freshness_sec', enabled: false, scopeProtocols: ['GB32960'] };
mocks.alertRulesV2.mockResolvedValue([speedRule, offlineRule]);
mocks.metricCatalog.mockResolvedValue({ metrics: [], asOf: '' });
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 6, offset: 0 });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=rules&automationSearch=%E7%A6%BB%E7%BA%BF&automationId=speed-rule']}><AlertsPage /><RouteState /></MemoryRouter></QueryClientProvider>);
await waitFor(() => expect(screen.getByTestId('alert-route-state')).toHaveTextContent('automationId=offline-rule'));
expect(screen.getByLabelText('车辆离线自动化 自动化流程')).toBeInTheDocument();
fireEvent.change(screen.getByRole('textbox', { name: '搜索自动化' }), { target: { value: '完全不匹配' } });
expect(await screen.findByText('没有匹配的自动化')).toBeInTheDocument();
expect(screen.getByText('选择一条自动化')).toBeInTheDocument();
expect(screen.queryByLabelText('车辆离线自动化 自动化流程')).not.toBeInTheDocument();
expect(screen.getByTestId('alert-route-state')).not.toHaveTextContent('automationId=');
expect(screen.getByTestId('alert-route-state')).toHaveTextContent('automationSearch=%E5%AE%8C%E5%85%A8%E4%B8%8D%E5%8C%B9%E9%85%8D');
});
test('paginates a large automation library and keeps page state shareable', async () => {
const rules = Array.from({ length: 55 }, (_, index) => ({
...alertRule(),
id: `scale-rule-${index + 1}`,
name: `规模规则 ${String(index + 1).padStart(2, '0')}`,
enabled: index % 4 !== 0
}));
mocks.alertRulesV2.mockResolvedValue(rules);
mocks.metricCatalog.mockResolvedValue({ metrics: [], asOf: '' });
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 6, 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 /><RouteState /></MemoryRouter></QueryClientProvider>);
await screen.findByRole('button', { name: /^规模规则 01/ });
expect(view.container.querySelectorAll('.v2-alert-automation-row')).toHaveLength(10);
expect(screen.getByText('服务端匹配 55 条 · 当前 55 条 · 归档 0 条')).toBeInTheDocument();
expect(screen.getByText('第 1/6 页 · 55 条')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^规模规则 11/ })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(await screen.findByRole('button', { name: /^规模规则 11/ })).toBeInTheDocument();
expect(view.container.querySelectorAll('.v2-alert-automation-row')).toHaveLength(10);
expect(screen.getByTestId('alert-route-state')).toHaveTextContent('automationOffset=10');
await waitFor(() => expect(screen.getByTestId('alert-route-state')).toHaveTextContent('automationId=scale-rule-11'));
});
test('archives a disabled automation with a reason and opens its read-only audit receipt', async () => {
const disabled = { ...alertRule(), enabled: false };
let archived: AlertRule | undefined;
mocks.alertRulesV2.mockResolvedValue([disabled]);
mocks.alertRuleLibraryV2.mockImplementation(async (params: URLSearchParams) => {
const archivedScope = params.get('lifecycle') === 'archived';
const items = archivedScope ? archived ? [archived] : [] : archived ? [] : [disabled];
return {
items,
total: items.length,
limit: 10,
offset: 0,
summary: { current: archived ? 0 : 1, enabled: 0, disabled: archived ? 0 : 1, archived: archived ? 1 : 0 }
};
});
mocks.archiveAlertRuleV2.mockImplementation(async (_id: string, request: { version: number; reason: string }) => {
archived = { ...disabled, version: request.version + 1, archivedAt: '2026-07-23T15:30:00+08:00', archivedBy: 'platform-admin', archiveReason: request.reason };
return archived;
});
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 6, offset: 0 });
mocks.metricCatalog.mockResolvedValue({ metrics: [], asOf: '' });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=rules&automationId=speed-rule']}><AlertsPage /><RouteState /></MemoryRouter></QueryClientProvider>);
await screen.findByLabelText('测试超速规则 自动化流程');
fireEvent.click(screen.getByRole('button', { name: /归档$/ }));
const dialog = await screen.findByRole('dialog', { name: '归档自动化规则' });
expect(within(dialog).getByRole('button', { name: '确认归档' })).toBeDisabled();
fireEvent.change(within(dialog).getByLabelText('自动化归档原因'), { target: { value: '旧规则已经由新版替代' } });
fireEvent.click(within(dialog).getByRole('button', { name: '确认归档' }));
await waitFor(() => expect(mocks.archiveAlertRuleV2).toHaveBeenCalledWith('speed-rule', { version: 3, reason: '旧规则已经由新版替代' }));
const archiveTab = await screen.findByRole('tab', { name: /审计归档.*1/ });
await waitFor(() => expect(archiveTab).toHaveAttribute('aria-selected', 'true'));
fireEvent.click(await screen.findByRole('button', { name: /^测试超速规则/ }));
const receipt = await screen.findByRole('region', { name: '自动化归档回执' });
expect(receipt).toHaveTextContent('已从运行规则库移出');
expect(receipt).toHaveTextContent('旧规则已经由新版替代');
expect(screen.queryByRole('button', { name: '编辑自动化' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /恢复到当前规则$/ })).toBeInTheDocument();
expect(screen.getByTestId('alert-route-state')).toHaveTextContent('automationScope=archived');
});
test('opens a lightweight mobile rule detail before editing in a Semi bottom SideSheet', async () => {
layout.mobile = true;
mocks.alertRulesV2.mockResolvedValue([alertRule()]);
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 3, offset: 0 });
mocks.metricCatalog.mockResolvedValue({
metrics: [{ key: 'speed_kmh', label: '速度', unit: 'km/h', category: 'driving', valueType: 'numeric', protocols: ['JT808'], 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 } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=rules']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
const ruleItem = await screen.findByRole('button', { name: /^测试超速规则/ });
await waitFor(() => expect(ruleItem).toHaveAttribute('aria-pressed', 'true'));
expect(screen.getByRole('region', { name: '自动化列表' })).toBeInTheDocument();
expect(ruleItem).toHaveAttribute('aria-haspopup', 'dialog');
expect(ruleItem).toHaveAttribute('aria-expanded', 'false');
expect(document.querySelector('.v2-alert-rule-editor')).not.toBeInTheDocument();
fireEvent.click(ruleItem);
const detail = await screen.findByRole('dialog', { name: '自动化详情' });
expect(within(detail).getByText('先核对配置,再进入编辑。')).toBeInTheDocument();
expect(within(detail).getByRole('button', { name: /运行记录/ })).toBeInTheDocument();
expect(within(detail).getByRole('button', { name: /版本历史/ })).toBeInTheDocument();
expect(within(detail).getByRole('button', { name: /复制/ })).toBeInTheDocument();
fireEvent.click(within(detail).getByRole('button', { name: '编辑自动化' }));
const editor = await screen.findByRole('dialog', { name: '事件自动化编辑' });
expect(document.querySelector('.v2-alert-rule-editor-sidesheet')).toHaveClass('semi-sidesheet-bottom', 'v2-workspace-editor-sidesheet');
expect(document.querySelector('.v2-alert-rule-editor-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(96dvh, 920px)' });
expect(within(editor).getByDisplayValue('测试超速规则')).toBeInTheDocument();
expect(within(editor).getByRole('navigation', { name: '自动化编辑步骤' })).toHaveTextContent(/事件与条件.*车辆范围.*执行动作.*检查并发布/);
expect(within(editor).getByRole('heading', { name: '什么时候触发' })).toBeInTheDocument();
expect(within(editor).queryByRole('heading', { name: '哪些车辆生效' })).not.toBeInTheDocument();
expect(within(editor).queryByRole('heading', { name: '系统自动做什么' })).not.toBeInTheDocument();
fireEvent.click(within(editor).getByRole('button', { name: '下一步:车辆范围' }));
expect(within(editor).getByRole('heading', { name: '哪些车辆生效' })).toBeInTheDocument();
fireEvent.click(within(editor).getByRole('button', { name: '下一步:执行动作' }));
expect(within(editor).getByRole('heading', { name: '系统自动做什么' })).toBeInTheDocument();
fireEvent.click(within(editor).getByRole('button', { name: '下一步:检查发布' }));
expect(within(editor).getByRole('region', { name: /样例事件测试/ })).toBeInTheDocument();
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeDisabled();
fireEvent.click(within(editor).getByRole('button', { name: '运行测试' }));
expect(await within(editor).findByText(/测试成功/)).toBeInTheDocument();
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeDisabled();
fireEvent.click(within(editor).getByRole('button', { name: '编辑事件来源' }));
fireEvent.change(within(editor).getByDisplayValue('测试超速规则'), { target: { value: '测试超速规则 v4' } });
fireEvent.click(within(editor).getByRole('button', { name: /检查并发布/ }));
fireEvent.click(within(editor).getByRole('button', { name: '运行测试' }));
expect(await within(editor).findByText(/测试成功/)).toBeInTheDocument();
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeEnabled();
expect(ruleItem).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(within(editor).getByRole('button', { name: '关闭事件自动化编辑' }));
fireEvent.click(within(await screen.findByRole('dialog', { name: '确认放弃自动化草稿' })).getByRole('button', { name: '放弃草稿' }));
await waitFor(() => expect(document.querySelector('.v2-alert-rule-editor')).not.toBeInTheDocument());
expect(document.querySelector('.v2-alert-rule-editor-sidesheet .semi-sidesheet-inner')).toHaveClass('semi-sidesheet-animation-content_hide_bottom');
expect(ruleItem).toHaveAttribute('aria-expanded', 'false');
const newRule = screen.getByRole('button', { name: /创建自动化/ });
expect(newRule).toHaveAttribute('aria-haspopup', 'dialog');
fireEvent.click(newRule);
await waitFor(() => expect(document.querySelector('.v2-alert-rule-editor')).toBeInTheDocument());
const newEditor = screen.getByRole('dialog', { name: '事件自动化编辑' });
expect(within(newEditor).getAllByText('创建自动化').length).toBeGreaterThan(0);
expect(newRule).toHaveAttribute('aria-expanded', 'true');
});
test('shows audited automation revisions and safely restores a historical configuration', async () => {
const current = alertRule();
const restored = { ...current, version: 4, threshold: 70, durationSec: 30, updatedBy: 'platform-admin' };
mocks.alertRulesV2.mockResolvedValueOnce([current]).mockResolvedValue([restored]);
mocks.alertRuleRevisionsV2.mockResolvedValue(alertRuleRevisions());
mocks.rollbackAlertRuleV2.mockResolvedValue(restored);
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 6, offset: 0 });
mocks.metricCatalog.mockResolvedValue({ metrics: [{ key: 'speed_kmh', label: '速度', unit: 'km/h', category: 'driving', valueType: 'numeric', protocols: ['JT808'], sourceFields: {}, searchable: true, chartable: true, alertable: true }], asOf: '' });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=rules&automationId=speed-rule']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
await screen.findByLabelText('测试超速规则 自动化流程');
fireEvent.click(screen.getByRole('button', { name: '版本历史' }));
const history = await screen.findByRole('dialog', { name: '自动化版本历史' });
expect(within(history).getByText('当前 v3')).toBeInTheDocument();
expect(await within(history).findByRole('list', { name: '自动化版本时间线' })).toHaveTextContent('v3更新配置');
expect(within(history).getByText('恢复时保持当前状态')).toBeInTheDocument();
const restoreButtons = within(history).getAllByRole('button', { name: '恢复此版本' });
fireEvent.click(restoreButtons[0]);
const confirmation = await screen.findByRole('dialog', { name: '确认恢复自动化版本' });
expect(confirmation).toHaveTextContent('生成 v4');
expect(confirmation).toHaveTextContent('保持启用');
expect(confirmation).toHaveTextContent('不会意外启动或停止自动化');
fireEvent.click(within(confirmation).getByRole('button', { name: '确认恢复并发布' }));
await waitFor(() => expect(mocks.rollbackAlertRuleV2).toHaveBeenCalledWith('speed-rule', { targetVersion: 2, currentVersion: 3 }));
await waitFor(() => expect(screen.getByText(/运行中 · v4/)).toBeInTheDocument());
});
test('opens automation version history from the mobile rule detail', async () => {
layout.mobile = true;
mocks.alertRulesV2.mockResolvedValue([alertRule()]);
mocks.alertRuleRevisionsV2.mockResolvedValue(alertRuleRevisions());
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 6, offset: 0 });
mocks.metricCatalog.mockResolvedValue({ metrics: [], asOf: '' });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=rules']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
fireEvent.click(await screen.findByRole('button', { name: /^测试超速规则/ }));
const detail = await screen.findByRole('dialog', { name: '自动化详情' });
fireEvent.click(within(detail).getByRole('button', { name: /版本历史/ }));
const history = await screen.findByRole('dialog', { name: '自动化版本历史' });
expect(document.querySelector('.v2-alert-rule-revision-sidesheet')).toHaveClass('semi-sidesheet-bottom', 'v2-workspace-detail-sidesheet');
expect(document.querySelector('.v2-alert-rule-revision-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(94dvh, 860px)' });
expect(within(history).getByText('当前 v3')).toBeInTheDocument();
});
test('keeps a changed mobile automation in its editor until discard is confirmed', async () => {
layout.mobile = true;
mocks.alertRulesV2.mockResolvedValue([alertRule()]);
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 3, offset: 0 });
mocks.metricCatalog.mockResolvedValue({
metrics: [{ key: 'speed_kmh', label: '速度', unit: 'km/h', category: 'driving', valueType: 'numeric', protocols: ['JT808'], sourceFields: {}, searchable: true, chartable: true, alertable: true }],
asOf: ''
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=rules']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
fireEvent.click(await screen.findByRole('button', { name: /^测试超速规则/ }));
const detail = await screen.findByRole('dialog', { name: '自动化详情' });
fireEvent.click(within(detail).getByRole('button', { name: '编辑自动化' }));
const editor = await screen.findByRole('dialog', { name: '事件自动化编辑' });
fireEvent.change(within(editor).getByDisplayValue('测试超速规则'), { target: { value: '移动端夜间超速规则' } });
expect(within(editor).getByRole('status')).toHaveTextContent('草稿未保存');
fireEvent.click(within(editor).getByRole('button', { name: '关闭事件自动化编辑' }));
const confirmation = await screen.findByRole('dialog', { name: '确认放弃自动化草稿' });
expect(document.querySelector('.v2-alert-rule-editor-sidesheet .v2-alert-rule-editor')).toBeInTheDocument();
fireEvent.click(within(confirmation).getByRole('button', { name: '放弃草稿' }));
await waitFor(() => expect(document.querySelector('.v2-alert-rule-editor-sidesheet .v2-alert-rule-editor')).not.toBeInTheDocument());
});
test('opens desktop automation editing in a centered review dialog and publishes valid changes without requiring a sample test', async () => {
mocks.alertRulesV2.mockResolvedValue([alertRule()]);
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 3, offset: 0 });
mocks.metricCatalog.mockResolvedValue({
metrics: [{ key: 'speed_kmh', label: '速度', unit: 'km/h', category: 'driving', valueType: 'numeric', protocols: ['JT808'], sourceFields: {}, searchable: true, chartable: true, alertable: true }],
asOf: ''
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=rules']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
await screen.findByRole('button', { name: /^测试超速规则/ });
fireEvent.click(await screen.findByRole('button', { name: '编辑自动化' }));
const editor = await screen.findByRole('dialog', { name: '事件自动化编辑' });
expect(editor.closest('.v2-alert-rule-editor-dialog')).toBeInTheDocument();
expect(document.querySelector('.v2-alert-rule-editor-sidesheet')).not.toBeInTheDocument();
expect(within(editor).getByLabelText('自动化配置摘要')).toBeInTheDocument();
expect(within(editor).getByRole('region', { name: /样例事件测试/ })).toBeInTheDocument();
expect(within(editor).getByRole('region', { name: '执行跟踪' })).toBeInTheDocument();
expect(within(editor).getByText('0 项变更')).toBeInTheDocument();
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeDisabled();
fireEvent.click(within(editor).getByRole('button', { name: '运行测试' }));
expect(await within(editor).findByText(/测试成功/)).toBeInTheDocument();
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeDisabled();
fireEvent.click(within(editor).getByRole('button', { name: '编辑事件来源' }));
fireEvent.change(within(editor).getByDisplayValue('测试超速规则'), { target: { value: '测试超速规则 v4' } });
fireEvent.click(within(editor).getByRole('button', { name: /检查并发布/ }));
expect(within(editor).getByText('1 项变更')).toBeInTheDocument();
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeEnabled();
expect(within(editor).getByRole('status')).toHaveTextContent('配置已就绪,可直接发布更新');
fireEvent.click(within(editor).getByRole('button', { name: '上一步' }));
expect(within(editor).getByRole('heading', { name: '系统自动做什么' })).toBeInTheDocument();
expect(within(editor).queryByRole('heading', { name: '什么时候触发' })).not.toBeInTheDocument();
expect(within(editor).getByRole('button', { name: '下一步:检查发布' })).toBeEnabled();
fireEvent.click(within(editor).getByRole('button', { name: /事件与条件/ }));
expect(within(editor).getByRole('heading', { name: '什么时候触发' })).toBeInTheDocument();
});
test('enables publishing a hydrogen pressure automation when only its recovery threshold changes', async () => {
const pressureRule: AlertRule = {
...alertRule(),
id: 'hydrogen-pressure-rule',
name: '氢系统压力过低',
description: '',
triggerType: 'metric',
metric: 'hydrogen_pressure_mpa',
operator: 'lt',
threshold: 2,
thresholdHigh: 100,
durationSec: 3,
recoveryOperator: 'gt',
recoveryThreshold: 3,
scopeProtocols: ['GB32960']
};
mocks.alertRulesV2.mockResolvedValue([pressureRule]);
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 3, offset: 0 });
mocks.metricCatalog.mockResolvedValue({
metrics: [{ key: 'hydrogen_pressure_mpa', label: '最高氢气压力', unit: 'MPa', category: 'fuel-cell', valueType: 'numeric', protocols: ['GB32960'], sourceFields: { GB32960: 'gb32960.fuel_cell.max_hydrogen_pressure_mpa' }, searchable: true, chartable: true, alertable: true }],
asOf: ''
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=rules&automationId=hydrogen-pressure-rule']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
await screen.findByRole('button', { name: /^氢系统压力过低/ });
fireEvent.click(await screen.findByRole('button', { name: '编辑自动化' }));
const editor = await screen.findByRole('dialog', { name: '事件自动化编辑' });
fireEvent.click(within(within(editor).getByRole('navigation', { name: '自动化编辑步骤' })).getByRole('button', { name: /执行动作/ }));
fireEvent.change(within(editor).getByDisplayValue('3'), { target: { value: '3.5' } });
fireEvent.click(within(editor).getByRole('button', { name: '下一步:检查发布' }));
expect(within(editor).getByRole('list', { name: '自动化待发布变更' })).toHaveTextContent('恢复条件');
expect(within(editor).getByText('1 项变更')).toBeInTheDocument();
expect(within(editor).getByRole('button', { name: '发布更新' })).toBeEnabled();
expect(within(editor).getByRole('status')).toHaveTextContent('配置已就绪,可直接发布更新');
});
test('copies an automation into an audited disabled draft without changing the source rule', async () => {
const source = alertRule();
const created = { ...source, id: 'speed-rule-copy', name: '测试超速规则(副本)', enabled: false, version: 1 };
mocks.alertRulesV2.mockResolvedValue([source]);
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 6, offset: 0 });
mocks.metricCatalog.mockResolvedValue({ metrics: [{ key: 'speed_kmh', label: '速度', unit: 'km/h', category: 'driving', valueType: 'numeric', protocols: ['JT808'], sourceFields: {}, searchable: true, chartable: true, alertable: true }], asOf: '' });
mocks.saveAlertRuleV2.mockResolvedValue(created);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=rules&automationId=speed-rule']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
await screen.findByLabelText('测试超速规则 自动化流程');
fireEvent.click(screen.getByRole('button', { name: '复制自动化' }));
const editor = await screen.findByRole('dialog', { name: '事件自动化编辑' });
expect(within(editor).getByText('待创建副本')).toBeInTheDocument();
expect(within(editor).getByText('2 项变更')).toBeInTheDocument();
expect(within(editor).getByRole('list', { name: '自动化待发布变更' })).toHaveTextContent('测试超速规则(副本)');
expect(within(editor).getByText('副本将保持停用')).toBeInTheDocument();
expect(within(editor).getByRole('button', { name: '创建副本' })).toBeEnabled();
fireEvent.click(within(editor).getByRole('button', { name: '创建副本' }));
await waitFor(() => expect(mocks.saveAlertRuleV2).toHaveBeenCalledWith(
expect.objectContaining({ id: '', version: 0, name: '测试超速规则(副本)', enabled: false }),
expect.anything()
));
expect(screen.getAllByLabelText('停用自动化:测试超速规则')).toHaveLength(2);
screen.getAllByLabelText('停用自动化:测试超速规则').forEach((control) => expect(control).toBeChecked());
});
test('protects an unpublished automation draft before closing or leaving the page', async () => {
mocks.alertRulesV2.mockResolvedValue([alertRule()]);
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 3, offset: 0 });
mocks.metricCatalog.mockResolvedValue({
metrics: [{ key: 'speed_kmh', label: '速度', unit: 'km/h', category: 'driving', valueType: 'numeric', protocols: ['JT808'], sourceFields: {}, searchable: true, chartable: true, alertable: true }],
asOf: ''
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=rules']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
await screen.findByRole('button', { name: /^测试超速规则/ });
fireEvent.click(screen.getByRole('button', { name: '编辑自动化' }));
const editor = await screen.findByRole('dialog', { name: '事件自动化编辑' });
fireEvent.click(within(editor).getByRole('button', { name: '编辑事件来源' }));
fireEvent.change(within(editor).getByDisplayValue('测试超速规则'), { target: { value: '夜间测试超速规则' } });
expect(within(editor).getByRole('status')).toHaveTextContent('草稿未保存');
expect(document.querySelector('.v2-alert-rule-editor-dialog .v2-workspace-dialog-title > .semi-tag')).toHaveTextContent('草稿未保存');
const unload = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(unload);
expect(unload.defaultPrevented).toBe(true);
fireEvent.click(within(editor).getByRole('button', { name: '取消' }));
const confirmation = await screen.findByRole('dialog', { name: '确认放弃自动化草稿' });
expect(confirmation).toHaveTextContent('放弃未发布的自动化草稿?');
expect(confirmation).toHaveTextContent('恢复 v3');
expect(confirmation).toHaveTextContent('线上自动化不受影响');
fireEvent.click(within(confirmation).getByRole('button', { name: '继续编辑' }));
expect(screen.getByRole('dialog', { name: '事件自动化编辑' })).toBeInTheDocument();
await waitFor(() => expect(confirmation).toHaveClass('semi-modal-content-animate-hide'));
fireEvent.click(within(editor).getByRole('button', { name: '取消' }));
await waitFor(() => expect(confirmation).not.toHaveClass('semi-modal-content-animate-hide'));
fireEvent.click(within(confirmation).getByRole('button', { name: '放弃草稿' }));
await waitFor(() => expect(document.querySelector('.v2-alert-rule-editor')).not.toBeInTheDocument());
expect(editor).toHaveClass('semi-modal-content-animate-hide');
expect(mocks.saveAlertRuleV2).not.toHaveBeenCalled();
});
test('creates auditable drafts from simple offline and hydrogen rule templates', async () => {
mocks.alertRulesV2.mockResolvedValue([alertRule()]);
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 3, offset: 0 });
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-list-heading')).toHaveClass('v2-workspace-panel-header');
expect(screen.getByRole('region', { name: '自动化列表' })).not.toHaveAttribute('tabindex');
expect(ruleItem).toHaveClass('semi-button', 'v2-alert-rule-item');
await waitFor(() => expect(ruleItem).toHaveAttribute('aria-pressed', 'true'));
expect(ruleItem).toHaveTextContent('JT/T 808');
expect(within(ruleItem.closest('.v2-alert-automation-row')!).getByLabelText('停用自动化:测试超速规则')).toBeChecked();
expect(screen.getByRole('tablist', { name: '自动化状态' })).toBeInTheDocument();
expect(screen.getByLabelText('测试超速规则 自动化流程')).toHaveTextContent('当');
expect(screen.getByLabelText('测试超速规则 自动化流程')).toHaveTextContent('如果');
expect(screen.getByLabelText('测试超速规则 自动化流程')).toHaveTextContent('就');
const offlineTemplate = await screen.findByRole('button', { name: /车辆长时间离线/ });
expect(offlineTemplate).toHaveClass('semi-button', 'v2-alert-template-card');
await waitFor(() => expect(offlineTemplate).toBeEnabled());
fireEvent.click(offlineTemplate);
const editor = await screen.findByRole('dialog', { name: '事件自动化编辑' });
expect(within(editor).getByRole('group', { name: '自动化事件类型' })).toBeInTheDocument();
expect(within(editor).getByRole('button', { name: /长时间离线/ })).toHaveAttribute('aria-pressed', 'true');
expect(within(editor).getByDisplayValue('车辆离线超过 10 小时')).toBeInTheDocument();
expect(within(editor).getByDisplayValue('36000')).toBeInTheDocument();
fireEvent.click(within(editor).getByRole('button', { name: '关闭事件自动化编辑' }));
fireEvent.click(within(await screen.findByRole('dialog', { name: '确认放弃自动化草稿' })).getByRole('button', { name: '放弃草稿' }));
fireEvent.click(screen.getByRole('button', { name: /车辆离开电子围栏/ }));
const fenceEditor = await screen.findByRole('dialog', { name: '事件自动化编辑' });
expect(within(fenceEditor).getByRole('button', { name: /电子围栏/ })).toHaveAttribute('aria-pressed', 'true');
expect(within(fenceEditor).getByRole('button', { name: '下一步:车辆范围' })).toBeDisabled();
expect(await within(fenceEditor).findByLabelText('地图绘制电子围栏')).toBeInTheDocument();
fireEvent.change(within(fenceEditor).getByPlaceholderText('例如:上海临港停车场'), { target: { value: '上海临港停车场' } });
fireEvent.click(within(fenceEditor).getByRole('button', { name: '开始绘制' }));
expect(within(fenceEditor).getByPlaceholderText('121.473701')).toHaveValue(121.91);
expect(within(fenceEditor).getByPlaceholderText('31.230416')).toHaveValue(30.9);
expect(within(fenceEditor).getByDisplayValue('860')).toBeInTheDocument();
fireEvent.click(within(fenceEditor).getByRole('button', { name: '下一步:车辆范围' }));
const quickVehicle = await within(fenceEditor).findByRole('option', { name: /粤A10001/ });
fireEvent.click(quickVehicle);
expect(within(fenceEditor).getByText('VIN-QUICK-001', { selector: '.semi-tag-content' })).toBeInTheDocument();
expect(within(fenceEditor).getByDisplayValue('VIN-QUICK-001')).toBeInTheDocument();
fireEvent.click(within(fenceEditor).getByRole('button', { name: '下一步:执行动作' }));
expect(within(fenceEditor).getAllByText('发送高优先级站内通知').length).toBeGreaterThan(0);
fireEvent.click(within(fenceEditor).getByRole('button', { name: '下一步:检查发布' }));
expect(within(fenceEditor).getByRole('button', { name: '发布自动化' })).toBeEnabled();
fireEvent.click(within(fenceEditor).getByRole('button', { name: '运行测试' }));
expect(await within(fenceEditor).findByText(/测试成功/)).toBeInTheDocument();
expect(within(fenceEditor).getByRole('button', { name: '发布自动化' })).toBeEnabled();
fireEvent.click(within(fenceEditor).getByRole('button', { name: '关闭事件自动化编辑' }));
fireEvent.click(within(await screen.findByRole('dialog', { name: '确认放弃自动化草稿' })).getByRole('button', { name: '放弃草稿' }));
fireEvent.click(screen.getByRole('button', { name: /最高氢浓度超限/ }));
const hydrogenEditor = await screen.findByRole('dialog', { name: '事件自动化编辑' });
expect(within(hydrogenEditor).getByDisplayValue('最高氢浓度超限')).toBeInTheDocument();
const hydrogenThreshold = within(hydrogenEditor).getByPlaceholderText('请按厂家标准填写百分比');
expect(hydrogenThreshold).toBeRequired();
expect(hydrogenThreshold).toHaveValue(null);
expect(within(hydrogenEditor).getByRole('button', { name: '下一步:车辆范围' })).toBeDisabled();
expect(within(hydrogenEditor).getByRole('alert')).toHaveTextContent('请填写有效的触发阈值');
fireEvent.change(hydrogenThreshold, { target: { value: '0.5' } });
expect(hydrogenThreshold).toHaveValue(0.5);
expect(within(hydrogenEditor).getByRole('button', { name: '下一步:车辆范围' })).toBeEnabled();
});
test('opens a recent automation run in a focused event inbox', async () => {
const event = alertEvent('recent-event', 'RECENTVIN00000001', '粤A近期01');
mocks.alertRulesV2.mockResolvedValue([alertRule()]);
mocks.metricCatalog.mockResolvedValue({
metrics: [{ key: 'speed_kmh', label: '速度', unit: 'km/h', category: 'driving', valueType: 'numeric', protocols: ['JT808'], sourceFields: {}, searchable: true, chartable: true, alertable: true }],
asOf: ''
});
mocks.alertEventsV2.mockResolvedValue({ items: [event], total: 1, limit: 3, offset: 0 });
mocks.alertEventV2.mockResolvedValue(event);
mocks.alertSummaryV2.mockResolvedValue({ active: 1, unprocessed: 1, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=rules&automationSearch=%E6%B5%8B%E8%AF%95&automationId=speed-rule']}><AlertsPage /><RouteState /></MemoryRouter></QueryClientProvider>);
const recentRun = await screen.findByRole('button', { name: '查看 粤A近期01 的 粤A近期01速度告警' });
expect(recentRun).toHaveClass('semi-button', 'v2-alert-automation-run');
fireEvent.click(recentRun);
expect(await screen.findByRole('tab', { name: /事件流/ })).toHaveAttribute('aria-selected', 'true');
await waitFor(() => expect(mocks.alertEventsV2).toHaveBeenLastCalledWith(expect.objectContaining({ keyword: 'RECENTVIN00000001', status: 'unprocessed' }), expect.anything()));
expect(await screen.findByText('recent-event')).toBeInTheDocument();
expect(await screen.findByDisplayValue('RECENTVIN00000001')).toBeInTheDocument();
const runContext = screen.getByRole('region', { name: '自动化运行来源' });
expect(runContext).toHaveTextContent('来自自动化运行记录正在核对粤A近期01速度告警事件车辆RECENTVIN00000001');
expect(screen.getByTestId('alert-route-state')).toHaveTextContent('automationSearch=%E6%B5%8B%E8%AF%95');
expect(screen.getByTestId('alert-route-state')).toHaveTextContent('runFromAutomationId=speed-rule');
fireEvent.click(within(runContext).getByRole('button', { name: '返回自动化' }));
expect(await screen.findByRole('tab', { name: /自动化/ })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByTestId('alert-route-state')).toHaveTextContent('tab=rules');
expect(screen.getByTestId('alert-route-state')).toHaveTextContent('automationSearch=%E6%B5%8B%E8%AF%95');
expect(screen.getByTestId('alert-route-state')).toHaveTextContent('automationId=speed-rule');
expect(screen.getByTestId('alert-route-state')).not.toHaveTextContent('eventId=');
expect(screen.getByTestId('alert-route-state')).not.toHaveTextContent('runFromAutomationId=');
});