// tests/unit/renderer/components/search/SearchPanel.test.tsx // // 守 audit-r33「搜索面板键盘交互」契约 // (src/renderer/src/components/search/SearchPanel.tsx 头注指向本文件, // 原先标 🔒(守卫缺失);r33 修订后的单一 handler 版本)。 // // r32 的 split-callback 设计( 上的 handler 把焦点挪到 //
,再由 listbox 上的另一份 handler 接管)看似遵循 // a11y 模式,实际违反 WAI-ARIA combobox 规范,并造成**用户可见**故障: // // 用户打完 "react" 按 ↓(焦点被挪到 listbox)→ 再打 "hooks" // ⇒ 字符进不了 input(div 不接受文字输入)⇒ 关键词停在 "react", // 用户以为自己搜的是 "reacthooks",实际搜的是 "react"。 // // 本测锁的契约(全部用户可见): // 1. 焦点**全程**留在 input:ArrowDown / ArrowUp / Enter 之后 // document.activeElement 仍是 input; // 2. 虚拟选中项由 input 的 aria-activedescendant 跟踪, // 对应行 aria-selected="true"; // 3. ArrowDown / ArrowUp 到边界不越界(不悬空、不绕回); // 4. Enter 打开**当前 active** 的命中项: // 笔记 → 选中笔记 + 切到 notes 面板 + 请求聚焦该笔记; // 文献 → 选中该文献(id 走 litResourceId)+ 切到 library 面板; // 两种情况都关闭搜索面板; // 5. IME 组字过程中按 Enter / 方向键**不**当成"打开命中项" // (否则误导航 + 误关面板,关键词还停在 stale 状态); // 6. combobox 语义:input role=combobox + aria-controls 指向真实存在的 // listbox + aria-expanded 随有无结果变化。 // // 退化后的用户可见后果: // - 1 退化 ⇒ 打完词按一下 ↓ 就再也打不出字了(搜索面板不可用); // - 4 退化 ⇒ 回车打开的是第一行而不是用户选中的那一行(跳错文件); // - 5 退化 ⇒ 中文输入法选词(回车上屏)会直接跳走并关掉面板。 // // 变异思路(哪几处改动会红): // - 在 input 的 keydown 里加 listboxRef.current?.focus() ⇒ 例 1 红; // - 把 aria-activedescendant 从 input 挪走 / 删掉 ⇒ 例 2、3 红; // - Enter 用 flatHits[0] 而不是 flatHits[activeIdx] ⇒ 例 4 红; // - 删掉 e.nativeEvent.isComposing 守卫 ⇒ 例 5 红; // - listbox 去掉 id / input 去掉 aria-controls ⇒ 例 6 红。 import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { render, fireEvent, screen, cleanup, waitFor } from '@testing-library/react'; vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k: string) => k }), })); vi.mock('i18next', () => ({ t: (k: string, opts?: { count?: number }) => opts && typeof opts.count === 'number' ? `${k}:${opts.count}` : k, })); const notesSelect = vi.fn(); const literatureSelect = vi.fn(); const setActive = vi.fn(); const requestFocusNote = vi.fn(); vi.mock('../../../../../src/renderer/src/store/notes', () => ({ useNotesStore: { getState: () => ({ select: notesSelect }) }, })); vi.mock('../../../../../src/renderer/src/store/literature', () => ({ useLiteratureStore: { getState: () => ({ select: literatureSelect }) }, })); vi.mock('../../../../../src/renderer/src/store/workspace', () => ({ useWorkspaceStore: { getState: () => ({ setActive, requestFocusNote }), }, })); import { SearchPanel } from '../../../../../src/renderer/src/components/search/SearchPanel'; import type { SearchHit } from '../../../../../src/shared/types'; const NOTE_HIT: SearchHit = { kind: 'note', id: 'note-1', relPath: 'a.md', title: '深度学习综述', snippet: '片段', score: 1, }; const FILE_HIT: SearchHit = { kind: 'file', id: 'file-1', relPath: 'b.pdf', title: '某论文', snippet: '片段', score: 1, }; const NOTE2_HIT: SearchHit = { kind: 'note', id: 'note-2', relPath: 'c.md', title: '另一篇笔记', snippet: '片段', score: 1, }; function optionEls(): HTMLElement[] { return Array.from(document.querySelectorAll('[role="option"]')); } /** 输入查询词 → 300ms debounce → IPC → 结果渲染完。 */ async function openWith(hits: SearchHit[], onClose = vi.fn()) { (window as unknown as { api: unknown }).api = { search: vi.fn().mockResolvedValue({ ok: true, data: hits }), }; render(); const input = screen.getByLabelText('search.inputAria') as HTMLInputElement; fireEvent.change(input, { target: { value: '关键词' } }); await waitFor(() => expect(optionEls().length).toBeGreaterThan(0), { timeout: 3000 }); return { input, onClose }; } describe('SearchPanel:键盘交互(audit-r33 单一 handler 契约)', () => { beforeEach(() => { notesSelect.mockClear(); literatureSelect.mockClear(); setActive.mockClear(); requestFocusNote.mockClear(); }); afterEach(() => { cleanup(); }); it('1. 焦点全程留在 input(方向键 / 回车之后都不挪走)', async () => { const { input } = await openWith([NOTE_HIT, FILE_HIT]); input.focus(); fireEvent.keyDown(input, { key: 'ArrowDown' }); expect(document.activeElement, 'ArrowDown 后焦点必须还在 input').toBe(input); fireEvent.keyDown(input, { key: 'ArrowUp' }); expect(document.activeElement, 'ArrowUp 后焦点必须还在 input').toBe(input); fireEvent.keyDown(input, { key: 'Enter' }); expect(document.activeElement, 'Enter 后焦点必须还在 input').toBe(input); }); it('2. 虚拟选中项由 aria-activedescendant 跟踪,对应行 aria-selected=true', async () => { const { input } = await openWith([NOTE_HIT, FILE_HIT]); expect(input.getAttribute('role')).toBe('combobox'); // 初始 activeIdx = 0 ⇒ 第一行 expect(input.getAttribute('aria-activedescendant')).toBe('search-hit-note-1'); expect(optionEls()[0]!.getAttribute('aria-selected')).toBe('true'); expect(optionEls()[1]!.getAttribute('aria-selected')).toBe('false'); fireEvent.keyDown(input, { key: 'ArrowDown' }); expect(input.getAttribute('aria-activedescendant')).toBe('search-hit-file-1'); expect(optionEls()[1]!.getAttribute('aria-selected')).toBe('true'); expect(optionEls()[0]!.getAttribute('aria-selected')).toBe('false'); }); it('3. 方向键到边界不越界(不悬空、不绕回)', async () => { const { input } = await openWith([NOTE_HIT, FILE_HIT]); // 已在第一行,再按 ArrowUp ⇒ 仍是第一行 fireEvent.keyDown(input, { key: 'ArrowUp' }); fireEvent.keyDown(input, { key: 'ArrowUp' }); expect(input.getAttribute('aria-activedescendant')).toBe('search-hit-note-1'); // 已在最后一行,再按 ArrowDown ⇒ 仍是最后一行 fireEvent.keyDown(input, { key: 'ArrowDown' }); fireEvent.keyDown(input, { key: 'ArrowDown' }); fireEvent.keyDown(input, { key: 'ArrowDown' }); expect(input.getAttribute('aria-activedescendant')).toBe('search-hit-file-1'); expect(document.getElementById('search-hit-file-1')).not.toBeNull(); }); it('4a. Enter 打开当前 active 的**笔记**命中:选中 + 切面板 + 请求聚焦 + 关面板', async () => { // 刻意让 active 落在**第二条**(不是第一条)—— 抓「回车总是打开第一行」的回归 const { input, onClose } = await openWith([NOTE_HIT, NOTE2_HIT, FILE_HIT]); fireEvent.keyDown(input, { key: 'ArrowDown' }); // active = 第二行(另一篇笔记) fireEvent.keyDown(input, { key: 'Enter' }); expect(notesSelect).toHaveBeenCalledWith(NOTE2_HIT.id); expect(notesSelect).not.toHaveBeenCalledWith(NOTE_HIT.id); expect(requestFocusNote).toHaveBeenCalledWith(NOTE2_HIT.id); expect(setActive).toHaveBeenCalledWith('notes'); expect(onClose).toHaveBeenCalledTimes(1); }); it('4b. Enter 打开当前 active 的**文献**命中:按 litResourceId 选中 + 切到 library', async () => { const { input, onClose } = await openWith([NOTE_HIT, NOTE2_HIT, FILE_HIT]); fireEvent.keyDown(input, { key: 'ArrowDown' }); fireEvent.keyDown(input, { key: 'ArrowDown' }); // active = 第三行(file) fireEvent.keyDown(input, { key: 'Enter' }); expect(literatureSelect).toHaveBeenCalledWith('lit:file-1'); expect(setActive).toHaveBeenCalledWith('library'); expect(notesSelect).not.toHaveBeenCalled(); expect(onClose).toHaveBeenCalledTimes(1); }); it('5. IME 组字中按 Enter / 方向键不当作打开命中项(不跳走、不关面板)', async () => { const { input, onClose } = await openWith([NOTE_HIT, FILE_HIT]); // isComposing = true:用户正在用输入法上屏选词 fireEvent.keyDown(input, { key: 'Enter', isComposing: true }); expect(notesSelect).not.toHaveBeenCalled(); expect(literatureSelect).not.toHaveBeenCalled(); expect(onClose, 'IME 组字时回车不得关面板').not.toHaveBeenCalled(); // 方向键同理放行:active 不动 fireEvent.keyDown(input, { key: 'ArrowDown', isComposing: true }); expect(input.getAttribute('aria-activedescendant')).toBe('search-hit-note-1'); }); it('6. combobox 语义:aria-controls 指向真实存在的 listbox,aria-expanded 随结果变化', async () => { const { input } = await openWith([NOTE_HIT, FILE_HIT]); expect(input.getAttribute('aria-controls')).toBe('search-listbox'); const listbox = document.getElementById('search-listbox'); expect(listbox).not.toBeNull(); expect(listbox!.getAttribute('role')).toBe('listbox'); // 有结果 ⇒ 展开 expect(input.getAttribute('aria-expanded')).toBe('true'); // listbox 不抢焦点(不参与 tab 序列) expect(listbox!.hasAttribute('tabindex')).toBe(false); }); it('7. Escape 关闭搜索面板', async () => { const { onClose } = await openWith([NOTE_HIT]); fireEvent.keyDown(document, { key: 'Escape' }); expect(onClose).toHaveBeenCalledTimes(1); }); });