初始化项目版本

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,197 @@
// @vitest-environment jsdom
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import {
OperationActions,
splitOperationActions,
type OperationActionItem,
} from '../../src/common/OperationActions';
const action = (key: string, label: string): OperationActionItem => ({
key,
label,
onClick: vi.fn(),
});
let root: Root | undefined;
let container: HTMLDivElement | undefined;
beforeAll(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
});
async function renderActions(element: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(element);
});
return container;
}
afterEach(() => {
if (root) {
act(() => root?.unmount());
}
container?.remove();
document.querySelectorAll('.vm-op-dropdown').forEach((menu) => menu.remove());
root = undefined;
container = undefined;
});
describe('splitOperationActions', () => {
it('separates the detail entry from history actions', () => {
const items = [
action('history', '操作记录'),
action('edit', '编辑'),
action('view', '查看详情'),
action('owner', '设置运维负责人'),
];
const result = splitOperationActions(items);
expect(result.view?.label).toBe('查看详情');
expect(result.edit?.label).toBe('编辑');
expect(result.more.map((item) => item.label)).toEqual([
'操作记录',
'设置运维负责人',
]);
});
it('does not promote 查看记录 as a detail entry', () => {
const result = splitOperationActions([
action('viewRecords', '查看记录'),
action('process', '处置'),
]);
expect(result.view).toBeUndefined();
expect(result.process?.label).toBe('处置');
expect(result.more.map((item) => item.label)).toEqual(['查看记录']);
});
});
describe('OperationActions', () => {
it('renders detail before the workflow action and more', async () => {
const rendered = await renderActions(
<OperationActions
view={{ label: '查看', onClick: vi.fn() }}
edit={{ label: '编辑', onClick: vi.fn() }}
process={{ label: '处置', onClick: vi.fn() }}
more={[action('owner', '设置运维负责人')]}
/>,
);
const buttonLabels = Array.from(rendered.querySelectorAll('button')).map(
(button) => button.getAttribute('aria-label'),
);
expect(buttonLabels).toEqual(['查看', '编辑', '更多操作']);
});
it('renders edit then process when there is no detail entry', async () => {
const rendered = await renderActions(
<OperationActions
edit={{ label: '编辑', onClick: vi.fn() }}
process={{ label: '处置', onClick: vi.fn() }}
/>,
);
const buttonLabels = Array.from(rendered.querySelectorAll('button')).map(
(button) => button.getAttribute('aria-label'),
);
expect(buttonLabels).toEqual(['编辑', '处置']);
expect(rendered.querySelector('[aria-label="更多操作"]')).toBeNull();
});
it('filters hidden primary actions before allocating external slots', async () => {
const rendered = await renderActions(
<OperationActions
view={{ label: '查看', onClick: vi.fn() }}
edit={{ label: '编辑', onClick: vi.fn(), hidden: true }}
process={{ label: '处置', onClick: vi.fn() }}
/>,
);
const buttonLabels = Array.from(rendered.querySelectorAll('button')).map(
(button) => button.getAttribute('aria-label'),
);
expect(buttonLabels).toEqual(['查看', '处置']);
});
it('does not render more when all menu actions are hidden', async () => {
const rendered = await renderActions(
<OperationActions
view={{ label: '详情', onClick: vi.fn() }}
more={[{ ...action('owner', '设置运维负责人'), hidden: true }]}
/>,
);
expect(rendered.querySelector('[aria-label="详情"]')).not.toBeNull();
expect(rendered.querySelector('[aria-label="更多操作"]')).toBeNull();
});
it('renders an empty marker and no buttons when all actions are hidden', async () => {
const rendered = await renderActions(
<OperationActions
view={{ label: '查看', onClick: vi.fn(), hidden: true }}
edit={{ label: '编辑', onClick: vi.fn(), hidden: true }}
process={{ label: '处置', onClick: vi.fn(), hidden: true }}
more={[{ ...action('owner', '设置运维负责人'), hidden: true }]}
/>,
);
expect(rendered.textContent).toBe('-');
expect(rendered.querySelectorAll('button')).toHaveLength(0);
});
it('portals more to document.body and closes it with Escape', async () => {
const rendered = await renderActions(
<OperationActions more={[action('owner', '设置运维负责人')]} />,
);
const moreButton = rendered.querySelector<HTMLButtonElement>(
'[aria-label="更多操作"]',
);
expect(moreButton).not.toBeNull();
await act(async () => {
moreButton?.click();
});
const menu = document.body.querySelector<HTMLElement>('.vm-op-dropdown');
expect(menu).not.toBeNull();
expect(menu?.parentElement).toBe(document.body);
await act(async () => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
});
expect(document.body.querySelector('.vm-op-dropdown')).toBeNull();
});
it('closes the portal menu on an outside mousedown', async () => {
const rendered = await renderActions(
<OperationActions more={[action('owner', '设置运维负责人')]} />,
);
const moreButton = rendered.querySelector<HTMLButtonElement>(
'[aria-label="更多操作"]',
);
await act(async () => {
moreButton?.click();
});
expect(document.body.querySelector('.vm-op-dropdown')).not.toBeNull();
await act(async () => {
document.body.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true }),
);
});
expect(document.body.querySelector('.vm-op-dropdown')).toBeNull();
});
});

View File

@@ -0,0 +1,18 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { V2Button } from '../../../src/prototypes/oneos-v2/UIComponents';
describe('OneOS V2 component exports', () => {
it('resolves the shared V2Button from the showcase component barrel', () => {
expect(typeof V2Button).toBe('function');
});
it('shows the canonical design-system version in the showcase', () => {
const designSource = readFileSync('src/resources/design-system/DESIGN.md', 'utf8');
const showcaseSource = readFileSync('src/prototypes/oneos-v2/DesignSystemShowcase.tsx', 'utf8');
const version = designSource.match(/^version:\s*"([^"]+)"/m)?.[1];
expect(version).toBe('2.6');
expect(showcaseSource).toContain(`v${version} 复杂台账列个性化规范`);
});
});

View File

@@ -0,0 +1,892 @@
// @vitest-environment jsdom
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import React, { act } from 'react';
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { DetailLicenseTab } from '../../src/prototypes/vehicle-management/components/DetailLicenseTab';
import type { VehicleRecord } from '../../src/prototypes/vehicle-management/types';
import { daysUntilExpire } from '../../src/prototypes/vehicle-management/utils/vehicle';
import {
DRIVER_LICENSE_WARN_DAYS,
licenseStatusFromDays,
resolveVehicleLicenses,
STANDARD_LICENSE_WARN_DAYS,
type VehicleLicenseGroup,
type VehicleLicenseImage,
VEHICLE_LICENSE_STATUS_LABELS,
} from '../../src/prototypes/vehicle-management/utils/vehicleLicenses';
import {
buttonByName,
click,
renderReact,
type RenderReactResult,
} from './detailTestUtils';
const REFERENCE_DATE = new Date(2026, 6, 26);
const DRIVER_FRONT_IMAGE = 'data:image/png;base64,iVBORw0KGgo=';
const DRIVER_BACK_IMAGE = 'data:image/png;base64,iVBORw0KGgo=back';
let rendered: RenderReactResult | undefined;
const scrollIntoView = vi.fn();
const originalScrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
Element.prototype,
'scrollIntoView',
);
beforeAll(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
Object.defineProperty(Element.prototype, 'scrollIntoView', {
configurable: true,
value: scrollIntoView,
});
});
afterAll(() => {
if (originalScrollIntoViewDescriptor) {
Object.defineProperty(
Element.prototype,
'scrollIntoView',
originalScrollIntoViewDescriptor,
);
} else {
Reflect.deleteProperty(Element.prototype, 'scrollIntoView');
}
});
beforeEach(() => {
scrollIntoView.mockClear();
});
afterEach(async () => {
await rendered?.unmount();
rendered = undefined;
});
function makeVehicle(overrides: Partial<VehicleRecord> = {}): VehicleRecord {
return {
id: 'vehicle-fixture',
plateNo: '粤A00000',
vin: 'TESTVIN0000000001',
vehicleNo: '0001',
color: '',
year: '',
purchaseDate: '',
parking: '',
ownership: '',
scrapDate: '2040-06-03',
ratingTime: '',
operateCompany: '',
vehicleSource: '',
leaseCompany: '',
vehicleType: '',
brand: '',
model: '',
customer: '',
department: '',
manager: '',
contractNo: '',
operateStatus: '',
vehicleStatus: '',
licenseStatus: '',
insuranceStatus: '',
mileage: '',
location: '',
gpsTime: '',
regDate: '2025-06-03',
inspectExpire: '2027-06-30',
lastDeliveryTime: '',
lastDeliveryMile: '',
lastReturnTime: '',
lastReturnMile: '',
outStatus: '',
onlineStatus: '',
projectName: '',
telematicsLinked: false,
...overrides,
} satisfies VehicleRecord;
}
function makeGroupsWithDriverImage(): VehicleLicenseGroup[] {
return makeGroupsWithDriverImages([
{
id: 'driver-front',
src: DRIVER_FRONT_IMAGE,
alt: '行驶证正页',
},
{
id: 'driver-back',
src: DRIVER_BACK_IMAGE,
alt: '行驶证副页',
},
{
id: 'driver-empty',
src: ' ',
alt: '空白图片',
},
{
id: 'driver-script',
src: 'javascript:alert(1)',
alt: '脚本图片',
},
]);
}
function makeGroupsWithDriverImages(
images: VehicleLicenseImage[],
): VehicleLicenseGroup[] {
return resolveVehicleLicenses(makeVehicle(), REFERENCE_DATE).map((group) => (
group.id === 'driver'
? {
...group,
images,
}
: group
));
}
function findClosingBrace(source: string, openingBraceIndex: number): number {
let depth = 0;
for (let index = openingBraceIndex; index < source.length; index += 1) {
if (source[index] === '{') {
depth += 1;
} else if (source[index] === '}') {
depth -= 1;
if (depth === 0) return index;
}
}
return -1;
}
function ruleFor(styles: string, selector: string): string {
const selectorIndex = styles.indexOf(`${selector} {`);
if (selectorIndex < 0) return '';
const openingBraceIndex = styles.indexOf('{', selectorIndex);
const closingBraceIndex = findClosingBrace(styles, openingBraceIndex);
return styles.slice(selectorIndex, closingBraceIndex + 1);
}
function reducedMotionMatchMedia(query: string): MediaQueryList {
return {
matches: query === '(prefers-reduced-motion: reduce)',
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
};
}
describe('vehicle license view model', () => {
it.each([
['2026-07-25', -1],
['2026-07-26', 0],
['2026-07-27', 1],
])(
'counts %s from a local 2026-07-26 reference by calendar day',
(targetDate, expectedDays) => {
expect(daysUntilExpire(targetDate, REFERENCE_DATE)).toBe(expectedDays);
},
);
it('exposes one frozen runtime source for the four status labels', () => {
expect(VEHICLE_LICENSE_STATUS_LABELS).toEqual({
normal: '正常',
expiring: '临期',
expired: '已到期',
missing: '未上传',
});
expect(Object.isFrozen(VEHICLE_LICENSE_STATUS_LABELS)).toBe(true);
});
it('normalizes the eight license groups in the required order', () => {
const groups = resolveVehicleLicenses(makeVehicle(), REFERENCE_DATE);
expect(groups.map(({ id }) => id)).toEqual([
'driver',
'transport',
'registration',
'special-registration',
'special-mark',
'hydrogen-card',
'safety-valve',
'pressure-gauge',
]);
expect(groups.map(({ label }) => label)).toEqual([
'行驶证',
'道路运输证',
'登记证',
'特种设备使用登记证',
'特种设备使用标识',
'加氢卡',
'安全阀',
'压力表',
]);
expect(groups.every(({ images }) => images.length === 0)).toBe(true);
});
it('maps current vehicle fields without inventing unavailable license data', () => {
const groups = resolveVehicleLicenses(makeVehicle(), REFERENCE_DATE);
const driver = groups.find(({ id }) => id === 'driver');
const transport = groups.find(({ id }) => id === 'transport');
expect(driver?.fields).toEqual([
{ label: '注册日期', value: '2025-06-03' },
{ label: '发证日期', value: '—' },
{ label: '强制报废日期', value: '2040-06-03' },
{
label: '检验有效期至',
value: '2027-06-30',
expiryDays: 339,
},
]);
expect(driver).toMatchObject({
status: 'normal',
statusLabel: '正常',
});
expect(transport?.fields).toEqual([
{ label: '经营许可证号', value: '—' },
{ label: '核发时间', value: '—' },
{ label: '证件有效期', value: '—' },
{ label: '审验有效期', value: '—' },
]);
});
it('derives only the hydrogen card binding state from telematics linkage', () => {
const bound = resolveVehicleLicenses(
makeVehicle({ telematicsLinked: true }),
REFERENCE_DATE,
).find(({ id }) => id === 'hydrogen-card');
const missing = resolveVehicleLicenses(
makeVehicle({ telematicsLinked: false }),
REFERENCE_DATE,
).find(({ id }) => id === 'hydrogen-card');
expect(bound).toMatchObject({
status: 'normal',
statusLabel: '正常',
fields: [{ label: '绑定状态', value: '已绑定' }],
});
expect(missing).toMatchObject({
status: 'missing',
statusLabel: '未上传',
fields: [{ label: '绑定状态', value: '未绑定' }],
});
expect(bound?.fields.some(({ label }) => label.includes('卡号'))).toBe(false);
});
it('keeps unavailable document groups explicit and missing', () => {
const groups = resolveVehicleLicenses(makeVehicle(), REFERENCE_DATE);
const byId = new Map(groups.map((group) => [group.id, group]));
expect(byId.get('registration')?.fields).toEqual([]);
expect(byId.get('special-registration')?.fields).toEqual([]);
expect(byId.get('special-mark')?.fields).toEqual([
{ label: '下次检验日期', value: '—' },
]);
expect(byId.get('safety-valve')?.fields).toEqual([
{ label: '本次检验日期', value: '—' },
{ label: '下次检测日期', value: '—' },
]);
expect(byId.get('pressure-gauge')?.fields).toEqual([
{ label: '本次检验日期', value: '—' },
{ label: '下次检验日期', value: '—' },
]);
for (const id of [
'transport',
'registration',
'special-registration',
'special-mark',
'hydrogen-card',
'safety-valve',
'pressure-gauge',
] as const) {
expect(byId.get(id)).toMatchObject({
status: 'missing',
statusLabel: '未上传',
});
}
});
it.each([
['blank', ' '],
['hyphen', ' - '],
['em dash', '—'],
])('normalizes %s driver metadata to em dashes and missing', (_name, emptyValue) => {
const driver = resolveVehicleLicenses(
makeVehicle({
regDate: emptyValue,
scrapDate: emptyValue,
inspectExpire: emptyValue,
}),
REFERENCE_DATE,
).find(({ id }) => id === 'driver');
expect(driver).toMatchObject({
status: 'missing',
statusLabel: '未上传',
});
expect(driver?.fields.map(({ value }) => value)).toEqual([
'—',
'—',
'—',
'—',
]);
});
it.each([
['registration date', { regDate: '2025-06-03' }],
['scrap date', { scrapDate: '2040-06-03' }],
['inspection expiry', { inspectExpire: '2027-06-30' }],
] satisfies Array<[string, Partial<VehicleRecord>]>)(
'treats a driver license with only %s as partial metadata',
(_name, presentMetadata) => {
const driver = resolveVehicleLicenses(
makeVehicle({
regDate: '-',
scrapDate: '-',
inspectExpire: '-',
...presentMetadata,
}),
REFERENCE_DATE,
).find(({ id }) => id === 'driver');
expect(driver).toMatchObject({
status: 'normal',
statusLabel: '正常',
images: [],
});
},
);
it('labels fixed driver expiry dates as expiring or expired', () => {
const expiring = resolveVehicleLicenses(
makeVehicle({ inspectExpire: '2026-08-25' }),
REFERENCE_DATE,
).find(({ id }) => id === 'driver');
const expired = resolveVehicleLicenses(
makeVehicle({ inspectExpire: '2026-07-25' }),
REFERENCE_DATE,
).find(({ id }) => id === 'driver');
expect(expiring).toMatchObject({
status: 'expiring',
statusLabel: '临期',
});
expect(expired).toMatchObject({
status: 'expired',
statusLabel: '已到期',
});
});
it('uses an explicit reference date without mutating it', () => {
const referenceDate = new Date(2030, 0, 15, 17, 30);
const originalTimestamp = referenceDate.getTime();
expect(daysUntilExpire('2030-01-16', referenceDate)).toBe(1);
expect(
(() => {
const fields = resolveVehicleLicenses(
makeVehicle({ inspectExpire: '2030-01-16' }),
referenceDate,
).find(({ id }) => id === 'driver')?.fields ?? [];
return fields[fields.length - 1]?.expiryDays;
})(),
).toBe(1);
expect(referenceDate.getTime()).toBe(originalTimestamp);
});
it('classifies document status from availability and remaining days', () => {
expect(DRIVER_LICENSE_WARN_DAYS).toBe(90);
expect(STANDARD_LICENSE_WARN_DAYS).toBe(60);
expect(licenseStatusFromDays(120, 90, false)).toBe('missing');
expect(licenseStatusFromDays(null, 90, true)).toBe('normal');
expect(licenseStatusFromDays(-1, 90, true)).toBe('expired');
expect(licenseStatusFromDays(0, 90, true)).toBe('expiring');
expect(licenseStatusFromDays(90, 90, true)).toBe('expiring');
expect(licenseStatusFromDays(91, 90, true)).toBe('normal');
expect(licenseStatusFromDays(60, 60, true)).toBe('expiring');
expect(licenseStatusFromDays(61, 60, true)).toBe('normal');
});
});
describe('DetailLicenseTab', () => {
it('renders eight keyboard-operable category buttons and a textual status legend', async () => {
rendered = await renderReact(<DetailLicenseTab record={makeVehicle()} />);
const nav = rendered.container.querySelector<HTMLElement>(
'nav[aria-label="证照分类"]',
);
const buttons = Array.from(nav?.querySelectorAll('button') ?? []);
const legend = rendered.container.querySelector<HTMLElement>(
'[aria-label="证照状态图例"]',
);
expect(nav).not.toBeNull();
expect(buttons).toHaveLength(8);
expect(buttons.every((button) => button.tagName === 'BUTTON')).toBe(true);
expect(buttons.map((button) => button.textContent?.trim())).toEqual([
'行驶证',
'道路运输证',
'登记证',
'特种设备使用登记证',
'特种设备使用标识',
'加氢卡',
'安全阀',
'压力表',
]);
expect(legend?.textContent).toContain('正常');
expect(legend?.textContent).toContain('临期');
expect(legend?.textContent).toContain('已到期');
expect(legend?.textContent).toContain('未上传');
expect(legend?.querySelectorAll('[aria-hidden="true"]')).toHaveLength(4);
});
it('renders eight semantic cards, four driver fields, and V2 image empty states without fake images', async () => {
rendered = await renderReact(<DetailLicenseTab record={makeVehicle()} />);
const articles = rendered.container.querySelectorAll<HTMLElement>(
'article[data-license-id]',
);
const driver = rendered.container.querySelector<HTMLElement>(
'article[data-license-id="driver"]',
);
const driverLabels = Array.from(
driver?.querySelectorAll('.va-license-description dt') ?? [],
(label) => label.textContent?.trim(),
);
expect(articles).toHaveLength(8);
expect(driverLabels).toEqual([
'注册日期',
'发证日期',
'强制报废日期',
'检验有效期至',
]);
expect(rendered.container.textContent).toContain('暂无行驶证图片');
expect(rendered.container.textContent).toContain('该证照尚未上传可预览图片');
expect(rendered.container.querySelector('img')).toBeNull();
});
it('renders only allowlisted image URLs and rejects control-character protocol bypasses', async () => {
const allowedImages: VehicleLicenseImage[] = [
{
id: 'allowed-https',
src: 'https://cdn.example.com/license.png',
alt: 'HTTPS 图片',
},
{
id: 'allowed-http',
src: 'http://cdn.example.com/license.png',
alt: 'HTTP 图片',
},
{
id: 'allowed-blob',
src: 'blob:https://example.com/license-id',
alt: 'Blob 图片',
},
{
id: 'allowed-data',
src: DRIVER_FRONT_IMAGE,
alt: 'Data 图片',
},
{
id: 'allowed-data-uppercase',
src: 'DATA:IMAGE/PNG;BASE64,iVBORw0KGgo=',
alt: '大写 Data 图片',
},
{
id: 'allowed-root-relative',
src: '/assets/license.png',
alt: '根路径图片',
},
{
id: 'allowed-relative',
src: 'assets/license.png',
alt: '相对路径图片',
},
];
const rejectedImages: VehicleLicenseImage[] = [
{ id: 'empty', src: ' ', alt: '空地址' },
{ id: 'javascript', src: 'javascript:alert(1)', alt: '脚本协议' },
{ id: 'javascript-newline', src: 'java\nscript:alert(1)', alt: '换行绕过' },
{ id: 'javascript-tab', src: 'java\tscript:alert(1)', alt: '制表符绕过' },
{ id: 'javascript-nul', src: '\0javascript:alert(1)', alt: 'NUL 绕过' },
{ id: 'data-text', src: 'data:text/html,<svg></svg>', alt: '文本 Data' },
{ id: 'data-space', src: 'data: image/png;base64,AAAA', alt: '变形 Data' },
{ id: 'mailto', src: 'mailto:test@example.com', alt: '邮件协议' },
{ id: 'invalid-url', src: 'http://[', alt: '无法解析地址' },
];
rendered = await renderReact(
<DetailLicenseTab
record={makeVehicle()}
groups={makeGroupsWithDriverImages([
...allowedImages,
...rejectedImages,
])}
/>,
);
const renderedAlts = Array.from(
rendered.container.querySelectorAll<HTMLImageElement>(
'.va-license-thumbnail img',
),
(image) => image.alt,
);
expect(renderedAlts).toEqual(allowedImages.map(({ alt }) => alt));
for (const { alt } of rejectedImages) {
expect(renderedAlts).not.toContain(alt);
}
});
it('uses the frozen status-label mapping instead of mutable injected label text', async () => {
const groups = resolveVehicleLicenses(makeVehicle(), REFERENCE_DATE).map((group) => (
group.id === 'driver'
? { ...group, statusLabel: '自定义状态文案' }
: group
));
rendered = await renderReact(
<DetailLicenseTab record={makeVehicle()} groups={groups} />,
);
const driver = rendered.container.querySelector<HTMLElement>(
'article[data-license-id="driver"]',
);
expect(driver?.textContent).toContain('正常');
expect(driver?.textContent).not.toContain('自定义状态文案');
});
it('closes the accessible preview by its button and by Escape while restoring focus', async () => {
rendered = await renderReact(
<DetailLicenseTab
record={makeVehicle()}
groups={makeGroupsWithDriverImage()}
/>,
);
const thumbnail = buttonByName(rendered.container, '预览行驶证正页');
expect(rendered.container.querySelectorAll('.va-license-thumbnail img')).toHaveLength(2);
expect(rendered.container.querySelector('img[alt="空白图片"]')).toBeNull();
expect(rendered.container.querySelector('img[alt="脚本图片"]')).toBeNull();
thumbnail.focus();
await click(thumbnail);
const dialog = document.body.querySelector<HTMLElement>(
'[role="dialog"][aria-label="证照图片预览"]',
);
const closeButton = buttonByName(dialog ?? document.body, '关闭预览');
const previewImage = dialog?.querySelector<HTMLImageElement>('img');
expect(dialog).not.toBeNull();
expect(previewImage?.getAttribute('src')).toBe(DRIVER_FRONT_IMAGE);
expect(previewImage?.getAttribute('alt')).toBe('行驶证正页');
expect(closeButton.type).toBe('button');
expect(document.activeElement).toBe(closeButton);
await click(closeButton);
expect(document.body.querySelector('[role="dialog"][aria-label="证照图片预览"]')).toBeNull();
expect(document.activeElement).toBe(thumbnail);
await click(thumbnail);
const reopenedDialog = document.body.querySelector<HTMLElement>(
'[role="dialog"][aria-label="证照图片预览"]',
);
const reopenedCloseButton = buttonByName(
reopenedDialog ?? document.body,
'关闭预览',
);
expect(reopenedDialog).not.toBeNull();
expect(document.activeElement).toBe(reopenedCloseButton);
await act(async () => {
document.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
}));
});
expect(document.body.querySelector('[role="dialog"][aria-label="证照图片预览"]')).toBeNull();
expect(document.activeElement).toBe(thumbnail);
});
it('isolates the modal, traps Tab focus, and restores document state after closing', async () => {
const previousBodyOverflow = document.body.style.overflow;
const preInertSibling = document.createElement('section');
const backgroundButton = document.createElement('button');
preInertSibling.setAttribute('inert', 'persisted');
preInertSibling.appendChild(backgroundButton);
document.body.appendChild(preInertSibling);
document.body.style.overflow = 'clip';
const addEventListener = vi.spyOn(document, 'addEventListener');
const removeEventListener = vi.spyOn(document, 'removeEventListener');
try {
rendered = await renderReact(
<DetailLicenseTab
record={makeVehicle()}
groups={makeGroupsWithDriverImage()}
/>,
);
const thumbnail = buttonByName(rendered.container, '预览行驶证正页');
await click(thumbnail);
const dialog = document.body.querySelector<HTMLElement>(
'[role="dialog"][aria-label="证照图片预览"]',
);
const closeButton = buttonByName(dialog ?? document.body, '关闭预览');
const addedKeydownListeners = addEventListener.mock.calls
.filter(([type]) => type === 'keydown')
.map(([, listener]) => listener);
expect(rendered.container.hasAttribute('inert')).toBe(true);
expect(preInertSibling.hasAttribute('inert')).toBe(true);
expect(document.body.style.overflow).toBe('hidden');
await act(async () => {
closeButton.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Tab',
bubbles: true,
}));
});
expect(document.activeElement).toBe(closeButton);
await act(async () => {
closeButton.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Tab',
shiftKey: true,
bubbles: true,
}));
});
expect(document.activeElement).toBe(closeButton);
backgroundButton.focus();
expect(document.activeElement).toBe(backgroundButton);
await act(async () => {
backgroundButton.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Tab',
bubbles: true,
}));
});
expect(document.activeElement).toBe(closeButton);
await click(closeButton);
const removedKeydownListeners = removeEventListener.mock.calls
.filter(([type]) => type === 'keydown')
.map(([, listener]) => listener);
expect(rendered.container.hasAttribute('inert')).toBe(false);
expect(preInertSibling.getAttribute('inert')).toBe('persisted');
expect(document.body.style.overflow).toBe('clip');
expect(removedKeydownListeners.some((listener) => (
addedKeydownListeners.includes(listener)
))).toBe(true);
} finally {
addEventListener.mockRestore();
removeEventListener.mockRestore();
preInertSibling.remove();
document.body.style.overflow = previousBodyOverflow;
}
});
it('restores modal isolation when unmounted while the preview is open', async () => {
const previousBodyOverflow = document.body.style.overflow;
const sibling = document.createElement('div');
document.body.appendChild(sibling);
document.body.style.overflow = 'clip';
try {
rendered = await renderReact(
<DetailLicenseTab
record={makeVehicle()}
groups={makeGroupsWithDriverImage()}
/>,
);
await click(buttonByName(rendered.container, '预览行驶证正页'));
expect(sibling.hasAttribute('inert')).toBe(true);
expect(document.body.style.overflow).toBe('hidden');
await rendered.unmount();
rendered = undefined;
expect(sibling.hasAttribute('inert')).toBe(false);
expect(document.body.style.overflow).toBe('clip');
} finally {
sibling.remove();
document.body.style.overflow = previousBodyOverflow;
}
});
it('does not restore focus to a preview trigger that has been disconnected', async () => {
rendered = await renderReact(
<DetailLicenseTab
record={makeVehicle()}
groups={makeGroupsWithDriverImage()}
/>,
);
const thumbnail = buttonByName(rendered.container, '预览行驶证副页');
const focus = vi.spyOn(thumbnail, 'focus');
thumbnail.focus();
await click(thumbnail);
focus.mockClear();
rendered.container.remove();
await act(async () => {
document.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
}));
});
expect(document.body.querySelector('[role="dialog"][aria-label="证照图片预览"]')).toBeNull();
expect(focus).not.toHaveBeenCalled();
expect(document.activeElement).not.toBe(thumbnail);
focus.mockRestore();
});
it('keeps dialog clicks open and closes from the scrim', async () => {
rendered = await renderReact(
<DetailLicenseTab
record={makeVehicle()}
groups={makeGroupsWithDriverImage()}
/>,
);
const thumbnail = buttonByName(rendered.container, '预览行驶证正页');
await click(thumbnail);
const scrim = document.body.querySelector<HTMLElement>(
'.va-license-preview-scrim',
);
const dialog = document.body.querySelector<HTMLElement>(
'[role="dialog"][aria-label="证照图片预览"]',
);
await act(async () => {
dialog?.click();
});
expect(document.body.querySelector('[role="dialog"][aria-label="证照图片预览"]')).not.toBeNull();
await act(async () => {
scrim?.click();
});
expect(document.body.querySelector('[role="dialog"][aria-label="证照图片预览"]')).toBeNull();
expect(document.activeElement).toBe(thumbnail);
});
it('opens the second real image and restores focus to its own trigger', async () => {
rendered = await renderReact(
<DetailLicenseTab
record={makeVehicle()}
groups={makeGroupsWithDriverImage()}
/>,
);
const secondThumbnail = buttonByName(rendered.container, '预览行驶证副页');
secondThumbnail.focus();
await click(secondThumbnail);
const dialog = document.body.querySelector<HTMLElement>(
'[role="dialog"][aria-label="证照图片预览"]',
);
expect(dialog?.querySelector('img')?.getAttribute('src')).toBe(DRIVER_BACK_IMAGE);
expect(dialog?.querySelector('img')?.getAttribute('alt')).toBe('行驶证副页');
await click(buttonByName(dialog ?? document.body, '关闭预览'));
expect(document.activeElement).toBe(secondThumbnail);
});
it('uses V2 surface fallbacks in each scoped license rule', () => {
const styles = readFileSync(
resolve(
process.cwd(),
'src/prototypes/vehicle-management/style.css',
),
'utf8',
);
const expectedBackground = (
'background: var(--ln-surface-pearl, var(--ln-canvas-soft, #f8fafc));'
);
for (const selector of [
'.va-license-toolbar',
'.va-license-thumbnail',
'.va-license-image-empty',
'.va-license-preview__close:hover',
'.va-license-preview__canvas',
]) {
expect(ruleFor(styles, selector)).toContain(expectedBackground);
}
});
it('smoothly scrolls the selected native index button to its card', async () => {
rendered = await renderReact(<DetailLicenseTab record={makeVehicle()} />);
const nav = rendered.container.querySelector<HTMLElement>(
'nav[aria-label="证照分类"]',
);
const driverButton = buttonByName(nav ?? rendered.container, '行驶证');
expect(driverButton.tagName).toBe('BUTTON');
expect(driverButton.type).toBe('button');
await click(driverButton);
expect(scrollIntoView).toHaveBeenCalledOnce();
expect(scrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'start',
});
});
it('uses auto scrolling when reduced motion is requested and restores matchMedia', async () => {
rendered = await renderReact(<DetailLicenseTab record={makeVehicle()} />);
const originalMatchMediaDescriptor = Object.getOwnPropertyDescriptor(
window,
'matchMedia',
);
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn(reducedMotionMatchMedia),
});
try {
const nav = rendered.container.querySelector<HTMLElement>(
'nav[aria-label="证照分类"]',
);
await click(buttonByName(nav ?? rendered.container, '道路运输证'));
expect(scrollIntoView).toHaveBeenCalledWith({
behavior: 'auto',
block: 'start',
});
} finally {
if (originalMatchMediaDescriptor) {
Object.defineProperty(
window,
'matchMedia',
originalMatchMediaDescriptor,
);
} else {
Reflect.deleteProperty(window, 'matchMedia');
}
}
});
});

View File

@@ -0,0 +1,172 @@
// @vitest-environment jsdom
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import React from 'react';
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
import { DetailModelParamsTab } from '../../src/prototypes/vehicle-management/components/DetailModelParamsTab';
import vehicles from '../../src/prototypes/vehicle-management/data/vehicles.json';
import type { VehicleRecord } from '../../src/prototypes/vehicle-management/types';
import { renderReact, type RenderReactResult } from './detailTestUtils';
let rendered: RenderReactResult | undefined;
function findClosingBrace(source: string, openingBraceIndex: number): number {
let depth = 0;
for (let index = openingBraceIndex; index < source.length; index += 1) {
if (source[index] === '{') {
depth += 1;
} else if (source[index] === '}') {
depth -= 1;
if (depth === 0) return index;
}
}
return -1;
}
beforeAll(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(async () => {
await rendered?.unmount();
rendered = undefined;
});
describe('DetailModelParamsTab', () => {
it('renders the model parameters as semantic readonly descriptions', async () => {
rendered = await renderReact(
<DetailModelParamsTab record={vehicles[0] as VehicleRecord} />,
);
const { container } = rendered;
const tab = container.querySelector<HTMLElement>(
'section[aria-label="型号参数(只读)"]',
);
const headings = Array.from(tab?.querySelectorAll('h3') ?? [], (heading) => (
heading.textContent?.trim()
));
const grids = Array.from(tab?.querySelectorAll<HTMLElement>('.va-description-grid') ?? []);
const items = Array.from(tab?.querySelectorAll<HTMLElement>('.va-description-item') ?? []);
const labels = items.map((item) => item.querySelector('dt')?.textContent?.trim());
expect(tab).not.toBeNull();
expect(tab?.tagName).toBe('SECTION');
expect(headings).toEqual([
'基本情况',
'轮胎情况',
'电气情况',
'供氢系统情况',
'其他系统情况',
'保养规则',
]);
expect(grids.length).toBeGreaterThan(0);
expect(grids.every((grid) => grid.tagName === 'DL')).toBe(true);
expect(items.length).toBeGreaterThanOrEqual(10);
expect(items.every((item) => (
item.tagName === 'DIV'
&& item.querySelector(':scope > dt') !== null
&& item.querySelector(':scope > dd') !== null
))).toBe(true);
expect(labels).toEqual([
'品牌',
'型号',
'车辆类型',
'公告型号',
'燃料种类',
'车牌颜色',
'车辆长度(米)',
'车辆宽度(米)',
'车辆高度(米)',
'轮胎数量',
'轮胎磨损费用(元/mm)',
'电池类型',
'电池厂家',
'储电量',
'电续航里程(KM)',
'氢瓶容量',
'仪表盘显示模式',
'氢续航里程(KM)',
'冷机厂家',
]);
expect(items[0].querySelector('dd')?.textContent).toBe('现代');
expect(tab?.querySelector('.va-model-param-item__value')).toBeNull();
expect(tab?.querySelector('input, select, textarea')).toBeNull();
expect(tab?.querySelector('.va-model-param-table')?.textContent).toContain('保养公里周期');
expect(tab?.querySelector('.va-model-param-table')?.textContent).toContain('变速器油');
});
it('associates the maintenance table with its heading and identifies column headers', async () => {
rendered = await renderReact(
<DetailModelParamsTab record={vehicles[0] as VehicleRecord} />,
);
const heading = Array.from(rendered.container.querySelectorAll<HTMLHeadingElement>('h3'))
.find((candidate) => candidate.textContent?.trim() === '保养规则');
const table = rendered.container.querySelector<HTMLTableElement>('.va-model-param-table');
const columnHeaders = Array.from(table?.querySelectorAll('th') ?? []);
expect(heading?.id).toBeTruthy();
expect(table?.getAttribute('aria-labelledby')).toBe(heading?.id);
expect(columnHeaders.length).toBeGreaterThan(0);
expect(columnHeaders.every((header) => header.getAttribute('scope') === 'col')).toBe(true);
});
it('renders empty parameter values as an em dash', async () => {
rendered = await renderReact(
<DetailModelParamsTab
record={{ ...vehicles[0], brand: '-' } as VehicleRecord}
/>,
);
const brandItem = Array.from(
rendered.container.querySelectorAll<HTMLElement>('.va-description-item'),
).find((item) => item.querySelector('dt')?.textContent?.trim() === '品牌');
expect(brandItem?.querySelector('dd')?.textContent).toBe('—');
});
it('uses the page mobile breakpoint for the single-column description grid', () => {
const descriptionGridSelector = '.va-model-param-tab .va-description-grid';
const styles = readFileSync(
resolve(process.cwd(), 'src/prototypes/vehicle-management/style.css'),
'utf8',
);
const selectorIndexes: number[] = [];
let selectorIndex = styles.indexOf(descriptionGridSelector);
while (selectorIndex >= 0) {
selectorIndexes.push(selectorIndex);
selectorIndex = styles.indexOf(descriptionGridSelector, selectorIndex + 1);
}
expect(selectorIndexes).toHaveLength(2);
const mobileSelectorIndexes = selectorIndexes.filter((index) => {
const ruleOpeningBrace = styles.indexOf('{', index);
const ruleClosingBrace = findClosingBrace(styles, ruleOpeningBrace);
const rule = styles.slice(index, ruleClosingBrace + 1);
return rule.includes('grid-template-columns: minmax(0, 1fr)');
});
expect(mobileSelectorIndexes).toHaveLength(1);
const mobileSelectorIndex = mobileSelectorIndexes[0];
const nearestMediaIndex = styles.lastIndexOf('@media', mobileSelectorIndex);
const nearestMediaBlockStart = styles.indexOf('{', nearestMediaIndex);
const nearestMediaBlockEnd = findClosingBrace(styles, nearestMediaBlockStart);
const nearestMediaHeader = styles
.slice(nearestMediaIndex, nearestMediaBlockStart)
.trim();
const mobileRuleStart = styles.indexOf('{', mobileSelectorIndex);
const mobileRuleEnd = findClosingBrace(styles, mobileRuleStart);
const mobileRule = styles.slice(mobileSelectorIndex, mobileRuleEnd + 1);
expect(nearestMediaHeader).toBe('@media (max-width: 767px)');
expect(mobileSelectorIndex).toBeGreaterThan(nearestMediaBlockStart);
expect(mobileSelectorIndex).toBeLessThan(nearestMediaBlockEnd);
expect(mobileRuleEnd).toBeLessThan(nearestMediaBlockEnd);
expect(mobileRule).toContain('grid-template-columns: minmax(0, 1fr)');
});
});

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);
});
});

View File

@@ -0,0 +1,98 @@
// @vitest-environment jsdom
import React from 'react';
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
import * as detailViewModule from '../../src/prototypes/vehicle-management/components/DetailView';
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';
const { DetailView } = detailViewModule;
let rendered: RenderReactResult | undefined;
beforeAll(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(async () => {
await rendered?.unmount();
rendered = undefined;
});
describe('vehicle DetailView tabs', () => {
it('does not export mutable shared tab layout configuration', () => {
expect('DETAIL_TABS_WITH_ASIDE' in detailViewModule).toBe(false);
});
it('defaults to lifecycle and adapts the aside layout to the selected tab', async () => {
const noOp = () => {};
rendered = await renderReact(
<DetailView
record={vehicles[0] as VehicleRecord}
onBack={noOp}
onOps={noOp}
onOperateCity={noOp}
onUpdate={noOp}
onToast={noOp}
/>,
);
const { container } = rendered;
const grid = container.querySelector<HTMLElement>('.va-form-page-grid');
expect(buttonByName(container, '生命周期').getAttribute('aria-selected')).toBe('true');
expect(grid?.classList.contains('is-wide-tab')).toBe(false);
expect(container.querySelector('aside[aria-label="车辆侧栏信息"]')).not.toBeNull();
await click(buttonByName(container, '型号参数'));
expect(grid?.classList.contains('is-wide-tab')).toBe(true);
expect(container.querySelector('aside[aria-label="车辆侧栏信息"]')).toBeNull();
await click(buttonByName(container, '基本信息'));
expect(grid?.classList.contains('is-wide-tab')).toBe(false);
expect(container.querySelector('aside[aria-label="车辆侧栏信息"]')).not.toBeNull();
});
it('integrates every redesigned full-width detail tab', async () => {
const noOp = () => {};
rendered = await renderReact(
<DetailView
record={vehicles[0] as VehicleRecord}
onBack={noOp}
onOps={noOp}
onOperateCity={noOp}
onUpdate={noOp}
onToast={noOp}
/>,
);
const { container } = rendered;
const cases = [
['型号参数', () => container.querySelector('section[aria-label="型号参数(只读)"]')],
['证照信息', () => container.querySelector('nav[aria-label="证照分类"]')],
['保险记录', () => container.querySelector('section[aria-label="车辆保险档案"]')],
['租赁记录', () => Array.from(container.querySelectorAll('th')).find((cell) => cell.textContent === '合同编码')],
['事故记录', () => Array.from(container.querySelectorAll('th')).find((cell) => cell.textContent === '事故编码')],
['故障记录', () => Array.from(container.querySelectorAll('th')).find((cell) => cell.textContent === '故障编号')],
['违章记录', () => Array.from(container.querySelectorAll('th')).find((cell) => cell.textContent === '违法行为')],
['异动记录', () => Array.from(container.querySelectorAll('th')).find((cell) => cell.textContent === '异动开始日期')],
['调拨记录', () => Array.from(container.querySelectorAll('th')).find((cell) => cell.textContent === '调拨日期')],
['年审记录', () => Array.from(container.querySelectorAll('th')).find((cell) => cell.textContent === '检验有效期至')],
] as const;
for (const [tabName, landmark] of cases) {
await click(buttonByName(container, tabName));
expect(landmark(), `${tabName} landmark`).toBeTruthy();
expect(container.querySelector('.va-form-page-grid')?.classList.contains('is-wide-tab')).toBe(true);
expect(container.querySelector('aside[aria-label="车辆侧栏信息"]')).toBeNull();
}
});
});

View File

@@ -0,0 +1,212 @@
// @vitest-environment jsdom
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { ListView } from '../../src/prototypes/vehicle-management/components/ListView';
import { EMPTY_FILTERS, type VehicleRecord } from '../../src/prototypes/vehicle-management/types';
let root: Root | undefined;
let container: HTMLDivElement | undefined;
const matchMediaMock = vi.fn();
function setMobileViewport(matches: boolean) {
matchMediaMock.mockReturnValue({
matches,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
}
function buttonsByAccessibleName(scope: ParentNode, name: string): HTMLButtonElement[] {
return Array.from(scope.querySelectorAll<HTMLButtonElement>('button')).filter((button) => {
const accessibleName = button.getAttribute('aria-label') ?? button.textContent?.trim();
return accessibleName === name;
});
}
beforeAll(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
window.matchMedia = matchMediaMock;
setMobileViewport(false);
});
afterEach(() => {
if (root) act(() => root?.unmount());
container?.remove();
document.querySelectorAll('.vm-op-dropdown').forEach((menu) => menu.remove());
root = undefined;
container = undefined;
});
describe('vehicle ListView operation column', () => {
it('keeps vehicle edit inside more and dispatches the selected row', async () => {
setMobileViewport(false);
const row = {
id: 'vehicle-1',
plateNo: '沪A12345',
vin: 'VIN123',
brand: '测试品牌',
model: '测试车型',
} as VehicleRecord;
const onOpenDetail = vi.fn();
const onEdit = vi.fn();
const onOps = vi.fn();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<ListView
records={[row]}
kpiCounts={{
all: 1,
lease: 0,
logistics: 0,
stock: 0,
nonOperating: 0,
exit: 0,
licenseAbnormal: 0,
insuranceAbnormal: 0,
}}
kpiTab="all"
onKpiChange={vi.fn()}
pendingFilters={EMPTY_FILTERS}
appliedFilters={EMPTY_FILTERS}
onPendingChange={vi.fn()}
onSearch={vi.fn()}
onReset={vi.fn()}
filtered={[row]}
insuranceMap={new Map()}
onOpenDetail={onOpenDetail}
onExport={vi.fn()}
onImportOpen={vi.fn()}
onOps={onOps}
onOperateCity={vi.fn()}
onMap={vi.fn()}
onEdit={onEdit}
onToast={vi.fn()}
page={1}
pageSize={10}
onPageChange={vi.fn()}
onPageSizeChange={vi.fn()}
/>,
);
});
const view = container.querySelector<HTMLButtonElement>('[aria-label="查看"]');
const edit = container.querySelector<HTMLButtonElement>('[aria-label="编辑"]');
const more = container.querySelector<HTMLButtonElement>('[aria-label="更多操作"]');
expect(view).not.toBeNull();
expect(edit).toBeNull();
expect(more).not.toBeNull();
await act(async () => {
view?.click();
more?.click();
});
const menuEdit = Array.from(
document.body.querySelectorAll<HTMLButtonElement>('.vm-op-dropdown button'),
).find((button) => button.textContent?.trim() === '编辑');
expect(menuEdit).not.toBeNull();
await act(async () => menuEdit?.click());
await act(async () => more?.click());
const owner = Array.from(
document.body.querySelectorAll<HTMLButtonElement>('.vm-op-dropdown button'),
).find((button) => button.textContent?.includes('设置运维负责人'));
expect(owner).not.toBeNull();
await act(async () => owner?.click());
expect(onOpenDetail).toHaveBeenCalledWith(row);
expect(onEdit).toHaveBeenCalledWith(row);
expect(onOps).toHaveBeenCalledWith(row);
});
it('renders one mobile action set and dispatches the mobile row', async () => {
setMobileViewport(true);
const mobileRow = {
id: 'vehicle-mobile-1',
plateNo: '沪B54321',
vin: 'VIN-MOBILE',
brand: '移动品牌',
model: '移动车型',
} as VehicleRecord;
const onOpenDetail = vi.fn();
const onEdit = vi.fn();
const onOps = vi.fn();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<ListView
records={[mobileRow]}
kpiCounts={{
all: 1,
lease: 0,
logistics: 0,
stock: 0,
nonOperating: 0,
exit: 0,
licenseAbnormal: 0,
insuranceAbnormal: 0,
}}
kpiTab="all"
onKpiChange={vi.fn()}
pendingFilters={EMPTY_FILTERS}
appliedFilters={EMPTY_FILTERS}
onPendingChange={vi.fn()}
onSearch={vi.fn()}
onReset={vi.fn()}
filtered={[mobileRow]}
insuranceMap={new Map()}
onOpenDetail={onOpenDetail}
onExport={vi.fn()}
onImportOpen={vi.fn()}
onOps={onOps}
onOperateCity={vi.fn()}
onMap={vi.fn()}
onEdit={onEdit}
onToast={vi.fn()}
page={1}
pageSize={10}
onPageChange={vi.fn()}
onPageSizeChange={vi.fn()}
/>,
);
});
const viewButtons = buttonsByAccessibleName(container, '查看');
const editButtons = buttonsByAccessibleName(container, '编辑');
const moreButtons = buttonsByAccessibleName(container, '更多操作');
expect(viewButtons).toHaveLength(1);
expect(editButtons).toHaveLength(0);
expect(moreButtons).toHaveLength(1);
await act(async () => {
viewButtons[0].click();
moreButtons[0].click();
});
const menuEditButtons = buttonsByAccessibleName(document.body, '编辑');
expect(menuEditButtons).toHaveLength(1);
await act(async () => menuEditButtons[0].click());
await act(async () => moreButtons[0].click());
const ownerButtons = buttonsByAccessibleName(document.body, '设置运维负责人');
expect(ownerButtons).toHaveLength(1);
await act(async () => ownerButtons[0].click());
expect(onOpenDetail).toHaveBeenCalledTimes(1);
expect(onOpenDetail).toHaveBeenCalledWith(mobileRow);
expect(onEdit).toHaveBeenCalledTimes(1);
expect(onEdit).toHaveBeenCalledWith(mobileRow);
expect(onOps).toHaveBeenCalledTimes(1);
expect(onOps).toHaveBeenCalledWith(mobileRow);
});
});

View File

@@ -0,0 +1,47 @@
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
export interface RenderReactResult {
container: HTMLDivElement;
unmount: () => Promise<void>;
}
export async function renderReact(element: React.ReactElement): Promise<RenderReactResult> {
const container = document.createElement('div');
const root: Root = createRoot(container);
document.body.appendChild(container);
await act(async () => {
root.render(element);
});
return {
container,
async unmount() {
await act(async () => {
root.unmount();
});
container.remove();
},
};
}
export async function click(button: HTMLButtonElement): Promise<void> {
await act(async () => {
button.click();
});
}
export function buttonByName(scope: ParentNode, name: string): HTMLButtonElement {
const button = Array.from(scope.querySelectorAll<HTMLButtonElement>('button')).find(
(candidate) => (
candidate.getAttribute('aria-label') ?? candidate.textContent?.trim()
) === name,
);
if (!button) {
throw new Error(`Could not find button with accessible name "${name}"`);
}
return button;
}

View File

@@ -0,0 +1,25 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
describe('vehicle management entrypoint styles', () => {
it('loads the shared operation actions stylesheet', () => {
const entrypoint = readFileSync(
new URL('../../src/prototypes/vehicle-management/index.tsx', import.meta.url),
'utf8',
);
expect(entrypoint).toContain("import '../../common/vm-operation-actions.css';");
});
it('uses the neutral design-system border for the lifecycle search focus state', () => {
const styles = readFileSync(
new URL('../../src/prototypes/vehicle-management/style.css', import.meta.url),
'utf8',
);
expect(styles).toContain('.va-life__search:focus-within');
expect(styles).toContain('border-color: var(--ln-hairline-strong, #d4d4d8);');
expect(styles).toContain('.va-life__search input:focus-visible');
expect(styles).toContain('outline: none;');
});
});