初始化项目版本
This commit is contained in:
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');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user