Files
OneOS-V2/tests/vehicle-management/DetailRecordTabs.test.tsx
2026-07-29 16:04:39 +08:00

556 lines
20 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.
// @vitest-environment jsdom
import React from 'react';
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
expectTypeOf,
it,
vi,
} from 'vitest';
import {
DetailRecordFilterBar,
DetailRecordFooter,
DetailRecordTable,
type DetailRecordColumn,
} from '../../src/prototypes/vehicle-management/components/DetailRecordPrimitives';
import { DetailInsuranceRecordsTab } from '../../src/prototypes/vehicle-management/components/DetailInsuranceRecordsTab';
import {
DetailAccidentRecordsTab,
DetailAnnualReviewRecordsTab,
DetailLeaseRecordsTab,
DetailMovementRecordsTab,
DetailTransferRecordsTab,
DetailViolationRecordsTab,
} from '../../src/prototypes/vehicle-management/components/DetailRecordTabs';
import { DetailFaultRecordsTab } from '../../src/prototypes/vehicle-management/components/DetailFaultRecordsTab';
import vehicles from '../../src/prototypes/vehicle-management/data/vehicles.json';
import type { VehicleRecord } from '../../src/prototypes/vehicle-management/types';
import {
buttonByName,
click,
renderReact,
type RenderReactResult,
} from './detailTestUtils';
interface FixtureRow {
id: string;
code: string;
status: string;
}
const renderCode = vi.fn((row: FixtureRow) => (
<span data-record-code>{`编号:${row.code}`}</span>
));
const renderStatus = vi.fn((row: FixtureRow) => (
<strong data-record-status>{`状态标签:${row.status || '—'}`}</strong>
));
const columns: DetailRecordColumn<FixtureRow>[] = [
{
key: 'code',
label: '编号',
width: 128,
className: 'code',
render: renderCode,
},
{
key: 'status',
label: '状态',
render: renderStatus,
},
];
let rendered: RenderReactResult | undefined;
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const originalActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
beforeAll(() => {
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
});
afterAll(() => {
if (originalActEnvironment === undefined) {
Reflect.deleteProperty(actEnvironment, 'IS_REACT_ACT_ENVIRONMENT');
} else {
actEnvironment.IS_REACT_ACT_ENVIRONMENT = originalActEnvironment;
}
});
afterEach(async () => {
await rendered?.unmount();
rendered = undefined;
vi.clearAllMocks();
});
describe('DetailRecordTable', () => {
it('keeps width numeric and render required in the column type', () => {
type Column = DetailRecordColumn<FixtureRow>;
type HasRequiredRenderer = Column extends {
render: (row: FixtureRow) => React.ReactNode;
} ? true : false;
expectTypeOf<Column['width']>().toEqualTypeOf<number | undefined>();
expectTypeOf<Column['render']>().toEqualTypeOf<
(row: FixtureRow) => React.ReactNode
>();
expectTypeOf<HasRequiredRenderer>().toEqualTypeOf<true>();
});
it('renders a named vm table with scoped headers, column widths, and cell renderers', async () => {
const rows: FixtureRow[] = [
{ id: 'row-1', code: 'REC-001', status: '正常' },
{ id: 'row-2', code: 'REC-002', status: '' },
];
rendered = await renderReact(
<DetailRecordTable
title="测试记录"
columns={columns}
rows={rows}
/>,
);
const table = rendered.container.querySelector<HTMLTableElement>('table');
const card = rendered.container.querySelector<HTMLElement>('.va-record-table-card');
const tableWrap = card?.querySelector<HTMLElement>('.va-table-wrap');
const headers = Array.from(table?.querySelectorAll('th') ?? []);
const bodyRows = Array.from(table?.querySelectorAll('tbody tr') ?? []);
expect(card?.classList.contains('vm-table-card')).toBe(true);
expect(tableWrap?.classList.contains('vm-table-wrap')).toBe(true);
expect(tableWrap?.classList.contains('vm-table-wrap--wide')).toBe(true);
expect(table?.classList.contains('vm-table')).toBe(true);
expect(table?.classList.contains('va-record-table')).toBe(true);
expect(table?.querySelector('caption')?.textContent).toBe('测试记录');
expect(headers.map((header) => header.textContent?.trim())).toEqual(['编号', '状态']);
expect(headers.every((header) => header.scope === 'col')).toBe(true);
expect(headers[0].style.minWidth).toBe('128px');
expect(bodyRows[0].querySelector('[data-record-code]')?.textContent).toBe('编号REC-001');
expect(bodyRows[0].querySelector('[data-record-status]')?.textContent).toBe('状态标签:正常');
expect(bodyRows[1].querySelector('[data-record-status]')?.textContent).toBe('状态标签:—');
expect(bodyRows[0].querySelector('td')?.classList.contains('code')).toBe(true);
expect(renderCode).toHaveBeenCalledTimes(2);
expect(renderCode).toHaveBeenNthCalledWith(1, rows[0]);
expect(renderCode).toHaveBeenNthCalledWith(2, rows[1]);
expect(renderStatus).toHaveBeenCalledTimes(2);
});
it('renders a clear status fallback instead of a malformed table with no columns', async () => {
rendered = await renderReact(
<DetailRecordTable<FixtureRow>
title="动态记录"
columns={[]}
rows={[{ id: 'row-1', code: 'REC-001', status: '正常' }]}
/>,
);
const status = rendered.container.querySelector<HTMLElement>('[role="status"]');
expect(rendered.container.querySelector('table')).toBeNull();
expect(status?.textContent?.trim()).toBe('暂无可显示字段');
expect(status?.closest('.va-record-table-card')).not.toBeNull();
});
it('renders one spanning empty cell with the default text', async () => {
rendered = await renderReact(
<DetailRecordTable title="空记录" columns={columns} rows={[]} />,
);
const cells = rendered.container.querySelectorAll<HTMLTableCellElement>('tbody td');
const emptyCell = cells.item(0);
expect(cells).toHaveLength(1);
expect(emptyCell.colSpan).toBe(columns.length);
expect(emptyCell.textContent).toBe('暂无数据');
});
});
describe('DetailRecordFooter', () => {
it('wraps the common pagination with its total and named navigation', async () => {
rendered = await renderReact(
<DetailRecordFooter
page={1}
pageSize={10}
total={21}
onPageChange={vi.fn()}
onPageSizeChange={vi.fn()}
/>,
);
const footer = rendered.container.querySelector('.vm-table-footer--compact');
const navigation = footer?.querySelector<HTMLElement>(
'[role="navigation"][aria-label="表格分页"]',
);
expect(footer?.classList.contains('vm-table-footer')).toBe(true);
expect(footer?.classList.contains('va-record-footer')).toBe(true);
expect(navigation).not.toBeNull();
expect(navigation?.textContent?.replace(/\s+/g, ' ').trim()).toContain('共 21 条');
expect(buttonByName(navigation as HTMLElement, '上一页').disabled).toBe(true);
expect(buttonByName(navigation as HTMLElement, '下一页').disabled).toBe(false);
});
});
describe('DetailRecordFilterBar', () => {
it('is a named region with a visible header, V2 actions, and expanded controlled content', async () => {
rendered = await renderReact(
<DetailRecordFilterBar title="记录" onQuery={vi.fn()} onReset={vi.fn()}>
<label>
<input name="status" />
</label>
</DetailRecordFilterBar>,
);
const region = rendered.container.querySelector<HTMLElement>(
'section[role="region"][aria-label="记录筛选"]',
);
const content = region?.querySelector<HTMLElement>('.va-record-filter__content');
const toggle = buttonByName(region as HTMLElement, '收起筛选');
const reset = buttonByName(region as HTMLElement, '重置');
const query = buttonByName(region as HTMLElement, '查询');
const input = region?.querySelector<HTMLInputElement>('input[name="status"]');
expect(region?.querySelector('.va-record-filter__header h3')?.textContent).toBe('记录筛选');
expect(region?.classList.contains('is-expanded')).toBe(true);
expect(content?.hidden).toBe(false);
expect(content?.id).toBeTruthy();
expect(toggle.getAttribute('aria-controls')).toBe(content?.id);
expect(toggle.getAttribute('aria-expanded')).toBe('true');
expect(toggle.classList.contains('v2-btn--ghost')).toBe(true);
expect(toggle.classList.contains('v2-btn--lg')).toBe(true);
expect(input).not.toBeNull();
expect(input?.labels?.item(0)?.textContent?.trim()).toBe('状态');
expect(reset.type).toBe('button');
expect(reset.classList.contains('v2-btn--secondary')).toBe(true);
expect(reset.classList.contains('v2-btn--lg')).toBe(true);
expect(query.type).toBe('button');
expect(query.classList.contains('v2-btn--primary')).toBe(true);
expect(query.classList.contains('v2-btn--lg')).toBe(true);
});
it('calls query before hiding the content and can reopen it', async () => {
let region: HTMLElement | null = null;
const onQuery = vi.fn(() => {
expect(region?.classList.contains('is-expanded')).toBe(true);
});
rendered = await renderReact(
<DetailRecordFilterBar title="记录" onQuery={onQuery} onReset={vi.fn()}>
<input aria-label="编号" />
</DetailRecordFilterBar>,
);
region = rendered.container.querySelector<HTMLElement>('.va-record-filter');
const content = region?.querySelector<HTMLElement>('.va-record-filter__content');
const query = buttonByName(region as HTMLElement, '查询');
query.focus();
expect(document.activeElement).toBe(query);
await click(query);
expect(onQuery).toHaveBeenCalledOnce();
expect(region?.classList.contains('is-expanded')).toBe(false);
expect(content?.hidden).toBe(true);
const reopen = buttonByName(region as HTMLElement, '展开筛选');
expect(reopen.getAttribute('aria-expanded')).toBe('false');
expect(document.activeElement).toBe(reopen);
expect(reopen.closest('[hidden]')).toBeNull();
await click(reopen);
expect(region?.classList.contains('is-expanded')).toBe(true);
expect(content?.hidden).toBe(false);
expect(buttonByName(region as HTMLElement, '收起筛选').getAttribute('aria-expanded')).toBe('true');
});
it('calls reset before hiding the content in a fresh render', async () => {
let region: HTMLElement | null = null;
const onReset = vi.fn(() => {
expect(region?.classList.contains('is-expanded')).toBe(true);
});
rendered = await renderReact(
<DetailRecordFilterBar title="记录" onQuery={vi.fn()} onReset={onReset}>
<input aria-label="编号" />
</DetailRecordFilterBar>,
);
region = rendered.container.querySelector<HTMLElement>('.va-record-filter');
const content = region?.querySelector<HTMLElement>('.va-record-filter__content');
const reset = buttonByName(region as HTMLElement, '重置');
reset.focus();
expect(document.activeElement).toBe(reset);
await click(reset);
expect(onReset).toHaveBeenCalledOnce();
expect(region?.classList.contains('is-expanded')).toBe(false);
expect(content?.hidden).toBe(true);
const reopen = buttonByName(region as HTMLElement, '展开筛选');
expect(reopen.getAttribute('aria-expanded')).toBe('false');
expect(document.activeElement).toBe(reopen);
expect(reopen.closest('[hidden]')).toBeNull();
});
});
function tableHeaders(root: ParentNode): string[] {
return Array.from(root.querySelectorAll('th'), (cell) => cell.textContent?.trim() ?? '');
}
describe('vehicle detail record contracts', () => {
it('renders the approved headers and filters for lease through annual review', async () => {
const onToast = vi.fn();
const cases = [
{
element: (
<DetailLeaseRecordsTab
onToast={onToast}
rows={[{
id: 'lease-1',
plateNo: '粤A00001',
contractNo: 'HT-001',
projectName: '示例项目',
customerName: '示例客户',
businessType: '租赁',
deliveryDate: '2026-01-01',
deliveryPerson: '张三',
returnDate: '',
returnPerson: '',
}]}
/>
),
headers: ['合同编码', '项目名称', '客户名称', '业务类型', '交车日期', '交车人', '还车日期', '还车人'],
filters: ['合同编码', '项目名称', '客户名称', '业务类型'],
},
{
element: (
<DetailAccidentRecordsTab
onToast={onToast}
rows={[{
id: 'accident-1',
accidentCode: 'ACC-001',
accidentTime: '2026-01-02 10:00',
accidentLocation: '示例地点',
accidentType: '追尾',
customerName: '示例客户',
accidentLevel: '一般',
ourDamageAmount: 100,
theirDamageAmount: 200,
responsibility: '我方全责',
status: '已结案',
closedTime: '2026-01-03 10:00',
}]}
/>
),
headers: ['事故编码', '事故时间', '事故地点', '事故类型', '客户名称', '我方定损金额', '对方定损金额', '责任划分', '事故状态', '结案时间'],
filters: ['事故时间', '客户名称', '事故等级', '事故状态'],
},
{
element: (
<DetailViolationRecordsTab
onToast={onToast}
rows={[{
id: 'violation-1',
violationTime: '2026-01-04',
violationLocation: '示例地点',
violationBehavior: '违反禁令标志',
pointsDeducted: 1,
fineAmount: 100,
paymentStatus: '已缴费',
processed: '已处理',
collectionUnit: '示例单位',
customerName: '示例客户',
}]}
/>
),
headers: ['违法时间', '违法地点', '违法行为', '扣分', '罚款金额', '缴费状态', '是否处理', '采集单位'],
filters: ['违法时间', '客户名称', '缴费状态', '是否处理'],
},
{
element: (
<DetailMovementRecordsTab
onToast={onToast}
rows={[{
id: 'movement-1',
startDate: '2026-01-05',
estimatedEndDate: '2026-01-06',
status: '已完成',
destinationType: '维修站',
destinationName: '示例维修站',
movementType: '年审',
estimatedMileageKm: 10,
startMileageKm: 100,
startBatteryKwh: 80,
startHydrogenPct: 70,
endMileageKm: 110,
endBatteryKwh: 75,
endHydrogenPct: 65,
createdBy: '张三',
createdAt: '2026-01-05 09:00',
}]}
/>
),
headers: ['异动开始日期', '异动预计结束日期', '异动状态', '异动目的地', '目的地名称', '异动类型', '预计异动里程 (km)', '异动开始里程 (km)', '异动开始电量 (kWh)', '异动开始氢量', '异动结束里程 (km)', '异动结束电量 (kWh)', '异动结束氢量', '创建人', '创建时间'],
filters: ['异动开始日期', '异动目的地', '异动类型'],
},
{
element: (
<DetailTransferRecordsTab
onToast={onToast}
rows={[{
id: 'transfer-1',
transferDate: '2026-01-07',
transferOutPerson: '张三',
recipientPerson: '李四',
departureArea: '出发区域',
receivingArea: '接收区域',
transferMethod: '自驾',
departureParking: '出发停车场',
receivingParking: '接收停车场',
receiveDate: '2026-01-07',
}]}
/>
),
headers: ['调拨日期', '调出人', '接收人', '出发区域', '接收区域', '调拨方式', '出发停车场', '接收停车场', '接收日期'],
filters: ['调拨日期', '调出人', '接收人'],
},
{
element: (
<DetailAnnualReviewRecordsTab
onToast={onToast}
rows={[{
id: 'annual-1',
inspectionValidUntil: '2027-01-01',
inspectionStation: '检测站',
inspectionCost: 280,
m2Station: '二保站',
m2Cost: 180,
zbStation: '',
zbCost: null,
executor: '张三',
executeTime: '2026-01-08 10:00',
}]}
/>
),
headers: ['检验有效期至', '检测服务站名称', '检测费用', '二保服务站名称', '二保费用', '整备服务站名称', '整备费用', '办理人', '完成时间'],
filters: ['完成时间', '检验有效期', '办理人'],
},
];
for (const recordCase of cases) {
rendered = await renderReact(recordCase.element);
expect(tableHeaders(rendered.container)).toEqual(recordCase.headers);
for (const label of recordCase.filters) {
expect(rendered.container.textContent).toContain(label);
}
expect(buttonByName(rendered.container, '查询')).toBeTruthy();
expect(buttonByName(rendered.container, '重置')).toBeTruthy();
await rendered.unmount();
rendered = undefined;
}
});
it('renders the insurance overview, reference history fields, and file actions', async () => {
const onToast = vi.fn();
rendered = await renderReact(
<DetailInsuranceRecordsTab
record={vehicles[0] as VehicleRecord}
insurance={{ compulsory: '2027-06-30', commercial: '2027-07-31' }}
rows={[{
id: 'insurance-1',
vehicleId: String(vehicles[0].id),
insuranceType: '交强险',
expireDate: '2027-06-30',
purchasedAt: '2026-06-30',
fileName: '交强险保单.pdf',
fileUrl: '/files/policy.pdf',
}]}
onToast={onToast}
/>,
);
expect(
Array.from(rendered.container.querySelectorAll('.va-insurance-type strong'), (node) => node.textContent),
).toEqual(['交强险', '商业险', '超赔险', '驾意险', '货物险']);
expect(rendered.container.textContent).toContain('保单号');
expect(rendered.container.textContent).toContain('保险公司');
expect(rendered.container.textContent).toContain('到期日期');
expect(rendered.container.textContent).toContain('保费');
expect(tableHeaders(rendered.container)).toEqual([
'保险导入时间',
'操作人',
'类型',
'保单号',
'保险状态',
'保险公司',
'付款时间',
'到期日期',
'保单文件',
]);
expect(rendered.container.textContent).toContain('—');
await click(buttonByName(rendered.container, '预览'));
await click(buttonByName(rendered.container, '下载'));
expect(onToast).toHaveBeenNthCalledWith(1, '预览保单文件(原型演示):交强险保单.pdf');
expect(onToast).toHaveBeenNthCalledWith(2, '已开始下载:交强险保单.pdf原型演示');
});
it('renders the complete fault contract and global view/edit actions', async () => {
const onView = vi.fn();
const onEdit = vi.fn();
const record = vehicles[0] as VehicleRecord;
const row = {
id: 'fault-1',
plateNo: record.plateNo,
faultNo: 'GZ-001',
resolutionStatus: '已解决',
brand: record.brand,
model: record.model,
operateCompany: record.operateCompany,
faultLevel: 'L2',
faultType: '冷机故障',
faultDescription: '制冷异常',
reportedAt: '2026-01-01 10:00',
resolvedAt: '2026-01-01 12:00',
aiMatched: true,
lastOperator: '张三',
};
rendered = await renderReact(
<DetailFaultRecordsTab
record={record}
rows={[row]}
onViewFaultDetail={onView}
onEditFaultRecord={onEdit}
/>,
);
expect(tableHeaders(rendered.container)).toEqual([
'故障编号',
'解决情况',
'车牌号',
'车辆品牌',
'车辆型号',
'运营公司',
'故障等级',
'故障类型',
'故障描述',
'上报时间',
'解决时间',
'AI匹配状态',
'最后操作人',
'操作',
]);
for (const label of ['上报时间', '故障编号', '故障等级', '故障类型', '解决情况', 'AI匹配状态']) {
expect(rendered.container.textContent).toContain(label);
}
await click(buttonByName(rendered.container, '查看'));
await click(buttonByName(rendered.container, '编辑'));
expect(onView).toHaveBeenCalledWith(record, row);
expect(onEdit).toHaveBeenCalledWith(record, row);
});
});