Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx

503 lines
36 KiB
TypeScript

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { afterEach, expect, test, vi } from 'vitest';
import OperationsPage from './OperationsPage';
const mocks = vi.hoisted(() => ({
opsHealth: vi.fn(), sourceReadiness: vi.fn(), session: vi.fn(), vehicleCoverage: vi.fn(),
vehicleSourceDiagnostic: vi.fn(), updateVehicleSourcePolicy: vi.fn(),
reconciliationSummary: vi.fn(), reconciliationIssues: vi.fn(), reconciliationIssue: vi.fn(), updateReconciliationIssue: vi.fn()
}));
const layout = vi.hoisted(() => ({ mobile: false }));
vi.mock('../../api/client', () => ({ api: mocks }));
vi.mock('../hooks/useMobileLayout', () => ({ useMobileLayout: () => layout.mobile }));
afterEach(() => { cleanup(); layout.mobile = false; Object.values(mocks).forEach((mock) => mock.mockReset()); });
function LocationProbe() {
const location = useLocation();
return <output data-testid="operations-location">{location.pathname}{location.search}</output>;
}
function renderOperations(client: QueryClient, initialEntries = ['/operations']) {
return render(<MemoryRouter initialEntries={initialEntries}><QueryClientProvider client={client}><OperationsPage /><LocationProbe /></QueryClientProvider></MemoryRouter>);
}
function seedSession() {
mocks.session.mockResolvedValue({ name: '平台管理员', role: 'admin', userType: 'admin', authMode: 'enforce', menuKeys: ['operations'] });
mocks.reconciliationSummary.mockResolvedValue({
active: 1, pending: 1, confirmed: 0, recovered: 2, overSla: 1,
byRule: [{ name: 'POSITION_DRIFT', count: 1 }], bySeverity: [{ name: 'major', count: 1 }],
trend: [{ date: '2026-07-16', detected: 1, new: 1, active: 1, recovered: 0 }],
lastRunAt: '2026-07-16 02:15:00', asOf: '2026-07-16 10:00:00'
});
mocks.reconciliationIssues.mockResolvedValue({
items: [{
id: 'issue-1', ruleCode: 'POSITION_DRIFT', category: 'location', severity: 'major', status: 'pending',
vin: 'VIN001', plate: '粤A00001', protocolA: 'GB32960', protocolB: 'JT808', title: '多来源实时位置漂移',
summary: '两个来源相差 1286 米', evidence: { distanceM: 1286 }, firstSeenAt: '2026-07-16 08:00:00',
lastSeenAt: '2026-07-16 10:00:00', occurrenceCount: 3, recoveredAt: '', resolutionNote: '', resolvedBy: '', version: 1
}],
total: 1, limit: 50, offset: 0
});
mocks.reconciliationIssue.mockResolvedValue({
id: 'issue-1', ruleCode: 'POSITION_DRIFT', category: 'location', severity: 'major', status: 'pending',
vin: 'VIN001', plate: '粤A00001', protocolA: 'GB32960', protocolB: 'JT808', title: '多来源实时位置漂移',
summary: '两个来源相差 1286 米', evidence: { distanceM: 1286 }, firstSeenAt: '2026-07-16 08:00:00',
lastSeenAt: '2026-07-16 10:00:00', occurrenceCount: 3, recoveredAt: '', resolutionNote: '', resolvedBy: '', version: 1,
actions: [{ id: 1, action: 'detect', fromStatus: '', toStatus: 'pending', actor: 'reconciliation-evaluator', note: '规则首次发现差异', createdAt: '2026-07-16 08:00:00' }]
});
}
test('renders reconciliation queue, loads evidence on demand and records review conclusion', async () => {
seedSession();
mocks.opsHealth.mockResolvedValue({
linkHealth: [{ name: 'MySQL realtime', status: 'ok', detail: '主库连接正常' }], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
tdengineWritable: true, mysqlWritable: true,
runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000, amapSecurityProxyEnabled: true, amapSecurityCodeExposed: false }
});
mocks.sourceReadiness.mockResolvedValue({ totalVehicles: 1, boundVehicles: 1, identityRequiredVehicles: 0, onlineVehicles: 1, sources: [] });
mocks.updateReconciliationIssue.mockResolvedValue({
...(await mocks.reconciliationIssue()),
status: 'confirmed_source_a',
resolutionNote: '来源 A 原始报文可信',
resolvedBy: '平台管理员',
version: 2
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
renderOperations(client);
expect(await screen.findByText('质量问题队列')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '运行与数据质量', level: 5 })).toBeInTheDocument();
expect(screen.getByLabelText('运维质量操作')).toHaveClass('v2-workspace-command-bar', 'v2-ops-command-bar');
expect(screen.getByRole('button', { name: '刷新全局证据' })).toHaveClass('v2-workspace-mobile-tool-button', 'is-muted');
const navigation = screen.getByRole('navigation', { name: '运维质量视图' });
expect(navigation).toHaveClass('v2-ops-navigation');
expect(within(navigation).getByText('每日自动对账')).toBeInTheDocument();
expect(await within(navigation).findByText('1 项待复核')).toBeInTheDocument();
expect(screen.getByRole('tab', { name: '差异处置 1', selected: true })).toHaveClass('semi-button');
expect(screen.getByRole('tabpanel', { name: '差异处置' })).toHaveAttribute('tabindex', '0');
expect(document.querySelector('.v2-ops-page')).toHaveClass('is-reconciliation');
expect(screen.queryByText('先选择一辆车')).not.toBeInTheDocument();
expect(screen.getByText('质量问题队列').closest('.semi-card')).toHaveClass('v2-reconcile-center');
expect(screen.getByText('质量问题队列').closest('.v2-workspace-panel-header')).toHaveClass('v2-reconcile-heading');
const reconciliationMetrics = screen.getByRole('list', { name: '差异处置队列概览' });
expect(reconciliationMetrics.closest('.semi-card')).toHaveClass('v2-workspace-metric-rail', 'v2-reconcile-metric-rail');
expect(within(reconciliationMetrics).getAllByRole('listitem')).toHaveLength(4);
expect(within(reconciliationMetrics).getByText('待复核').closest('[role="listitem"]')).toHaveClass('is-primary');
expect(within(reconciliationMetrics).getByText('SLA 超时').closest('[role="listitem"]')).toHaveClass('is-danger');
expect(within(reconciliationMetrics).getByText('已自动恢复').closest('[role="listitem"]')).toHaveClass('is-secondary', 'is-success');
expect(screen.getByText('当前筛选').closest('.v2-workspace-metric-context')).toHaveTextContent('1');
expect(document.querySelectorAll('.v2-reconcile-kpi-card')).toHaveLength(0);
expect(screen.queryByText('活跃问题', { exact: true })).not.toBeInTheDocument();
expect(screen.queryByText('近 30 天趋势')).not.toBeInTheDocument();
const trendButton = screen.getByRole('button', { name: '30 天趋势' });
expect(trendButton).toHaveClass('v2-workspace-mobile-tool-button', 'is-muted');
expect(screen.getByRole('button', { name: '刷新结果' })).toHaveClass('v2-workspace-mobile-tool-button', 'is-muted');
expect(trendButton).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(trendButton);
const trendDialog = await screen.findByRole('dialog', { name: '30 天差异趋势' });
expect(trendDialog.closest('.semi-sidesheet')).toHaveClass('v2-workspace-detail-sidesheet', 'v2-reconcile-trend-sidesheet');
expect(within(trendDialog).getByText('趋势明细').closest('.semi-card')).toHaveClass('v2-reconcile-trend');
expect(within(trendDialog).getByText('趋势明细').closest('.v2-workspace-panel-header')).toHaveClass('is-compact');
expect(within(trendDialog).getByRole('list', { name: '30 天差异趋势摘要' })).toBeInTheDocument();
expect(trendButton).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(within(trendDialog).getByRole('button', { name: '关闭 30 天差异趋势' }));
expect(trendButton).toHaveAttribute('aria-expanded', 'false');
expect(document.querySelector('.v2-reconcile-table.semi-table-wrapper')).toBeInTheDocument();
expect(screen.getByLabelText('运维质量操作')).toHaveTextContent('上海时间');
expect(screen.getAllByText('2026-07-16 18:00:00').length).toBeGreaterThan(0);
expect(document.querySelector('.v2-platform-time.v2-ops-time')).toBeInTheDocument();
expect((await screen.findAllByText('多来源实时位置漂移')).length).toBeGreaterThan(0);
expect(document.querySelector('.v2-reconcile-mobile-card.semi-card')).not.toBeInTheDocument();
expect(document.querySelector('.v2-reconcile-table-wrap > table')).not.toBeInTheDocument();
const reconcileRow = screen.getByTestId('reconcile-row-issue-1');
expect(reconcileRow).toHaveAttribute('role', 'button');
expect(reconcileRow).toHaveAttribute('aria-expanded', 'false');
fireEvent.keyDown(reconcileRow, { key: ' ' });
expect(await screen.findByText('规则证据')).toBeInTheDocument();
expect(reconcileRow).toHaveAttribute('aria-expanded', 'true');
const detailDialog = screen.getByRole('dialog', { name: '差异证据与处置' });
expect(detailDialog.closest('.semi-sidesheet')).toHaveClass('v2-workspace-detail-sidesheet', 'v2-reconcile-detail-sidesheet');
expect(within(detailDialog).getByRole('list', { name: '差异证据与处置摘要' })).toBeInTheDocument();
expect(screen.getByText('规则证据').closest('.semi-card')).toHaveClass('v2-reconcile-evidence');
expect(screen.getByText('复核结论').closest('.semi-card')).toHaveClass('v2-reconcile-review');
expect(screen.getByText('处理履历').closest('.semi-card')).toHaveClass('v2-reconcile-actions');
expect(screen.getByText('规则证据').closest('.v2-workspace-panel-header')).toHaveClass('is-compact');
expect(document.querySelector('.v2-reconcile-detail-heading.v2-workspace-panel-header')).not.toBeInTheDocument();
expect(screen.getByText('复核提示')).toBeInTheDocument();
expect(screen.getByText('坐标差距')).toBeInTheDocument();
expect(screen.getByText('distanceM')).toBeInTheDocument();
expect(screen.getByText('1,286 m')).toBeInTheDocument();
fireEvent.click(within(detailDialog).getByRole('button', { name: '关闭差异证据与处置' }));
expect(reconcileRow).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(reconcileRow);
expect(await screen.findByText('规则证据')).toBeInTheDocument();
fireEvent.click(screen.getByRole('radio', { name: '采信 GB32960' }));
fireEvent.change(screen.getByLabelText('说明(必填)'), { target: { value: '来源 A 原始报文可信' } });
fireEvent.click(screen.getByRole('button', { name: '保存结论并记录履历' }));
await waitFor(() => expect(mocks.updateReconciliationIssue).toHaveBeenCalledWith('issue-1', {
version: 1, status: 'confirmed_source_a', note: '来源 A 原始报文可信'
}));
});
test('renders only the compact mobile reconciliation surface and defers secondary controls', async () => {
layout.mobile = true;
seedSession();
mocks.opsHealth.mockResolvedValue({
linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
tdengineWritable: true, mysqlWritable: true,
runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000, amapSecurityProxyEnabled: true, amapSecurityCodeExposed: false }
});
mocks.sourceReadiness.mockResolvedValue({ totalVehicles: 1, boundVehicles: 1, identityRequiredVehicles: 0, onlineVehicles: 1, sources: [] });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
renderOperations(client);
expect(await screen.findByText('多来源实时位置漂移')).toBeInTheDocument();
expect(document.querySelector('.v2-reconcile-mobile-card.semi-card')).toBeInTheDocument();
expect(screen.getByRole('region', { name: '差异队列,可上下滚动' })).toHaveAttribute('tabindex', '0');
expect(document.querySelector('.v2-reconcile-table.semi-table-wrapper')).not.toBeInTheDocument();
expect(mocks.reconciliationIssues).toHaveBeenCalledWith(expect.objectContaining({ limit: 20 }), expect.any(AbortSignal));
const mobileIssueAction = screen.getByRole('button', { name: '查看 粤A00001 差异证据' });
expect(mobileIssueAction).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(mobileIssueAction);
expect(await screen.findByRole('dialog', { name: '差异证据与处置' })).toBeInTheDocument();
expect(document.querySelector('.v2-reconcile-detail-sidesheet .v2-reconcile-detail.is-sheet')).toBeInTheDocument();
expect(document.querySelector('.v2-reconcile-detail-sidesheet')).toHaveClass('semi-sidesheet-bottom');
expect(document.querySelector('.v2-reconcile-detail-sidesheet')).toHaveClass('v2-workspace-detail-sidesheet');
expect(document.querySelector('.v2-reconcile-detail-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(88dvh, 780px)' });
expect(await screen.findByRole('button', { name: '关闭差异证据与处置' })).toBeInTheDocument();
expect(mobileIssueAction).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(screen.getByRole('button', { name: '关闭差异证据与处置' }));
expect(mobileIssueAction).toHaveAttribute('aria-expanded', 'false');
const toolbar = document.querySelector('.v2-reconcile-toolbar');
expect(toolbar).toHaveClass('is-mobile-collapsed');
expect(screen.getByRole('button', { name: /修改筛选差异:活跃差异 · 1 条/ })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /修改筛选差异/ }));
expect(toolbar).not.toHaveClass('is-mobile-collapsed');
expect(screen.queryByText('近 30 天趋势')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '30 天趋势' }));
expect(await screen.findByRole('dialog', { name: '30 天差异趋势' })).toBeInTheDocument();
expect(document.querySelector('.v2-reconcile-trend-sidesheet')).toHaveClass('semi-sidesheet-bottom', 'v2-workspace-detail-sidesheet');
expect(document.querySelector('.v2-reconcile-trend-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(72dvh, 620px)' });
const trend = screen.getByText('趋势明细').closest('.v2-reconcile-trend');
expect(trend).toBeInTheDocument();
expect(screen.getByText(/最近运行/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '关闭 30 天差异趋势' }));
expect(screen.queryByText('趋势明细')).not.toBeInTheDocument();
});
test('reconciles service identities with bound and identity-required vehicles', async () => {
seedSession();
mocks.opsHealth.mockResolvedValue({
linkHealth: [{ name: 'MySQL realtime', status: 'ok', detail: '主库连接正常' }], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
tdengineWritable: true, mysqlWritable: true,
runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000, amapSecurityProxyEnabled: true, amapSecurityCodeExposed: false }
});
mocks.sourceReadiness.mockResolvedValue({
totalVehicles: 1024, boundVehicles: 1024, identityRequiredVehicles: 11, onlineVehicles: 234,
kafkaLag: 0, activeConnections: 10, redisOnlineKeys: 5, platformRelease: 'test-release', sources: [{
protocol: 'GB32960', role: '仪表盘里程与整车状态', total: 1024, online: 234, severity: 'ok',
evidence: '实时来源覆盖稳定', action: '保持在线率监测', acceptance: '在线率与上报周期持续满足阈值'
}]
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
renderOperations(client, ['/operations?view=health']);
expect(screen.getByRole('tab', { name: '全局健康', selected: true })).toBeInTheDocument();
expect(screen.getByTestId('operations-location')).toHaveTextContent('/operations?view=health');
expect(await screen.findByText('1024 / 234')).toBeInTheDocument();
expect(within(screen.getByRole('navigation', { name: '运维质量视图' })).getByText('15 秒刷新运行证据')).toBeInTheDocument();
expect(within(screen.getByRole('navigation', { name: '运维质量视图' })).getByText('运行正常')).toBeInTheDocument();
for (const key of [['ops-health-v2'], ['ops-source-readiness-v2']]) {
const query = client.getQueryCache().find({ queryKey: key });
const liveOptions = query?.options as { refetchIntervalInBackground?: boolean; refetchOnWindowFocus?: boolean };
expect(liveOptions.refetchIntervalInBackground).toBe(false);
expect(liveOptions.refetchOnWindowFocus).toBe(true);
}
expect(screen.getByText('车辆总数 / 在线')).toBeInTheDocument();
expect(screen.getByText('协议就绪 / 总数')).toBeInTheDocument();
expect(screen.getByText('全部协议达到当前口径')).toBeInTheDocument();
expect(screen.getByText('已绑定 1024 · 待绑定 11')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '运行概览', level: 5 })).toBeInTheDocument();
expect(screen.getByText('车辆总数 / 在线').closest('[role="listitem"]')).toHaveClass('is-secondary');
expect(screen.getByRole('list', { name: '运行健康指标' })).toHaveClass('v2-workspace-metric-list');
expect(screen.getByText('Kafka Lag').closest('.v2-workspace-metric-context')).toHaveClass('v2-workspace-metric-context');
expect(screen.getByText('全部正常')).toBeInTheDocument();
expect(screen.getByText('运行时安全').closest('.semi-card')).toHaveClass('v2-ops-runtime');
expect(screen.getByText('协议来源就绪度').closest('.semi-card')).toHaveClass('v2-ops-sources');
expect(screen.getByRole('heading', { name: '数据链路', level: 5 })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '运行时安全', level: 5 })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '协议来源就绪度', level: 5 })).toBeInTheDocument();
expect(document.querySelector('.v2-ops-link-card')).toHaveTextContent('MySQL 实时连接MySQL realtime主库连接正常');
expect(document.querySelector('.v2-ops-link-card.semi-card')).not.toBeInTheDocument();
expect(document.querySelector('.v2-ops-runtime-descriptions.semi-descriptions')).toBeInTheDocument();
expect(screen.getByText('容量检查通过').closest('.semi-empty')).toHaveClass('v2-ops-capacity-empty');
expect(document.querySelector('.v2-ops-source-list.semi-card-group')).toBeInTheDocument();
expect(screen.getByText('GB32960').closest('.semi-card')).toHaveClass('v2-ops-source-card');
expect(screen.getByLabelText('GB32960 在线覆盖率')).toBeInTheDocument();
expect(screen.getByText('22.9% 在线')).toBeInTheDocument();
expect(screen.getByText('验收标准').closest('.semi-card')).toHaveClass('v2-ops-source-card');
expect(screen.queryByText('单车多来源诊断')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: '单车诊断' }));
expect(screen.getByTestId('operations-location')).toHaveTextContent('/operations?view=diagnostic');
expect(within(screen.getByRole('navigation', { name: '运维质量视图' })).getByText('按需读取单车来源')).toBeInTheDocument();
expect(within(screen.getByRole('navigation', { name: '运维质量视图' })).getByText('精确诊断')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '诊断车辆', level: 5 })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '诊断车辆', level: 5 }).closest('.semi-card')).toHaveClass('v2-source-filter-panel', 'v2-workspace-filter-panel');
expect(screen.getByText('先选择一辆车').closest('.semi-empty')).toHaveClass('v2-source-empty');
expect(screen.getByText('单车多来源诊断').closest('.semi-card')).toHaveClass('v2-source-diagnostic');
expect(screen.queryByText('统一车辆视角')).not.toBeInTheDocument();
});
test('normalizes nullable health evidence arrays without crashing the workspace', async () => {
seedSession();
mocks.opsHealth.mockResolvedValue({
linkHealth: null, kafkaLag: 0, activeConnections: 10, capacityFindings: null, redisOnlineKeys: 5,
tdengineWritable: true, mysqlWritable: true,
runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000, amapSecurityProxyEnabled: true, amapSecurityCodeExposed: false }
});
mocks.sourceReadiness.mockResolvedValue({
totalVehicles: 1024, boundVehicles: 1024, identityRequiredVehicles: 0, onlineVehicles: 234,
kafkaLag: 0, activeConnections: 10, redisOnlineKeys: 5, platformRelease: 'test-release', sources: null
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
renderOperations(client, ['/operations?view=health']);
expect(await screen.findByRole('heading', { name: '运行概览', level: 5 })).toBeInTheDocument();
expect(await screen.findAllByText('暂无协议来源')).toHaveLength(2);
expect(screen.getByText('暂无链路探针')).toBeInTheDocument();
expect(screen.getByText('容量检查通过')).toBeInTheDocument();
expect(screen.queryByText('当前模块暂时无法显示')).not.toBeInTheDocument();
});
test('includes protocol readiness in the global health verdict and prioritizes its evidence', async () => {
seedSession();
mocks.opsHealth.mockResolvedValue({
linkHealth: [{ name: 'MySQL realtime', status: 'ok', detail: '主库连接正常' }], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
tdengineWritable: true, mysqlWritable: true,
runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000, amapSecurityProxyEnabled: true, amapSecurityCodeExposed: false }
});
mocks.sourceReadiness.mockResolvedValue({
totalVehicles: 1024, boundVehicles: 1024, identityRequiredVehicles: 11, onlineVehicles: 234,
sources: [
{
protocol: 'GB32960', role: '仪表盘里程与整车状态', total: 494, online: 123, severity: 'warning',
status: '覆盖不足', evidence: '在线覆盖偏低', action: '核对接入范围', acceptance: '缺失车辆下降'
},
{
protocol: 'YUTONG_MQTT', role: '宇通补充来源', total: 50, online: 12, severity: 'error',
status: '链路异常', evidence: '投影停滞', action: '恢复投影与回补', acceptance: '日里程恢复'
}
]
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
renderOperations(client, ['/operations?view=health']);
expect(await screen.findByText('需要处理')).toBeInTheDocument();
expect(screen.getByText('协议覆盖 2 项 · 链路运行 0 项')).toBeInTheDocument();
expect(screen.getByText('0 / 2')).toBeInTheDocument();
expect(screen.getByText('2 个协议需要处置')).toBeInTheDocument();
expect(within(screen.getByRole('navigation', { name: '运维质量视图' })).getByText('2 项关注')).toBeInTheDocument();
const sourcePanel = screen.getByRole('heading', { name: '协议来源就绪度', level: 5 }).closest('.v2-ops-sources');
const infrastructureGrid = document.querySelector('.v2-ops-grid');
expect(sourcePanel).toHaveClass('has-attention');
expect(sourcePanel).not.toBeNull();
expect(infrastructureGrid).not.toBeNull();
expect(Boolean(sourcePanel!.compareDocumentPosition(infrastructureGrid!) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true);
});
test('keeps health evidence visible when source readiness fails and retries that section', async () => {
seedSession();
mocks.opsHealth.mockResolvedValue({
linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
tdengineWritable: true, mysqlWritable: true,
runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000, amapSecurityProxyEnabled: true, amapSecurityCodeExposed: false }
});
mocks.sourceReadiness.mockRejectedValueOnce(new Error('来源就绪度暂时不可用')).mockResolvedValueOnce({
totalVehicles: 1024, boundVehicles: 1024, identityRequiredVehicles: 11, onlineVehicles: 234,
kafkaLag: 0, activeConnections: 10, redisOnlineKeys: 5, platformRelease: 'test-release', sources: []
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
renderOperations(client);
fireEvent.click(screen.getByRole('tab', { name: '全局健康' }));
expect(await screen.findByText('来源就绪度暂时不可用')).toBeInTheDocument();
expect(screen.getByText('协议来源证据不可用')).toBeInTheDocument();
expect(screen.getByText('证据缺失')).toBeInTheDocument();
expect(screen.getByLabelText('运维质量操作')).toHaveTextContent('上海时间 · 版本 test-release');
expect(screen.getByRole('heading', { name: '数据链路', level: 5 })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /重试/ }));
expect(await screen.findByText('1024 / 234')).toBeInTheDocument();
expect(screen.queryByText('来源就绪度暂时不可用')).not.toBeInTheDocument();
expect(mocks.sourceReadiness).toHaveBeenCalledTimes(2);
});
test('fuzzy searches a vehicle and renders all source diagnosis evidence', async () => {
seedSession();
mocks.opsHealth.mockResolvedValue({
linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
tdengineWritable: true, mysqlWritable: true,
runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000, amapSecurityProxyEnabled: true, amapSecurityCodeExposed: false }
});
mocks.sourceReadiness.mockResolvedValue({ totalVehicles: 1, boundVehicles: 1, identityRequiredVehicles: 0, onlineVehicles: 1, sources: [] });
mocks.vehicleCoverage.mockResolvedValue({ items: [{ vin: 'VIN001', plate: '粤A00001', protocols: ['JT808'], missingProtocols: [], sourceStatus: [], sourceCount: 2, onlineSourceCount: 2, online: true, lastSeen: '', bindingStatus: 'bound' }], total: 1, limit: 20, offset: 0 });
const diagnostic = {
evidence: {
vin: 'VIN001', plate: '粤A00001', mileageDate: '2026-07-16', recommendedLocationProtocol: 'JT808',
recommendedLocationLabel: 'G7', locationConflict: true, conflictDistanceM: 233,
locationSources: [{
protocol: 'JT808', sourceLabel: 'G7', providerOverride: '', terminalLabel: '终端 133****0001', sourceKind: 'PLATFORM', sourceRef: 'a'.repeat(64),
selectedWithinProtocol: true, recommended: true, enabled: true, priority: 20, policyRemark: '保持当前优先级', online: true, qualityStatus: 'OK', qualityReason: '',
longitude: 113.1, latitude: 23.1, speedKmh: 30, totalMileageKm: 1000, eventTime: '2026-07-16 10:00:00',
receivedAt: '2026-07-16 10:00:01', firstSeenAt: '2026-03-01 08:00:00', reportIntervalSec: 10, reportSampleCount: 1000,
selectionReason: '当前融合推荐'
}],
mileageSources: [], comparison: { locationMaxDistanceM: 233, totalMileageDeltaKm: 0, dailyMileageDeltaKm: 0, reportTimeDeltaSeconds: 0 }, asOf: ''
},
policy: { vin: 'VIN001', version: 1, updatedBy: 'system', updatedAt: '', audit: [] },
recommendationReason: '当前推荐 G7', refreshHint: '下一次有效上报后重新选举'
};
mocks.vehicleSourceDiagnostic.mockResolvedValue(diagnostic);
mocks.updateVehicleSourcePolicy.mockResolvedValue(diagnostic);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
renderOperations(client);
fireEvent.click(screen.getByRole('tab', { name: '单车诊断' }));
fireEvent.change(screen.getByLabelText('按车牌或 VIN 搜索诊断车辆'), { target: { value: '粤A' } });
const candidateButton = await waitFor(() => {
const button = document.querySelector<HTMLButtonElement>('.v2-source-candidates button');
expect(button).toBeTruthy();
return button!;
});
fireEvent.click(candidateButton);
expect(await screen.findByText('当前推荐 G7')).toBeInTheDocument();
expect(screen.getByText('1 个来源')).toBeInTheDocument();
expect(screen.getByRole('group', { name: '车辆来源诊断概览' })).toHaveClass('v2-source-summary-region');
const sourceSummary = screen.getByRole('list', { name: '车辆来源诊断概览' });
expect(sourceSummary.closest('.v2-source-summary-rail')).toHaveClass('v2-workspace-metric-rail', 'is-queue');
expect(sourceSummary.querySelectorAll(':scope > [role="listitem"]')).toHaveLength(4);
expect(sourceSummary).toHaveTextContent('来源状态1 / 1位置冲突 233m');
expect(sourceSummary).toHaveTextContent('当前推荐G7JT808');
expect(sourceSummary).toHaveTextContent('车辆粤A00001VIN001');
expect(sourceSummary).toHaveTextContent('策略版本v1尚无人工调整');
expect(sourceSummary.querySelector(':scope > [role="listitem"]')).toHaveClass('is-warning', 'is-primary');
expect(screen.getByText('推荐说明').closest('.semi-card')).toHaveClass('v2-source-recommendation');
expect(screen.getByText('最近策略审计').closest('.semi-card')).toHaveClass('v2-source-audit');
expect(screen.getByText('终端 133****0001')).toBeInTheDocument();
expect(screen.getByText('10s')).toBeInTheDocument();
expect(document.querySelector('.v2-source-table.semi-table-wrapper')).toBeInTheDocument();
expect(document.querySelector('.v2-source-table-wrap > table')).not.toBeInTheDocument();
expect(document.querySelector('.v2-source-mobile-list')).not.toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: '来源 / 终端' })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: '运维策略' })).toBeInTheDocument();
expect(screen.getByText('优先级 20')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '维护策略' }));
expect(await screen.findByRole('dialog', { name: '车辆来源策略' })).toBeInTheDocument();
expect(document.querySelector('.v2-source-policy-sidesheet .semi-sidesheet-inner')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('G7 提供方'), { target: { value: 'G7s' } });
const save = screen.getByRole('button', { name: '保存策略' });
expect(save).toBeDisabled();
expect(screen.getByLabelText('G7 策略备注')).toHaveValue('保持当前优先级');
fireEvent.change(screen.getByLabelText('G7 提供方核验依据'), { target: { value: 'GPS 运维终端清单 2026-07-16' } });
fireEvent.click(save);
await waitFor(() => expect(mocks.updateVehicleSourcePolicy).toHaveBeenCalledWith('VIN001', {
version: 1,
sourceRef: 'a'.repeat(64),
providerName: 'G7s',
providerEvidence: 'GPS 运维终端清单 2026-07-16',
enabled: true,
priority: 20,
remark: '保持当前优先级'
}));
await waitFor(() => expect(mocks.vehicleSourceDiagnostic).toHaveBeenCalledWith('VIN001', expect.any(AbortSignal)));
});
test('allows provider maintenance but keeps canonical source policy read only', async () => {
layout.mobile = true;
seedSession();
mocks.opsHealth.mockResolvedValue({
linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
tdengineWritable: true, mysqlWritable: true,
runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000, amapSecurityProxyEnabled: true, amapSecurityCodeExposed: false }
});
mocks.sourceReadiness.mockResolvedValue({ totalVehicles: 1, boundVehicles: 1, identityRequiredVehicles: 0, onlineVehicles: 1, sources: [] });
mocks.vehicleCoverage.mockResolvedValue({ items: [{ vin: 'VIN-CANONICAL', plate: '沪A00001', protocols: ['JT808'], missingProtocols: [], sourceStatus: [], sourceCount: 1, onlineSourceCount: 1, online: true, lastSeen: '', bindingStatus: 'bound' }], total: 1, limit: 20, offset: 0 });
const diagnostic = {
evidence: {
vin: 'VIN-CANONICAL', plate: '沪A00001', mileageDate: '', recommendedLocationProtocol: 'JT808',
recommendedLocationLabel: 'JT808', locationConflict: false,
locationSources: [{
protocol: 'JT808', sourceLabel: 'JT808', providerOverride: '', terminalLabel: '', sourceKind: 'CANONICAL', sourceRef: 'b'.repeat(64),
selectedWithinProtocol: true, recommended: true, enabled: true, priority: 100, policyRemark: '', online: true, qualityStatus: 'OK', qualityReason: '',
longitude: 113.1, latitude: 23.1, eventTime: '2026-07-16 10:00:00', receivedAt: '2026-07-16 10:00:01',
selectionReason: '协议融合快照只能维护提供方'
}],
mileageSources: [], comparison: { locationMaxDistanceM: 0, totalMileageDeltaKm: 0, dailyMileageDeltaKm: 0, reportTimeDeltaSeconds: 0 }, asOf: ''
},
policy: { vin: 'VIN-CANONICAL', version: 1, updatedBy: 'system', updatedAt: '', audit: [] },
recommendationReason: '当前推荐 JT808 的协议融合结果', refreshHint: '等待下一次上报'
};
mocks.vehicleSourceDiagnostic.mockResolvedValue(diagnostic);
mocks.updateVehicleSourcePolicy.mockResolvedValue(diagnostic);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
renderOperations(client);
fireEvent.click(screen.getByRole('tab', { name: '单车诊断' }));
const filterToggle = screen.getByRole('button', { name: '修改诊断车辆:尚未选择车辆' });
expect(filterToggle).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(filterToggle);
const filterDialog = await screen.findByRole('dialog', { name: '诊断车辆筛选' });
expect(document.querySelector('.v2-source-mobile-filter-sidesheet')).toHaveClass('semi-sidesheet-bottom');
expect(document.querySelector('.v2-source-mobile-filter-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(78dvh, 660px)' });
expect(within(filterDialog).getByText('输入车牌或 VIN 开始查找')).toBeInTheDocument();
fireEvent.change(within(filterDialog).getByLabelText('按车牌或 VIN 搜索诊断车辆'), { target: { value: '沪A' } });
const candidateButton = await waitFor(() => {
const button = filterDialog.querySelector<HTMLButtonElement>('.v2-source-candidates button');
expect(button).toBeTruthy();
return button!;
});
fireEvent.click(candidateButton);
expect(await screen.findByText('协议融合快照只能维护提供方')).toBeInTheDocument();
const selectedToggle = screen.getByRole('button', { name: '修改诊断车辆:已选 沪A00001' });
expect(selectedToggle).toHaveAttribute('aria-expanded', 'false');
expect(filterDialog).toHaveClass('semi-sidesheet-animation-content_hide_bottom');
fireEvent.click(selectedToggle);
const selectedSearch = within(filterDialog).getByLabelText('按车牌或 VIN 搜索诊断车辆');
expect(selectedSearch).toHaveValue('沪A00001');
fireEvent.change(selectedSearch, { target: { value: '其他车辆' } });
fireEvent.click(within(filterDialog).getByRole('button', { name: '关闭诊断车辆筛选' }));
fireEvent.click(selectedToggle);
expect(selectedSearch).toHaveValue('沪A00001');
fireEvent.click(within(filterDialog).getByRole('button', { name: '关闭诊断车辆筛选' }));
expect(document.querySelector('.v2-source-table')).not.toBeInTheDocument();
expect(document.querySelector('.v2-source-mobile-card.semi-card')).toBeInTheDocument();
expect(document.querySelector('.v2-source-mobile-descriptions.semi-descriptions')).toBeInTheDocument();
expect(screen.getByRole('group', { name: '车辆来源诊断概览' })).toHaveClass('v2-source-summary-region');
expect(screen.getByRole('list', { name: '车辆来源诊断概览' }).closest('.v2-source-summary-rail')).toHaveClass('v2-workspace-metric-rail', 'is-queue');
expect(screen.getByRole('list', { name: '车辆来源诊断概览' }).querySelectorAll(':scope > [role="listitem"]')).toHaveLength(4);
expect(screen.getByText('优先级 100')).toBeInTheDocument();
expect(screen.queryByLabelText('JT808 优先级')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '维护提供方' }));
expect(await screen.findByRole('dialog', { name: '车辆来源策略' })).toBeInTheDocument();
expect(await screen.findByLabelText('JT808 优先级')).toBeDisabled();
expect(screen.getByLabelText('JT808 策略备注')).toBeDisabled();
expect(screen.getByRole('checkbox', { name: '启用' })).toBeDisabled();
expect(screen.getByLabelText('JT808 提供方')).toBeEnabled();
fireEvent.change(screen.getByLabelText('JT808 提供方'), { target: { value: '东方北斗' } });
fireEvent.change(screen.getByLabelText('JT808 提供方核验依据'), { target: { value: 'GPS 运维终端清单 2026-07-16' } });
fireEvent.click(screen.getByRole('button', { name: '保存提供方' }));
await waitFor(() => expect(mocks.updateVehicleSourcePolicy).toHaveBeenCalledWith('VIN-CANONICAL', {
version: 1,
sourceRef: 'b'.repeat(64),
providerName: '东方北斗',
providerEvidence: 'GPS 运维终端清单 2026-07-16',
enabled: true,
priority: 100,
remark: ''
}));
});