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-collapse.semi-collapse')).toBeInTheDocument(); expect(screen.getByText('3 个 · 未启用')).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('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(); const heading = await screen.findByRole('heading', { level: 5, name: /通知列表/ }); await waitFor(() => expect(heading).toHaveTextContent('1 条')); expect(heading.querySelector('.v2-alert-notification-total.semi-tag')).toBeInTheDocument(); expect(screen.getByRole('button', { name: '本页已读' })).toBeInTheDocument(); }); 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.getByLabelText('告警中心工作区')).toHaveClass('v2-workspace-command-bar', 'v2-alert-command-bar'); expect(screen.getByRole('heading', { level: 5, name: '告警处置工作台' })).toBeInTheDocument(); expect(screen.getByText('核验事件证据、治理规则并追踪通知状态')).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-event-table .v2-alert-protocol-tag.semi-tag')).toHaveTextContent('JT/T 808'); 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(screen.getByLabelText('告警事件上下文')).toHaveTextContent('数据来源JT/T 808触发时间07-16 12:00:00处理记录1 条'); expect(screen.getByLabelText('告警触发证据')).toHaveTextContent('触发值90 km/h阈值条件> 80 km/h,持续 60 秒超限幅度+10 km/h'); expect(screen.getByText('1 条记录').closest('.semi-tag')).toBeInTheDocument(); expect(screen.getByText('可操作').closest('.semi-tag')).toBeInTheDocument(); 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.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(view.container.querySelectorAll('.v2-alert-kpis .v2-metric-action')).toHaveLength(3); expect(screen.queryByRole('button', { name: /查看未读通知/ })).not.toBeInTheDocument(); expect(screen.getByRole('tab', { name: /站内通知/ })).toBeInTheDocument(); expect(action).toHaveAttribute('aria-expanded', 'false'); expect(action).toHaveTextContent('超限幅度+10 km/h'); expect(action.querySelector('.v2-alert-protocol-tag.semi-tag')).toHaveTextContent('JT/T 808'); expect(action).toHaveTextContent('核验并处置'); 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'); expect(document.querySelector('.v2-alert-detail-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(90dvh, 800px)' }); 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.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('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).getByLabelText('告警起始时间')).toBeInTheDocument(); expect(within(dialog).getByLabelText('告警结束时间')).toBeInTheDocument(); 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('keeps mobile rule selection focused and opens editing in a Semi bottom SideSheet', async () => { layout.mobile = true; 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.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 editor = await screen.findByRole('dialog', { name: '告警规则编辑' }); expect(document.querySelector('.v2-alert-rule-editor-sidesheet')).toHaveClass('semi-sidesheet-bottom'); expect(document.querySelector('.v2-alert-rule-editor-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(92dvh, 820px)' }); expect(within(editor).getByDisplayValue('测试超速规则')).toBeInTheDocument(); expect(within(editor).getByRole('button', { name: '保存规则' })).toBeInTheDocument(); expect(within(editor).queryByRole('region', { name: '告警规则模板' })).not.toBeInTheDocument(); expect(ruleItem).toHaveAttribute('aria-expanded', 'true'); fireEvent.click(within(editor).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).getByText('新建规则')).toBeInTheDocument(); expect(within(newEditor).getByRole('region', { name: '告警规则模板' })).toBeInTheDocument(); expect(newRule).toHaveAttribute('aria-expanded', 'true'); }); 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(); expect(screen.queryByRole('region', { name: '告警规则模板' })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /新建规则/ })); 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(); });