Files
Literature-Management/tests/unit/renderer/components/search/search-snippet-highlight.test.tsx
2026-10-21 14:42:59 +08:00

144 lines
5.8 KiB
TypeScript

// 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>');
});
});