update
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
// tests/unit/renderer/components/codemirror-history-semantics.test.ts
|
||||
//
|
||||
// 守「笔记撤销 / 重做」所依赖的 **CodeMirror 6 真实语义**(用真库,不用假实现)。
|
||||
//
|
||||
// 为什么要有这层:markdown-editor-history.test.tsx 里 CodeMirror 是被 mock 掉的,
|
||||
// 它只能证明「我们的代码传了正确的参数」;证明不了「这些参数在真 CodeMirror 上
|
||||
// 真的产生我们想要的结果」。若哪天 CodeMirror 改了 history 的语义(例如
|
||||
// addToHistory:false 不再清栈 / setState 不再重置 history),mock 版测试照样全绿,
|
||||
// 而真机上会悄悄退化成「跨笔记撤销污染」。这层就是那个沉默退化的警报器。
|
||||
//
|
||||
// 锁的三条真实语义(本轮实现的前提):
|
||||
// 1. history 是挂在 EditorState 上的 StateField ⇒ view.setState(新 state)
|
||||
// ⇒ 撤销栈**归零**(这是「切笔记清空撤销栈」唯一可靠的实现方式);
|
||||
// 2. 整段替换 + Transaction.addToHistory.of(false) ⇒ 外部灌入的正文不进栈;
|
||||
// 3. 关键场景:笔记 A 里敲了字 → 切到笔记 B(setState)→ 在 B 里按撤销
|
||||
// ⇒ **撤不了任何东西、也不能把 A 的字撤回来**(跨笔记污染 = 数据事故:
|
||||
// 撤回来的 A 内容会被 autosave 写进 B)。
|
||||
//
|
||||
// 变异思路(哪几处改动会红):
|
||||
// - 把「切文档」从 setState 改成普通 dispatch ⇒ 例 3 红(B 里 undo 会撤掉 A 的字);
|
||||
// - 去掉 addToHistory.of(false) ⇒ 例 2 红;
|
||||
// - 断言写反(把「栈归零」写成「栈保留」)⇒ 例 1、例 2 红。
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { EditorState, Transaction } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { history, undoDepth, redoDepth, undo, redo } from '@codemirror/commands';
|
||||
|
||||
/** 造一个挂载在 jsdom 里的真 CodeMirror 视图。 */
|
||||
function mount(doc: string): EditorView {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
return new EditorView({
|
||||
state: EditorState.create({ doc, extensions: [history()] }),
|
||||
parent: host,
|
||||
});
|
||||
}
|
||||
|
||||
/** 模拟一次用户击键(带 userEvent,CodeMirror 才会把它记进撤销栈)。 */
|
||||
function type(view: EditorView, text: string): void {
|
||||
view.dispatch({ changes: { from: view.state.doc.length, insert: text }, userEvent: 'input.type' });
|
||||
}
|
||||
|
||||
describe('CodeMirror 6 真实语义:撤销栈', () => {
|
||||
it('1. setState(新 state)⇒ 撤销栈归零 + 正文/DOM 换成新文档', () => {
|
||||
const view = mount('第一篇');
|
||||
type(view, '改过了');
|
||||
expect(undoDepth(view.state), '敲字后有 1 层可撤').toBe(1);
|
||||
|
||||
view.setState(EditorState.create({ doc: '第二篇', extensions: [history()] }));
|
||||
|
||||
expect(view.state.doc.toString(), '正文换成新文档').toBe('第二篇');
|
||||
expect(undoDepth(view.state), '撤销栈必须归零').toBe(0);
|
||||
expect(redoDepth(view.state), '重做栈也必须归零').toBe(0);
|
||||
expect(view.dom.textContent, 'DOM 显示的也是新正文').toContain('第二篇');
|
||||
expect(view.contentDOM, 'contentDOM 仍在(焦点/光标依赖它)').toBeTruthy();
|
||||
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('2. 整段替换 + addToHistory:false ⇒ 外部灌入的正文不进撤销栈', () => {
|
||||
const view = mount('abc');
|
||||
type(view, 'X');
|
||||
expect(undoDepth(view.state)).toBe(1);
|
||||
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: 'ZZZ' },
|
||||
annotations: Transaction.addToHistory.of(false),
|
||||
});
|
||||
|
||||
expect(view.state.doc.toString()).toBe('ZZZ');
|
||||
expect(undoDepth(view.state), '外部替换后旧事件被 mapping 吃掉 ⇒ 栈空').toBe(0);
|
||||
expect(undo(view), '没有可撤的东西时 undo 命令返回 false').toBe(false);
|
||||
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('3. 跨笔记:在 A 里敲的字,切到 B 之后按撤销撤不回来(数据污染防线)', () => {
|
||||
const view = mount('A 的正文');
|
||||
type(view, '!!!');
|
||||
expect(view.state.doc.toString()).toBe('A 的正文!!!');
|
||||
expect(undoDepth(view.state)).toBe(1);
|
||||
|
||||
// 切到笔记 B:重建 state(本轮实现里由 docToken 变化触发)
|
||||
view.setState(EditorState.create({ doc: 'B 的正文', extensions: [history()] }));
|
||||
expect(view.state.doc.toString()).toBe('B 的正文');
|
||||
|
||||
// 在 B 里按撤销 ⇒ 什么也撤不了,B 的正文一个字都不能少
|
||||
expect(undo(view), 'B 里没有可撤的编辑 ⇒ undo 返回 false').toBe(false);
|
||||
expect(view.state.doc.toString(), 'B 的正文未被 A 的编辑污染').toBe('B 的正文');
|
||||
|
||||
// B 里的新编辑可以正常撤销(撤销功能本身没被 setState 弄坏)
|
||||
type(view, '?');
|
||||
expect(undoDepth(view.state)).toBe(1);
|
||||
expect(undo(view)).toBe(true);
|
||||
expect(view.state.doc.toString()).toBe('B 的正文');
|
||||
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('4. undo / redo 命令真实生效(undo 改回正文、redo 再改回来)', () => {
|
||||
const view = mount('abc');
|
||||
type(view, 'X');
|
||||
|
||||
expect(undo(view)).toBe(true);
|
||||
expect(view.state.doc.toString()).toBe('abc');
|
||||
expect(redoDepth(view.state), '撤销后有 1 层可重做').toBe(1);
|
||||
|
||||
expect(redo(view)).toBe(true);
|
||||
expect(view.state.doc.toString()).toBe('abcX');
|
||||
|
||||
view.destroy();
|
||||
});
|
||||
});
|
||||
144
tests/unit/renderer/components/folder-row-interactions.test.tsx
Normal file
144
tests/unit/renderer/components/folder-row-interactions.test.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
// tests/unit/renderer/components/folder-row-interactions.test.tsx
|
||||
//
|
||||
// 守 r174「文件夹行是死的」契约 —— FolderRowOption 此前是 Phase 1 stub:
|
||||
// 声明了 onContextMenu / onContextMenuAt / onHover 三个 prop 却一个都没用,
|
||||
// 并且 draggable={false} 让浏览器根本不发起原生 drag。后果:
|
||||
// - 右键文件夹 → 菜单永远不弹(NotesList / LiteratureList 里那套
|
||||
// folderMenuItems:剪切 / 粘贴 / 移到根目录 / 删除 全都够不着);
|
||||
// - hover 文件夹 → activeId 不跟手(SR 的 aria-activedescendant 落在
|
||||
// 别的行上);
|
||||
// - 拖文件夹 → r173 接好的 folders-move-dir 链路永远收不到载荷。
|
||||
//
|
||||
// 本测是**行为级** guard:不锁实现细节,只锁「用户做的动作真的到达了
|
||||
// 父级回调」。父级接线(坐标 vs 事件)另由 NotesList / LiteratureList 侧
|
||||
// 负责。
|
||||
//
|
||||
// r176(树状改造)追加:a11y 语义升格 —— 侧栏从 listbox/option 变成
|
||||
// tree/treeitem,aria-level / aria-expanded 必须在行上,否则读屏器
|
||||
// 报「树里没有层级」;缩进也必须真的按 depth 走(层级感是树的核心
|
||||
// 视觉线索)。另 `onOpen` 改名 `onActivate`(语义从「钻入」变成
|
||||
// 「设为落点 + 切换展开」)。
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { render, fireEvent, cleanup } from '@testing-library/react';
|
||||
import { afterEach } from 'vitest';
|
||||
import { FolderRowOption } from '../../../../src/renderer/src/components/common/FolderChrome';
|
||||
import type { DiskFolderNode } from '../../../../src/shared/types';
|
||||
|
||||
// i18n:组件内 FolderNameInput / aria 走 useTranslation —— 测试里只关心
|
||||
// 交互,不关心文案,直接 mock 成 key 原样返回。
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
const folder: DiskFolderNode = { relPath: 'a/b', name: 'b', parentRelPath: 'a' };
|
||||
|
||||
function renderRow(overrides: Partial<React.ComponentProps<typeof FolderRowOption>> = {}) {
|
||||
const props = {
|
||||
folder,
|
||||
domId: 'note-row-folder-a/b',
|
||||
isActive: false,
|
||||
isCut: false,
|
||||
isDropHot: false,
|
||||
isRenaming: false,
|
||||
depth: 1,
|
||||
expanded: false,
|
||||
hasChildren: true,
|
||||
isTarget: false,
|
||||
posInSet: 1,
|
||||
setSize: 3,
|
||||
onActivate: vi.fn(),
|
||||
onContextMenu: vi.fn(),
|
||||
onContextMenuAt: vi.fn(),
|
||||
onHover: vi.fn(),
|
||||
onDragStart: vi.fn(),
|
||||
onDragOver: vi.fn(),
|
||||
onDragLeave: vi.fn(),
|
||||
onDrop: vi.fn(),
|
||||
onCommitRename: vi.fn().mockResolvedValue(true),
|
||||
onCancelRename: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
const utils = render(<FolderRowOption {...props} />);
|
||||
const row = utils.container.querySelector('[data-folder-relpath]') as HTMLElement;
|
||||
return { ...utils, row, props };
|
||||
}
|
||||
|
||||
describe('FolderRowOption 交互接线(r174)', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('右键文件夹 → 父级 onContextMenu 收到 (folder, event)', () => {
|
||||
const { row, props } = renderRow();
|
||||
fireEvent.contextMenu(row, { clientX: 12, clientY: 34 });
|
||||
expect(props.onContextMenu).toHaveBeenCalledTimes(1);
|
||||
const [f, ev] = (props.onContextMenu as ReturnType<typeof vi.fn>).mock.calls[0]!;
|
||||
expect(f).toBe(folder);
|
||||
expect((ev as MouseEvent).clientX).toBe(12);
|
||||
});
|
||||
|
||||
it('hover 文件夹 → 父级 onHover 收到 folder', () => {
|
||||
const { row, props } = renderRow();
|
||||
fireEvent.mouseEnter(row);
|
||||
expect(props.onHover).toHaveBeenCalledWith(folder);
|
||||
});
|
||||
|
||||
it('文件夹行 draggable=true,dragstart 真的到达父级', () => {
|
||||
const { row, props } = renderRow();
|
||||
expect(row.getAttribute('draggable')).toBe('true');
|
||||
fireEvent.dragStart(row);
|
||||
expect(props.onDragStart).toHaveBeenCalledTimes(1);
|
||||
expect((props.onDragStart as ReturnType<typeof vi.fn>).mock.calls[0]![0]).toBe(folder);
|
||||
});
|
||||
|
||||
it('renaming 态 draggable=false(别拖 input)', () => {
|
||||
const { row } = renderRow({ isRenaming: true });
|
||||
expect(row.getAttribute('draggable')).toBe('false');
|
||||
});
|
||||
|
||||
it('Shift+F10 → 父级 onContextMenuAt 收到坐标', () => {
|
||||
const { row, props } = renderRow();
|
||||
fireEvent.keyDown(row, { key: 'F10', shiftKey: true });
|
||||
expect(props.onContextMenuAt).toHaveBeenCalledTimes(1);
|
||||
const [f, x, y] = (props.onContextMenuAt as ReturnType<typeof vi.fn>).mock.calls[0]!;
|
||||
expect(f).toBe(folder);
|
||||
expect(typeof x).toBe('number');
|
||||
expect(typeof y).toBe('number');
|
||||
});
|
||||
|
||||
it('Enter → 父级 onActivate 收到 folder', () => {
|
||||
const { row, props } = renderRow();
|
||||
fireEvent.keyDown(row, { key: 'Enter' });
|
||||
expect(props.onActivate).toHaveBeenCalledWith(folder);
|
||||
});
|
||||
|
||||
// === r176:树语义 ===
|
||||
|
||||
it('行是 treeitem(不是 option)—— aria-level / aria-expanded 只在 treeitem 上有效', () => {
|
||||
const { row } = renderRow({ depth: 2, expanded: true, hasChildren: true });
|
||||
expect(row.getAttribute('role')).toBe('treeitem');
|
||||
expect(row.getAttribute('aria-level')).toBe('3'); // depth + 1
|
||||
expect(row.getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('叶子文件夹(无子节点)不带 aria-expanded —— 免得读屏 announce 一个不存在的展开态', () => {
|
||||
const { row } = renderRow({ hasChildren: false, expanded: false });
|
||||
expect(row.hasAttribute('aria-expanded')).toBe(false);
|
||||
});
|
||||
|
||||
it('缩进按 depth 递增(层级感是树的核心视觉线索)', () => {
|
||||
const { row: d0 } = renderRow({ depth: 0 });
|
||||
const { row: d2 } = renderRow({ depth: 2 });
|
||||
expect(d0.style.paddingLeft).toBe('12px');
|
||||
expect(d2.style.paddingLeft).toBe('36px'); // 12 + 2*12
|
||||
});
|
||||
|
||||
it('落点文件夹(isTarget)带 list-row-tree-target 类(与主选中态区分的视觉)', () => {
|
||||
const { row } = renderRow({ isTarget: true });
|
||||
expect(row.className).toContain('list-row-tree-target');
|
||||
});
|
||||
});
|
||||
89
tests/unit/renderer/components/import-no-auto-open.test.ts
Normal file
89
tests/unit/renderer/components/import-no-auto-open.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// tests/unit/renderer/components/import-no-auto-open.test.ts
|
||||
//
|
||||
// 守卫(r177):「导入文件后不应该马上自动打开」。
|
||||
//
|
||||
// 旧行为(notes 侧 audit-r22 引入的 macOS Finder 风格、literature 侧同款):
|
||||
// 导入成功 → select(最后一条导入的资源) + setActive('notes'/'library')
|
||||
// + requestFocusNote(光标丢进 body)
|
||||
// → 右侧面板被强制切到刚导入的文件,用户正在读的笔记/PDF 被顶掉,读一半
|
||||
// 的内容被切走,还得再点回去。
|
||||
//
|
||||
// 期望契约:导入只做「落盘(必要时搬进当前文件夹)+ toast(+ 列表滚到新行)」,
|
||||
// **不改变选中态、不切面板、不抢焦点**。要打开,用户自己点那一行。
|
||||
//
|
||||
// 本测是**源码契约 guard**:两个列表组件的 importFiles 回调体里不允许出现
|
||||
// select( / setActive( / requestFocusNote(
|
||||
// 为什么用源码扫描而不是渲染组件测:
|
||||
// NotesList / LiteratureList 都依赖 react-virtuoso,jsdom 下容器高度为 0,
|
||||
// 行根本不渲染 —— 渲染测测不到「点了导入之后有没有自动打开」这个行为,
|
||||
// 只能退到源码契约。仓库里 layering / i18n-parity 同款做法。
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
/**
|
||||
* 从 `const importFiles = useCallback(` 起,做花括号配平,截出回调体文本。
|
||||
* 先剥掉注释(注释里可能有花括号),字符串字面量里的花括号在本仓库这两个
|
||||
* 回调里不存在(importFiles 的文案 key 都不带 '{}')。
|
||||
*/
|
||||
function extractCallbackBody(fileRel: string, marker: string): string {
|
||||
const src = readFileSync(join(ROOT, fileRel), 'utf8');
|
||||
const start = src.indexOf(marker);
|
||||
expect(start, `${fileRel} 里找不到 ${marker}`).toBeGreaterThan(-1);
|
||||
const open = src.indexOf('{', start);
|
||||
expect(open, `${fileRel} 里 ${marker} 之后没有 '{'`).toBeGreaterThan(-1);
|
||||
const stripped = src
|
||||
.slice(open)
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/^[ \t]*\/\/.*$/gm, '');
|
||||
let depth = 0;
|
||||
for (let i = 0; i < stripped.length; i++) {
|
||||
const ch = stripped[i];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) return stripped.slice(0, i);
|
||||
}
|
||||
}
|
||||
throw new Error(`${fileRel}: ${marker} 的回调体花括号没配平,提取失败`);
|
||||
}
|
||||
|
||||
const NOTES = 'src/renderer/src/components/notes/NotesList.tsx';
|
||||
const LIT = 'src/renderer/src/components/literature/LiteratureList.tsx';
|
||||
|
||||
describe('导入后不自动打开(r177)', () => {
|
||||
it('notes 侧 importFiles 不 select / 不切面板 / 不抢焦点', () => {
|
||||
const body = extractCallbackBody(NOTES, 'const importFiles = useCallback(');
|
||||
expect(body).not.toMatch(/\bselect\(/);
|
||||
expect(body).not.toMatch(/\bsetActive\(/);
|
||||
expect(body).not.toMatch(/\brequestFocusNote\(/);
|
||||
});
|
||||
|
||||
it('literature 侧 importFiles 不 select / 不切面板', () => {
|
||||
const body = extractCallbackBody(LIT, 'const importFiles = useCallback(');
|
||||
expect(body).not.toMatch(/\bselect\(/);
|
||||
expect(body).not.toMatch(/\bsetActive\(/);
|
||||
expect(body).not.toMatch(/\brequestFocusNote\(/);
|
||||
});
|
||||
|
||||
it('notes 侧导入仍然落盘 + 仍搬进当前文件夹 + 仍出成功 toast', () => {
|
||||
const body = extractCallbackBody(NOTES, 'const importFiles = useCallback(');
|
||||
expect(body).toMatch(/\bimportFromFile\(/);
|
||||
expect(body).toMatch(/\bmoveToFolder\(/);
|
||||
expect(body).toMatch(/toast\.importSuccess/);
|
||||
});
|
||||
|
||||
it('literature 侧导入仍然落盘 + 仍搬进当前文件夹 + 仍出成功 toast', () => {
|
||||
const body = extractCallbackBody(LIT, 'const importFiles = useCallback(');
|
||||
expect(body).toMatch(/\bimportFromFile\(/);
|
||||
expect(body).toMatch(/\bapi\.litMove\(/);
|
||||
expect(body).toMatch(/literature\.importSuccess/);
|
||||
});
|
||||
|
||||
it('notes 侧仍把新行滚进视野(落点可见),但只是滚动不是选中', () => {
|
||||
const body = extractCallbackBody(NOTES, 'const importFiles = useCallback(');
|
||||
expect(body).toMatch(/scrollToIndex/);
|
||||
});
|
||||
});
|
||||
116
tests/unit/renderer/components/list-panel-reset-width.test.tsx
Normal file
116
tests/unit/renderer/components/list-panel-reset-width.test.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
// tests/unit/renderer/components/list-panel-reset-width.test.tsx
|
||||
//
|
||||
// 守 r240「恢复默认宽度」按钮的行为契约。
|
||||
//
|
||||
// 背景:左侧列表面板(WorkbenchListPanel)的宽度可拖拽,默认值在 r132/r175/r238
|
||||
// 三轮里来回调过(320→240→280)。用户拖过分隔条之后没有显式途径回退到默认
|
||||
// —— 唯一的手势是「双击分隔条」(VerticalResizeHandle 的 onDoubleClick),
|
||||
// 那是隐藏手势,用户不会知道它存在。r240 加了一个显式按钮。
|
||||
//
|
||||
// 本测锁(行为级,不测实现细节):
|
||||
// A. 按钮真实存在、可点、且**不在 tablist 内部** —— tablist 的 a11y 结构
|
||||
// 只能含 tab;按钮塞进去会让 NVDA 念出一堆无关项(与 Tabs 的 roving
|
||||
// tabindex 约定也冲突:Tab 键到不了它)。
|
||||
// B. 点一下 ⇒ 面板宽度回到默认(不是回落到「上次拖拽前的值」)。
|
||||
// C. 复位**会落盘**(localStorage 写成默认值)⇒ 重启后仍生效,不是一次性
|
||||
// 的视觉回弹(hook 的 [width] effect 负责写盘)。
|
||||
// D. 已经在默认值时点按钮 ⇒ 不抛错、宽度不变(幂等)。
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, fireEvent, cleanup } from '@testing-library/react';
|
||||
|
||||
// i18n:只关心交互与键名,不在乎文案
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
// workspace store:ListPanel 只用 active / setActive
|
||||
vi.mock('../../../../src/renderer/src/store/workspace', () => {
|
||||
const state = { active: 'library', setActive: vi.fn() };
|
||||
const useWorkspaceStore = ((selector?: (s: unknown) => unknown) =>
|
||||
selector ? selector(state) : state) as unknown as {
|
||||
(selector?: (s: unknown) => unknown): unknown;
|
||||
getState: () => unknown;
|
||||
};
|
||||
useWorkspaceStore.getState = () => state;
|
||||
return { useWorkspaceStore };
|
||||
});
|
||||
|
||||
// 懒加载的列表组件:本测不关心列表内容,换掉避免 react-virtuoso / IPC 噪声
|
||||
vi.mock('../../../../src/renderer/src/components/literature/LiteratureList', () => ({
|
||||
LiteratureList: () => <div data-testid="lit-list" />,
|
||||
}));
|
||||
vi.mock('../../../../src/renderer/src/components/notes/NotesList', () => ({
|
||||
NotesList: () => <div data-testid="notes-list" />,
|
||||
}));
|
||||
|
||||
import { WorkbenchListPanel } from '../../../../src/renderer/src/components/layout/WorkbenchListPanel';
|
||||
import {
|
||||
LIST_PANEL_DEFAULT,
|
||||
LIST_PANEL_WIDTH_KEY,
|
||||
} from '../../../../src/renderer/src/components/layout/list-panel-width';
|
||||
|
||||
const RESET_LABEL = 'workbench.listPanelResetWidth';
|
||||
|
||||
function renderPanel() {
|
||||
const { container } = render(<WorkbenchListPanel />);
|
||||
const section = container.querySelector('section') as HTMLElement;
|
||||
const button = container.querySelector(`button[aria-label="${RESET_LABEL}"]`) as HTMLElement;
|
||||
return { container, section, button };
|
||||
}
|
||||
|
||||
describe('左侧列表面板「恢复默认宽度」按钮(r240)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('按钮存在,且在 tablist **之外**(不污染 tablist 的 a11y 结构)', () => {
|
||||
const { container, button } = renderPanel();
|
||||
expect(button).toBeTruthy();
|
||||
expect(container.querySelector('[role="tablist"]')).toBeTruthy();
|
||||
// 按钮不能是 tablist 的后代
|
||||
expect(container.querySelector(`[role="tablist"] button[aria-label="${RESET_LABEL}"]`)).toBeNull();
|
||||
// 也不能被塞进 tabpanel(那是列表内容的容器)
|
||||
expect(container.querySelector(`[role="tabpanel"] button[aria-label="${RESET_LABEL}"]`)).toBeNull();
|
||||
});
|
||||
|
||||
it('点按钮 ⇒ 宽度回到默认值(从用户拖过的宽度回退)', () => {
|
||||
// 模拟用户拖到 400(偏好已落盘)
|
||||
localStorage.setItem(LIST_PANEL_WIDTH_KEY, '400');
|
||||
const { section, button } = renderPanel();
|
||||
expect(section.style.width).toBe('400px');
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(section.style.width).toBe(`${LIST_PANEL_DEFAULT}px`);
|
||||
});
|
||||
|
||||
it('复位会落盘 ⇒ 重启后仍是默认值(不是一次性视觉回弹)', () => {
|
||||
localStorage.setItem(LIST_PANEL_WIDTH_KEY, '400');
|
||||
const { button } = renderPanel();
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(localStorage.getItem(LIST_PANEL_WIDTH_KEY)).toBe(String(LIST_PANEL_DEFAULT));
|
||||
});
|
||||
|
||||
it('已经在默认值时点按钮 ⇒ 幂等(宽度不变,不抛错)', () => {
|
||||
const { section, button } = renderPanel();
|
||||
expect(section.style.width).toBe(`${LIST_PANEL_DEFAULT}px`);
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(section.style.width).toBe(`${LIST_PANEL_DEFAULT}px`);
|
||||
expect(localStorage.getItem(LIST_PANEL_WIDTH_KEY)).toBe(String(LIST_PANEL_DEFAULT));
|
||||
});
|
||||
|
||||
it('复位目标始终是**当前**默认值常量(不是硬编码的旧数字)', () => {
|
||||
// 语义锁:按钮复位到 list-panel-width.ts 里的默认值 ——
|
||||
// 以后默认值再变(r240 之后可能还有 r24x),按钮自动跟随,不会退回旧数字。
|
||||
localStorage.setItem(LIST_PANEL_WIDTH_KEY, '400');
|
||||
const { section, button } = renderPanel();
|
||||
fireEvent.click(button);
|
||||
expect(section.style.width).toBe(`${LIST_PANEL_DEFAULT}px`);
|
||||
expect(LIST_PANEL_DEFAULT).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
143
tests/unit/renderer/components/list-panel-width-policy.test.ts
Normal file
143
tests/unit/renderer/components/list-panel-width-policy.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
// tests/unit/renderer/components/list-panel-width-policy.test.ts
|
||||
//
|
||||
// 守左侧列表面板(文献/笔记 Tab)的宽度策略(r175 起,r241 改默认值):
|
||||
//
|
||||
// 1) 数值本身:min=200、default=300、max=720。
|
||||
// (r241:用户第二次说「左侧栏默认宽度可以稍微宽一些」⇒ default 280→300;
|
||||
// min / max 不动 —— 拖宽天花板保留,最小宽度不动)
|
||||
// 2) **storageKey 升版** —— 每次改默认值都必须换 key。原因:hook 的
|
||||
// [width] effect 会把当前宽度(含首次挂载时的默认值)落盘到 localStorage
|
||||
// ⇒ 「默认值」第一次运行后就被固化。只改常量不改 key ⇒ 存量用户永远
|
||||
// 看不到新默认(r175 / r238 / r241 各踩过一次)。
|
||||
// 3) **定向迁移** —— 升版的同时把旧 key 里「用户真拖过」的宽度搬到新 key;
|
||||
// 等于旧默认(280)的那个值视为自动落盘,不迁移 ⇒ 新默认生效。
|
||||
// 这比 r175 的「纯升版,手动偏好一起重置」更友好。
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act, cleanup } from '@testing-library/react';
|
||||
import {
|
||||
LIST_PANEL_MIN,
|
||||
LIST_PANEL_MAX,
|
||||
LIST_PANEL_DEFAULT,
|
||||
LIST_PANEL_WIDTH_KEY,
|
||||
LEGACY_LIST_PANEL_WIDTH_KEY,
|
||||
migrateLegacyListPanelWidth,
|
||||
} from '../../../../src/renderer/src/components/layout/list-panel-width';
|
||||
import { useResizablePanel } from '../../../../src/renderer/src/hooks/useResizablePanel';
|
||||
|
||||
function mountPanel(storageKey: string = LIST_PANEL_WIDTH_KEY) {
|
||||
return renderHook(() =>
|
||||
useResizablePanel({
|
||||
storageKey,
|
||||
defaultWidth: LIST_PANEL_DEFAULT,
|
||||
min: LIST_PANEL_MIN,
|
||||
max: LIST_PANEL_MAX,
|
||||
side: 'left',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe('左侧列表面板宽度策略(r241)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('min / default / max 关系合法', () => {
|
||||
expect(LIST_PANEL_MIN).toBeGreaterThan(0);
|
||||
expect(LIST_PANEL_DEFAULT).toBeGreaterThanOrEqual(LIST_PANEL_MIN);
|
||||
expect(LIST_PANEL_MAX).toBeGreaterThan(LIST_PANEL_DEFAULT);
|
||||
});
|
||||
|
||||
it('默认宽度 = 300(r241:280→300),min 200 / max 720 不动', () => {
|
||||
expect(LIST_PANEL_MIN).toBe(200);
|
||||
expect(LIST_PANEL_DEFAULT).toBe(300);
|
||||
expect(LIST_PANEL_MAX).toBe(720);
|
||||
});
|
||||
|
||||
it('storageKey 已升版 —— v3 旧 key 不再被直接读取', () => {
|
||||
expect(LIST_PANEL_WIDTH_KEY).not.toBe(LEGACY_LIST_PANEL_WIDTH_KEY);
|
||||
expect(LIST_PANEL_WIDTH_KEY).toBe('workbench.listPanel.width.v4');
|
||||
expect(LEGACY_LIST_PANEL_WIDTH_KEY).toBe('workbench.listPanel.width.v3');
|
||||
});
|
||||
|
||||
it('无偏好时用默认宽度', () => {
|
||||
const { result } = mountPanel();
|
||||
expect(result.current.width).toBe(LIST_PANEL_DEFAULT);
|
||||
});
|
||||
|
||||
it('新 key 里越界的旧值被丢弃 → 回落默认', () => {
|
||||
localStorage.setItem(LIST_PANEL_WIDTH_KEY, '1500');
|
||||
const { result } = mountPanel();
|
||||
expect(result.current.width).toBe(LIST_PANEL_DEFAULT);
|
||||
});
|
||||
|
||||
it('新 key 里的合法偏好被保留(手动拖过的宽度不丢)', () => {
|
||||
localStorage.setItem(LIST_PANEL_WIDTH_KEY, '400');
|
||||
const { result } = mountPanel();
|
||||
expect(result.current.width).toBe(400);
|
||||
});
|
||||
|
||||
it('setWidth 夹紧到 [min, max] —— 最小宽度真的生效', () => {
|
||||
const { result } = mountPanel();
|
||||
act(() => result.current.setWidth(10));
|
||||
expect(result.current.width).toBe(LIST_PANEL_MIN);
|
||||
act(() => result.current.setWidth(99999));
|
||||
expect(result.current.width).toBe(LIST_PANEL_MAX);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v3 → v4 定向迁移(r241)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('v3 里「用户真拖过」的宽度被搬到 v4', () => {
|
||||
localStorage.setItem(LEGACY_LIST_PANEL_WIDTH_KEY, '400');
|
||||
migrateLegacyListPanelWidth();
|
||||
expect(localStorage.getItem(LIST_PANEL_WIDTH_KEY)).toBe('400');
|
||||
});
|
||||
|
||||
it('v3 里等于旧默认 280 的值不迁移 ⇒ 新默认 300 生效', () => {
|
||||
localStorage.setItem(LEGACY_LIST_PANEL_WIDTH_KEY, '280');
|
||||
migrateLegacyListPanelWidth();
|
||||
expect(localStorage.getItem(LIST_PANEL_WIDTH_KEY)).toBeNull();
|
||||
const { result } = mountPanel();
|
||||
expect(result.current.width).toBe(LIST_PANEL_DEFAULT);
|
||||
});
|
||||
|
||||
it('v4 已有值时不覆盖(幂等 —— 不推翻新 key 下已有的偏好)', () => {
|
||||
localStorage.setItem(LIST_PANEL_WIDTH_KEY, '360');
|
||||
localStorage.setItem(LEGACY_LIST_PANEL_WIDTH_KEY, '400');
|
||||
migrateLegacyListPanelWidth();
|
||||
expect(localStorage.getItem(LIST_PANEL_WIDTH_KEY)).toBe('360');
|
||||
});
|
||||
|
||||
it('v3 里越界的脏值不迁移', () => {
|
||||
localStorage.setItem(LEGACY_LIST_PANEL_WIDTH_KEY, '1500');
|
||||
migrateLegacyListPanelWidth();
|
||||
expect(localStorage.getItem(LIST_PANEL_WIDTH_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('v3 缺失(全新安装)⇒ 不迁移,直接用新默认', () => {
|
||||
migrateLegacyListPanelWidth();
|
||||
expect(localStorage.getItem(LIST_PANEL_WIDTH_KEY)).toBeNull();
|
||||
const { result } = mountPanel();
|
||||
expect(result.current.width).toBe(LIST_PANEL_DEFAULT);
|
||||
});
|
||||
|
||||
it('v2 及更早的 key 不再参与迁移(只认上一版 v3)', () => {
|
||||
// v2 是 r238 之前的 key —— r241 的迁移源是 v3。
|
||||
// 若实现误把 v2 也当来源,r238 时代「手动拖过 400」的老用户会在
|
||||
// 两次升版后被重新当成有效偏好(本该在 r238 就处理完)。
|
||||
localStorage.setItem('workbench.listPanel.width.v2', '400');
|
||||
migrateLegacyListPanelWidth();
|
||||
expect(localStorage.getItem(LIST_PANEL_WIDTH_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
271
tests/unit/renderer/components/markdown-editor-history.test.tsx
Normal file
271
tests/unit/renderer/components/markdown-editor-history.test.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
// tests/unit/renderer/components/markdown-editor-history.test.tsx
|
||||
//
|
||||
// 守「笔记编辑的撤销 / 重做」在**编辑器层**的三条契约:
|
||||
// 1. 能力上报:doc 变了之后,编辑器把「现在能不能撤 / 能不能重做」告诉上层,
|
||||
// 且**只在翻转时**上报(每次击键都上报 ⇒ 父级每次击键都重渲 = perf 回归);
|
||||
// 2. 外部灌入的正文**不进撤销栈**(用户按撤销撤的是「自己敲的字」,
|
||||
// 不是「外部替换进来的整篇」)⇒ dispatch 必须带 addToHistory:false;
|
||||
// 3. 换文档(docToken 变化)⇒ 重建 state ⇒ 撤销栈归零 —— 否则在新笔记里
|
||||
// 按「撤销」会撤掉**上一篇**笔记的字(还会被 autosave 写进新笔记,
|
||||
// 跨笔记污染正文)。
|
||||
//
|
||||
// 第 3 条是本轮最隐蔽的一条:CodeMirror 的 history 是挂在 EditorState 上的
|
||||
// StateField,切笔记只是把正文整段替换掉,旧笔记的编辑事件仍留在栈里。
|
||||
// 「整段替换 + addToHistory:false」顺带清栈的路子依赖「替换范围覆盖全部
|
||||
// 历史事件」—— 正文为空时替换是空操作 ⇒ 清不掉 ⇒ 必须走 setState 重建。
|
||||
//
|
||||
// 变异思路(哪几处改动会红):
|
||||
// - 去掉 reportHistory 调用 / 不比较翻转 ⇒ 例 1 红;
|
||||
// - 去掉 annotations(Transaction.addToHistory.of(false)) ⇒ 例 2 红;
|
||||
// - setState 路径改成普通 dispatch(或不重建) ⇒ 例 3 红。
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { render, act, waitFor } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
// ── 假 CodeMirror ─────────────────────────────────────────────────────────
|
||||
// 够用即可:实现本组件用到的那几个 API,并把 updateListener 的回调暴露出来,
|
||||
// 让测试能手动「敲一次键」。撤销栈用两个计数器模拟:
|
||||
// undoDepth/redoDepth —— 当前栈深(> 0 即「能撤 / 能重做」);
|
||||
// EditorState.create —— 造新 state ⇒ 栈归零(真实 CodeMirror 的 history
|
||||
// 是 StateField,新 state = 字段初始值 = 空栈)。
|
||||
const cm = vi.hoisted(() => {
|
||||
const api = {
|
||||
doc: '',
|
||||
undoDepth: 0,
|
||||
redoDepth: 0,
|
||||
dispatches: [] as Array<{ changes?: { insert?: string }; annotations?: unknown }>,
|
||||
listeners: [] as Array<(u: unknown) => void>,
|
||||
setStates: [] as Array<{ doc: string }>,
|
||||
focusCalls: 0,
|
||||
undoCalls: 0,
|
||||
redoCalls: 0,
|
||||
view: null as null | {
|
||||
state: unknown;
|
||||
dispatch: (tr: unknown) => void;
|
||||
setState: (s: unknown) => void;
|
||||
focus: () => void;
|
||||
},
|
||||
makeDoc() {
|
||||
return {
|
||||
get length() {
|
||||
return api.doc.length;
|
||||
},
|
||||
toString() {
|
||||
return api.doc;
|
||||
},
|
||||
};
|
||||
},
|
||||
reset() {
|
||||
api.doc = '';
|
||||
api.undoDepth = 0;
|
||||
api.redoDepth = 0;
|
||||
api.dispatches = [];
|
||||
api.listeners = [];
|
||||
api.setStates = [];
|
||||
api.focusCalls = 0;
|
||||
api.undoCalls = 0;
|
||||
api.redoCalls = 0;
|
||||
api.view = null;
|
||||
},
|
||||
};
|
||||
return api;
|
||||
});
|
||||
|
||||
vi.mock('@codemirror/state', () => ({
|
||||
EditorState: {
|
||||
create({ doc, extensions }: { doc: string; extensions: unknown[] }) {
|
||||
cm.doc = doc;
|
||||
cm.listeners = [];
|
||||
// 新 state ⇒ 撤销栈归零(history 是 StateField,新 state = 初始值)
|
||||
cm.undoDepth = 0;
|
||||
cm.redoDepth = 0;
|
||||
for (const ext of extensions) {
|
||||
const e = ext as { __kind?: string; __fn?: (u: unknown) => void };
|
||||
if (e && e.__kind === 'updateListener' && typeof e.__fn === 'function') {
|
||||
cm.listeners.push(e.__fn);
|
||||
}
|
||||
}
|
||||
return { doc: cm.makeDoc(), extensions };
|
||||
},
|
||||
},
|
||||
Transaction: {
|
||||
addToHistory: { of: (v: boolean) => ({ __kind: 'addToHistory', value: v }) },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@codemirror/view', () => {
|
||||
const facet = (kind: string) => ({
|
||||
of: (v: unknown) => ({ __kind: kind, __fn: v }),
|
||||
});
|
||||
class EditorView {
|
||||
state: unknown;
|
||||
parent: unknown;
|
||||
constructor(opts: { state: unknown; parent: unknown }) {
|
||||
this.state = opts.state;
|
||||
this.parent = opts.parent;
|
||||
cm.view = this as never;
|
||||
}
|
||||
dispatch(tr: { changes?: { insert?: string }; annotations?: unknown }) {
|
||||
cm.dispatches.push(tr);
|
||||
if (tr && tr.changes) {
|
||||
cm.doc = tr.changes.insert ?? cm.doc;
|
||||
this.state = { doc: cm.makeDoc() };
|
||||
for (const l of cm.listeners) l({ docChanged: true, state: this.state });
|
||||
}
|
||||
}
|
||||
setState(state: { doc: { toString: () => string } }) {
|
||||
cm.setStates.push({ doc: state.doc.toString() });
|
||||
cm.doc = state.doc.toString();
|
||||
this.state = state;
|
||||
}
|
||||
focus() {
|
||||
cm.focusCalls += 1;
|
||||
}
|
||||
destroy() {
|
||||
/* noop */
|
||||
}
|
||||
static theme() {
|
||||
return { __kind: 'theme' };
|
||||
}
|
||||
static lineWrapping = { __kind: 'lineWrapping' };
|
||||
static contentAttributes = facet('contentAttributes');
|
||||
static updateListener = facet('updateListener');
|
||||
}
|
||||
return {
|
||||
EditorView,
|
||||
keymap: { of: () => ({ __kind: 'keymap' }) },
|
||||
drawSelection: () => ({ __kind: 'drawSelection' }),
|
||||
dropCursor: () => ({ __kind: 'dropCursor' }),
|
||||
rectangularSelection: () => ({ __kind: 'rectangularSelection' }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@codemirror/commands', () => ({
|
||||
defaultKeymap: [],
|
||||
historyKeymap: [],
|
||||
indentWithTab: { __kind: 'indentWithTab' },
|
||||
history: () => ({ __kind: 'history' }),
|
||||
undoDepth: () => cm.undoDepth,
|
||||
redoDepth: () => cm.redoDepth,
|
||||
undo: () => {
|
||||
cm.undoCalls += 1;
|
||||
return true;
|
||||
},
|
||||
redo: () => {
|
||||
cm.redoCalls += 1;
|
||||
return true;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@codemirror/lang-markdown', () => ({
|
||||
markdown: () => ({ __kind: 'markdown' }),
|
||||
markdownLanguage: { __kind: 'markdownLanguage' },
|
||||
}));
|
||||
|
||||
import { MarkdownEditor, type EditorHistoryFlags } from '../../../../src/renderer/src/components/editor/MarkdownEditor';
|
||||
|
||||
/** 模拟一次击键:doc 变了 ⇒ CodeMirror 触发 updateListener(并进撤销栈)。 */
|
||||
function type(text: string): void {
|
||||
cm.doc = text;
|
||||
cm.undoDepth = 1;
|
||||
for (const l of cm.listeners) l({ docChanged: true, state: cm.view?.state });
|
||||
}
|
||||
|
||||
describe('MarkdownEditor:撤销 / 重做(编辑器层)', () => {
|
||||
beforeEach(() => {
|
||||
cm.reset();
|
||||
});
|
||||
|
||||
it('1. 能力上报:doc 变了之后上报 canUndo,只在**翻转**时上报', async () => {
|
||||
const seen: EditorHistoryFlags[] = [];
|
||||
function Harness() {
|
||||
const [v, setV] = useState('hello');
|
||||
return (
|
||||
<MarkdownEditor
|
||||
value={v}
|
||||
onChange={setV}
|
||||
onHistoryChange={(f) => seen.push(f)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
await waitFor(() => expect(cm.view).not.toBeNull());
|
||||
|
||||
// 还没敲过字 ⇒ 栈是空的 ⇒ 上报一次 canUndo=false
|
||||
expect(seen.length).toBeGreaterThan(0);
|
||||
expect(seen[seen.length - 1]).toEqual({ canUndo: false, canRedo: false });
|
||||
|
||||
// 敲一次字 ⇒ 栈深 1 ⇒ 上报 canUndo=true
|
||||
act(() => {
|
||||
type('hello!');
|
||||
});
|
||||
expect(seen[seen.length - 1]).toEqual({ canUndo: true, canRedo: false });
|
||||
|
||||
// 再敲几次 ⇒ 能力位没翻转 ⇒ **不得**再上报(否则父级每次击键都重渲)
|
||||
const countAfterFlip = seen.length;
|
||||
act(() => {
|
||||
type('hello!!');
|
||||
type('hello!!!');
|
||||
});
|
||||
expect(seen.length, '能力位没翻转就不该再上报').toBe(countAfterFlip);
|
||||
});
|
||||
|
||||
it('2. 外部灌入的正文不进撤销栈(dispatch 带 addToHistory:false)', async () => {
|
||||
function Controlled({ value }: { value: string }) {
|
||||
return <MarkdownEditor value={value} onChange={() => {}} />;
|
||||
}
|
||||
const { rerender } = render(<Controlled value="hello" />);
|
||||
await waitFor(() => expect(cm.view).not.toBeNull());
|
||||
|
||||
rerender(<Controlled value="外部写进来的正文" />);
|
||||
await waitFor(() => expect(cm.dispatches.length).toBeGreaterThan(0));
|
||||
|
||||
const tr = cm.dispatches[0] as { annotations?: { __kind?: string; value?: boolean } };
|
||||
expect(tr.annotations?.__kind, '外部同步必须带 addToHistory 标注').toBe('addToHistory');
|
||||
expect(tr.annotations?.value).toBe(false);
|
||||
});
|
||||
|
||||
it('3. 换文档(docToken 变化)⇒ 重建 state ⇒ 撤销栈归零 + 灌入新正文', async () => {
|
||||
function Controlled({ value, token }: { value: string; token: number }) {
|
||||
return <MarkdownEditor value={value} onChange={() => {}} docToken={token} />;
|
||||
}
|
||||
const { rerender } = render(<Controlled value="第一篇" token={1} />);
|
||||
await waitFor(() => expect(cm.view).not.toBeNull());
|
||||
|
||||
// 用户在第一篇里敲了字 ⇒ 栈深 1
|
||||
act(() => {
|
||||
type('第一篇改过了');
|
||||
});
|
||||
expect(cm.undoDepth).toBe(1);
|
||||
|
||||
// 切到第二篇(token 变)⇒ 重建 state ⇒ 栈归零
|
||||
rerender(<Controlled value="第二篇" token={2} />);
|
||||
await waitFor(() => expect(cm.setStates.length).toBe(1));
|
||||
expect(cm.setStates[0].doc).toBe('第二篇');
|
||||
expect(cm.undoDepth, '换文档后撤销栈必须归零').toBe(0);
|
||||
});
|
||||
|
||||
it('4. 命令式句柄:undo / redo 走 CodeMirror 的命令,并把焦点还给编辑器', async () => {
|
||||
const apiRef: { current: import('../../../../src/renderer/src/components/editor/MarkdownEditor').MarkdownEditorApi | null } =
|
||||
{ current: null };
|
||||
render(<MarkdownEditor value="hello" onChange={() => {}} apiRef={apiRef} />);
|
||||
await waitFor(() => expect(cm.view).not.toBeNull());
|
||||
expect(apiRef.current, '父级必须拿得到命令式句柄').toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
apiRef.current?.undo();
|
||||
});
|
||||
expect(cm.undoCalls).toBe(1);
|
||||
// 点按钮会把焦点挪到按钮上 ⇒ 撤销后必须把焦点还给编辑器,光标才看得见
|
||||
expect(cm.focusCalls).toBeGreaterThan(0);
|
||||
|
||||
act(() => {
|
||||
apiRef.current?.redo();
|
||||
});
|
||||
expect(cm.redoCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
// tests/unit/renderer/components/markdown-editor-sync-cost.test.tsx
|
||||
//
|
||||
// 守 r247「每次击键造两份全文字符串」的修复契约 ——
|
||||
// MarkdownEditor 的「value → view 同步」这一步不得再把整篇正文
|
||||
// (doc.toString())重新构一遍。
|
||||
//
|
||||
// 背景(用户诉求「使用流畅,不要有卡顿」):
|
||||
// 旧实现在**每次击键**做两次全文字符串构建:
|
||||
// ① updateListener:`onChangeRef.current(u.state.doc.toString())`
|
||||
// —— 必需(要把新正文交给上层),O(全文长);
|
||||
// ② effect [value]:`view.state.doc.toString() !== value`
|
||||
// —— 又造一份同样的全文字符串,只为了回答「doc 是不是已经等于
|
||||
// value?」;而答案几乎恒为「相等」(value 就是 ① 刚发上去的那份)
|
||||
// ⇒ 整次 O(n) 构建 + 比较纯属浪费。
|
||||
// 10 万字笔记 = 每次击键多造一个 10 万字符的字符串,连续输入时
|
||||
// GC 压力明显(整串不可增量复用)。
|
||||
//
|
||||
// 修法:lastEmittedRef 记「本编辑器最后一次自己发出去的正文」;
|
||||
// value === lastEmitted ⇒ doc 里已经是它 ⇒ O(1) 短路,连 toString 都不调。
|
||||
//
|
||||
// 本测锁两个可观测量:
|
||||
// A. 击键 N 次 ⇒ doc.toString() 调用次数 = 1(mount 时的初始比对)+ N(每次
|
||||
// 击键 listener 那一次)—— **effect 那一步必须是 0 次**;
|
||||
// B. 外部改 value(切笔记 / 外部写 body)⇒ 仍必须走完整比较并 dispatch
|
||||
// (短路不能把外部改动吃掉)。
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { render, act, waitFor } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
// ── 假 CodeMirror ─────────────────────────────────────────────────────────
|
||||
// 够用即可:只实现本组件用到的那几个 API,并把 updateListener 的回调
|
||||
// 暴露出来,让测试能手动「敲一次键」。
|
||||
const cm = vi.hoisted(() => {
|
||||
const api = {
|
||||
doc: '',
|
||||
toStringCalls: 0,
|
||||
dispatches: [] as Array<unknown>,
|
||||
listeners: [] as Array<(u: unknown) => void>,
|
||||
view: null as null | { state: unknown; dispatch: (tr: unknown) => void },
|
||||
// 撤销栈深度(假 CodeMirror 专用)—— 供 undoDepth/redoDepth 回答
|
||||
undoDepth: 0,
|
||||
redoDepth: 0,
|
||||
makeDoc() {
|
||||
return {
|
||||
get length() {
|
||||
return api.doc.length;
|
||||
},
|
||||
toString() {
|
||||
api.toStringCalls += 1;
|
||||
return api.doc;
|
||||
},
|
||||
};
|
||||
},
|
||||
reset() {
|
||||
api.doc = '';
|
||||
api.toStringCalls = 0;
|
||||
api.dispatches = [];
|
||||
api.listeners = [];
|
||||
api.view = null;
|
||||
api.undoDepth = 0;
|
||||
api.redoDepth = 0;
|
||||
},
|
||||
};
|
||||
return api;
|
||||
});
|
||||
|
||||
vi.mock('@codemirror/state', () => ({
|
||||
EditorState: {
|
||||
create({ doc, extensions }: { doc: string; extensions: unknown[] }) {
|
||||
cm.doc = doc;
|
||||
cm.listeners = [];
|
||||
for (const ext of extensions) {
|
||||
const e = ext as { __kind?: string; __fn?: (u: unknown) => void };
|
||||
if (e && e.__kind === 'updateListener' && typeof e.__fn === 'function') {
|
||||
cm.listeners.push(e.__fn);
|
||||
}
|
||||
}
|
||||
return { doc: cm.makeDoc(), extensions };
|
||||
},
|
||||
},
|
||||
// 撤销栈相关:外部灌入的正文必须带 addToHistory:false(不进撤销栈)
|
||||
Transaction: {
|
||||
addToHistory: { of: (v: boolean) => ({ __kind: 'addToHistory', value: v }) },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@codemirror/view', () => {
|
||||
const facet = (kind: string) => ({
|
||||
of: (v: unknown) => ({ __kind: kind, __fn: v }),
|
||||
});
|
||||
class EditorView {
|
||||
state: unknown;
|
||||
parent: unknown;
|
||||
constructor(opts: { state: unknown; parent: unknown }) {
|
||||
this.state = opts.state;
|
||||
this.parent = opts.parent;
|
||||
cm.view = this as never;
|
||||
}
|
||||
dispatch(tr: { changes?: { insert?: string } }) {
|
||||
cm.dispatches.push(tr);
|
||||
if (tr && tr.changes) {
|
||||
cm.doc = tr.changes.insert ?? cm.doc;
|
||||
this.state = { doc: cm.makeDoc() };
|
||||
for (const l of cm.listeners) l({ docChanged: true, state: this.state });
|
||||
}
|
||||
}
|
||||
destroy() {
|
||||
/* noop */
|
||||
}
|
||||
static theme() {
|
||||
return { __kind: 'theme' };
|
||||
}
|
||||
static lineWrapping = { __kind: 'lineWrapping' };
|
||||
static contentAttributes = facet('contentAttributes');
|
||||
static updateListener = facet('updateListener');
|
||||
}
|
||||
return {
|
||||
EditorView,
|
||||
keymap: { of: () => ({ __kind: 'keymap' }) },
|
||||
drawSelection: () => ({ __kind: 'drawSelection' }),
|
||||
dropCursor: () => ({ __kind: 'dropCursor' }),
|
||||
rectangularSelection: () => ({ __kind: 'rectangularSelection' }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@codemirror/commands', () => ({
|
||||
defaultKeymap: [],
|
||||
historyKeymap: [],
|
||||
indentWithTab: { __kind: 'indentWithTab' },
|
||||
history: () => ({ __kind: 'history' }),
|
||||
// 撤销栈深度 —— 编辑器每次状态变化都会问一次(按钮 enabled 态的来源)
|
||||
undoDepth: () => cm.undoDepth,
|
||||
redoDepth: () => cm.redoDepth,
|
||||
undo: () => true,
|
||||
redo: () => true,
|
||||
}));
|
||||
|
||||
vi.mock('@codemirror/lang-markdown', () => ({
|
||||
markdown: () => ({ __kind: 'markdown' }),
|
||||
markdownLanguage: { __kind: 'markdownLanguage' },
|
||||
}));
|
||||
|
||||
import { MarkdownEditor } from '../../../../src/renderer/src/components/editor/MarkdownEditor';
|
||||
|
||||
/** 模拟一次击键:doc 变了 ⇒ CodeMirror 触发 updateListener。 */
|
||||
function type(text: string): void {
|
||||
cm.doc = text;
|
||||
for (const l of cm.listeners) l({ docChanged: true, state: cm.view?.state });
|
||||
}
|
||||
|
||||
function Harness({ initial }: { initial: string }) {
|
||||
const [v, setV] = useState(initial);
|
||||
return (
|
||||
<>
|
||||
<MarkdownEditor value={v} onChange={setV} />
|
||||
<output data-testid="out">{v}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 受控 harness:同一个编辑器实例上换 value(模拟外部改 body / 换笔记)。 */
|
||||
function Controlled({ value }: { value: string }) {
|
||||
return <MarkdownEditor value={value} onChange={() => {}} />;
|
||||
}
|
||||
|
||||
describe('perf r247:MarkdownEditor 击键同步成本', () => {
|
||||
beforeEach(() => {
|
||||
cm.reset();
|
||||
});
|
||||
|
||||
it('连击 N 次 ⇒ doc.toString() 只在「mount 比对 + 每次击键的 listener」被调,effect 那一步为 0', async () => {
|
||||
const { getByTestId } = render(<Harness initial="hello" />);
|
||||
await waitFor(() => expect(cm.view).not.toBeNull());
|
||||
// mount:创建 view 后比对一次(initialDoc === value ⇒ 不 dispatch)
|
||||
expect(cm.toStringCalls, 'mount 只应比对一次').toBe(1);
|
||||
|
||||
for (const text of ['hello!', 'hello!!', 'hello!!!']) {
|
||||
act(() => {
|
||||
type(text);
|
||||
});
|
||||
expect(getByTestId('out').textContent, '击键内容必须真的到了上层').toBe(text);
|
||||
}
|
||||
|
||||
// 3 次击键 ⇒ listener 3 次 + mount 1 次 = 4
|
||||
// 旧实现 = 4 + 3(effect 每次再构一份全文)= 7
|
||||
expect(cm.toStringCalls, '同步 effect 不得再构全文').toBe(4);
|
||||
});
|
||||
|
||||
it('外部改 value ⇒ 仍走完整比较并 dispatch(短路不能吃掉外部改动)', async () => {
|
||||
const { rerender } = render(<Controlled value="hello" />);
|
||||
await waitFor(() => expect(cm.view).not.toBeNull());
|
||||
|
||||
rerender(<Controlled value="外部写进来的正文" />);
|
||||
|
||||
await waitFor(() => expect(cm.dispatches.length).toBeGreaterThan(0));
|
||||
const tr = cm.dispatches[0] as { changes: { insert: string } };
|
||||
expect(tr.changes.insert, '外部正文必须被 dispatch 进编辑器').toBe('外部写进来的正文');
|
||||
expect(cm.doc).toBe('外部写进来的正文');
|
||||
});
|
||||
});
|
||||
232
tests/unit/renderer/components/panel-resize-drag.test.tsx
Normal file
232
tests/unit/renderer/components/panel-resize-drag.test.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
// tests/unit/renderer/components/panel-resize-drag.test.tsx
|
||||
//
|
||||
// 守「拖拽分隔条改面板宽度」这一交互在**拖拽过程中**的行为契约。
|
||||
//
|
||||
// 背景(r199 修复的三个用户可见缺陷):
|
||||
// 1. 左侧列表面板:宽度写在 outer <section> 上,而 hook 的 panelRef 绑在
|
||||
// inner div → 拖拽期间 hook 直写的是 inner div 的 width,outer section
|
||||
// 的 width 仍是 React state ⇒ **面板在拖拽过程中根本不变**,内容溢出
|
||||
// 压到分隔条上,分隔条也不跟鼠标走 ⇒ 「鼠标点击拉动」操作失效。
|
||||
// 2. 文献 PDF 区域:iframe 直接 100% 跟随容器宽度 ⇒ 拖拽每一帧都在重排
|
||||
// Chromium PDF 渲染(实测每帧 +16ms,60fps 掉到 ~30fps)⇒ 缩小时很卡。
|
||||
// 3. 拖拽期间父级若重渲,React 会用旧 state 覆写 style.width ⇒ 面板跳回。
|
||||
//
|
||||
// 本测锁(行为级):
|
||||
// A. 拖拽过程中,承载视觉宽度的元素宽度必须**跟着鼠标实时变**;
|
||||
// B. 拖拽过程中,PDF iframe 的尺寸必须**冻结**(不让 PDF 每帧重排),
|
||||
// mouseup 之后才允许跟上新尺寸;
|
||||
// C. 拖拽结束后,宽度落定且与拖拽结果一致。
|
||||
import { describe, expect, it, vi, beforeAll, beforeEach, afterEach } from 'vitest';
|
||||
import { render, fireEvent, cleanup, act } from '@testing-library/react';
|
||||
|
||||
// jsdom 没有 PointerEvent(见 memory jsdom-test-gaps:按需 per-case 垫片,
|
||||
// 不污染全局 setup)。手柄改用 pointer 事件后,必须补上这个类 —— 否则
|
||||
// fireEvent.pointerDown 退化成普通 Event,clientX / button / pointerId
|
||||
// 全部丢失,测出来的行为不是浏览器里的行为。
|
||||
class FakePointerEvent extends MouseEvent {
|
||||
pointerId: number;
|
||||
pointerType: string;
|
||||
isPrimary: boolean;
|
||||
constructor(type: string, init: PointerEventInit = {}) {
|
||||
super(type, init);
|
||||
this.pointerId = init.pointerId ?? 1;
|
||||
this.pointerType = init.pointerType ?? 'mouse';
|
||||
this.isPrimary = init.isPrimary ?? true;
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
(window as unknown as { PointerEvent: unknown }).PointerEvent = FakePointerEvent;
|
||||
});
|
||||
|
||||
// i18n:只关心交互,不关心文案
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
// workspace store:ListPanel 只用 active / setActive
|
||||
vi.mock('../../../../src/renderer/src/store/workspace', () => {
|
||||
const state = { active: 'library', setActive: vi.fn() };
|
||||
const useWorkspaceStore = ((selector?: (s: unknown) => unknown) =>
|
||||
selector ? selector(state) : state) as unknown as {
|
||||
(selector?: (s: unknown) => unknown): unknown;
|
||||
getState: () => unknown;
|
||||
};
|
||||
useWorkspaceStore.getState = () => state;
|
||||
return { useWorkspaceStore };
|
||||
});
|
||||
|
||||
// 懒加载的列表组件:本测不关心列表内容,换掉避免 react-virtuoso / IPC 噪声
|
||||
vi.mock('../../../../src/renderer/src/components/literature/LiteratureList', () => ({
|
||||
LiteratureList: () => <div data-testid="lit-list" />,
|
||||
}));
|
||||
vi.mock('../../../../src/renderer/src/components/notes/NotesList', () => ({
|
||||
NotesList: () => <div data-testid="notes-list" />,
|
||||
}));
|
||||
|
||||
// notes store:WorkbenchContentPanel 只读 selectedId(笔记列是否打开)
|
||||
vi.mock('../../../../src/renderer/src/store/notes', () => {
|
||||
const state = { selectedId: 'n1', select: vi.fn() };
|
||||
const useNotesStore = ((selector?: (s: unknown) => unknown) =>
|
||||
selector ? selector(state) : state) as unknown as {
|
||||
(selector?: (s: unknown) => unknown): unknown;
|
||||
getState: () => unknown;
|
||||
};
|
||||
useNotesStore.getState = () => state;
|
||||
return { useNotesStore };
|
||||
});
|
||||
|
||||
// literature store:WorkbenchContentPanel 读 items + selectedId 派生 ReaderFile
|
||||
vi.mock('../../../../src/renderer/src/store/literature', () => {
|
||||
const state = {
|
||||
items: [{ id: 'l1', absPath: 'C:/papers/a.pdf' }],
|
||||
selectedId: 'l1',
|
||||
select: vi.fn(),
|
||||
};
|
||||
const useLiteratureStore = ((selector?: (s: unknown) => unknown) =>
|
||||
selector ? selector(state) : state) as unknown as {
|
||||
(selector?: (s: unknown) => unknown): unknown;
|
||||
getState: () => unknown;
|
||||
};
|
||||
useLiteratureStore.getState = () => state;
|
||||
return { useLiteratureStore };
|
||||
});
|
||||
|
||||
// 懒加载的阅读器 / 笔记视图:本测只关心分隔条与列宽,不关心内容
|
||||
vi.mock('../../../../src/renderer/src/components/reader/Reader', () => ({
|
||||
Reader: () => <div data-testid="reader" />,
|
||||
}));
|
||||
vi.mock('../../../../src/renderer/src/components/notes/NotesView', () => ({
|
||||
NotesView: () => <div data-testid="notes-view" />,
|
||||
}));
|
||||
|
||||
import { WorkbenchListPanel } from '../../../../src/renderer/src/components/layout/WorkbenchListPanel';
|
||||
import { WorkbenchContentPanel } from '../../../../src/renderer/src/components/layout/WorkbenchContentPanel';
|
||||
import {
|
||||
LIST_PANEL_MIN,
|
||||
LIST_PANEL_DEFAULT as DEFAULT_W,
|
||||
} from '../../../../src/renderer/src/components/layout/list-panel-width';
|
||||
import { isResizeDragActive } from '../../../../src/renderer/src/lib/resize-drag-registry';
|
||||
|
||||
function drag(separator: HTMLElement, fromX: number, toX: number) {
|
||||
// r234:整条手势必须发 **pointer 系**事件 —— 真实 Chromium 在
|
||||
// pointerdown 被 preventDefault(useResizablePanel.onResizeStart 就是这么
|
||||
// 做的)后,整个手势**不再发任何兼容 mouse 事件**(PointerEvents-v3;
|
||||
// 真机 Electron 30 / Chromium 124 + CDP 实证:页面只见 pointerdown /
|
||||
// pointermove / pointerup,mousemove / mouseup 一个都没有)。
|
||||
// 旧版这里发 pointerdown + mousemove + mouseup 的混合序列 —— 真实浏览器
|
||||
// 从不这么发,jsdom 里却畅通 ⇒ hook 监听 mouse 系时测试假绿、真机卡死。
|
||||
fireEvent.pointerDown(separator, { clientX: fromX, clientY: 100, button: 0, pointerId: 1 });
|
||||
act(() => {
|
||||
fireEvent.pointerMove(window, { clientX: toX, clientY: 100, pointerId: 1 });
|
||||
});
|
||||
return () => {
|
||||
act(() => {
|
||||
fireEvent.pointerUp(window, { clientX: toX, clientY: 100, pointerId: 1 });
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
describe('面板分隔条拖拽', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('拖拽过程中承载宽度的 section 宽度实时跟随鼠标(不只在 mouseup 后才变)', () => {
|
||||
const { container } = render(<WorkbenchListPanel />);
|
||||
const section = container.querySelector('section') as HTMLElement;
|
||||
const separator = container.querySelector('[role="separator"]') as HTMLElement;
|
||||
|
||||
expect(section).toBeTruthy();
|
||||
expect(separator).toBeTruthy();
|
||||
// 初始宽度来自 state
|
||||
expect(section.style.width).toBe(`${DEFAULT_W}px`);
|
||||
|
||||
const end = drag(separator, 1000, 1100); // 向右拖 100px ⇒ 加宽 100px
|
||||
|
||||
// 拖拽中:宽度必须已经变了(实时跟手),而不是停在初始值
|
||||
expect(section.style.width).toBe(`${DEFAULT_W + 100}px`);
|
||||
|
||||
end();
|
||||
// mouseup 后仍然一致
|
||||
expect(section.style.width).toBe(`${DEFAULT_W + 100}px`);
|
||||
});
|
||||
|
||||
it('拖拽收窄时被最小宽度夹住(不会缩到 0)', () => {
|
||||
const { container } = render(<WorkbenchListPanel />);
|
||||
const section = container.querySelector('section') as HTMLElement;
|
||||
const separator = container.querySelector('[role="separator"]') as HTMLElement;
|
||||
|
||||
const end = drag(separator, 1000, 200); // 向左拖 800px ⇒ 远超最小宽度
|
||||
expect(section.style.width).toBe(`${LIST_PANEL_MIN}px`);
|
||||
end();
|
||||
expect(section.style.width).toBe(`${LIST_PANEL_MIN}px`);
|
||||
});
|
||||
|
||||
it('拖拽期间全局登记「正在拖拽」—— PDF 阅读器据此冻结尺寸', () => {
|
||||
const { container } = render(<WorkbenchListPanel />);
|
||||
const separator = container.querySelector('[role="separator"]') as HTMLElement;
|
||||
expect(isResizeDragActive()).toBe(false);
|
||||
|
||||
const end = drag(separator, 1000, 1100);
|
||||
expect(isResizeDragActive()).toBe(true);
|
||||
end();
|
||||
expect(isResizeDragActive()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// r241:默认宽度 280→300,storageKey v3→v4 —— 面板层面验证:
|
||||
// (1) v3 里「用户真拖过」的宽度被迁移并生效(不被新默认覆盖);
|
||||
// (2) v3 里等于旧默认 280 的值 = 自动落盘 ⇒ 走新默认 300。
|
||||
// (r238 那轮是 v2→v3 / 240→280;迁移源随每次升版往前挪一版。)
|
||||
describe('左侧面板宽度默认值升级(r241)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('v3 里手动拖过的宽度(400)被迁移过来并生效', () => {
|
||||
localStorage.setItem('workbench.listPanel.width.v3', '400');
|
||||
const { container } = render(<WorkbenchListPanel />);
|
||||
const section = container.querySelector('section') as HTMLElement;
|
||||
expect(section.style.width).toBe('400px');
|
||||
});
|
||||
|
||||
it('v3 里是旧默认 280 ⇒ 不迁移,回落到新默认 300', () => {
|
||||
localStorage.setItem('workbench.listPanel.width.v3', '280');
|
||||
const { container } = render(<WorkbenchListPanel />);
|
||||
const section = container.querySelector('section') as HTMLElement;
|
||||
expect(section.style.width).toBe('300px');
|
||||
});
|
||||
});
|
||||
|
||||
describe('主区文献列(双列)分隔条拖拽', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('文献列(PDF 区域)宽度在拖拽过程中实时跟随鼠标', () => {
|
||||
const { container } = render(<WorkbenchContentPanel />);
|
||||
const separator = container.querySelector('[role="separator"]') as HTMLElement;
|
||||
expect(separator).toBeTruthy();
|
||||
// 分隔条左边那个 div 就是文献列(承载 width 的元素)
|
||||
const readerCol = separator.previousElementSibling as HTMLElement;
|
||||
expect(readerCol.style.width).toBe('640px');
|
||||
|
||||
const end = drag(separator, 1000, 900); // 向左拖 100px ⇒ 文献列收窄 100px
|
||||
expect(readerCol.style.width).toBe('540px');
|
||||
|
||||
end();
|
||||
expect(readerCol.style.width).toBe('540px');
|
||||
});
|
||||
});
|
||||
207
tests/unit/renderer/components/pdf-reader-resize-freeze.test.tsx
Normal file
207
tests/unit/renderer/components/pdf-reader-resize-freeze.test.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
// tests/unit/renderer/components/pdf-reader-resize-freeze.test.tsx
|
||||
//
|
||||
// 守 r199「拖面板宽度时 PDF 区域很卡」的修复契约 —— PDF iframe 的**尺寸**
|
||||
// 在面板拖拽期间必须冻结,拖拽结束才跟上。
|
||||
//
|
||||
// 为什么这是行为契约(而不是实现细节):
|
||||
// Chromium 的 PDF 渲染器在 iframe 尺寸变化时必须重排 + 重新光栅化可见页面。
|
||||
// 实测(Chromium,本地 http 多页 PDF,每帧改一次宽度):
|
||||
// 不动 → 16.3ms/帧
|
||||
// 改普通 HTML iframe 尺寸 → 16.4ms/帧
|
||||
// 改 PDF iframe 尺寸 → 32.8ms/帧 ← 60fps 掉到 ~30fps
|
||||
// 改容器尺寸但冻结 iframe 尺寸 → 16.4ms/帧
|
||||
// 即「iframe 尺寸每帧变」就是那 ~16ms/帧的来源。本测锁住「尺寸冻结」
|
||||
// 这条契约 —— 将来谁把 iframe 改回 w-full 跟随容器,本测立刻转红。
|
||||
//
|
||||
// 附带锁:拖拽结束后尺寸必须**跟上**最终值(冻结不能变成「永远不更新」)。
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, cleanup, act } from '@testing-library/react';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
import { PDFReader } from '../../../../src/renderer/src/components/reader/PDFReader';
|
||||
import {
|
||||
beginResizeDrag,
|
||||
endResizeDrag,
|
||||
resetResizeDragRegistry,
|
||||
} from '../../../../src/renderer/src/lib/resize-drag-registry';
|
||||
|
||||
// ── jsdom 垫片 ────────────────────────────────────────────────────────────
|
||||
// jsdom 没有 ResizeObserver,也没有真实布局(getBoundingClientRect 全 0)。
|
||||
// 这里自造一个可手动触发的 ResizeObserver + 一个可变矩形。
|
||||
type Observed = { target: Element; contentRect: DOMRectReadOnly };
|
||||
const observers: Array<{ cb: (entries: Observed[]) => void; targets: Set<Element> }> = [];
|
||||
|
||||
class FakeResizeObserver {
|
||||
private rec: { cb: (entries: Observed[]) => void; targets: Set<Element> };
|
||||
constructor(cb: (entries: Observed[]) => void) {
|
||||
this.rec = { cb, targets: new Set() };
|
||||
observers.push(this.rec);
|
||||
}
|
||||
observe(el: Element) {
|
||||
this.rec.targets.add(el);
|
||||
}
|
||||
unobserve(el: Element) {
|
||||
this.rec.targets.delete(el);
|
||||
}
|
||||
disconnect() {
|
||||
this.rec.targets.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function emitResize(): void {
|
||||
for (const rec of observers) {
|
||||
rec.cb(
|
||||
Array.from(rec.targets).map((target) => ({
|
||||
target,
|
||||
contentRect: { width: 0, height: 0 } as DOMRectReadOnly,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let rect = { width: 640, height: 800 };
|
||||
|
||||
function iframeOf(container: HTMLElement): HTMLIFrameElement {
|
||||
return container.querySelector('iframe') as HTMLIFrameElement;
|
||||
}
|
||||
|
||||
describe('PDF 阅读器:面板拖拽期间冻结 iframe 尺寸(r199)', () => {
|
||||
beforeEach(() => {
|
||||
observers.length = 0;
|
||||
rect = { width: 640, height: 800 };
|
||||
resetResizeDragRegistry();
|
||||
vi.stubGlobal('ResizeObserver', FakeResizeObserver);
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: rect.width,
|
||||
bottom: rect.height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetResizeDragRegistry();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('首帧就把 iframe 尺寸对齐容器(不留在 100% 跟随态)', () => {
|
||||
const { container } = render(<PDFReader absPath="C:/x/a.pdf" filename="a.pdf" />);
|
||||
const iframe = iframeOf(container);
|
||||
expect(iframe.style.width).toBe('640px');
|
||||
expect(iframe.style.height).toBe('800px');
|
||||
});
|
||||
|
||||
it('拖拽期间容器变窄 → iframe 尺寸冻结不动(不让 PDF 每帧重排)', () => {
|
||||
const { container } = render(<PDFReader absPath="C:/x/a.pdf" filename="a.pdf" />);
|
||||
const iframe = iframeOf(container);
|
||||
expect(iframe.style.width).toBe('640px');
|
||||
|
||||
act(() => {
|
||||
beginResizeDrag();
|
||||
});
|
||||
// 拖拽中:容器变窄(面板收窄),iframe 尺寸必须**保持** 640
|
||||
rect = { width: 400, height: 800 };
|
||||
act(() => {
|
||||
emitResize();
|
||||
});
|
||||
expect(iframe.style.width).toBe('640px');
|
||||
|
||||
// 再收窄一次,依然冻结
|
||||
rect = { width: 320, height: 800 };
|
||||
act(() => {
|
||||
emitResize();
|
||||
});
|
||||
expect(iframe.style.width).toBe('640px');
|
||||
});
|
||||
|
||||
it('拖拽结束 → iframe 一次性跟上最终尺寸(冻结不是「永远不更新」)', () => {
|
||||
const { container } = render(<PDFReader absPath="C:/x/a.pdf" filename="a.pdf" />);
|
||||
const iframe = iframeOf(container);
|
||||
|
||||
act(() => {
|
||||
beginResizeDrag();
|
||||
});
|
||||
rect = { width: 400, height: 800 };
|
||||
act(() => {
|
||||
emitResize();
|
||||
});
|
||||
expect(iframe.style.width).toBe('640px');
|
||||
|
||||
act(() => {
|
||||
endResizeDrag();
|
||||
});
|
||||
expect(iframe.style.width).toBe('400px');
|
||||
expect(iframe.style.height).toBe('800px');
|
||||
});
|
||||
|
||||
it('非拖拽场景(窗口缩放):尺寸落定后更新', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { container } = render(<PDFReader absPath="C:/x/a.pdf" filename="a.pdf" />);
|
||||
const iframe = iframeOf(container);
|
||||
expect(iframe.style.width).toBe('640px');
|
||||
|
||||
rect = { width: 500, height: 800 };
|
||||
act(() => {
|
||||
emitResize();
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
expect(iframe.style.width).toBe('500px');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
// audit 2026-10-21 (r252):「落定定时器」与「拖拽开始」的竞态。
|
||||
// 旧实现只在 ResizeObserver 回调那一刻查拖拽状态 —— 排好的 150ms 定时器
|
||||
// 到点时**不再查** ⇒ 若这 150ms 里用户开始了拖拽,定时器会在拖拽**中途**
|
||||
// 改 iframe 尺寸 ⇒ Chromium 重排 + 重新光栅化 PDF(实测每帧 +16ms)
|
||||
// ⇒ 拖拽第一帧掉帧。
|
||||
it('拖拽开始时已挂着待落定定时器 ⇒ 定时器到点也不得在拖拽中改尺寸', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { container } = render(<PDFReader absPath="C:/x/a.pdf" filename="a.pdf" />);
|
||||
const iframe = iframeOf(container);
|
||||
expect(iframe.style.width).toBe('640px');
|
||||
|
||||
// ① 非拖拽场景的一次 resize ⇒ 排一个 150ms 的落定定时器
|
||||
rect = { width: 500, height: 800 };
|
||||
act(() => {
|
||||
emitResize();
|
||||
});
|
||||
expect(iframe.style.width, '定时器还没到点,尺寸不变').toBe('640px');
|
||||
|
||||
// ② 定时器到点之前,用户开始拖分隔条
|
||||
act(() => {
|
||||
beginResizeDrag();
|
||||
});
|
||||
|
||||
// ③ 定时器到点 ⇒ **不得**在拖拽中途改 iframe 尺寸
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
expect(iframe.style.width, '拖拽中不得改尺寸(PDF 每帧重排 = 掉帧)').toBe('640px');
|
||||
|
||||
// ④ 拖拽结束 → 照旧一次性跟上最终尺寸(跳过定时器不会丢更新)
|
||||
act(() => {
|
||||
endResizeDrag();
|
||||
});
|
||||
expect(iframe.style.width).toBe('500px');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
97
tests/unit/renderer/components/reader-decode-memo.test.tsx
Normal file
97
tests/unit/renderer/components/reader-decode-memo.test.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
// tests/unit/renderer/components/reader-decode-memo.test.tsx
|
||||
//
|
||||
// 守 r247「Reader 每次重渲都重解一遍整个文件」的修复契约 ——
|
||||
// 文本类文件的 UTF-8 解码必须**只在内容真的变了**时做一次。
|
||||
//
|
||||
// 背景(用户诉求「使用流畅,不要有卡顿」):
|
||||
// 旧实现在**每次 render** 都执行 `TEXT_DECODER.decode(state.data)` ——
|
||||
// 这是 O(字节数) 的 UTF-8 解码 + 一份新字符串分配。state.data 本身是
|
||||
// 稳定引用,内容没变却在每次重渲时重解一遍:
|
||||
// 1MB 的 txt/md = 每次 render 造一个 1M 字符的字符串。
|
||||
// Reader 会在窗口 resize / 重试 nonce / 主题切换 / 父级任何 state 变化
|
||||
// 时重渲 ⇒「拖着窗口边缘抖一下」就能把主线程塞满解码。
|
||||
//
|
||||
// 修法:useMemo([ext, state.data])—— ext / data 都没变 ⇒ 不重解。
|
||||
//
|
||||
// 本测锁:① 首屏解码一次;② 父级用**新对象**重渲(内容没变)不再解码;
|
||||
// ③ 换文件(内容真变了)必须再解一次(短路不能把新文件吃掉)。
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
const readFileForReader = vi.fn();
|
||||
vi.mock('../../../../src/renderer/src/lib/api', () => ({
|
||||
api: { readFileForReader: (...args: unknown[]) => readFileForReader(...args) },
|
||||
unwrap: (r: unknown) => r,
|
||||
IpcError: class IpcError extends Error {},
|
||||
asIpcError: () => null,
|
||||
hasApi: () => true,
|
||||
}));
|
||||
|
||||
import { Reader } from '../../../../src/renderer/src/components/reader/Reader';
|
||||
|
||||
const FILE = {
|
||||
id: 'f1',
|
||||
extension: '.txt',
|
||||
absPath: '/tmp/rkms-perf/a.txt',
|
||||
filename: 'a.txt',
|
||||
};
|
||||
|
||||
function bytesOf(text: string): Uint8Array {
|
||||
// jsdom 陷阱(实测):`new TextEncoder().encode()` 返回的是**另一个 realm** 的
|
||||
// Uint8Array —— `b instanceof Uint8Array === false` ⇒ Reader 的
|
||||
// `rawData instanceof Uint8Array` 守卫会走「文件格式损坏」分支。
|
||||
// 这里用本 realm 的构造器直接造字节(手写 UTF-8 编码,不走 TextEncoder)。
|
||||
const enc = encodeURIComponent(text).replace(
|
||||
/%([0-9A-F]{2})/g,
|
||||
(_m, h: string) => String.fromCharCode(parseInt(h, 16)),
|
||||
);
|
||||
const out = new Uint8Array(enc.length);
|
||||
for (let i = 0; i < enc.length; i += 1) out[i] = enc.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('perf r247:Reader 文本解码必须 memo 化', () => {
|
||||
beforeEach(() => {
|
||||
readFileForReader.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('首屏解码一次;父级用新对象重渲(内容未变)不再解码', async () => {
|
||||
const decodeSpy = vi.spyOn(TextDecoder.prototype, 'decode');
|
||||
readFileForReader.mockResolvedValue({ ok: true, data: { data: bytesOf('hello 世界') } });
|
||||
|
||||
const { container, rerender } = render(<Reader file={{ ...FILE }} />);
|
||||
|
||||
// 正文渲染出来 ⇒ 解码确实发生了
|
||||
await waitFor(() => expect(container.textContent).toContain('hello 世界'));
|
||||
expect(decodeSpy, '首屏只应解码一次').toHaveBeenCalledTimes(1);
|
||||
|
||||
// 父级重渲,但 file 是**新对象**(内容一模一样)—— 模拟 resize / 主题切换等
|
||||
const before = decodeSpy.mock.calls.length;
|
||||
rerender(<Reader file={{ ...FILE }} />);
|
||||
expect(decodeSpy.mock.calls.length, '内容没变就不该再解一遍').toBe(before);
|
||||
expect(decodeSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('换文件(内容真变了)⇒ 必须再解一次', async () => {
|
||||
const decodeSpy = vi.spyOn(TextDecoder.prototype, 'decode');
|
||||
readFileForReader.mockResolvedValueOnce({ ok: true, data: { data: bytesOf('第一份') } });
|
||||
const { container, rerender } = render(<Reader file={{ ...FILE }} />);
|
||||
await waitFor(() => expect(container.textContent).toContain('第一份'));
|
||||
expect(decodeSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
readFileForReader.mockResolvedValueOnce({ ok: true, data: { data: bytesOf('第二份') } });
|
||||
rerender(
|
||||
<Reader file={{ ...FILE, id: 'f2', absPath: '/tmp/rkms-perf/b.txt', filename: 'b.txt' }} />,
|
||||
);
|
||||
await waitFor(() => expect(container.textContent).toContain('第二份'));
|
||||
expect(decodeSpy, '新文件必须重新解码').toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
// tests/unit/renderer/components/reader-language-freshness.test.tsx
|
||||
//
|
||||
// 守 audit 2026-10-21 (r252):Reader 的文案必须**跟着界面语言**走。
|
||||
//
|
||||
// 两处「烤死语言」的坑:
|
||||
// ① toast:「在系统应用打开」失败时的 toast 走 useCallback([file?.absPath])
|
||||
// —— react-i18next 的 t 是 `i18n.getFixedT(lng)`,身份跟着语言走;
|
||||
// deps 里没有 t ⇒ 用户在设置里换语言后回调**不重建** ⇒ 闭包里的 t
|
||||
// 停在旧语言 ⇒ toast 用旧语言渲染(实测 getFixedT('zh-CN') 与
|
||||
// getFixedT('en-US') 是两个不同函数,身份不等)。
|
||||
// ② 错误描述:`t('reader.errorGeneric')` / `t('reader.errorFileFormatCorrupt')`
|
||||
// 在**出错那一刻**翻译好、烤进 state ⇒ 换语言后描述还是旧语言,
|
||||
// 而标题(渲染期翻译)是新语言 ⇒ **中英混排**。
|
||||
//
|
||||
// 本测用一份**可控语言**的 i18n mock(模拟 react-i18next 的真实语义:
|
||||
// 语言不变 ⇒ t 身份稳定;语言变 ⇒ t 换新身份),锁:
|
||||
// ① 换语言后 toast 用新语言;
|
||||
// ② 换语言后错误描述用新语言(且标题/描述同语言,不混排)。
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent, cleanup, act } from '@testing-library/react';
|
||||
|
||||
// ── 可控语言的 i18n mock(行为对齐 react-i18next)────────────────────────────
|
||||
// t 的身份:每个语言一个固定函数 ⇒ 语言不变时稳定(不会导致无谓重建),
|
||||
// 换语言 ⇒ 换新身份(依赖 t 的 useCallback 会重建)。
|
||||
const i18nMock = vi.hoisted(() => {
|
||||
const state = { lang: 'zh-CN' };
|
||||
const dict: Record<string, Record<string, string>> = {
|
||||
'reader.errorGeneric': { 'zh-CN': '无法显示该文件', 'en-US': 'Cannot display this file' },
|
||||
'reader.errorFileFormatCorrupt': {
|
||||
'zh-CN': '文件内容格式异常',
|
||||
'en-US': 'File content format is invalid',
|
||||
},
|
||||
'reader.errorOpenExternal': {
|
||||
'zh-CN': '在系统应用打开',
|
||||
'en-US': 'Open in system app',
|
||||
},
|
||||
'reader.errorRetry': { 'zh-CN': '重试', 'en-US': 'Retry' },
|
||||
'toast.openFailed': { 'zh-CN': '打开失败: {msg}', 'en-US': 'Open failed: {msg}' },
|
||||
};
|
||||
const cache = new Map<string, (k: string, opts?: { msg?: string }) => string>();
|
||||
function makeT(lang: string) {
|
||||
let fn = cache.get(lang);
|
||||
if (!fn) {
|
||||
fn = (k: string, opts?: { msg?: string }) => {
|
||||
const s = dict[k]?.[lang] ?? k;
|
||||
return opts?.msg !== undefined ? s.replace('{msg}', opts.msg) : s;
|
||||
};
|
||||
cache.set(lang, fn);
|
||||
}
|
||||
return fn;
|
||||
}
|
||||
return { state, makeT };
|
||||
});
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: i18nMock.makeT(i18nMock.state.lang) }),
|
||||
}));
|
||||
|
||||
const readFileForReader = vi.fn();
|
||||
const openPath = vi.fn();
|
||||
vi.mock('../../../../src/renderer/src/lib/api', () => ({
|
||||
api: {
|
||||
readFileForReader: (...args: unknown[]) => readFileForReader(...args),
|
||||
openPath: (...args: unknown[]) => openPath(...args),
|
||||
},
|
||||
unwrap: (r: unknown) => r,
|
||||
IpcError: class IpcError extends Error {},
|
||||
asIpcError: () => null,
|
||||
hasApi: () => true,
|
||||
}));
|
||||
|
||||
import { Reader } from '../../../../src/renderer/src/components/reader/Reader';
|
||||
import { useToast } from '../../../../src/renderer/src/lib/toast';
|
||||
|
||||
const FILE = {
|
||||
id: 'f1',
|
||||
extension: '.txt',
|
||||
absPath: '/tmp/rkms-lang/a.txt',
|
||||
filename: 'a.txt',
|
||||
};
|
||||
|
||||
describe('Reader 文案必须跟着界面语言走(r252)', () => {
|
||||
beforeEach(() => {
|
||||
i18nMock.state.lang = 'zh-CN';
|
||||
readFileForReader.mockReset();
|
||||
openPath.mockReset();
|
||||
useToast.setState({ toasts: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('换语言后,「在系统应用打开」失败的 toast 用新语言', async () => {
|
||||
readFileForReader.mockResolvedValue({ ok: false, error: { code: 'IO_ERROR', message: 'boom' } });
|
||||
openPath.mockRejectedValue(new Error('boom'));
|
||||
|
||||
const { rerender } = render(<Reader file={{ ...FILE }} />);
|
||||
await waitFor(() => expect(screen.getByText('在系统应用打开')).toBeTruthy());
|
||||
|
||||
// 中文:点一次 → toast 是中文
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('在系统应用打开'));
|
||||
});
|
||||
expect(useToast.getState().toasts.at(-1)?.message).toBe('打开失败: boom');
|
||||
|
||||
// 换语言(真实场景:设置里改「界面语言」⇒ i18n 触发整棵树重渲)
|
||||
i18nMock.state.lang = 'en-US';
|
||||
rerender(<Reader file={{ ...FILE }} />);
|
||||
await waitFor(() => expect(screen.getByText('Open in system app')).toBeTruthy());
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('Open in system app'));
|
||||
});
|
||||
expect(
|
||||
useToast.getState().toasts.at(-1)?.message,
|
||||
'换语言后 toast 必须用新语言(旧实现:回调不重建 ⇒ 停在旧语言)',
|
||||
).toBe('Open failed: boom');
|
||||
});
|
||||
|
||||
it('换语言后,错误描述用新语言(标题与描述同语言,不混排)', async () => {
|
||||
// 走「文件内容格式异常」那条路径:返回的 data 不是 Uint8Array
|
||||
readFileForReader.mockResolvedValue({ ok: true, data: { data: 'not-bytes' } });
|
||||
|
||||
const { rerender } = render(<Reader file={{ ...FILE }} />);
|
||||
await waitFor(() => expect(screen.getByText('文件内容格式异常')).toBeTruthy());
|
||||
expect(screen.getByText('无法显示该文件'), '标题此刻是中文').toBeTruthy();
|
||||
|
||||
i18nMock.state.lang = 'en-US';
|
||||
rerender(<Reader file={{ ...FILE }} />);
|
||||
await waitFor(() => expect(screen.getByText('File content format is invalid')).toBeTruthy());
|
||||
expect(
|
||||
screen.queryByText('文件内容格式异常'),
|
||||
'旧语言的描述不得残留(旧实现:翻译结果被烤进 state)',
|
||||
).toBeNull();
|
||||
expect(screen.getByText('Cannot display this file'), '标题也跟着换').toBeTruthy();
|
||||
});
|
||||
});
|
||||
229
tests/unit/renderer/components/search/SearchPanel.test.tsx
Normal file
229
tests/unit/renderer/components/search/SearchPanel.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>');
|
||||
});
|
||||
});
|
||||
99
tests/unit/renderer/components/sidebar-op-parity.test.ts
Normal file
99
tests/unit/renderer/components/sidebar-op-parity.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
// tests/unit/renderer/components/sidebar-op-parity.test.ts
|
||||
//
|
||||
// 守用户诉求「左侧的文献和笔记列表 … 相关的文件操作也是对应的」——
|
||||
// 两个侧栏的**文件行右键菜单**必须提供同一组操作。
|
||||
//
|
||||
// 旧状态(实测):笔记行有 rename / cut / copy / paste / showInFolder / trash,
|
||||
// 文献行只有 cut / copy / paste / showInFolder / delete —— **没有重命名**。
|
||||
// 用户视角:笔记能右键改名,文献不能;想在文献上改名只能去资源管理器,
|
||||
// 改完 app 里还是旧名(两侧行为不对应)。
|
||||
//
|
||||
// r187 给文献域补上 rename(literature:rename-file 通道 + 服务层 + 弹窗),
|
||||
// 本测把「两侧操作集相同」这条契约锁住 —— 以后任一侧删/加操作都会红。
|
||||
//
|
||||
// 为什么用源码扫描而不是渲染测:
|
||||
// NotesList / LiteratureList 都依赖 react-virtuoso,jsdom 下容器高度为 0,
|
||||
// 行根本不渲染(同 import-no-auto-open.test.ts 的理由),右键菜单更是
|
||||
// 交互后才出现 ⇒ 退到源码契约。
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
const NOTES = 'src/renderer/src/components/notes/NotesList.tsx';
|
||||
const LIT = 'src/renderer/src/components/literature/LiteratureList.tsx';
|
||||
|
||||
/**
|
||||
* 抽出 `const menuItems = useMemo<ContextMenuItem[]>(() => { ... })` 的
|
||||
* 回调体文本(花括号配平;先剥注释避免注释里的花括号干扰配平)。
|
||||
*/
|
||||
function extractMenuItemsBody(fileRel: string): string {
|
||||
const src = readFileSync(join(ROOT, fileRel), 'utf8');
|
||||
const marker = 'const menuItems = useMemo<ContextMenuItem[]>(() => {';
|
||||
const start = src.indexOf(marker);
|
||||
expect(start, `${fileRel} 里找不到 ${marker}`).toBeGreaterThan(-1);
|
||||
const open = src.indexOf('{', start);
|
||||
const stripped = src
|
||||
.slice(open)
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/^[ \t]*\/\/.*$/gm, '');
|
||||
let depth = 0;
|
||||
for (let i = 0; i < stripped.length; i += 1) {
|
||||
const ch = stripped[i];
|
||||
if (ch === '{') depth += 1;
|
||||
else if (ch === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return stripped.slice(0, i);
|
||||
}
|
||||
}
|
||||
throw new Error(`${fileRel}: menuItems 回调体花括号没配平`);
|
||||
}
|
||||
|
||||
/** 收集回调体里的菜单项 id(只取 `id: 'xxx'` 形态的字面量)。 */
|
||||
function menuItemIds(body: string): string[] {
|
||||
const out: string[] = [];
|
||||
const re = /\bid:\s*'([a-zA-Z0-9_-]+)'/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(body)) !== null) {
|
||||
out.push(m[1]!);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 两侧的语义对齐表:notes 的 'trash' 与 literature 的 'delete' 是同一件事
|
||||
* (都把文件移进系统回收站),归一到 'delete' 再比。 */
|
||||
const ALIAS: Record<string, string> = { trash: 'delete' };
|
||||
|
||||
function normalizedSet(fileRel: string): Set<string> {
|
||||
const ids = menuItemIds(extractMenuItemsBody(fileRel));
|
||||
return new Set(ids.filter((id) => id !== 'sep1' && id !== 'sep2').map((id) => ALIAS[id] ?? id));
|
||||
}
|
||||
|
||||
describe('两侧栏文件行操作集对应(r187)', () => {
|
||||
it('文献行菜单与笔记行菜单提供同一组操作', () => {
|
||||
const notes = normalizedSet(NOTES);
|
||||
const lit = normalizedSet(LIT);
|
||||
// 双方都必须有的操作
|
||||
for (const op of ['rename', 'cut', 'copy', 'paste', 'showInFolder', 'delete']) {
|
||||
expect(notes.has(op), `notes 侧缺「${op}」`).toBe(true);
|
||||
expect(lit.has(op), `literature 侧缺「${op}」`).toBe(true);
|
||||
}
|
||||
// 且不许出现「只有一侧有」的操作(两侧集合完全相等)
|
||||
const onlyNotes = [...notes].filter((op) => !lit.has(op));
|
||||
const onlyLit = [...lit].filter((op) => !notes.has(op));
|
||||
expect({ onlyNotes, onlyLit }).toEqual({ onlyNotes: [], onlyLit: [] });
|
||||
});
|
||||
|
||||
it('文献侧重命名真的接到了 store.renameFile(不是空壳菜单项)', () => {
|
||||
const src = readFileSync(join(ROOT, LIT), 'utf8');
|
||||
// store 取用
|
||||
expect(src).toMatch(/useLiteratureStore\(\(s\) => s\.renameFile\)/);
|
||||
// 菜单项回调真的调 openRename
|
||||
const body = extractMenuItemsBody(LIT);
|
||||
expect(body).toMatch(/id:\s*'rename'/);
|
||||
expect(body).toMatch(/openRename\(/);
|
||||
// openRename 真的调 store 的 renameFile
|
||||
expect(src).toMatch(/await renameFile\(/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
// tests/unit/renderer/components/standalone-editor-id-drift.test.tsx
|
||||
//
|
||||
// 守 r192「保存时 id 漂移」在**编辑器**这一侧的行为契约。
|
||||
//
|
||||
// 场景(用户可复现):
|
||||
// 1. 用户打开一篇笔记,改了标题(标记 dirty);
|
||||
// 2. 自动保存触发 —— 主进程把 frontmatter id 重钉为路径哈希,返回**新 id**;
|
||||
// 3. 用户**在保存往返期间**又敲了几个字(新内容还没落盘);
|
||||
// 4. renderer 拿到新 id,编辑器收到的 note prop 的 id 变了。
|
||||
//
|
||||
// 旧行为(两处都丢字):
|
||||
// - 编辑器把「id 变了」当成**切到另一篇笔记**:用**已死的旧 id** 去 flush
|
||||
// (主进程已删掉旧 byId 键)⇒ 必撞 NOT_FOUND ⇒ 正文只进 failedDrafts
|
||||
// (旧 id 键)⇒ 再也读不回来;
|
||||
// - flush 之后 setBody(note.body) / setTitle(note.title) 把用户在保存之后
|
||||
// 新敲的字**直接擦掉**(note.body 是保存那一刻的快照,不含第 3 步的字)。
|
||||
//
|
||||
// 本测锁(行为级,只锁用户可见的行为,不锁实现):
|
||||
// 1. 同一个 relPath、id 变了 ⇒ **不得**再用旧 id 发起保存;
|
||||
// 2. 本地正文 / 标题**不被**回滚(用户保存后敲的字还在);
|
||||
// 3. 反面对照:换成**另一篇**笔记(relPath 不同)⇒ 仍然照旧 flush 旧笔记
|
||||
// (不能把「切笔记」这条路一起关掉)。
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, fireEvent, screen, act, cleanup } from '@testing-library/react';
|
||||
|
||||
// i18n:只关心交互,不关心文案 —— key 原样返回
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
// workspace store:编辑器只用到 pendingSaveActiveNote.version(⌘S 通道)
|
||||
// + consumeSaveActiveNoteRequest();本测不走 ⌘S,后者恒 false。
|
||||
vi.mock('../../../../src/renderer/src/store/workspace', () => {
|
||||
const state = {
|
||||
pendingSaveActiveNote: { version: 0 },
|
||||
consumeSaveActiveNoteRequest: () => false,
|
||||
};
|
||||
const useWorkspaceStore = ((selector?: (s: unknown) => unknown) =>
|
||||
selector ? selector(state) : state) as unknown as {
|
||||
(selector?: (s: unknown) => unknown): unknown;
|
||||
getState: () => unknown;
|
||||
};
|
||||
useWorkspaceStore.getState = () => state;
|
||||
return { useWorkspaceStore };
|
||||
});
|
||||
|
||||
// notes store:编辑器只在两处读它 ——
|
||||
// 1) 切笔记 flush 前查「旧笔记还在不在列表里」(mustContain 旧 id,否则
|
||||
// 走 skip 分支,测不到死 id flush);
|
||||
// 2) ⌘S 通道读 selectedId(本测不走)。
|
||||
vi.mock('../../../../src/renderer/src/store/notes', () => {
|
||||
const state = {
|
||||
notes: [{ id: 'oldid', relPath: 'a.md' }],
|
||||
selectedId: 'oldid',
|
||||
};
|
||||
return {
|
||||
useNotesStore: {
|
||||
getState: () => state,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// MarkdownEditor 是 CodeMirror(jsdom 里不可测)⇒ 换成普通 textarea,
|
||||
// 行为等价(受控 value + onChange(string))。
|
||||
vi.mock('../../../../src/renderer/src/components/editor/MarkdownEditor', () => ({
|
||||
MarkdownEditor: (props: { value: string; onChange: (v: string) => void }) =>
|
||||
(
|
||||
<textarea
|
||||
data-testid="body-editor"
|
||||
value={props.value}
|
||||
onChange={(e) => props.onChange(e.target.value)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
import { StandaloneNoteEditorCore } from '../../../../src/renderer/src/components/notes/StandaloneNoteEditorCore';
|
||||
import type { StandaloneNote } from '../../../../src/shared/types';
|
||||
|
||||
const OLD_ID = 'oldid';
|
||||
const NEW_ID = 'newid';
|
||||
|
||||
function note(id: string, relPath: string, body: string, title = 'T1'): StandaloneNote {
|
||||
return {
|
||||
id,
|
||||
relPath,
|
||||
filename: relPath.split('/').pop() ?? relPath,
|
||||
title,
|
||||
tags: null,
|
||||
mtime: 1,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
describe('StandaloneNoteEditorCore:id 漂移 ≠ 切笔记(r192)', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
// 假定时器:autosave 防抖(默认 1.5s)不能在测试里自己 fire ——
|
||||
// 本测只关心「手动点保存」这一条路径。
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('同一文件换了 id:不得用旧 id 发起保存,且本地标题不被回滚', async () => {
|
||||
let resolveSave: (() => void) | undefined;
|
||||
const onSave = vi.fn(
|
||||
(_id: string, _body: string, _title: string) =>
|
||||
new Promise<void>((res) => {
|
||||
resolveSave = res;
|
||||
}),
|
||||
);
|
||||
|
||||
const { rerender } = render(
|
||||
<StandaloneNoteEditorCore note={note(OLD_ID, 'a.md', 'B1')} onSave={onSave} titleEditable />,
|
||||
);
|
||||
|
||||
const titleInput = screen.getByLabelText('common.noteTitleAria') as HTMLInputElement;
|
||||
const bodyInput = screen.getByTestId('body-editor') as HTMLTextAreaElement;
|
||||
|
||||
// 1. 改标题 + 改正文 → dirty
|
||||
fireEvent.change(titleInput, { target: { value: 'T2' } });
|
||||
fireEvent.change(bodyInput, { target: { value: 'B2' } });
|
||||
expect(titleInput.value).toBe('T2');
|
||||
|
||||
// 2. 手动保存(autosave 被假定时器压住)→ onSave 以**旧 id** 发起
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.save/ }));
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
expect(onSave.mock.calls[0]![0]).toBe(OLD_ID);
|
||||
expect(onSave.mock.calls[0]![1]).toBe('B2');
|
||||
|
||||
// 3. 保存往返期间用户又敲了字(这些字**还没落盘**)
|
||||
fireEvent.change(titleInput, { target: { value: 'T3' } });
|
||||
fireEvent.change(bodyInput, { target: { value: 'B3' } });
|
||||
|
||||
// 4. 保存落地 —— 主进程把 fm.id 重钉后返回新 id
|
||||
await act(async () => {
|
||||
resolveSave?.();
|
||||
});
|
||||
|
||||
// 5. renderer 拿到新 id(store 已重映射)⇒ 编辑器收到的 note 换了 id,
|
||||
// 但**同一个文件**(relPath 不变),body/title 是保存那一刻的快照。
|
||||
rerender(
|
||||
<StandaloneNoteEditorCore
|
||||
note={note(NEW_ID, 'a.md', 'B2', 'T2')}
|
||||
onSave={onSave}
|
||||
titleEditable
|
||||
/>,
|
||||
);
|
||||
|
||||
// 断言 1:不得拿旧 id(已死)再发起一次保存
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
onSave.mock.calls.some((call) => call[0] === OLD_ID && call[1] !== 'B2'),
|
||||
'漂移后不得用旧 id 发起 flush',
|
||||
).toBe(false);
|
||||
|
||||
// 断言 2:用户保存后敲的字不能被回滚
|
||||
expect((screen.getByLabelText('common.noteTitleAria') as HTMLInputElement).value).toBe('T3');
|
||||
expect((screen.getByTestId('body-editor') as HTMLTextAreaElement).value).toBe('B3');
|
||||
});
|
||||
|
||||
it('反面对照:真的换到另一篇笔记(relPath 不同)⇒ 仍然照旧把旧笔记 flush 掉', async () => {
|
||||
const onSave = vi.fn((_id: string, _body: string, _title: string) => Promise.resolve());
|
||||
|
||||
const { rerender } = render(
|
||||
<StandaloneNoteEditorCore note={note(OLD_ID, 'a.md', 'B1')} onSave={onSave} titleEditable />,
|
||||
);
|
||||
|
||||
const titleInput = screen.getByLabelText('common.noteTitleAria') as HTMLInputElement;
|
||||
fireEvent.change(titleInput, { target: { value: 'T2' } });
|
||||
|
||||
// 切到**另一篇**笔记 —— 旧笔记必须被 flush(否则这段字就丢了)
|
||||
rerender(
|
||||
<StandaloneNoteEditorCore note={note('other', 'b.md', 'X1')} onSave={onSave} titleEditable />,
|
||||
);
|
||||
|
||||
const flushCalls = onSave.mock.calls.filter((call) => call[0] === OLD_ID);
|
||||
expect(flushCalls.length, '切到另一篇笔记时,旧笔记必须照旧 flush').toBe(1);
|
||||
expect(flushCalls[0]![2]).toBe('T2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
// tests/unit/renderer/components/standalone-editor-note-moved.test.tsx
|
||||
//
|
||||
// 守 audit 2026-10-21(P0 数据丢失):「笔记被搬走 / 改名」时,编辑器里**未落盘**
|
||||
// 的内容必须落到**新 id** 上,而不是主进程已经删掉的旧 id。
|
||||
//
|
||||
// 旧行为(两条路都丢字,且**永久**丢):
|
||||
// 1. 主进程的笔记 id = relPath 的哈希(standalone.ts repin 规则)⇒ 搬移/改名
|
||||
// 后同一个文件换了 id,旧 id 在 byId 里被 delete ⇒ 成了**死键**;
|
||||
// 2. 编辑器判「id 变了 = 换了另一篇笔记」用的是 relPath(r192 的漂移判据)
|
||||
// ⇒ 搬移/改名**必然**改 relPath ⇒ 必然被判成「用户切了笔记」;
|
||||
// 3. 于是按**旧 id** flush ⇒ 主进程 NOT_FOUND ⇒ 内容只进 failedDrafts
|
||||
// (**旧 id 键**)⇒ 而笔记现在的新 id 取不到那份草稿 ⇒ 这段字永久消失。
|
||||
//
|
||||
// 本测锁(行为级,只锁用户可见的行为):
|
||||
// M1. 搬移:flush 必须打到**新 id**;本地正文不得被搬移前的快照回滚。
|
||||
// M2. 改名(full 为 null ⇒ 编辑器整条链卸载):flush 同样打到**新 id**。
|
||||
// M3. 对照:真的切到**另一篇**笔记(没有重映射)⇒ 仍然照旧 flush 旧 id。
|
||||
// M4. store 层:搬移后 notes[] 里不得留着死 id(否则右侧渲染「笔记已不可用」,
|
||||
// 而且编辑器被卸载 ⇒ 未落盘内容只能走卸载 flush)。
|
||||
// M5. store 层:搬移后的保存不得带 expectedMtime —— 主进程 repin 重写了文件,
|
||||
// 磁盘 mtime 必然变,带搬移前的旧 mtime 必报**假 CONFLICT**(「文件被外部
|
||||
// 修改」,其实是我们自己搬的)。
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, fireEvent, screen, act, cleanup } from '@testing-library/react';
|
||||
|
||||
// i18n:只关心交互,不关心文案 —— key 原样返回
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
// workspace store:编辑器只用到 pendingSaveActiveNote.version(⌘S 通道)
|
||||
// + consumeSaveActiveNoteRequest();本测不走 ⌘S,后者恒 false。
|
||||
// 注意:编辑器是**当 hook 用**的(useWorkspaceStore(selector))⇒ mock 必须是
|
||||
// 可调用的,不能只给 getState。
|
||||
vi.mock('../../../../src/renderer/src/store/workspace', () => {
|
||||
const state = {
|
||||
pendingSaveActiveNote: { version: 0 },
|
||||
consumeSaveActiveNoteRequest: () => false,
|
||||
};
|
||||
const useWorkspaceStore = ((selector?: (s: unknown) => unknown) =>
|
||||
selector ? selector(state) : state) as unknown as {
|
||||
(selector?: (s: unknown) => unknown): unknown;
|
||||
getState: () => unknown;
|
||||
};
|
||||
useWorkspaceStore.getState = () => state;
|
||||
return { useWorkspaceStore };
|
||||
});
|
||||
|
||||
// toast:store 的 CONFLICT 分支会弹 toast,本测不让它真的弹。
|
||||
vi.mock('../../../../src/renderer/src/lib/toast', () => ({
|
||||
useToast: { getState: () => ({ show: vi.fn() }) },
|
||||
}));
|
||||
|
||||
// MarkdownEditor 是 CodeMirror(jsdom 里不可测)⇒ 换成普通 textarea,
|
||||
// 行为等价(受控 value + onChange(string))。
|
||||
vi.mock('../../../../src/renderer/src/components/editor/MarkdownEditor', () => ({
|
||||
MarkdownEditor: (props: { value: string; onChange: (v: string) => void }) => (
|
||||
<textarea
|
||||
data-testid="body-editor"
|
||||
value={props.value}
|
||||
onChange={(e) => props.onChange(e.target.value)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
import { useNotesStore } from '../../../../src/renderer/src/store/notes';
|
||||
import { resetNoteIdRemap } from '../../../../src/renderer/src/lib/note-id-remap';
|
||||
import { StandaloneNoteEditorCore } from '../../../../src/renderer/src/components/notes/StandaloneNoteEditorCore';
|
||||
import type { StandaloneNote } from '../../../../src/shared/types';
|
||||
|
||||
const OLD_ID = 'old-note';
|
||||
const NEW_ID = 'new-note';
|
||||
|
||||
interface MockApi {
|
||||
writeNote: ReturnType<typeof vi.fn>;
|
||||
readNote: ReturnType<typeof vi.fn>;
|
||||
listNotes: ReturnType<typeof vi.fn>;
|
||||
renameNoteFile: ReturnType<typeof vi.fn>;
|
||||
onNotesChanged: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function note(id: string, relPath: string, body: string, title = 'T1', mtime = 111): StandaloneNote {
|
||||
return {
|
||||
id,
|
||||
relPath,
|
||||
filename: relPath.split('/').pop() ?? relPath,
|
||||
title,
|
||||
tags: null,
|
||||
mtime,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
let api: MockApi;
|
||||
|
||||
function setupApi(): void {
|
||||
api = {
|
||||
writeNote: vi.fn((id: string, body: string, title: string) =>
|
||||
Promise.resolve({ ok: true, data: note(id, 'a.md', body, title, 222) }),
|
||||
),
|
||||
readNote: vi.fn(),
|
||||
listNotes: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||
renameNoteFile: vi.fn(),
|
||||
onNotesChanged: vi.fn().mockReturnValue(() => {}),
|
||||
};
|
||||
(window as unknown as { api: MockApi }).api = api;
|
||||
}
|
||||
|
||||
function seedStore(n: StandaloneNote): void {
|
||||
useNotesStore.setState({
|
||||
notes: [
|
||||
{
|
||||
id: n.id,
|
||||
relPath: n.relPath,
|
||||
filename: n.filename,
|
||||
title: n.title,
|
||||
tags: n.tags,
|
||||
mtime: n.mtime,
|
||||
},
|
||||
],
|
||||
selectedId: n.id,
|
||||
selectedIds: new Set<string>([n.id]),
|
||||
anchorId: n.id,
|
||||
activeNoteFull: n,
|
||||
loadingFull: false,
|
||||
clipboard: null,
|
||||
lastError: null,
|
||||
displayOrderById: {},
|
||||
});
|
||||
}
|
||||
|
||||
describe('笔记被搬走/改名:未落盘内容必须落到新 id(audit 2026-10-21 P0)', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
// 登记表是模块级的 ⇒ 每个用例重来,别让上一个用例的映射串进来。
|
||||
resetNoteIdRemap();
|
||||
setupApi();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('M1. 搬移:未落盘内容必须按新 id 落盘,且本地正文不被搬移前的快照回滚', () => {
|
||||
const target = note(OLD_ID, 'a.md', 'B0');
|
||||
seedStore(target);
|
||||
|
||||
const onSave = (id: string, body: string, title: string): Promise<void> =>
|
||||
useNotesStore.getState().save(id, body, title).then(() => undefined);
|
||||
|
||||
const { rerender } = render(
|
||||
<StandaloneNoteEditorCore note={target} onSave={onSave} titleEditable />,
|
||||
);
|
||||
|
||||
// 用户在搬移前敲了字(还没落盘)
|
||||
fireEvent.change(screen.getByTestId('body-editor'), { target: { value: 'B1' } });
|
||||
|
||||
// 搬移:主进程 fs.rename + repin ⇒ id 变了;store 收到 idRemap
|
||||
act(() => {
|
||||
useNotesStore.getState().applyMoveRemap(
|
||||
new Map([[OLD_ID, { id: NEW_ID, relPath: 'sub/a.md' }]]),
|
||||
);
|
||||
});
|
||||
|
||||
// 父组件按新 store 状态重渲(真实路径:selectedId / activeNoteFull 都换了)
|
||||
const st = useNotesStore.getState();
|
||||
expect(st.activeNoteFull?.id, 'store 必须把 activeNoteFull 换到新 id').toBe(NEW_ID);
|
||||
rerender(
|
||||
<StandaloneNoteEditorCore note={st.activeNoteFull!} onSave={onSave} titleEditable />,
|
||||
);
|
||||
|
||||
// 断言 1:flush 必须打到**新 id**(旧 id 已是主进程死键)
|
||||
const calls = api.writeNote.mock.calls;
|
||||
expect(calls.length, '搬移后必须补落盘一次').toBeGreaterThan(0);
|
||||
expect(
|
||||
calls.every((c) => c[0] !== OLD_ID),
|
||||
'绝不能按旧 id 写 —— 那是主进程的死键(必撞 NOT_FOUND ⇒ 永久丢字)',
|
||||
).toBe(true);
|
||||
expect(calls[0]![0], '必须按新 id 写').toBe(NEW_ID);
|
||||
expect(calls[0]![1], '必须带上用户刚敲的正文').toBe('B1');
|
||||
|
||||
// 断言 2:本地正文不得被「搬移前」的 note.body 回滚(否则用户刚敲的字消失)
|
||||
expect((screen.getByTestId('body-editor') as HTMLTextAreaElement).value).toBe('B1');
|
||||
});
|
||||
|
||||
it('M2. 改名(full 清空 ⇒ 编辑器卸载):未落盘内容同样必须按新 id 落盘', async () => {
|
||||
const target = note(OLD_ID, 'a.md', 'B0');
|
||||
seedStore(target);
|
||||
|
||||
const renamed = note(NEW_ID, 'renamed.md', 'B0');
|
||||
api.renameNoteFile.mockResolvedValue({ ok: true, data: renamed });
|
||||
api.listNotes.mockResolvedValue({
|
||||
ok: true,
|
||||
data: [
|
||||
{
|
||||
id: NEW_ID,
|
||||
relPath: 'renamed.md',
|
||||
filename: 'renamed.md',
|
||||
title: renamed.title,
|
||||
tags: null,
|
||||
mtime: 222,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const onSave = (id: string, body: string, title: string): Promise<void> =>
|
||||
useNotesStore.getState().save(id, body, title).then(() => undefined);
|
||||
|
||||
const { rerender } = render(
|
||||
<StandaloneNoteEditorCore note={target} onSave={onSave} titleEditable />,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByTestId('body-editor'), { target: { value: 'B1' } });
|
||||
|
||||
// 改名:主进程 repin frontmatter id ⇒ 旧 id 成死键
|
||||
await act(async () => {
|
||||
await useNotesStore.getState().renameFile(OLD_ID, 'renamed.md');
|
||||
});
|
||||
|
||||
// 真实路径:activeNoteFull 被清空 ⇒ NotesView 渲染 Skeleton ⇒ 编辑器卸载
|
||||
rerender(<div />);
|
||||
|
||||
const calls = api.writeNote.mock.calls;
|
||||
expect(calls.length, '改名后必须补落盘一次').toBeGreaterThan(0);
|
||||
expect(calls[0]![0], '必须按新 id 写').toBe(NEW_ID);
|
||||
expect(calls[0]![1], '必须带上用户刚敲的正文').toBe('B1');
|
||||
});
|
||||
|
||||
it('M3. 对照:真的切到另一篇笔记(无重映射)⇒ 仍然照旧 flush 旧 id', () => {
|
||||
const target = note(OLD_ID, 'a.md', 'B0');
|
||||
seedStore(target);
|
||||
|
||||
const onSave = (id: string, body: string, title: string): Promise<void> =>
|
||||
useNotesStore.getState().save(id, body, title).then(() => undefined);
|
||||
|
||||
const { rerender } = render(
|
||||
<StandaloneNoteEditorCore note={target} onSave={onSave} titleEditable />,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByTestId('body-editor'), { target: { value: 'B1' } });
|
||||
|
||||
// 切到**另一篇**笔记(没有重映射登记)
|
||||
rerender(
|
||||
<StandaloneNoteEditorCore note={note('other', 'c.md', 'X0')} onSave={onSave} titleEditable />,
|
||||
);
|
||||
|
||||
const calls = api.writeNote.mock.calls;
|
||||
expect(calls.length, '切笔记时旧笔记必须被 flush').toBeGreaterThan(0);
|
||||
expect(calls[0]![0], '切笔记 ⇒ 照旧写回旧 id(旧笔记还活着)').toBe(OLD_ID);
|
||||
expect(calls[0]![1]).toBe('B1');
|
||||
});
|
||||
|
||||
it('M4. 搬移后 notes[] 里不得留着死 id(否则右侧渲染「笔记已不可用」)', () => {
|
||||
const target = note(OLD_ID, 'a.md', 'B0');
|
||||
seedStore(target);
|
||||
|
||||
act(() => {
|
||||
useNotesStore.getState().applyMoveRemap(
|
||||
new Map([[OLD_ID, { id: NEW_ID, relPath: 'sub/a.md' }]]),
|
||||
);
|
||||
});
|
||||
|
||||
const st = useNotesStore.getState();
|
||||
expect(
|
||||
st.notes.some((n) => n.id === OLD_ID),
|
||||
'列表里不得留着死 id(点它必然 NOT_FOUND)',
|
||||
).toBe(false);
|
||||
const moved = st.notes.find((n) => n.id === NEW_ID);
|
||||
expect(moved, '列表里必须有新 id 那一行').toBeTruthy();
|
||||
expect(moved?.relPath, '新行的 relPath 必须跟着搬').toBe('sub/a.md');
|
||||
});
|
||||
|
||||
it('M5. 搬移后的保存不得带 expectedMtime(否则必报假 CONFLICT)', async () => {
|
||||
const target = note(OLD_ID, 'a.md', 'B0', 'T1', 111);
|
||||
seedStore(target);
|
||||
|
||||
act(() => {
|
||||
useNotesStore.getState().applyMoveRemap(
|
||||
new Map([[OLD_ID, { id: NEW_ID, relPath: 'sub/a.md' }]]),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await useNotesStore.getState().save(NEW_ID, 'B1', 'T1');
|
||||
});
|
||||
|
||||
const call = api.writeNote.mock.calls.at(-1)!;
|
||||
expect(call[0]).toBe(NEW_ID);
|
||||
expect(
|
||||
call[3],
|
||||
'搬移后磁盘 mtime 必然变(repin 重写了文件)⇒ 不得再拿旧 mtime 比对',
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
// tests/unit/renderer/components/system/error-boundary-fallback.test.tsx
|
||||
//
|
||||
// 守 ErrorBoundary 的三契约(src/renderer/src/components/system/ErrorBoundary.tsx
|
||||
// 头注指向本文件,audit-r112 登记,本轮补齐 —— 原先标 🔒(守卫缺失)):
|
||||
//
|
||||
// 1. **吞抛**(onError 自身失败不能再次触发 boundary)
|
||||
// 上报回调通常是网络/遥测调用,它在 boundary 之外抛错 ⇒ React 会
|
||||
// 把它当成新的渲染错误,boundary 已经在处理错误态 ⇒ 结果是
|
||||
// 「错误处理又炸了」,用户看到的是崩溃而不是可重试的兜底界面。
|
||||
// 2. **reset 重挂载**
|
||||
// 兜底界面上的「重试」必须真的把子树重新挂载(而不是只把错误态
|
||||
// 清掉后继续渲染同一棵已炸的树)。
|
||||
// 3. **fallback 优先**
|
||||
// 调用方给了自定义 fallback ⇒ 用它,不用内置 ErrorFallback。
|
||||
//
|
||||
// 附带守:无错时正常透传 children(不劫持正常渲染路径)。
|
||||
//
|
||||
// 变异思路:去掉 try/catch ⇒ 例 1 红;reset 不 setState ⇒ 例 2 红;
|
||||
// 忽略 props.fallback ⇒ 例 3 红。
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../../src/renderer/src/lib/toast', () => ({
|
||||
useToast: () => () => undefined,
|
||||
}));
|
||||
|
||||
import { ErrorBoundary } from '../../../../../src/renderer/src/components/system/ErrorBoundary';
|
||||
|
||||
/** 必炸的子组件 —— 用来验证 boundary 真的接住了。 */
|
||||
function Boom({ onRender }: { onRender?: () => void }): React.ReactElement {
|
||||
onRender?.();
|
||||
throw new Error('boom');
|
||||
}
|
||||
|
||||
describe('ErrorBoundary 契约(audit-r112 补齐)', () => {
|
||||
let consoleError: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
// React 在捕获到渲染错误时必然打 console.error —— 这里静音,
|
||||
// 只留断言本身说话。
|
||||
consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => {
|
||||
consoleError.mockRestore();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('无错时正常渲染 children(不劫持正常路径)', () => {
|
||||
render(
|
||||
<ErrorBoundary name="Ok">
|
||||
<div data-testid="child">hello</div>
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByTestId('child').textContent).toBe('hello');
|
||||
});
|
||||
|
||||
it('子组件抛错 ⇒ 渲染内置 ErrorFallback(role=alert),不是白屏', () => {
|
||||
render(
|
||||
<ErrorBoundary name="Reader">
|
||||
<Boom />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByRole('alert')).toBeTruthy();
|
||||
expect(screen.queryByTestId('child')).toBeNull();
|
||||
});
|
||||
|
||||
it('onError 被调用,并带上 error 与 componentStack', () => {
|
||||
const onError = vi.fn();
|
||||
render(
|
||||
<ErrorBoundary name="Reader" onError={onError}>
|
||||
<Boom />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
const [err, info] = onError.mock.calls[0];
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect((err as Error).message).toBe('boom');
|
||||
expect(info).toHaveProperty('componentStack');
|
||||
});
|
||||
|
||||
it('onError 自身抛错 ⇒ 被吞掉,兜底界面照常显示(错误处理不二次爆炸)', () => {
|
||||
const onError = vi.fn(() => {
|
||||
throw new Error('reporter down');
|
||||
});
|
||||
expect(() =>
|
||||
render(
|
||||
<ErrorBoundary name="Reader" onError={onError}>
|
||||
<Boom />
|
||||
</ErrorBoundary>,
|
||||
),
|
||||
).not.toThrow();
|
||||
// 兜底界面仍在(没被 reporter 的异常顶掉)
|
||||
expect(screen.getByRole('alert')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('「重试」⇒ reset ⇒ 子树重新挂载(第二次不炸就恢复渲染)', () => {
|
||||
let shouldThrow = true;
|
||||
const renders: number[] = [];
|
||||
function Flaky() {
|
||||
renders.push(renders.length);
|
||||
if (shouldThrow) throw new Error('boom');
|
||||
return <div data-testid="recovered">ok</div>;
|
||||
}
|
||||
render(
|
||||
<ErrorBoundary name="Reader">
|
||||
<Flaky />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByRole('alert')).toBeTruthy();
|
||||
expect(screen.queryByTestId('recovered')).toBeNull();
|
||||
|
||||
shouldThrow = false;
|
||||
fireEvent.click(screen.getByRole('button', { name: /重试|retry/i }));
|
||||
expect(screen.getByTestId('recovered').textContent).toBe('ok');
|
||||
// 兜底界面已被换掉
|
||||
expect(screen.queryByRole('alert')).toBeNull();
|
||||
});
|
||||
|
||||
it('自定义 fallback 优先于内置 ErrorFallback', () => {
|
||||
render(
|
||||
<ErrorBoundary
|
||||
name="Reader"
|
||||
fallback={({ error }) => <div data-testid="custom">{error.message}</div>}
|
||||
>
|
||||
<Boom />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByTestId('custom').textContent).toBe('boom');
|
||||
// 内置的 role=alert 不应出现
|
||||
expect(screen.queryByRole('alert')).toBeNull();
|
||||
});
|
||||
|
||||
it('自定义 fallback 能拿到 reset(ErrorFallbackProps 契约)', () => {
|
||||
let shouldThrow = true;
|
||||
function Flaky() {
|
||||
if (shouldThrow) throw new Error('boom');
|
||||
return <div data-testid="recovered">ok</div>;
|
||||
}
|
||||
render(
|
||||
<ErrorBoundary
|
||||
name="Reader"
|
||||
fallback={({ reset }) => (
|
||||
<button data-testid="custom-retry" onClick={reset}>
|
||||
retry
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<Flaky />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
shouldThrow = false;
|
||||
fireEvent.click(screen.getByTestId('custom-retry'));
|
||||
expect(screen.getByTestId('recovered').textContent).toBe('ok');
|
||||
});
|
||||
|
||||
it('子组件挂载被记录过一次(证明 boundary 是渲染期接住,不是事后补画)', () => {
|
||||
const onRender = vi.fn();
|
||||
render(
|
||||
<ErrorBoundary name="Reader">
|
||||
<Boom onRender={onRender} />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(onRender).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
// tests/unit/renderer/components/system/error-fallback-i18n-echo.test.tsx
|
||||
//
|
||||
// 守 ErrorFallback 的 **i18n 回声兜底 + 插值 + 剪贴板** 三案契约
|
||||
// (src/renderer/src/components/system/ErrorFallback.tsx 头注指向本文件,
|
||||
// audit-r112 登记,本轮补齐 —— 原先标 🔒(守卫缺失))。
|
||||
//
|
||||
// 为什么这条重要:错误界面是**兜底界面** —— 它出现的时刻往往正是应用
|
||||
// 已经不正常的时刻。若此时 useTranslation 还没就绪,t() 会回声 key 本身,
|
||||
// 用户看到的不是「出错了」而是 `errorBoundary.title` / `errorBoundary.regionLabel`
|
||||
// 这种裸 key,比白屏还让人困惑(而且这恰好是「出错时还想给用户一个清楚
|
||||
// 的解释」这件事被自己搞砸的典型)。
|
||||
//
|
||||
// 三案各自钉住:
|
||||
// 回声(interp):t() 返回 key 形态 ⇒ 走 zh-CN 直接 import 的 FALLBACK 兜底,
|
||||
// 屏幕上**不能**出现裸 key;
|
||||
// 插值:兜底串里的 {name} 必须被替换成真实区域名(且缺失变量保留字面量);
|
||||
// 剪贴板:复制成功/失败各弹一条**已本地化**的 toast(不是 key)。
|
||||
//
|
||||
// 变异思路:把 t() 改成永远回声 / 永远就绪 / 把 FALLBACK 换成 en / 去掉
|
||||
// interpolate 替换 —— 本文件逐条会红。
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
|
||||
// i18n 状态开关 —— 'echo' 模拟 i18n 未就绪(t 回声 key);
|
||||
// 'ready' 模拟 i18n 已就绪(t 返回带前缀的真实值)。
|
||||
const i18nState = { mode: 'echo' as 'echo' | 'ready' };
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => (i18nState.mode === 'echo' ? key : `T:${key}`),
|
||||
}),
|
||||
}));
|
||||
|
||||
// toast —— 只记录调用,不引入 zustand store。
|
||||
const toastCalls: { type: string; message: string }[] = [];
|
||||
vi.mock('../../../../../src/renderer/src/lib/toast', () => ({
|
||||
useToast: () => (opts: { type: string; message: string }) => {
|
||||
toastCalls.push(opts);
|
||||
},
|
||||
}));
|
||||
|
||||
import { ErrorFallback } from '../../../../../src/renderer/src/components/system/ErrorFallback';
|
||||
|
||||
const ZH = {
|
||||
title: '出错了',
|
||||
description: '应用遇到了一个意外错误,可以重试或复制堆栈上报。',
|
||||
errorInfoLabel: '错误信息',
|
||||
noMessage: '(无错误信息)',
|
||||
collapseStack: '收起堆栈',
|
||||
expandStack: '展开堆栈',
|
||||
retry: '重试',
|
||||
copyStack: '复制堆栈',
|
||||
regionLabel: '区域: {name}',
|
||||
topLevelError: '顶层错误',
|
||||
};
|
||||
|
||||
function makeError(message = 'boom', stack = 'Error: boom\n at x.ts:1:1') {
|
||||
const e = new Error(message);
|
||||
e.stack = stack;
|
||||
return e;
|
||||
}
|
||||
|
||||
describe('ErrorFallback 的 i18n 回声兜底(audit-r112 补齐)', () => {
|
||||
beforeEach(() => {
|
||||
i18nState.mode = 'echo';
|
||||
toastCalls.length = 0;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('i18n 未就绪(t 回声 key)⇒ 渲染 zh-CN 兜底文案,屏幕上不出现裸 key', () => {
|
||||
render(<ErrorFallback error={makeError()} reset={() => {}} name="LibraryReader" />);
|
||||
const text = document.body.textContent ?? '';
|
||||
expect(text).toContain(ZH.title);
|
||||
expect(text).toContain(ZH.description);
|
||||
expect(text).toContain(ZH.retry);
|
||||
// 裸 key 绝不能出现在用户可见文本里
|
||||
expect(text).not.toContain('errorBoundary.');
|
||||
expect(text).not.toContain('toast.');
|
||||
});
|
||||
|
||||
it('区域名走 regionLabel 插值:显示真实 name,不留 {name} 占位符', () => {
|
||||
render(<ErrorFallback error={makeError()} reset={() => {}} name="LibraryReader" />);
|
||||
const text = document.body.textContent ?? '';
|
||||
expect(text).toContain('LibraryReader');
|
||||
expect(text).not.toContain('{name}');
|
||||
expect(text).toContain('区域:');
|
||||
});
|
||||
|
||||
it('没有 name ⇒ 走 topLevelError(不是 regionLabel 的空插值)', () => {
|
||||
render(<ErrorFallback error={makeError()} reset={() => {}} />);
|
||||
const text = document.body.textContent ?? '';
|
||||
expect(text).toContain(ZH.topLevelError);
|
||||
expect(text).not.toContain('{name}');
|
||||
});
|
||||
|
||||
it('i18n 已就绪 ⇒ 用 i18n 的值,不回退到 zh-CN 兜底', () => {
|
||||
i18nState.mode = 'ready';
|
||||
render(<ErrorFallback error={makeError()} reset={() => {}} name="X" />);
|
||||
const text = document.body.textContent ?? '';
|
||||
expect(text).toContain('T:errorBoundary.title');
|
||||
expect(text).not.toContain(ZH.title);
|
||||
});
|
||||
|
||||
it('error.message 为空 ⇒ 显示 noMessage 兜底(不显示空白)', () => {
|
||||
render(<ErrorFallback error={makeError('')} reset={() => {}} />);
|
||||
expect(document.body.textContent ?? '').toContain(ZH.noMessage);
|
||||
});
|
||||
|
||||
it('堆栈默认折叠(aria-expanded=false);点击后展开 <pre> 并可再收起', () => {
|
||||
render(<ErrorFallback error={makeError()} reset={() => {}} />);
|
||||
const toggle = screen.getByRole('button', { name: new RegExp(ZH.expandStack) });
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(document.querySelector('pre')).toBeNull();
|
||||
|
||||
fireEvent.click(toggle);
|
||||
const collapse = screen.getByRole('button', { name: new RegExp(ZH.collapseStack) });
|
||||
expect(collapse.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(document.querySelector('pre')?.textContent).toContain('at x.ts:1:1');
|
||||
|
||||
fireEvent.click(collapse);
|
||||
expect(document.querySelector('pre')).toBeNull();
|
||||
});
|
||||
|
||||
it('无 stack ⇒ 不渲染折叠按钮(不给用户一个点不开的开关)', () => {
|
||||
const e = new Error('boom');
|
||||
e.stack = '';
|
||||
render(<ErrorFallback error={e} reset={() => {}} />);
|
||||
expect(screen.queryByRole('button', { name: new RegExp(ZH.expandStack) })).toBeNull();
|
||||
});
|
||||
|
||||
it('复制堆栈成功 ⇒ 弹 success toast,消息是**已本地化**的文案(不是 key)', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
});
|
||||
render(<ErrorFallback error={makeError()} reset={() => {}} name="R" />);
|
||||
fireEvent.click(screen.getByRole('button', { name: new RegExp(ZH.copyStack) }));
|
||||
await vi.waitFor(() => expect(toastCalls).toHaveLength(1));
|
||||
expect(toastCalls[0].type).toBe('success');
|
||||
expect(toastCalls[0].message).toBe('堆栈已复制到剪贴板');
|
||||
expect(toastCalls[0].message).not.toContain('toast.');
|
||||
// 复制内容含区域名 + 错误消息
|
||||
expect(writeText).toHaveBeenCalledTimes(1);
|
||||
expect(String(writeText.mock.calls[0][0])).toContain('R');
|
||||
expect(String(writeText.mock.calls[0][0])).toContain('boom');
|
||||
});
|
||||
|
||||
it('复制堆栈失败 ⇒ 弹 error toast(已本地化文案),不吞异常', async () => {
|
||||
const writeText = vi.fn().mockRejectedValue(new Error('denied'));
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
});
|
||||
render(<ErrorFallback error={makeError()} reset={() => {}} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: new RegExp(ZH.copyStack) }));
|
||||
await vi.waitFor(() => expect(toastCalls).toHaveLength(1));
|
||||
expect(toastCalls[0].type).toBe('error');
|
||||
expect(toastCalls[0].message).toBe('复制失败,请手动选择');
|
||||
});
|
||||
|
||||
it('「重试」按钮调用 reset(ErrorBoundary 用它重挂载子树)', () => {
|
||||
const reset = vi.fn();
|
||||
render(<ErrorFallback error={makeError()} reset={reset} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: new RegExp(ZH.retry) }));
|
||||
expect(reset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('错误区是 role="alert"(SR 立即播报,不等用户去逛)', () => {
|
||||
render(<ErrorFallback error={makeError()} reset={() => {}} />);
|
||||
expect(screen.getByRole('alert')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
134
tests/unit/renderer/components/theme-picker.test.tsx
Normal file
134
tests/unit/renderer/components/theme-picker.test.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
// tests/unit/renderer/components/theme-picker.test.tsx
|
||||
//
|
||||
// 守 r247「设置页主题选择器改成色卡网格」的行为契约。
|
||||
//
|
||||
// 背景:预设从 4 档扩到 15 档后,原来那排「文字芯片 + 一个小图标」完全
|
||||
// 撑不住了 —— 15 个芯片换行铺满,用户看不出每个配色长什么样,只能一个个
|
||||
// 点下去试。改成网格色卡后,每张卡片带一块**真实配色**的缩略预览。
|
||||
//
|
||||
// 本测锁的行为面:
|
||||
// A. radiogroup 语义完整(容器 role / 一张卡片 role=radio / 选中态唯一 /
|
||||
// roving tabindex)—— 键盘用户要能只用方向键切主题。
|
||||
// B. 点卡片 → 回调该预设 id(设置页据此落盘)。
|
||||
// C. 每张卡片的缩略图挂在**正确的 token 作用域**上:
|
||||
// 具体预设挂 data-theme-preset="<id>",「默认」挂 data-theme-preview
|
||||
// (跟随当前浅色 / 深色设置)—— 预览色必须等于真正切过去后的配色。
|
||||
// D. 「默认」预设的预览跟随 settings.theme(light → light 预览)。
|
||||
// E. 选中「强制明暗」的预设时给出提示(否则上面的「浅色 / 深色」开关
|
||||
// 看起来像失灵了)。
|
||||
// F. ThemePicker.tsx 里**不含任何硬编码颜色** —— 配色只有 globals.css
|
||||
// 一份,预览不可能与真实主题漂移。
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, fireEvent, cleanup } from '@testing-library/react';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
// i18n:只关心键名与插值结构,不在乎文案
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (k: string, opts?: Record<string, string>) => (opts?.polarity ? `${k}[${opts.polarity}]` : k),
|
||||
}),
|
||||
}));
|
||||
|
||||
// settings store:ThemePicker 只读 settings.theme(决定「默认」预设的预览极性)
|
||||
vi.mock('../../../../src/renderer/src/store/settings', () => {
|
||||
const state = { settings: { theme: 'dark' } as { theme: string } };
|
||||
const useSettingsStore = ((selector?: (s: unknown) => unknown) =>
|
||||
selector ? selector(state) : state) as unknown as {
|
||||
(selector?: (s: unknown) => unknown): unknown;
|
||||
getState: () => unknown;
|
||||
};
|
||||
useSettingsStore.getState = () => state;
|
||||
return { useSettingsStore };
|
||||
});
|
||||
|
||||
import { ThemePicker, PRESET_OPTIONS } from '../../../../src/renderer/src/components/settings/ThemePicker';
|
||||
import { THEME_PRESETS } from '../../../../src/shared/types';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
function renderPicker(value: string = 'default') {
|
||||
const onChange = vi.fn();
|
||||
const utils = render(
|
||||
<ThemePicker
|
||||
value={value as never}
|
||||
onChange={onChange}
|
||||
labelledBy="preset-label"
|
||||
describedBy="preset-desc"
|
||||
/>,
|
||||
);
|
||||
return { ...utils, onChange };
|
||||
}
|
||||
|
||||
describe('主题选择器色卡网格(r247)', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('PRESET_OPTIONS 覆盖全部预设且顺序与 THEME_PRESETS 一致', () => {
|
||||
expect(PRESET_OPTIONS.map((p) => p.id)).toEqual([...THEME_PRESETS]);
|
||||
});
|
||||
|
||||
it('容器是 radiogroup,每档一张 role=radio 卡片', () => {
|
||||
const { container } = renderPicker();
|
||||
const group = container.querySelector('[role="radiogroup"]');
|
||||
expect(group).not.toBeNull();
|
||||
expect(group?.getAttribute('aria-labelledby')).toBe('preset-label');
|
||||
expect(group?.getAttribute('aria-describedby')).toBe('preset-desc');
|
||||
const cards = container.querySelectorAll('[role="radio"]');
|
||||
expect(cards.length).toBe(THEME_PRESETS.length);
|
||||
});
|
||||
|
||||
it('选中态唯一:只有当前预设 aria-checked=true 且 tabIndex=0', () => {
|
||||
const { container } = renderPicker('nord');
|
||||
const cards = Array.from(container.querySelectorAll('[role="radio"]'));
|
||||
const checked = cards.filter((c) => c.getAttribute('aria-checked') === 'true');
|
||||
expect(checked.length).toBe(1);
|
||||
expect(checked[0].getAttribute('tabindex')).toBe('0');
|
||||
for (const c of cards.filter((x) => x !== checked[0])) {
|
||||
expect(c.getAttribute('tabindex')).toBe('-1');
|
||||
}
|
||||
});
|
||||
|
||||
it('点卡片回调该预设 id', () => {
|
||||
const { container, onChange } = renderPicker('default');
|
||||
const cards = Array.from(container.querySelectorAll('[role="radio"]'));
|
||||
fireEvent.click(cards[3]);
|
||||
expect(onChange).toHaveBeenCalledWith(THEME_PRESETS[3]);
|
||||
});
|
||||
|
||||
it('具体预设的缩略图挂在 data-theme-preset 上(预览 = 真实配色)', () => {
|
||||
const { container } = renderPicker('default');
|
||||
for (const preset of THEME_PRESETS) {
|
||||
if (preset === 'default') continue;
|
||||
const scoped = container.querySelector(`[data-theme-preset="${preset}"]`);
|
||||
expect(scoped, `${preset} 的缩略图没有挂 data-theme-preset`).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('「默认」预设的缩略图跟随浅色 / 深色设置(而不是固定一档)', () => {
|
||||
const { container } = renderPicker('default');
|
||||
const scoped = container.querySelector('[data-theme-preview]');
|
||||
expect(scoped).not.toBeNull();
|
||||
expect(scoped?.getAttribute('data-theme-preview')).toBe('dark');
|
||||
});
|
||||
|
||||
it('选中强制明暗的预设时提示「主题」开关对它无效', () => {
|
||||
const { container } = renderPicker('nord');
|
||||
expect(container.textContent).toContain('settings.appearanceThemePresetFixed');
|
||||
const { container: c2 } = renderPicker('default');
|
||||
expect(c2.textContent).not.toContain('settings.appearanceThemePresetFixed');
|
||||
});
|
||||
|
||||
it('ThemePicker.tsx 里不含硬编码颜色(配色只有 globals.css 一份)', () => {
|
||||
const src = readFileSync(
|
||||
join(ROOT, 'src/renderer/src/components/settings/ThemePicker.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
const hexes = src.match(/#[0-9a-fA-F]{3,8}\b/g) ?? [];
|
||||
expect(hexes, `色卡组件里不该有硬编码颜色: ${hexes.join(', ')}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
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