This commit is contained in:
2026-10-21 14:42:59 +08:00
commit 954236d3f9
399 changed files with 85885 additions and 0 deletions

View File

@@ -0,0 +1,156 @@
// tests/unit/renderer/components/ui/button-contract.test.tsx
//
// 守 Button 原语的**行为契约**(src/renderer/src/components/ui/Button.tsx 头注
// 指向本文件,audit-r118 登记 6 例 / 7 变异精确;本轮补齐,原先标 🔒(守卫缺失))。
//
// Button 是全站按钮唯一入口(确认框 / 工具栏 / 错误页 / 设置页),它身上钉着
// 几件**别处不会重复**的语义:
// 1. 默认 type="button" —— HTML <button> 的默认 type 是 "submit",在
// <form> 里点一下会误提交。Button 必须显式兜住。
// 2. loading ⇒ 自动 disabled + aria-busy + 插入 spinner + **摘掉 onClick**
// (r143:防异步动作期间重复点击;spinner 在前、文字不变避免宽度跳变)。
// 3. data-* 透传(r18)—— ConfirmDialog 的 autoFocusSelector 需要用
// querySelector 命中真按钮,故必须透传,不能被 Button 的 prop 白名单吃掉。
// 4. size lg = h-11(r150 a11y:WCAG 2.5.5 AAA 触摸目标 ≥ 44×44)。
//
// 变异思路:每条断言都对着**一个具体会被改坏的点**,不是快照式对比。
import { describe, expect, it, vi } from 'vitest';
import { render, fireEvent } from '@testing-library/react';
import { Button } from '../../../../../src/renderer/src/components/ui/Button';
function renderButton(ui: React.ReactElement) {
const { container, unmount } = render(ui);
const btn = container.querySelector('button');
if (!btn) throw new Error('Button 未渲染出 <button>');
return { btn, container, unmount };
}
describe('Button 契约(audit-r118 补齐)', () => {
it('默认 type="button"(HTML 默认是 submit,在 <form> 里会误提交)', () => {
const { btn, unmount } = renderButton(<Button>ok</Button>);
expect(btn.getAttribute('type')).toBe('button');
unmount();
});
it('loading ⇒ disabled + aria-busy + spinner + 摘掉 onClick', () => {
const onClick = vi.fn();
const { btn, container, unmount } = renderButton(
<Button loading onClick={onClick}>
保存
</Button>,
);
expect(btn.disabled, 'loading 期间必须禁用').toBe(true);
expect(btn.getAttribute('aria-busy')).toBe('true');
// 文字不变 + spinner 在前(宽度不跳变)
expect(btn.textContent).toContain('保存');
const svg = container.querySelector('svg');
expect(svg, 'loading 时应有 Loader2 图标').not.toBeNull();
expect(svg?.getAttribute('aria-hidden')).toBe('true');
expect(svg?.getAttribute('class')).toContain('animate-spin');
// 视觉:不可点 cursor + 轻微透明
expect(btn.className).toContain('cursor-not-allowed');
// disabled 按钮的 click 由浏览器/React 双重挡住 ⇒ onClick 不得触发
fireEvent.click(btn);
expect(onClick).not.toHaveBeenCalled();
unmount();
});
it('loading 覆盖 disabled 的语义(两者都传也只 disabled 一次,不报错)', () => {
const { btn, unmount } = renderButton(
<Button loading disabled>
x
</Button>,
);
expect(btn.disabled).toBe(true);
expect(btn.getAttribute('aria-busy')).toBe('true');
unmount();
});
it('非 loading 时 disabled ⇒ 点击不触发 onClick;非 disabled ⇒ 触发一次', () => {
const onClick = vi.fn();
const { btn, unmount } = renderButton(
<Button disabled onClick={onClick}>
x
</Button>,
);
fireEvent.click(btn);
expect(onClick).not.toHaveBeenCalled();
unmount();
const onClick2 = vi.fn();
const { btn: btn2, unmount: unmount2 } = renderButton(
<Button onClick={onClick2}>x</Button>,
);
fireEvent.click(btn2);
expect(onClick2).toHaveBeenCalledTimes(1);
unmount2();
});
it('data-* 透传(r18:ConfirmDialog 的 autoFocusSelector 要靠它命中真按钮)', () => {
const { btn, unmount } = renderButton(
<Button data-testid="confirm-ok" data-confirm-role="primary">
确认
</Button>,
);
expect(btn.getAttribute('data-testid')).toBe('confirm-ok');
expect(btn.getAttribute('data-confirm-role')).toBe('primary');
unmount();
});
it('非 data-* 的多余 prop 不落到 DOM(rest 白名单外一律丢弃)', () => {
const { btn, unmount } = renderButton(
<Button {...({ nonsense: 'x' } as Record<string, unknown>)}>y</Button>,
);
expect(btn.getAttribute('nonsense')).toBeNull();
unmount();
});
it('size 档位类名映射(xs/sm/md/lg,lg = h-11 的 44px 触摸目标)', () => {
const cases: [ 'xs' | 'sm' | 'md' | 'lg', string ][] = [
['xs', 'h-6'],
['sm', 'h-[26px]'],
['md', 'h-[30px]'],
['lg', 'h-11'],
];
for (const [size, expectCls] of cases) {
const { btn, unmount } = renderButton(<Button size={size}>z</Button>);
expect(btn.className, `size=${size} 应带 ${expectCls}`).toContain(expectCls);
unmount();
}
});
it('variant 档位类名映射(primary/secondary/ghost/danger)+ 默认 secondary', () => {
const variants = ['primary', 'secondary', 'ghost', 'danger'] as const;
for (const v of variants) {
const { btn, unmount } = renderButton(<Button variant={v}>z</Button>);
expect(btn.className, `variant=${v} 应带 btn-${v}`).toContain(`btn-${v}`);
unmount();
}
const { btn, unmount } = renderButton(<Button>z</Button>);
expect(btn.className, '默认 variant 必须是 secondary').toContain('btn-secondary');
unmount();
});
it('aria-label / title 透传(缺省时属性不存在,不留空串)', () => {
const { btn, unmount } = renderButton(
<Button aria-label="关闭面板" title="tooltip">
z
</Button>,
);
expect(btn.getAttribute('aria-label')).toBe('关闭面板');
expect(btn.getAttribute('title')).toBe('tooltip');
unmount();
const { btn: bare, unmount: unmount2 } = renderButton(<Button>z</Button>);
expect(bare.getAttribute('aria-label')).toBeNull();
expect(bare.getAttribute('title')).toBeNull();
unmount2();
});
it('className 由调用方追加(不覆盖内置档位类)', () => {
const { btn, unmount } = renderButton(<Button className="my-cls">z</Button>);
expect(btn.className).toContain('my-cls');
expect(btn.className).toContain('btn');
unmount();
});
});

View File

@@ -0,0 +1,69 @@
// 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);
});
});

View File

@@ -0,0 +1,195 @@
// tests/unit/renderer/components/ui/modal-contract.test.tsx
//
// 守 Modal 原语的**对话框语义契约**(src/renderer/src/components/ui/Modal.tsx
// 头注指向本文件,audit-r117 登记,本轮补齐 —— 原先标 🔒(守卫缺失))。
//
// Modal 是全站唯一「role=dialog」的模态壳(确认框 / 重命名 / 文献详情都用它),
// 它身上钉着几件**语义载体**属性,错了 SR 用户直接失能:
// 1. role="dialog" + aria-modal="true" —— SR 才知道这是对话框并把背景
// 判为惰性;
// 2. aria-labelledby 指向标题 h2 —— SR 念的是「对话框标题」而不是
// 整段正文;
// 3. description ⇒ aria-describedby + 一个含说明的节点 —— **最关键**:
// 「此操作不可撤销」「.md 扩展名会自动追加」这类后果说明,SR 用户
// 光听标题听不到,只写在视觉上等于没写;
// 4. dismissible=false ⇒ **不渲染**右上角 ✕ —— 「点不动的假按钮」
// 比「没有按钮」更像 bug(audit 2026-10-04 MED);
// 5. autoFocusSelector / initialFocusRef 必须**真的转发**给 Overlay
// (r124:此前 Modal 壳根本没接收这两个 prop,Overlay 头注的承诺悬空,
// 导致 NotesView rename 弹窗的 autoFocus 被 Overlay 兜底 focus 偷走)。
//
// 变异思路:去掉 aria-describedby 接线 / 去掉 dismissible 分支 / 不转发
// initialFocusRef / description 传 ReactNode 时包错标签 —— 逐条会红。
import { describe, expect, it, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, act } from '@testing-library/react';
import { useRef } from 'react';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
import { Modal } from '../../../../../src/renderer/src/components/ui/Modal';
async function flushFocus() {
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
}
afterEach(() => {
cleanup();
});
function renderModal(ui: React.ReactElement) {
const { container, unmount } = render(ui);
return { container, unmount };
}
describe('Modal 对话框语义契约(audit-r117 补齐)', () => {
it('open=false ⇒ 什么都不渲染', () => {
renderModal(
<Modal open={false} onClose={() => {}} title="T">
body
</Modal>,
);
expect(document.querySelector('[role="dialog"]')).toBeNull();
});
it('role=dialog + aria-modal=true + aria-labelledby 指向标题 h2', () => {
renderModal(
<Modal open onClose={() => {}} title="重命名笔记">
body
</Modal>,
);
const dialog = screen.getByRole('dialog');
expect(dialog.getAttribute('aria-modal')).toBe('true');
const labelledBy = dialog.getAttribute('aria-labelledby');
expect(labelledBy, 'aria-labelledby 不得为空').toBeTruthy();
const title = document.getElementById(labelledBy!);
expect(title?.textContent).toBe('重命名笔记');
expect(title?.tagName).toBe('H2');
});
it('description 传 string ⇒ 自动包 <p> 并接上 aria-describedby', () => {
renderModal(
<Modal open onClose={() => {}} title="T" description="此操作不可撤销">
body
</Modal>,
);
const dialog = screen.getByRole('dialog');
const descId = dialog.getAttribute('aria-describedby');
expect(descId, '有 description 时必须挂 aria-describedby').toBeTruthy();
const desc = document.getElementById(descId!);
expect(desc?.textContent).toBe('此操作不可撤销');
expect(desc?.tagName, 'string 形态必须包成 <p>').toBe('P');
});
it('description 传 ReactNode ⇒ 包 <div> 渲染(不强制 <p>)', () => {
renderModal(
<Modal
open
onClose={() => {}}
title="T"
description={<strong data-testid="rich">重点</strong>}
>
body
</Modal>,
);
const dialog = screen.getByRole('dialog');
const descId = dialog.getAttribute('aria-describedby');
const desc = document.getElementById(descId!);
expect(desc?.tagName).toBe('DIV');
expect(desc?.querySelector('[data-testid="rich"]')).not.toBeNull();
});
it('不传 description ⇒ 不挂 aria-describedby(别让 SR 念空串)', () => {
renderModal(
<Modal open onClose={() => {}} title="T">
body
</Modal>,
);
expect(screen.getByRole('dialog').getAttribute('aria-describedby')).toBeNull();
});
it('dismissible 默认 true ⇒ 渲染右上角关闭按钮,点它调 onClose', () => {
const onClose = vi.fn();
renderModal(
<Modal open onClose={onClose} title="T">
body
</Modal>,
);
const btn = screen.getByRole('button', { name: 'common.close' });
fireEvent.click(btn);
expect(onClose).toHaveBeenCalledTimes(1);
});
it('dismissible=false ⇒ 不渲染关闭按钮(不装一个点了没反应的假控件)', () => {
renderModal(
<Modal open onClose={() => {}} title="T" dismissible={false}>
body
</Modal>,
);
expect(screen.queryByRole('button', { name: 'common.close' })).toBeNull();
});
it('footer 传了才渲染底部条', () => {
renderModal(
<Modal open onClose={() => {}} title="T" footer={<button>确定</button>}>
body
</Modal>,
);
expect(screen.getByRole('button', { name: '确定' })).toBeTruthy();
cleanup();
renderModal(
<Modal open onClose={() => {}} title="T">
body
</Modal>,
);
expect(screen.queryByRole('button', { name: '确定' })).toBeNull();
});
it('initialFocusRef 被转发给 Overlay(焦点落在指定元素,不被面板兜底偷走)', async () => {
function Case() {
const ref = useRef<HTMLInputElement>(null);
return (
<Modal open onClose={() => {}} title="T" initialFocusRef={ref}>
<input ref={ref} data-testid="target" />
</Modal>
);
}
render(<Case />);
await flushFocus();
expect(document.activeElement).toBe(screen.getByTestId('target'));
});
it('autoFocusSelector 被转发给 Overlay', async () => {
render(
<Modal open onClose={() => {}} title="T" autoFocusSelector="[data-auto]">
<button data-auto data-testid="auto">
auto
</button>
</Modal>,
);
await flushFocus();
expect(document.activeElement).toBe(screen.getByTestId('auto'));
});
it('width prop 落到面板 className(默认 max-w-xl)', () => {
const { unmount } = renderModal(
<Modal open onClose={() => {}} title="T">
body
</Modal>,
);
expect(screen.getByRole('dialog').className).toContain('max-w-xl');
unmount();
const { unmount: u2 } = renderModal(
<Modal open onClose={() => {}} title="T" width="max-w-3xl">
body
</Modal>,
);
expect(screen.getByRole('dialog').className).toContain('max-w-3xl');
u2();
});
});

View File

@@ -0,0 +1,279 @@
// tests/unit/renderer/components/ui/overlay-effects.test.tsx
//
// 守 Overlay 的**六项副作用契约**(src/renderer/src/components/ui/Overlay.tsx
// 头注指向本文件,audit-r115 登记「六项承诺逐条 11 例 + 9 变异验红」,
// 本轮补齐 —— 原先标 🔒(守卫缺失))。
//
// Overlay 是 Modal / ConfirmDialog / Drawer 的**唯一**副作用容器 ——
// 「打开就挡住整个视口」这件事的六个副作用全在这里,别处不重复实现。
// 任何一条退化,后果都是**用户可见**的:
//
// 1. Portal 到 body —— 否则 transform/opacity 上下文污染,
// 面板的 fixed 定位相对错误的祖先算;
// 2. body 滚动锁 —— 背景能滚 ⇒ 弹窗期间页面在动,用户失去方位;
// 3. ESC 关闭(仅栈顶) —— 多层弹层时一次 ESC 只应关最上面那层;
// 4. 进入移焦 / 关闭还焦—— 键盘用户 Tab 进弹窗后焦点仍在背景,
// 关闭后焦点丢失(落 body)⇒ 下一步操作失向;
// 5. Tab 焦点陷阱 —— Tab 跑出弹窗 = 焦点跑到被遮住的内容上;
// 6. 遮罩点击关闭 —— 且**只**在点到遮罩本身时关(点到面板内不关)。
//
// 变异思路:去掉 portal / 去掉滚动锁 / 去掉 isTop 判定 / 去掉焦点归还 /
// 去掉 trapFocus 分支 / 去掉 e.target===e.currentTarget 判定 —— 逐条会红。
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, act } from '@testing-library/react';
import { useRef } from 'react';
import {
Overlay,
pushOverlayLayer,
removeOverlayLayer,
isOverlayLayerTop,
} from '../../../../../src/renderer/src/components/ui/Overlay';
/** Overlay 内部用 window.setTimeout(0) 做「等面板渲染完再 focus」。 */
async function flushFocus() {
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
}
afterEach(() => {
cleanup();
document.body.style.overflow = '';
document.body.style.paddingRight = '';
});
describe('Overlay 六项副作用契约(audit-r115 补齐)', () => {
it('1. Portal 到 body(面板不在 React 容器里)', () => {
const { container } = render(
<Overlay open onClose={() => {}}>
<div data-testid="panel">panel</div>
</Overlay>,
);
expect(container.querySelector('[data-testid="panel"]')).toBeNull();
expect(document.body.querySelector('[data-testid="panel"]')).not.toBeNull();
});
it('2. 打开时锁 body 滚动 + 补偿滚动条宽度;关闭后原样还原', () => {
const { rerender } = render(
<Overlay open onClose={() => {}}>
<div>p</div>
</Overlay>,
);
expect(document.body.style.overflow).toBe('hidden');
rerender(
<Overlay open={false} onClose={() => {}}>
<div>p</div>
</Overlay>,
);
expect(document.body.style.overflow).toBe('');
expect(document.body.style.paddingRight).toBe('');
});
it('3. ESC 关闭;closeOnEscape=false 时不响应', () => {
const onClose = vi.fn();
render(
<Overlay open onClose={onClose}>
<div>p</div>
</Overlay>,
);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
const onClose2 = vi.fn();
render(
<Overlay open onClose={onClose2} closeOnEscape={false}>
<div>q</div>
</Overlay>,
);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose2).not.toHaveBeenCalled();
});
it('3b. 多层弹层:ESC 只关栈顶那层(不是一次全关)', () => {
const closeOuter = vi.fn();
const closeInner = vi.fn();
render(
<Overlay open onClose={closeOuter}>
<div>
<Overlay open onClose={closeInner}>
<div>inner</div>
</Overlay>
</div>
</Overlay>,
);
fireEvent.keyDown(document, { key: 'Escape' });
expect(closeInner).toHaveBeenCalledTimes(1);
expect(closeOuter).not.toHaveBeenCalled();
});
it('4. 打开后焦点移入面板;关闭后焦点归还给打开前的元素', async () => {
const trigger = document.createElement('button');
document.body.appendChild(trigger);
trigger.focus();
expect(document.activeElement).toBe(trigger);
const { rerender } = render(
<Overlay open onClose={() => {}}>
<div data-testid="panel">p</div>
</Overlay>,
);
await flushFocus();
const panel = document.querySelector('[data-testid="panel"]')!;
// 面板容器(Overlay 自己的 panel div)拿到焦点
expect(document.activeElement).not.toBe(trigger);
expect(panel.parentElement?.contains(document.activeElement)).toBe(true);
rerender(
<Overlay open={false} onClose={() => {}}>
<div data-testid="panel">p</div>
</Overlay>,
);
await flushFocus();
expect(document.activeElement).toBe(trigger);
trigger.remove();
});
it('4b. initialFocusRef 优先于 autoFocusSelector,两者都优先于面板兜底', async () => {
function Case({ mode }: { mode: 'ref' | 'selector' | 'none' }) {
const ref = useRef<HTMLButtonElement>(null);
return (
<Overlay
open
onClose={() => {}}
initialFocusRef={mode === 'ref' ? ref : undefined}
autoFocusSelector={mode === 'selector' ? '[data-auto]' : undefined}
>
<button ref={ref} data-testid="by-ref">
ref
</button>
<button data-auto data-testid="by-selector">
selector
</button>
</Overlay>
);
}
const { unmount } = render(<Case mode="ref" />);
await flushFocus();
expect(document.activeElement).toBe(screen.getByTestId('by-ref'));
unmount();
const { unmount: unmount2 } = render(<Case mode="selector" />);
await flushFocus();
expect(document.activeElement).toBe(screen.getByTestId('by-selector'));
unmount2();
});
it('5. Tab 焦点陷阱:焦点在面板末尾时 Tab 回到面板首个可聚焦元素', async () => {
render(
<Overlay open onClose={() => {}}>
<button data-testid="first">1</button>
<button data-testid="last">2</button>
</Overlay>,
);
await flushFocus();
const last = screen.getByTestId('last');
last.focus();
expect(document.activeElement).toBe(last);
const ev = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
document.dispatchEvent(ev);
expect(ev.defaultPrevented, '焦点在末尾时 Tab 必须被拦下').toBe(true);
expect(document.activeElement).toBe(screen.getByTestId('first'));
});
it('5b. Shift+Tab 在面板首个元素时绕到末尾', async () => {
render(
<Overlay open onClose={() => {}}>
<button data-testid="first">1</button>
<button data-testid="last">2</button>
</Overlay>,
);
await flushFocus();
screen.getByTestId('first').focus();
const ev = new KeyboardEvent('keydown', {
key: 'Tab',
shiftKey: true,
bubbles: true,
cancelable: true,
});
document.dispatchEvent(ev);
expect(ev.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(screen.getByTestId('last'));
});
it('5c. trapFocus=false ⇒ 不拦 Tab', async () => {
render(
<Overlay open onClose={() => {}} trapFocus={false}>
<button data-testid="first">1</button>
</Overlay>,
);
await flushFocus();
const ev = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
document.dispatchEvent(ev);
expect(ev.defaultPrevented).toBe(false);
});
it('6. 点遮罩关闭;点面板内部不关', () => {
const onClose = vi.fn();
render(
<Overlay open onClose={onClose}>
<div data-testid="panel">p</div>
</Overlay>,
);
const scrim = document.querySelector('[data-testid="panel"]')!.parentElement!.parentElement!;
fireEvent.click(scrim);
expect(onClose).toHaveBeenCalledTimes(1);
onClose.mockClear();
fireEvent.click(screen.getByTestId('panel'));
expect(onClose).not.toHaveBeenCalled();
});
it('6b. closeOnBackdrop=false ⇒ 点遮罩不关', () => {
const onClose = vi.fn();
render(
<Overlay open onClose={onClose} closeOnBackdrop={false}>
<div data-testid="panel">p</div>
</Overlay>,
);
const scrim = document.querySelector('[data-testid="panel"]')!.parentElement!.parentElement!;
fireEvent.click(scrim);
expect(onClose).not.toHaveBeenCalled();
});
it('open=false ⇒ 什么都不渲染(且不锁滚动)', () => {
render(
<Overlay open={false} onClose={() => {}}>
<div data-testid="panel">p</div>
</Overlay>,
);
expect(document.querySelector('[data-testid="panel"]')).toBeNull();
expect(document.body.style.overflow).toBe('');
});
});
describe('Overlay 栈 API(r80:ContextMenu 接入同一栈)', () => {
beforeEach(() => {
// 每个用例从空栈开始 —— 栈是模块级状态,跨用例会串。
// push/remove 走公开 API 清理。
for (let i = 0; i < 10; i++) removeOverlayLayer(Symbol('probe'));
});
it('push → 自己是栈顶;再 push 一个则前者不是栈顶', () => {
const a = Symbol('a');
const b = Symbol('b');
pushOverlayLayer(a);
expect(isOverlayLayerTop(a)).toBe(true);
pushOverlayLayer(b);
expect(isOverlayLayerTop(b)).toBe(true);
expect(isOverlayLayerTop(a)).toBe(false);
removeOverlayLayer(b);
expect(isOverlayLayerTop(a)).toBe(true);
removeOverlayLayer(a);
});
it('remove 一个不在栈里的 id 不抛错(幂等)', () => {
const a = Symbol('a');
expect(() => removeOverlayLayer(a)).not.toThrow();
});
});

View File

@@ -0,0 +1,168 @@
// 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();
});
});