Files
Literature-Management/tests/unit/renderer/components/ui/input-aria-contract.test.tsx
2026-10-21 14:42:59 +08:00

70 lines
3.0 KiB
TypeScript

// tests/unit/renderer/components/ui/input-aria-contract.test.tsx
//
// 守 audit r252:Input 的 aria-describedby 必须**同时**指向调用方给的说明
// 与组件自己挂的错误文案节点。
//
// 旧实现的坑:属性写在 `{...rest}` **之前** ——
// `aria-describedby={hasError ? errId : rest['aria-describedby']}`
// 而展开在后面 ⇒ rest 里若也带 aria-describedby,展开会把它整个盖掉
// ⇒ 错误文案 <p role="alert"> 还在 DOM 里,却不再被 input 引用
// ⇒ 读屏用户念 input 时**听不到错误原因**(视觉正常、a11y 断链)。
// 今天是「潜在」缺陷(现有 6 个调用点没有同时传 error + aria-describedby),
// 但它是**契约级**的雷:下一个照着文档写的调用方就会踩到。
//
// 本测锁:
// 1. 同时传 error + aria-describedby ⇒ 两个 id 都在(不被覆盖);
// 2. 只传 error ⇒ 指到错误节点;
// 3. 只传 aria-describedby ⇒ 原样保留;
// 4. 都不传 ⇒ 不产出该属性。
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
import { Input } from '../../../../../src/renderer/src/components/ui/Input';
describe('Input 的 aria 契约(r252)', () => {
it('同时传 error + aria-describedby ⇒ 两个 id 都被引用(错误节点不丢)', () => {
render(
<Input
value="x"
onChange={() => undefined}
error="文件名已存在"
aria-describedby="hint-1"
/>,
);
const input = screen.getByRole('textbox');
const described = input.getAttribute('aria-describedby') ?? '';
const ids = described.split(/\s+/).filter(Boolean);
// 调用方给的说明
expect(ids, '调用方给的 aria-describedby 不得被覆盖').toContain('hint-1');
// 组件挂的错误节点:DOM 里那个 role=alert 的 <p> 的 id 必须在列表里
const alert = screen.getByRole('alert');
expect(alert.textContent).toBe('文件名已存在');
expect(ids, '错误文案节点必须被 input 引用(否则读屏听不到错误原因)').toContain(
alert.getAttribute('id') ?? '',
);
expect(alert.getAttribute('id')).toBeTruthy();
});
it('只传 error ⇒ 指到错误节点', () => {
render(<Input value="x" onChange={() => undefined} error="必填" />);
const input = screen.getByRole('textbox');
const alert = screen.getByRole('alert');
expect(input.getAttribute('aria-describedby')).toBe(alert.getAttribute('id'));
expect(input.getAttribute('aria-invalid')).toBe('true');
});
it('只传 aria-describedby ⇒ 原样保留', () => {
render(<Input value="x" onChange={() => undefined} aria-describedby="hint-2" />);
expect(screen.getByRole('textbox').getAttribute('aria-describedby')).toBe('hint-2');
});
it('都不传 ⇒ 不产出该属性', () => {
render(<Input value="x" onChange={() => undefined} />);
expect(screen.getByRole('textbox').hasAttribute('aria-describedby')).toBe(false);
});
});