初始化项目版本

This commit is contained in:
Axhub Make
2026-07-29 16:04:39 +08:00
commit 4305a1082b
2629 changed files with 760590 additions and 0 deletions

View File

@@ -0,0 +1,555 @@
// @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);
});
});