update
This commit is contained in:
156
tests/unit/renderer/components/ui/button-contract.test.tsx
Normal file
156
tests/unit/renderer/components/ui/button-contract.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
195
tests/unit/renderer/components/ui/modal-contract.test.tsx
Normal file
195
tests/unit/renderer/components/ui/modal-contract.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
279
tests/unit/renderer/components/ui/overlay-effects.test.tsx
Normal file
279
tests/unit/renderer/components/ui/overlay-effects.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
168
tests/unit/renderer/components/ui/tabs-keyboard-nav.test.tsx
Normal file
168
tests/unit/renderer/components/ui/tabs-keyboard-nav.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user