import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { afterEach, expect, test, vi } from 'vitest'; import { MemoryRouter } from 'react-router-dom'; import type { AlertEvent, AlertRule, Page } from '../../api/types'; import AlertsPage from './AlertsPage'; import { ROUTER_FUTURE } from '../routing/routerConfig'; const mocks = vi.hoisted(() => ({ alertSummaryV2: vi.fn(), alertEventsV2: vi.fn(), alertEventV2: vi.fn(), actOnAlertV2: vi.fn(), alertRulesV2: vi.fn(), metricCatalog: vi.fn(), alertNotificationsV2: vi.fn(), readAlertNotificationsV2: vi.fn(), saveAlertRuleV2: vi.fn(), setAlertRuleEnabledV2: vi.fn() })); const layout = vi.hoisted(() => ({ mobile: false })); vi.mock('../../api/client', () => ({ api: mocks })); vi.mock('../auth/AuthGate', () => ({ usePlatformSession: () => ({ session: { role: 'admin' } }) })); vi.mock('../hooks/useMobileLayout', () => ({ useMobileLayout: () => layout.mobile })); afterEach(() => { cleanup(); layout.mobile = false; Object.values(mocks).forEach((mock) => mock.mockReset()); }); 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: '' }; } 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.findByText('仅站内通道具备真实送达与已读状态')).toBeInTheDocument(); fireEvent.click(screen.getByRole('tab', { name: /告警事件/ })); expect(await screen.findByPlaceholderText('车牌 / VIN / 规则名称')).toHaveValue('保留筛选草稿'); }); 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).toHaveBeenCalledTimes(1); expect(mocks.alertNotificationsV2.mock.calls[0][0].toString()).toBe('unreadOnly=true&limit=1'); }); 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('仅站内通道具备真实送达与已读状态'); await screen.findByText('暂无站内通知'); expect(view.container.querySelector('.v2-alert-notifications')).toHaveClass('semi-card'); expect(screen.getByRole('heading', { level: 5, name: '站内通知' })).toBeInTheDocument(); expect(view.container.querySelector('.v2-alert-notification-empty.semi-empty')).toBeInTheDocument(); expect(view.container.querySelector('.v2-alert-notification-pagination')).toBeInTheDocument(); expect(screen.getByRole('combobox', { name: '每页通知数' })).toBeInTheDocument(); expect(view.container.querySelector('.v2-alert-channel-card.semi-card')).toBeInTheDocument(); expect(view.container.querySelector('.v2-alert-channel-descriptions.semi-descriptions')).toBeInTheDocument(); expect(mocks.alertRulesV2).not.toHaveBeenCalled(); expect(mocks.metricCatalog).not.toHaveBeenCalled(); expect(mocks.alertNotificationsV2).toHaveBeenCalledTimes(2); expect(mocks.alertNotificationsV2.mock.calls.map(([params]) => params.toString())).toEqual(expect.arrayContaining([ 'unreadOnly=true&limit=1', 'limit=20&offset=0' ])); }); 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 markRead = await screen.findByRole('button', { name: '标为已读' }); expect(markRead.closest('.v2-alert-notification-card.semi-card')).toBeInTheDocument(); expect(markRead.closest('.v2-alert-notification-list')?.querySelector('article')).not.toBeInTheDocument(); fireEvent.click(markRead); expect(await screen.findByText('通知状态更新超时')).toBeInTheDocument(); expect(screen.getByRole('button', { name: '标为已读' })).toBeEnabled(); }); 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-kpis-card', 'v2-alert-context-card', 'v2-alert-table-card']) { expect(view.container.querySelector(`.${className}.semi-card`)).toBeInTheDocument(); } expect(view.container.querySelector('.v2-alert-navigation')).toBeInTheDocument(); expect(screen.getByText('可处置事件')).toBeInTheDocument(); 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-alert-kpis .v2-metric-action')).toHaveLength(4); expect(view.container.querySelectorAll('.v2-alert-secondary-states .semi-button')).toHaveLength(3); expect(screen.getByRole('button', { name: '查看未读通知,共 0 条' })).toHaveClass('is-notice'); expect(view.container.querySelector('.v2-alert-event-table.semi-table-wrapper')).toBeInTheDocument(); expect(view.container.querySelector('.v2-alert-event-table.semi-table-wrapper')).not.toHaveClass('is-compact'); expect(screen.getByRole('columnheader', { name: '触发 / 阈值' })).toBeInTheDocument(); expect(screen.getByRole('columnheader', { name: '位置' })).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(screen.queryByRole('columnheader', { name: '触发 / 阈值' })).not.toBeInTheDocument(); expect(screen.queryByRole('columnheader', { name: '位置' })).not.toBeInTheDocument(); expect(screen.getByText('已展开处置详情 · 点击其他事件快速切换')).toBeInTheDocument(); expect(desktopAlertRow).toHaveAttribute('aria-expanded', 'true'); expect(view.container.querySelector('.v2-alert-focus-card[aria-label="告警处置摘要"]')).toHaveClass('v2-alert-detail-card'); 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.getByRole('columnheader', { name: '触发 / 阈值' })).toBeInTheDocument(); expect(screen.getByRole('columnheader', { name: '位置' })).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: '筛选全部事件,共 8 条' })).toHaveAttribute('aria-pressed', 'false'); expect(screen.getByRole('button', { name: '筛选未处理,共 2 条' })).toHaveAttribute('aria-pressed', 'true'); expect(screen.getByRole('button', { name: '已恢复 4' })).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '筛选全部事件,共 8 条' })); await waitFor(() => expect(mocks.alertEventsV2).toHaveBeenLastCalledWith(expect.not.objectContaining({ status: expect.anything() }), expect.anything())); expect(screen.getByRole('button', { name: '筛选全部事件,共 8 条' })).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(action).toHaveAttribute('aria-expanded', 'false'); fireEvent.click(action); expect(action).toHaveAttribute('aria-pressed', 'true'); expect(action).toHaveAttribute('aria-expanded', 'true'); expect(await screen.findByRole('dialog', { name: '告警事件详情' })).toBeInTheDocument(); expect(await screen.findByText('mobile-event')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '关闭告警详情' })); expect(screen.queryByText('mobile-event')).not.toBeInTheDocument(); expect(action).toHaveAttribute('aria-expanded', 'false'); }); test('keeps the primary alert query compact and applies advanced filters from a SideSheet', async () => { mocks.alertSummaryV2.mockResolvedValue({ active: 0, unprocessed: 0, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' }); mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 20, offset: 0 }); mocks.alertRulesV2.mockResolvedValue([alertRule()]); mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const view = render(); await screen.findByText('当前筛选条件没有告警事件'); expect(view.container.querySelector('.v2-alert-filter-primary')).toBeInTheDocument(); expect(screen.queryByLabelText('协议')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /更多筛选/ })); const dialog = await screen.findByRole('dialog', { name: '告警高级筛选' }); fireEvent.click(within(dialog).getByRole('combobox', { name: '协议' })); fireEvent.click(await screen.findByText('JT808')); fireEvent.click(within(dialog).getByRole('button', { name: '应用筛选' })); await waitFor(() => expect(mocks.alertEventsV2).toHaveBeenLastCalledWith(expect.objectContaining({ protocol: 'JT808' }), expect.anything())); expect(screen.getByRole('button', { name: /更多筛选 · 1/ })).toHaveAttribute('aria-expanded', 'false'); }); test('creates auditable drafts from simple offline and hydrogen rule templates', async () => { mocks.alertRulesV2.mockResolvedValue([alertRule()]); mocks.metricCatalog.mockResolvedValue({ metrics: [ { key: 'freshness_sec', label: '离线时长', unit: 's', category: 'quality', valueType: 'numeric', protocols: ['GB32960', 'JT808', 'YUTONG_MQTT'], sourceFields: {}, searchable: true, chartable: true, alertable: true }, { key: 'hydrogen_concentration_percent', label: '最高氢浓度', unit: '%', category: 'fuel-cell', valueType: 'numeric', protocols: ['GB32960'], sourceFields: {}, searchable: true, chartable: true, alertable: true } ], asOf: '' }); mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const view = render(); const ruleItem = await screen.findByRole('button', { name: /测试超速规则/ }); expect(view.container.querySelector('.v2-alert-rule-list')).toHaveClass('semi-card'); expect(view.container.querySelector('.v2-alert-rule-editor')).toHaveClass('semi-card'); expect(view.container.querySelector('.v2-alert-rule-list-heading')).toHaveClass('v2-workspace-panel-header'); expect(view.container.querySelector('.v2-alert-rule-editor-heading')).toHaveClass('v2-workspace-panel-header'); expect(screen.getByRole('region', { name: '告警规则列表,可左右滚动选择' })).toHaveAttribute('tabindex', '0'); expect(ruleItem).toHaveClass('semi-button', 'v2-alert-rule-item'); await waitFor(() => expect(ruleItem).toHaveAttribute('aria-pressed', 'true')); expect(within(ruleItem).getByText('重要').closest('.semi-tag')).toBeTruthy(); expect(within(ruleItem).getByText('已启用').closest('.semi-tag')).toBeTruthy(); const offlineTemplate = await screen.findByRole('button', { name: /离线超过 10 小时/ }); expect(offlineTemplate).toHaveClass('semi-button', 'v2-alert-template-card'); expect(within(offlineTemplate).getByText('GB32960 / JT808 / YUTONG_MQTT').closest('.semi-tag')).toBeTruthy(); await waitFor(() => expect(offlineTemplate).toBeEnabled()); fireEvent.click(offlineTemplate); expect(screen.getByDisplayValue('车辆离线超过 10 小时')).toBeInTheDocument(); expect(screen.getByDisplayValue('36000')).toBeInTheDocument(); expect(screen.getByDisplayValue('GB32960,JT808,YUTONG_MQTT')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /最高氢浓度/ })); expect(screen.getByDisplayValue('最高氢浓度超限')).toBeInTheDocument(); expect(screen.getByPlaceholderText('请按厂家标准填写百分比')).toBeRequired(); expect(screen.getByPlaceholderText('请按厂家标准填写百分比')).toHaveValue(null); expect(screen.getByDisplayValue('GB32960')).toBeInTheDocument(); });