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,229 @@
// tests/unit/renderer/components/search/SearchPanel.test.tsx
//
// 守 audit-r33「搜索面板键盘交互」契约
// (src/renderer/src/components/search/SearchPanel.tsx 头注指向本文件,
// 原先标 🔒(守卫缺失);r33 修订后的单一 handler 版本)。
//
// r32 的 split-callback 设计(<input> 上的 handler 把焦点挪到
// <div role="listbox">,再由 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<HTMLElement>('[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(<SearchPanel open onClose={onClose} />);
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);
});
});

View File

@@ -0,0 +1,174 @@
// tests/unit/renderer/components/search/search-panel-slice-consistency.test.tsx
//
// 守 audit-r77「渲染行集合 == 键盘可达集合」契约
// (src/renderer/src/components/search/SearchPanel.tsx 头注指向本文件,
// 原先标 🔒(守卫缺失))。
//
// 旧实现的 bug:section 渲染用的是**未 slice** 的 noteHits / fileHits,
// 而键盘导航用的 flatHits 是 **slice(0, 50)** 之后的集合 —— 两者只在
// 「主进程恰好也返回 50 条」时才相等。一旦主进程 limit 调大:
//
// * 屏幕上多出一批行(section 头还按未截断的 count 虚报),
// * 但 ArrowDown 永远走不到它们(flatHits 只有 50 条)⇒
// 「鼠标能点、键盘到不了」的行,SR 用户(只能靠键盘)直接看不到。
//
// 本测锁的用户可见结果:
// 1. 渲染出来的命中行数 == 面板上限(50),不多不少;
// 2. section 头的 count 与**该 section 实际渲染的行数**一致(不虚报);
// 3. 键盘从第一行一路走到最后,停下的那一行就是**屏幕上最后一行**
// (渲染集 == 可达集);
// 4. 命中数不足上限时,渲染行数 == 命中数(不被误截断)。
//
// 退化后的用户可见后果:
// - 1/3 退化 ⇒ 底部几行鼠标能点、键盘永远到不了;SR 用户无法定位这些结果;
// - 2 退化 ⇒ section 头写着「文献 30」,实际只列了 20 条,用户以为自己看全了。
//
// 变异思路(哪几处改动会红):
// - section 改回用未 slice 的 noteHits / fileHits ⇒ 例 1、2、3 红;
// - MAX_VISIBLE_RESULTS 与渲染集解耦(比如渲染不限量)⇒ 例 1 红;
// - section count 用未截断的数量 ⇒ 例 2 红。
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, fireEvent, screen, cleanup, waitFor } from '@testing-library/react';
// i18n:面板走 useTranslation;section 头走 i18next.t 的 count 插值 ——
// 把 count 一起渲染出来,才能断言「count 与实际行数一致」。
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 MAX_VISIBLE = 50;
function noteHits(n: number): SearchHit[] {
return Array.from({ length: n }, (_, i) => ({
kind: 'note' as const,
id: `note-${i}`,
relPath: `note-${i}.md`,
title: `笔记 ${i}`,
snippet: `片段 ${i}`,
score: 1,
}));
}
function fileHits(n: number): SearchHit[] {
return Array.from({ length: n }, (_, i) => ({
kind: 'file' as const,
id: `file-${i}`,
relPath: `file-${i}.pdf`,
title: `文献 ${i}`,
snippet: `片段 ${i}`,
score: 1,
}));
}
/** 所有渲染出来的命中行(Overlay 走 Portal,行在 document.body 里)。 */
function optionEls(): HTMLElement[] {
return Array.from(document.querySelectorAll<HTMLElement>('[role="option"]'));
}
/** 输入查询词 → 面板 300ms debounce 后才发 IPC → 等结果渲染完。 */
async function search(text: string, hits: SearchHit[]) {
(window as unknown as { api: unknown }).api = {
search: vi.fn().mockResolvedValue({ ok: true, data: hits }),
};
render(<SearchPanel open onClose={() => {}} />);
const input = screen.getByLabelText('search.inputAria') as HTMLInputElement;
fireEvent.change(input, { target: { value: text } });
await waitFor(() => expect(optionEls().length).toBeGreaterThan(0), { timeout: 3000 });
return input;
}
describe('SearchPanel:渲染集 == 键盘可达集(audit-r77)', () => {
beforeEach(() => {
notesSelect.mockClear();
literatureSelect.mockClear();
setActive.mockClear();
requestFocusNote.mockClear();
});
afterEach(() => {
cleanup();
});
it('1. 命中数超过上限时,渲染行数被截到上限(不多渲染)', async () => {
await search('关键词', [...noteHits(30), ...fileHits(30)]);
expect(optionEls()).toHaveLength(MAX_VISIBLE);
});
it('2. section 头的 count 与该 section 实际渲染的行数一致(不虚报)', async () => {
await search('关键词', [...noteHits(30), ...fileHits(30)]);
const noteRows = optionEls().filter((el) => el.id.startsWith('search-hit-note-'));
const fileRows = optionEls().filter((el) => el.id.startsWith('search-hit-file-'));
// 截断后:notes 30 条全留,files 只留前 20 条
expect(noteRows).toHaveLength(30);
expect(fileRows).toHaveLength(20);
const body = document.body.textContent ?? '';
expect(body).toContain('search.sectionNotes:30');
expect(body).toContain('search.sectionLibrary:20');
});
it('3. 键盘走到底,停下的就是屏幕上最后一行(渲染集 == 可达集)', async () => {
const input = await search('关键词', [...noteHits(30), ...fileHits(30)]);
// 一路按到底(远超行数,验证不会越界/绕回)
for (let i = 0; i < MAX_VISIBLE + 20; i++) {
fireEvent.keyDown(input, { key: 'ArrowDown' });
}
const rows = optionEls();
const last = rows[rows.length - 1]!;
// 当前 active 行 = 最后一行
const active = rows.filter((el) => el.getAttribute('aria-selected') === 'true');
expect(active).toHaveLength(1);
expect(active[0]!.id).toBe(last.id);
// 且 input 的 aria-activedescendant 指向这一行
expect(input.getAttribute('aria-activedescendant')).toBe(last.id);
expect(document.getElementById(input.getAttribute('aria-activedescendant')!)).not.toBeNull();
});
it('3b. 每一次 ArrowDown 之后,active 指向的行都真实存在(不悬空)', async () => {
const input = await search('关键词', [...noteHits(30), ...fileHits(30)]);
for (let i = 0; i < MAX_VISIBLE + 5; i++) {
fireEvent.keyDown(input, { key: 'ArrowDown' });
const id = input.getAttribute('aria-activedescendant');
expect(id, 'aria-activedescendant 不能为空').toBeTruthy();
expect(document.getElementById(id!), `第 ${i} 次 ArrowDown 后指向的行必须存在`).not.toBeNull();
}
});
it('4. 命中数不足上限时,渲染行数 == 命中数(不被误截断)', async () => {
await search('关键词', [...noteHits(3), ...fileHits(2)]);
expect(optionEls()).toHaveLength(5);
const body = document.body.textContent ?? '';
expect(body).toContain('search.sectionNotes:3');
expect(body).toContain('search.sectionLibrary:2');
});
});

View File

@@ -0,0 +1,143 @@
// tests/unit/renderer/components/search/search-snippet-highlight.test.tsx
//
// 守 r194「搜索结果预览把 <mark> 标签当普通文字显示」契约。
//
// 现象:主进程 FTS5 的 snippet() 产出的是「命中片段包在 <mark> / </mark>
// 里」的字符串;旧实现直接在结果行渲染 `{hit.snippet}` —— React 把它当
// 纯文本 ⇒ 用户看到的预览是
//
// 本文介绍<mark>学习</mark>模型的基...
//
// 即标签的字面量,而不是高亮。命中预览是搜索面板里最显眼的输出,这个
// 缺陷等于「高亮功能从未生效」。
//
// 本测锁三件事:
// 1. 用户可见结果:命中片段渲染成真正的 <mark> 元素,文本里不再出现
// "<mark>" 字面量;
// 2. 安全:片段正文来自用户写的笔记,渲染路径**不得**把它当 HTML 解析
// (dangerouslySetInnerHTML)—— 笔记里的 "<script>" 只能是文本;
// 3. 边界:缺标记 / 只有半个标记 / 空串都不崩、不丢字。
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, cleanup, waitFor, fireEvent } from '@testing-library/react';
import { SearchSnippet, splitSearchSnippet } from '../../../../../src/renderer/src/components/search/SearchSnippet';
// i18n:面板走 useTranslation / i18next.t —— 只关心结构,不关心文案
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
vi.mock('i18next', () => ({
t: (k: string) => k,
}));
// store:面板只在「点中命中项」时读 store.getState(),渲染期不读 ——
// 这里给最小桩,避免把整个 notes/literature store 的 IPC 副作用拖进测试
vi.mock('../../../../../src/renderer/src/store/notes', () => ({
useNotesStore: { getState: () => ({ select: vi.fn() }) },
}));
vi.mock('../../../../../src/renderer/src/store/literature', () => ({
useLiteratureStore: { getState: () => ({ select: vi.fn() }) },
}));
vi.mock('../../../../../src/renderer/src/store/workspace', () => ({
useWorkspaceStore: { getState: () => ({ setActive: vi.fn(), requestFocusNote: vi.fn() }) },
}));
const MARKED = '本文介绍<mark>学习</mark>模型的基本原理';
describe('splitSearchSnippet(r194)', () => {
it('无标记 → 单个非命中段', () => {
expect(splitSearchSnippet('纯文本')).toEqual([{ text: '纯文本', hit: false }]);
});
it('有标记 → 命中段与上下文段分开', () => {
expect(splitSearchSnippet(MARKED)).toEqual([
{ text: '本文介绍', hit: false },
{ text: '学习', hit: true },
{ text: '模型的基本原理', hit: false },
]);
});
it('空串 → 空数组', () => {
expect(splitSearchSnippet('')).toEqual([]);
});
it('只有开始标记 → 剩余整段算命中,不丢字', () => {
expect(splitSearchSnippet('前缀<mark>尾巴')).toEqual([
{ text: '前缀', hit: false },
{ text: '尾巴', hit: true },
]);
});
it('只有结束标记 → 当普通文本(不吞字)', () => {
expect(splitSearchSnippet('尾巴</mark>')).toEqual([{ text: '尾巴</mark>', hit: false }]);
});
});
describe('SearchSnippet 渲染(r194)', () => {
afterEach(() => {
cleanup();
});
it('命中片段渲染成真正的 <mark> 元素,标签不再字面量出现', () => {
const { container } = render(<SearchSnippet snippet={MARKED} />);
const marks = container.querySelectorAll('mark');
expect(marks).toHaveLength(1);
expect(marks[0]!.textContent).toBe('学习');
// 可见文本 = 原文去掉标记
expect(container.textContent).toBe('本文介绍学习模型的基本原理');
// 字面量不得出现在可见文本里
expect(container.textContent).not.toContain('<mark>');
expect(container.textContent).not.toContain('</mark>');
});
it('笔记正文里的 <script> 只作为文本显示,不被解析成元素', () => {
const nasty = '正文<script>alert(1)</script>结尾<mark>命中</mark>';
const { container } = render(<SearchSnippet snippet={nasty} />);
// 没有任何 script 元素落进 DOM
expect(container.querySelector('script')).toBeNull();
// 内容以文本形式完整保留
expect(container.textContent).toContain('<script>alert(1)</script>');
expect(container.querySelectorAll('mark')).toHaveLength(1);
});
it('空片段 → 不渲染任何内容且不崩', () => {
const { container } = render(<SearchSnippet snippet="" />);
expect(container.textContent).toBe('');
});
});
// === 面板级:用户实际看到的东西 ===
describe('SearchPanel 命中行(r194)', () => {
const hit = {
kind: 'note' as const,
id: 'note-1',
relPath: 'a.md',
title: '深度学习综述',
snippet: MARKED,
score: 1,
};
beforeEach(() => {
(window as unknown as { api: unknown }).api = {
search: vi.fn().mockResolvedValue({ ok: true, data: [hit] }),
};
});
afterEach(() => {
cleanup();
});
it('命中行渲染出 <mark> 元素,预览里没有标签字面量', async () => {
const { SearchPanel } = await import(
'../../../../../src/renderer/src/components/search/SearchPanel'
);
render(<SearchPanel open onClose={() => {}} />);
// 输入查询词(面板 300ms debounce 后才发 IPC)
// Overlay 走 Portal 渲染到 document.body —— input 不在 container 里
const input = document.querySelector('input')!;
fireEvent.change(input, { target: { value: '学习' } });
await waitFor(
() => {
expect(document.querySelectorAll('mark').length).toBeGreaterThan(0);
},
{ timeout: 3000 },
);
expect(document.querySelector('mark')!.textContent).toBe('学习');
// 整个面板的可读文本里都不该出现标签字面量
expect(document.body.textContent).not.toContain('<mark>');
expect(document.body.textContent).not.toContain('</mark>');
});
});