初始化项目版本
This commit is contained in:
197
tests/common/OperationActions.test.tsx
Normal file
197
tests/common/OperationActions.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
18
tests/prototypes/oneos-v2/UIComponents.test.tsx
Normal file
18
tests/prototypes/oneos-v2/UIComponents.test.tsx
Normal 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} 复杂台账列个性化规范`);
|
||||
});
|
||||
});
|
||||
892
tests/vehicle-management/DetailLicenseTab.test.tsx
Normal file
892
tests/vehicle-management/DetailLicenseTab.test.tsx
Normal 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');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
172
tests/vehicle-management/DetailModelParamsTab.test.tsx
Normal file
172
tests/vehicle-management/DetailModelParamsTab.test.tsx
Normal 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)');
|
||||
});
|
||||
});
|
||||
555
tests/vehicle-management/DetailRecordTabs.test.tsx
Normal file
555
tests/vehicle-management/DetailRecordTabs.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
98
tests/vehicle-management/DetailView.tabs.test.tsx
Normal file
98
tests/vehicle-management/DetailView.tabs.test.tsx
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
212
tests/vehicle-management/ListView.operation-actions.test.tsx
Normal file
212
tests/vehicle-management/ListView.operation-actions.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
47
tests/vehicle-management/detailTestUtils.tsx
Normal file
47
tests/vehicle-management/detailTestUtils.tsx
Normal 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;
|
||||
}
|
||||
25
tests/vehicle-management/entrypoint-styles.test.ts
Normal file
25
tests/vehicle-management/entrypoint-styles.test.ts
Normal 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;');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user