import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { afterEach, expect, test, vi } from 'vitest'; import { api } from '../../api/client'; import type { CustomerMileageResponse } from '../../api/customerMileage'; import CustomerMileagePage from './CustomerMileagePage'; afterEach(() => { cleanup(); vi.restoreAllMocks(); }); function fixture(): CustomerMileageResponse { return { rules: { version: 3, lowMileageKm: 50, targetMileageKm: 200, deviationKm: 30, deviationPercent: 20, minimumGpsKm: 50, includeToday: false }, canConfigure: true, assignmentSource: 'oneos-live', assignmentObservedAt: '2026-09-30T01:00:00Z', assignmentStale: false, excludedUnassignedCount: 3, dates: ['2026-09-29'], groups: [{ customer: '客户甲', vehicleCount: 2, departments: ['业务一部'], responsiblePeople: ['负责人甲'], days: [{ date: '2026-09-29', vehicleCount: 2, gpsVehicleCount: 2, hydrogenVehicleCount: 1, gpsKm: 250, hydrogenKm: 100, suspectedChargingCount: 1, dataIssueCount: 1, lowMileageCount: 0, provisional: false }], vehicles: [{ vin: 'LNX00000000000001', plate: '沪A12345', customer: '客户甲', department: '业务一部', responsiblePerson: '负责人甲', date: '2026-09-29', gpsKm: 200, hydrogenKm: 100, gpsStatus: 'on-target', hydrogenStatus: 'below-target', differenceKm: 100, differencePercent: 50, alert: 'suspected-charging', provisional: false }, { vin: 'LNX00000000000002', plate: '沪A67890', customer: '客户甲', department: '', responsiblePerson: '', date: '2026-09-29', gpsKm: 50, hydrogenKm: null, gpsStatus: 'below-target', hydrogenStatus: 'missing', differenceKm: null, differencePercent: null, alert: 'missing-data', provisional: false }] }] }; } function mount() { return render(); } test('preserves missing measurements and filters per-vehicle alerts', async () => { vi.spyOn(api, 'customerMileage').mockResolvedValue(fixture()); mount(); fireEvent.click(await screen.findByRole('button', { name: '查看客户甲 2026-09-29车辆明细' })); await screen.findByText('沪A12345'); const row = screen.getByText('沪A67890').closest('tr')!; expect(row).toHaveTextContent('无数据'); expect(within(row).getAllByText('—').length).toBe(2); expect(within(screen.getByRole('dialog', { name: '客户甲' })).getByText('覆盖 2 / 1 / 2')).toBeInTheDocument(); fireEvent.change(screen.getByRole('combobox', { name: '筛选预警' }), { target: { value: 'suspected-charging' } }); expect(screen.queryByText('沪A67890')).not.toBeInTheDocument(); expect(screen.getByText('沪A12345')).toBeInTheDocument(); }); test('submits dates and customer filters, rejecting inverted date range', async () => { const request = vi.spyOn(api, 'customerMileage').mockResolvedValue(fixture()); mount(); fireEvent.click(await screen.findByRole('button', { name: '查看客户甲 2026-09-29车辆明细' })); await screen.findByText('沪A12345'); fireEvent.click(screen.getByRole('button', { name: '关闭车辆明细' })); fireEvent.change(screen.getByLabelText('开始日期'), { target: { value: '2026-01-10' } }); fireEvent.change(screen.getByLabelText('结束日期'), { target: { value: '2026-01-01' } }); fireEvent.click(screen.getByRole('button', { name: '查询' })); expect(screen.getByRole('alert')).toHaveTextContent('有效日期范围'); expect(request).toHaveBeenCalledTimes(1); fireEvent.change(screen.getByLabelText('结束日期'), { target: { value: '2026-01-31' } }); fireEvent.change(screen.getByPlaceholderText('搜索客户名称'), { target: { value: '客户甲' } }); fireEvent.click(screen.getByRole('button', { name: '查询' })); await waitFor(() => expect(request).toHaveBeenCalledTimes(2)); expect(request.mock.calls[1][0].get('dateFrom')).toBe('2026-01-10'); expect(request.mock.calls[1][0].get('customer')).toBe('客户甲'); }); test('saves versioned rules then refetches evaluated data', async () => { const request = vi.spyOn(api, 'customerMileage').mockResolvedValue(fixture()); const save = vi.spyOn(api, 'saveCustomerMileageRules').mockImplementation(async r => ({ ...r, version: 4 })); mount(); fireEvent.click(await screen.findByRole('button', { name: '查看客户甲 2026-09-29车辆明细' })); await screen.findByText('沪A12345'); fireEvent.click(screen.getByRole('button', { name: '关闭车辆明细' })); fireEvent.click(screen.getByRole('button', { name: '预警规则' })); fireEvent.change(await screen.findByLabelText('偏差绝对值下限(km)'), { target: { value: '80' } }); fireEvent.click(screen.getByRole('button', { name: '保存并重新计算' })); await waitFor(() => expect(save).toHaveBeenCalled()); expect(save.mock.calls[0][0]).toMatchObject({ version: 3, deviationKm: 80 }); await waitFor(() => expect(request).toHaveBeenCalledTimes(2)); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); test('readers inspect rules without edit controls', async () => { const response = fixture(); response.canConfigure = false; vi.spyOn(api, 'customerMileage').mockResolvedValue(response); mount(); fireEvent.click(await screen.findByRole('button', { name: '查看客户甲 2026-09-29车辆明细' })); await screen.findByText('沪A12345'); fireEvent.click(screen.getByRole('button', { name: '关闭车辆明细' })); fireEvent.click(screen.getByRole('button', { name: '查看规则' })); expect(await screen.findByLabelText('达标里程(km)')).toBeDisabled(); expect(screen.queryByRole('button', { name: '保存并重新计算' })).not.toBeInTheDocument(); }); test('GZEV source replaces telemetry jobs and calculation notices', async () => { const response = fixture(); response.hydrogenJobs = [{ date: '2026-09-29', status: 'pending', attempts: 0, completedAt: '' }]; vi.spyOn(api, 'customerMileage').mockResolvedValue(response); mount(); await screen.findByRole('button', { name: '查看客户甲 2026-09-29车辆明细' }); expect(screen.queryByText(/正在后台计算/)).not.toBeInTheDocument(); expect(screen.getByText(/hyRunMileage/)).toBeInTheDocument(); }); test('compares customers before opening details and keeps the selected customer and date together', async () => { const response = fixture(); response.dates.push('2026-09-28'); const first = response.groups[0]; first.days.push({ ...first.days[0], date: '2026-09-28', gpsKm: 20, suspectedChargingCount: 0 }); first.vehicles.push({ ...first.vehicles[0], date: '2026-09-28', plate: '沪A前一天' }); response.groups.push({ ...first, customer: '客户乙', days: first.days.map(d => ({ ...d, suspectedChargingCount: 0, dataIssueCount: 0 })), vehicles: first.vehicles.map(v => ({ ...v, plate: '沪B乙客户', customer: '客户乙' })) }); vi.spyOn(api, 'customerMileage').mockResolvedValue(response); mount(); const cell = await screen.findByRole('button', { name: '查看客户甲 2026-09-29车辆明细' }); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(screen.getByText('客户乙')).toBeInTheDocument(); fireEvent.click(screen.getByRole('radio', { name: '疑似充电' })); expect(screen.queryByText('客户乙')).not.toBeInTheDocument(); fireEvent.click(cell); expect(await screen.findByText('沪A12345')).toBeInTheDocument(); fireEvent.change(screen.getByRole('combobox', { name: '明细日期' }), { target: { value: '2026-09-28' } }); expect(screen.getByText('沪A前一天')).toBeInTheDocument(); expect(screen.queryByText('沪A12345')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '关闭车辆明细' })); fireEvent.click(screen.getByRole('radio', { name: '全部客户' })); expect(screen.getByText('客户乙')).toBeInTheDocument(); }); test('paginates the customer overview and sorts the complete result', async () => { const response = fixture(); const first = response.groups[0]; response.groups = Array.from({ length: 16 }, (_, index) => ({ ...first, customer: `客户${String(index).padStart(2, '0')}`, vehicleCount: index + 1 })); vi.spyOn(api, 'customerMileage').mockResolvedValue(response); mount(); await screen.findByText('客户00'); expect(screen.queryByText('客户15')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '下一页客户' })); expect(screen.getByText('客户15')).toBeInTheDocument(); expect(screen.queryByText('客户00')).not.toBeInTheDocument(); fireEvent.change(screen.getByRole('combobox', { name: '客户排序' }), { target: { value: 'vehicles' } }); expect(screen.getByText('客户15')).toBeInTheDocument(); expect(screen.queryByText('客户00')).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: '上一页客户' })).toBeDisabled(); }); test('resets dates and all customer filters to the initial query', async () => { const request = vi.spyOn(api, 'customerMileage').mockResolvedValue(fixture()); mount(); await screen.findByRole('button', { name: '查看客户甲 2026-09-29车辆明细' }); const initial = request.mock.calls[0][0]; fireEvent.change(screen.getByLabelText('客户'), { target: { value: '客户甲' } }); fireEvent.change(screen.getByLabelText('部门'), { target: { value: '业务一部' } }); fireEvent.change(screen.getByLabelText('业务负责人'), { target: { value: '负责人甲' } }); fireEvent.change(screen.getByLabelText('开始日期'), { target: { value: '2026-01-01' } }); fireEvent.click(screen.getByRole('button', { name: '查询' })); await waitFor(() => expect(request).toHaveBeenCalledTimes(2)); await waitFor(() => expect(screen.getByRole('button', { name: '重置' })).toBeEnabled()); fireEvent.click(screen.getByRole('button', { name: '重置' })); expect(screen.getByLabelText('客户')).toHaveValue(''); expect(screen.getByLabelText('部门')).toHaveValue(''); expect(screen.getByLabelText('业务负责人')).toHaveValue(''); expect(screen.getByLabelText('开始日期')).toHaveValue(initial.get('dateFrom')); expect(screen.getByLabelText('结束日期')).toHaveValue(initial.get('dateTo')); fireEvent.click(screen.getByRole('button', { name: '快捷日期' })); expect(screen.getByRole('radio', { name: '近 7 天' })).toBeChecked(); }); test('shows deviation and below-target counts independently without counting missing data', async () => { vi.spyOn(api, 'customerMileage').mockResolvedValue(fixture()); mount(); const cell = await screen.findByRole('button', {name:'查看客户甲 2026-09-29车辆明细'}); expect(within(cell).getByText('偏差 1')).toBeInTheDocument(); expect(within(cell).getByText('未达标 2')).toBeInTheDocument(); fireEvent.click(cell); expect(screen.getByText('GPS 达标 · 纯氢 未达标')).toBeInTheDocument(); expect(screen.getByText('GPS 未达标 · 纯氢 无数据,无法判断')).toBeInTheDocument(); }); test('drills into interval totals and then daily details', async () => { vi.spyOn(api, 'customerMileage').mockResolvedValue(fixture()); mount(); fireEvent.click(await screen.findByRole('button', {name:'查看客户甲区间累计明细'})); expect(await screen.findByRole('table',{name:'车辆区间累计'})).toHaveTextContent('沪A12345'); expect(screen.getByRole('table',{name:'车辆区间累计'})).toHaveTextContent('有数据 0 天'); fireEvent.click(screen.getByRole('button',{name:'2026-09-29'})); expect(await screen.findByRole('table',{name:'客户车辆里程明细'})).toBeInTheDocument(); }); test('filters customer interval GPS totals with greater less and equal', async () => { vi.spyOn(api, 'customerMileage').mockResolvedValue(fixture()); mount(); await screen.findByRole('button',{name:'查看客户甲区间累计明细'}); fireEvent.click(screen.getByRole('button',{name:'区间里程条件'})); fireEvent.change(screen.getByRole('spinbutton',{name:'区间 GPS 里程阈值'}),{target:{value:'250'}}); expect(screen.queryByRole('button',{name:'查看客户甲区间累计明细'})).not.toBeInTheDocument(); fireEvent.change(screen.getByRole('combobox',{name:'区间 GPS 里程比较'}),{target:{value:'eq'}}); expect(screen.getByRole('button',{name:'查看客户甲区间累计明细'})).toBeInTheDocument(); fireEvent.change(screen.getByRole('combobox',{name:'区间 GPS 里程比较'}),{target:{value:'lt'}}); fireEvent.change(screen.getByRole('spinbutton',{name:'区间 GPS 里程阈值'}),{target:{value:'251'}}); expect(screen.getByRole('button',{name:'查看客户甲区间累计明细'})).toBeInTheDocument(); fireEvent.click(screen.getByRole('button',{name:'清除'})); expect(screen.getByRole('spinbutton',{name:'区间 GPS 里程阈值'})).toHaveValue(null); }); test('filters individual vehicles by GPS mileage in interval and daily details', async () => { vi.spyOn(api, 'customerMileage').mockResolvedValue(fixture()); mount(); fireEvent.click(await screen.findByRole('button',{name:'查看客户甲区间累计明细'})); fireEvent.change(screen.getByRole('spinbutton',{name:'单车 GPS 里程阈值'}),{target:{value:'100'}}); expect(screen.getByText('沪A12345')).toBeInTheDocument(); expect(screen.queryByText('沪A67890')).not.toBeInTheDocument(); fireEvent.change(screen.getByRole('combobox',{name:'单车 GPS 里程比较'}),{target:{value:'lt'}}); expect(screen.queryByText('沪A12345')).not.toBeInTheDocument(); expect(screen.getByText('沪A67890')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button',{name:'2026-09-29'})); fireEvent.change(screen.getByRole('spinbutton',{name:'单车 GPS 里程阈值'}),{target:{value:'100'}}); expect(screen.getByText('沪A12345')).toBeInTheDocument(); expect(screen.queryByText('沪A67890')).not.toBeInTheDocument(); fireEvent.change(screen.getByRole('combobox',{name:'单车 GPS 里程比较'}),{target:{value:'lt'}}); expect(screen.getByText('沪A67890')).toBeInTheDocument(); expect(screen.queryByText('沪A12345')).not.toBeInTheDocument(); }); test('offers searchable dropdowns, keeps all options after filtering, and submits partial names', async () => { const response = fixture(); response.groups.push({ ...response.groups[0], customer: '客户乙', departments: ['业务二部'], responsiblePeople: ['负责人乙'] }); const request = vi.spyOn(api, 'customerMileage').mockImplementation(async params => ({ ...response, groups: params.get('customer') ? response.groups.slice(0, 1) : response.groups })); mount(); await screen.findByText('客户乙'); fireEvent.click(screen.getByRole('button', { name: '展开客户选项' })); expect(within(screen.getByRole('listbox', { name: '客户选项' })).getAllByRole('option').map(option => option.textContent)).toEqual(['客户甲', '客户乙']); fireEvent.change(screen.getByRole('combobox', { name: '客户' }), { target: { value: '甲' } }); expect(within(screen.getByRole('listbox', { name: '客户选项' })).getAllByRole('option').map(option => option.textContent)).toEqual(['客户甲']); fireEvent.keyDown(screen.getByRole('combobox', { name: '客户' }), { key: 'ArrowDown' }); fireEvent.keyDown(screen.getByRole('combobox', { name: '客户' }), { key: 'Enter' }); expect(screen.getByLabelText('客户')).toHaveValue('客户甲'); expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); fireEvent.change(screen.getByLabelText('部门'), { target: { value: '一部' } }); expect(screen.getByRole('option', { name: '业务一部' })).toBeInTheDocument(); fireEvent.blur(screen.getByLabelText('部门')); fireEvent.change(screen.getByLabelText('业务负责人'), { target: { value: '甲' } }); expect(screen.getByRole('option', { name: '负责人甲' })).toBeInTheDocument(); fireEvent.blur(screen.getByLabelText('业务负责人')); fireEvent.click(screen.getByRole('button', { name: '查询' })); await waitFor(() => expect(request).toHaveBeenCalledTimes(2)); expect(request.mock.calls[1][0].get('department')).toBe('一部'); expect(request.mock.calls[1][0].get('responsiblePerson')).toBe('甲'); fireEvent.click(screen.getByRole('button', { name: '展开客户选项' })); expect(screen.getByRole('option', { name: '客户乙' })).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '清除客户' })); expect(screen.getByLabelText('客户')).toHaveValue(''); });