Files
Literature-Management/tests/unit/renderer/components/ui/tabs-keyboard-nav.test.tsx
2026-10-21 14:42:59 +08:00

169 lines
6.5 KiB
TypeScript

// tests/unit/renderer/components/ui/tabs-keyboard-nav.test.tsx
//
// 守 Tabs 的 **WAI-ARIA tab pattern 键盘契约**(src/renderer/src/components/ui/Tabs.tsx
// 头注指向本文件,audit-r111 登记,本轮补齐 —— 原先标 🔒(守卫缺失))。
//
// 这条契约全站只有 Tabs 一处实现,漏了不会有第二个实现提醒你:
// 1. ← / → 在 tab 间移动(带环绕),Home / End 跳首尾;
// 2. **roving tabindex** —— 只有 selected 的 tab tabIndex=0,其余 -1,
// 整个 tablist 在 Tab 序列里只占一个停靠点;
// 3. 选中态随键盘移动,且**焦点跟着走**(WAI-ARIA 要求焦点与 selection
// 同步,否则 SR 读到的选中项与视觉焦点不一致);
// 4. active 指向一个不存在的 tab ⇒ 键盘处理整体 no-op(不吞默认行为);
// 5. 单 tab 列表:←/→/Home/End 算回原位 ⇒ **不** preventDefault
// (否则会吞掉 textarea 里方向键移动光标等默认行为,audit 2026-09-07)。
//
// 变异思路:每条断言钉一个具体会被改坏的点(环绕 / roving / 焦点跟随 /
// no-op 分支 / 单 tab 不拦默认)。
import { describe, expect, it, vi } from 'vitest';
import { useState } from 'react';
import { render, fireEvent, act } from '@testing-library/react';
import { Tabs } from '../../../../../src/renderer/src/components/ui/Tabs';
const TABS = [
{ id: 'a', label: 'A' },
{ id: 'b', label: 'B' },
{ id: 'c', label: 'C' },
];
/** rAF 在 jsdom 里是 ~16ms 的定时器,键盘切换后要把那一帧跑完。 */
async function flushFrame() {
await act(async () => {
await new Promise((r) => setTimeout(r, 40));
});
}
function renderTabs(active = 'b', tabs = TABS) {
const onChange = vi.fn();
const { container, unmount } = render(
<Tabs tabs={tabs} active={active} onChange={onChange} ariaLabel="模式" />,
);
const list = container.querySelector('[role="tablist"]');
if (!list) throw new Error('Tabs 未渲染 tablist');
return { list, onChange, container, unmount };
}
/**
* 受控外壳 —— Tabs 是受控组件(active 由 caller 持有),键盘导航的
* **环绕**行为只有在 active 真的跟着变时才能观察到。静态 active 恒为
* 'b' 时,每次 → 都只能算出同一个 next,环绕是看不见的。
*/
function renderControlled(initial = 'b', tabs = TABS) {
const onChange = vi.fn();
function Harness() {
const [active, setActive] = useState(initial);
return (
<Tabs
tabs={tabs}
active={active}
onChange={(id) => {
onChange(id);
setActive(id);
}}
ariaLabel="模式"
/>
);
}
const { container, unmount } = render(<Harness />);
const list = container.querySelector('[role="tablist"]');
if (!list) throw new Error('Tabs 未渲染 tablist');
return { list, onChange, container, unmount };
}
function tabButtons(container: HTMLElement) {
return Array.from(container.querySelectorAll<HTMLButtonElement>('[role="tab"]'));
}
describe('Tabs 键盘导航契约(audit-r111 补齐)', () => {
it('→ 前进一档(到末尾后环绕回首),← 后退一档(到首位后环绕到末)', () => {
const { list, onChange, unmount } = renderControlled('b');
fireEvent.keyDown(list, { key: 'ArrowRight' });
expect(onChange).toHaveBeenLastCalledWith('c');
fireEvent.keyDown(list, { key: 'ArrowRight' });
expect(onChange).toHaveBeenLastCalledWith('a'); // 环绕
fireEvent.keyDown(list, { key: 'ArrowLeft' });
expect(onChange).toHaveBeenLastCalledWith('c'); // 反向环绕
expect(onChange).toHaveBeenCalledTimes(3);
unmount();
});
it('Home / End 跳到首 / 尾', () => {
const { list, onChange, unmount } = renderTabs('b');
fireEvent.keyDown(list, { key: 'End' });
expect(onChange).toHaveBeenLastCalledWith('c');
fireEvent.keyDown(list, { key: 'Home' });
expect(onChange).toHaveBeenLastCalledWith('a');
unmount();
});
it('roving tabindex:只有 selected 的 tab 可 Tab 进入,其余 -1', () => {
const { container, unmount } = renderTabs('b');
const btns = tabButtons(container);
expect(btns.map((b) => b.getAttribute('tabindex'))).toEqual(['-1', '0', '-1']);
unmount();
});
it('键盘切换后焦点跟到新选中的 tab(WAI-ARIA:焦点与 selection 同步)', async () => {
const { list, container, unmount } = renderTabs('b');
fireEvent.keyDown(list, { key: 'ArrowRight' });
await flushFrame();
const btns = tabButtons(container);
// 组件是受控的(active 不变则仍渲染旧选中态),但**焦点**已经移到 c
expect(document.activeElement).toBe(btns[2]);
unmount();
});
it('active 指向不存在的 tab ⇒ 键盘处理整体 no-op(不吞默认行为)', () => {
const { list, onChange, unmount } = renderTabs('missing');
const ev = fireEvent.keyDown(list, { key: 'ArrowRight' });
expect(onChange).not.toHaveBeenCalled();
expect(ev).toBe(true); // preventDefault 未被调用 ⇒ 返回 true
unmount();
});
it('单 tab 列表:←/→/Home/End 不 preventDefault、不触发 onChange', () => {
const one = [{ id: 'only', label: 'O' }];
const { list, onChange, unmount } = renderTabs('only', one);
for (const key of ['ArrowLeft', 'ArrowRight', 'Home', 'End']) {
const ev = fireEvent.keyDown(list, { key });
expect(ev, `${key} 不应被 preventDefault 拦下`).toBe(true);
}
expect(onChange).not.toHaveBeenCalled();
unmount();
});
it('非导航键(Enter / 空格 / 普通字符)完全不管', () => {
const { list, onChange, unmount } = renderTabs('b');
for (const key of ['Enter', ' ', 'x', 'Tab']) {
fireEvent.keyDown(list, { key });
}
expect(onChange).not.toHaveBeenCalled();
unmount();
});
it('ARIA 接线:tablist/tab 角色、aria-selected、id 与 aria-controls 配对', () => {
const { container, unmount } = renderTabs('b');
const list = container.querySelector('[role="tablist"]');
expect(list?.getAttribute('aria-label')).toBe('模式');
const btns = tabButtons(container);
expect(btns.map((b) => b.id)).toEqual(['tab-a', 'tab-b', 'tab-c']);
expect(btns.map((b) => b.getAttribute('aria-selected'))).toEqual(['false', 'true', 'false']);
expect(btns.map((b) => b.getAttribute('aria-controls'))).toEqual([
'panel-a',
'panel-b',
'panel-c',
]);
unmount();
});
it('鼠标点击某个 tab ⇒ onChange(该 id)', () => {
const { container, onChange, unmount } = renderTabs('a');
const btns = tabButtons(container);
fireEvent.click(btns[2]);
expect(onChange).toHaveBeenCalledWith('c');
unmount();
});
});