Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.test.tsx
2026-07-18 22:58:07 +08:00

416 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
const keyword = await screen.findByPlaceholderText('车牌 / VIN / 规则名称');
fireEvent.change(keyword, { target: { value: '保留筛选草稿' } });
fireEvent.click(screen.getByRole('tab', { name: /站内通知/ }));
expect(await screen.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(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
await screen.findByText('当前筛选条件没有告警事件');
expect(mocks.alertRulesV2).toHaveBeenCalledTimes(1);
expect(mocks.metricCatalog).not.toHaveBeenCalled();
expect(mocks.alertNotificationsV2).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(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=notifications']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
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(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=notifications']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
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(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=notifications']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
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<AlertEvent>) => void;
mocks.alertSummaryV2.mockResolvedValue({ active: 1, unprocessed: 1, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
mocks.alertEventsV2.mockImplementation((query: { keyword?: string }) => query.keyword === 'OLDVIN'
? Promise.resolve({ items: [oldEvent], total: 1, limit: 20, offset: 0 })
: new Promise<Page<AlertEvent>>((resolve) => { resolveNew = resolve; }));
mocks.alertEventV2.mockImplementation((id: string) => Promise.resolve(id === oldEvent.id ? oldEvent : newEvent));
mocks.alertRulesV2.mockResolvedValue([]);
mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?keyword=OLDVIN']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
expect((await screen.findAllByText('旧告警车牌')).length).toBeGreaterThan(0);
for (const className of ['v2-alert-filter-card', 'v2-alert-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(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?keyword=TESTVIN&status=unprocessed']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
await screen.findByText('当前筛选条件没有告警事件');
const summaryScope = mocks.alertSummaryV2.mock.calls[0][0];
expect(summaryScope).toEqual(expect.objectContaining({ keyword: 'TESTVIN' }));
expect(summaryScope).not.toHaveProperty('status');
expect(screen.getByRole('button', { name: '筛选全部事件,共 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(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
const action = await screen.findByRole('button', { name: '查看 粤A移动01 粤A移动01速度告警 告警详情' });
expect(action).toHaveClass('semi-button', 'v2-alert-mobile-action');
expect(action.closest('.semi-card')).toHaveClass('v2-alert-mobile-card');
expect(view.container.querySelector('.v2-alert-table-scroll.is-mobile-scroll')).toHaveAttribute('tabindex', '0');
expect(view.container.querySelector('.v2-alert-table-scroll.is-mobile-scroll')).toHaveAttribute('aria-label', '告警事件列表,可上下滚动');
expect(view.container.querySelector('.v2-alert-event-table')).not.toBeInTheDocument();
expect(view.container.querySelectorAll('.v2-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(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
await screen.findByText('当前筛选条件没有告警事件');
expect(view.container.querySelector('.v2-alert-filter-primary')).toBeInTheDocument();
expect(screen.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(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
await screen.findByText('当前筛选条件没有告警事件');
const trigger = screen.getByRole('button', { name: /修改事件筛选/ });
expect(trigger).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(trigger);
const dialog = await screen.findByRole('dialog', { name: '告警事件筛选' });
expect(document.querySelector('.v2-alert-mobile-filter-sidesheet')).toHaveClass('semi-sidesheet-bottom');
expect(document.querySelector('.v2-alert-mobile-filter-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(82dvh, 690px)' });
expect(within(dialog).getByRole('textbox', { name: /关键词/ })).toBeInTheDocument();
expect(within(dialog).getByRole('combobox', { name: '协议' })).toBeInTheDocument();
expect(within(dialog).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(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=rules']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
const ruleItem = await screen.findByRole('button', { name: /测试超速规则/ });
await waitFor(() => expect(ruleItem).toHaveAttribute('aria-pressed', 'true'));
expect(screen.getByRole('region', { name: '告警规则列表' })).toBeInTheDocument();
expect(ruleItem).toHaveAttribute('aria-haspopup', 'dialog');
expect(ruleItem).toHaveAttribute('aria-expanded', 'false');
expect(document.querySelector('.v2-alert-rule-editor')).not.toBeInTheDocument();
fireEvent.click(ruleItem);
const 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(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=rules']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
const ruleItem = await screen.findByRole('button', { name: /测试超速规则/ });
expect(view.container.querySelector('.v2-alert-rule-list')).toHaveClass('semi-card');
expect(view.container.querySelector('.v2-alert-rule-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();
});