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 }) =>
{value.longitude},{value.latitude},{value.radiusM}
})); 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 {location.pathname}{location.search}; } 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¬ifyOffset=119¬ifySearch=%E7%B2%A4A¬ifyRead=unread¬ifyDelivery=failed¬ificationId=18'))).toEqual({ limit: 50, offset: 100, search: '粤A', readFilter: 'unread', deliveryFilter: 'failed', selectedID: 18 }); expect(alertNotificationViewFromParams(new URLSearchParams('notifyLimit=999¬ifyOffset=-1¬ificationId=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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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((resolve) => { resolveDetail = resolve; })); mocks.alertRulesV2.mockResolvedValue([]); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render(); 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(); 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((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(); 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(); 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(); 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(); 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(); 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¬ifyOffset=50¬ificationId=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¬ifyOffset=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(); 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(); 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¬ificationId=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(); 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(); 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(); 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(); 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(); 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) => 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>((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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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='); });