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();
});