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();
|
||||
});
|
||||
});
|
||||
477
tests/unit/renderer/hooks/disk-folder-dnd.test.ts
Normal file
477
tests/unit/renderer/hooks/disk-folder-dnd.test.ts
Normal file
@@ -0,0 +1,477 @@
|
||||
// tests/unit/renderer/hooks/disk-folder-dnd.test.ts
|
||||
//
|
||||
// 守卫:useDiskFolderSidebar 的 startItemsDrag + handleTargetDrop 端到端
|
||||
// 跑通。dragstart 写 RKMS_MOVE_MIME(payload.ids + kind),drop 端读
|
||||
// payload、调 moveItems、走 api.notesMove(id, targetRelPath)。这是
|
||||
// 用户反馈「文件拖入文件夹不工作」的最高风险路径,必须在 jsdom 里
|
||||
// 完整实跑(r166 之前这些 handler 是 stub 形态,丢 mime / 读 mime 错位
|
||||
// 是隐性回归点)。
|
||||
//
|
||||
// 与 notes-cut-paste.test.ts 互补 —— 后者守 store 路径(store paste
|
||||
// 自动走 api.notesMove),本测守 drag-into-folder 路径(走 disk hook
|
||||
// 的 handleTargetDrop),两条 pipeline 都要自洽。
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import {
|
||||
useDiskFolderSidebar,
|
||||
ROOT_DROP_ID,
|
||||
} from '../../../../src/renderer/src/hooks/useDiskFolderSidebar';
|
||||
import { readMovePayload } from '../../../../src/renderer/src/lib/folder-dnd';
|
||||
import type { Api } from '../../../../src/shared/api-types';
|
||||
|
||||
// Api 类型 90+ 方法,这里直接 `as unknown as Api` 假装接口形状;被测 hook 只
|
||||
// 用 notesListDirs / litListDirs / notesMove / notesMoveDir。
|
||||
function makeApi(): Api & { notesMove: ReturnType<typeof vi.fn>; notesListDirs: ReturnType<typeof vi.fn>; litListDirs: ReturnType<typeof vi.fn>; notesMoveDir: ReturnType<typeof vi.fn> } {
|
||||
return {
|
||||
notesMove: vi.fn(),
|
||||
notesMoveDir: vi.fn(),
|
||||
notesListDirs: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||
litListDirs: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||
} as unknown as Api & {
|
||||
notesMove: ReturnType<typeof vi.fn>;
|
||||
notesListDirs: ReturnType<typeof vi.fn>;
|
||||
litListDirs: ReturnType<typeof vi.fn>;
|
||||
notesMoveDir: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}
|
||||
|
||||
function makeDataTransfer(): DataTransfer {
|
||||
const types: string[] = [];
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
types,
|
||||
store,
|
||||
setData(mime: string, val: string) {
|
||||
if (!store.has(mime)) types.push(mime);
|
||||
store.set(mime, val);
|
||||
},
|
||||
getData(mime: string) {
|
||||
return store.get(mime) ?? '';
|
||||
},
|
||||
effectAllowed: 'move' as 'move' | 'copy' | 'none' | 'link',
|
||||
dropEffect: 'move' as 'move' | 'copy' | 'none' | 'link',
|
||||
files: [] as unknown as FileList,
|
||||
items: [] as unknown as DataTransferItemList,
|
||||
clearData() {
|
||||
store.clear();
|
||||
types.length = 0;
|
||||
},
|
||||
} as unknown as DataTransfer;
|
||||
}
|
||||
|
||||
describe('useDiskFolderSidebar drag-into-folder 链路', () => {
|
||||
let api: ReturnType<typeof makeApi>;
|
||||
|
||||
beforeEach(() => {
|
||||
api = makeApi();
|
||||
});
|
||||
|
||||
it('startItemsDrag 写 RKMS_MOVE_MIME 含 ids + kind', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
|
||||
);
|
||||
const dt = makeDataTransfer();
|
||||
act(() => {
|
||||
result.current.startItemsDrag(['n1', 'n2'], {
|
||||
dataTransfer: dt,
|
||||
} as unknown as Parameters<typeof result.current.startItemsDrag>[1]);
|
||||
});
|
||||
const raw = dt.getData('application/x-rkms-move');
|
||||
const payload = JSON.parse(raw);
|
||||
expect(payload.type).toBe('items');
|
||||
expect(payload.kind).toBe('note');
|
||||
expect(payload.ids).toEqual(['n1', 'n2']);
|
||||
});
|
||||
|
||||
it('handleTargetDrop 读 MIME + 调 api.notesMove 用 targetRelPath', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
|
||||
);
|
||||
// drag 段:用 startItemsDrag 写
|
||||
const dt = makeDataTransfer();
|
||||
act(() => {
|
||||
result.current.startItemsDrag(['n1', 'n2'], {
|
||||
dataTransfer: dt,
|
||||
} as unknown as Parameters<typeof result.current.startItemsDrag>[1]);
|
||||
});
|
||||
api.notesMove.mockResolvedValue({ ok: true, data: { id: 'n1', relPath: 'target/n1.md' } });
|
||||
|
||||
// drop 段:落地到 'archive' 文件夹
|
||||
await act(async () => {
|
||||
await result.current.handleTargetDrop({
|
||||
dataTransfer: dt,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as unknown as React.DragEvent, 'archive');
|
||||
});
|
||||
|
||||
expect(api.notesMove).toHaveBeenCalledTimes(2);
|
||||
expect(api.notesMove).toHaveBeenNthCalledWith(1, 'n1', 'archive');
|
||||
expect(api.notesMove).toHaveBeenNthCalledWith(2, 'n2', 'archive');
|
||||
});
|
||||
|
||||
it('handleTargetDrop 落到 ROOT_DROP_ID 等价 null / 空串(根语义)', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
|
||||
);
|
||||
const dt = makeDataTransfer();
|
||||
act(() => {
|
||||
result.current.startItemsDrag(['n1'], {
|
||||
dataTransfer: dt,
|
||||
} as unknown as Parameters<typeof result.current.startItemsDrag>[1]);
|
||||
});
|
||||
api.notesMove.mockResolvedValue({ ok: true, data: { id: 'n1', relPath: 'n1.md' } });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleTargetDrop({
|
||||
dataTransfer: dt,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as unknown as React.DragEvent, ROOT_DROP_ID);
|
||||
});
|
||||
|
||||
// 落根:moveItems 把 null 转 '' 走 IPC,与 store paste(null) 同语义
|
||||
expect(api.notesMove).toHaveBeenCalledWith('n1', '');
|
||||
});
|
||||
|
||||
it('handleTargetDrop 跨 kind 直接忽略(notes MIME 投到 literature 树上)', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'literature', searchActive: false, api }),
|
||||
);
|
||||
const dt = makeDataTransfer();
|
||||
act(() => {
|
||||
// 手工写一份 note kind 的 payload,模拟「跨 kind 越界」
|
||||
dt.setData(
|
||||
'application/x-rkms-move',
|
||||
JSON.stringify({ type: 'items', kind: 'note', ids: ['n1'], relPaths: [] }),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.handleTargetDrop({
|
||||
dataTransfer: dt,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as unknown as React.DragEvent, 'archive');
|
||||
});
|
||||
expect(api.notesMove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handleTargetDrop 部分失败累积 → 首个 throw(errors 含 ok=false 项)', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
|
||||
);
|
||||
const dt = makeDataTransfer();
|
||||
act(() => {
|
||||
result.current.startItemsDrag(['n1', 'n2'], {
|
||||
dataTransfer: dt,
|
||||
} as unknown as Parameters<typeof result.current.startItemsDrag>[1]);
|
||||
});
|
||||
api.notesMove.mockImplementation((id: string) => {
|
||||
if (id === 'n2') {
|
||||
return Promise.resolve({ ok: false, code: 'IO_ERROR', message: 'disk fail' });
|
||||
}
|
||||
return Promise.resolve({ ok: true, data: { id, relPath: 'archive/' + id + '.md' } });
|
||||
});
|
||||
|
||||
let caught: unknown = null;
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.handleTargetDrop({
|
||||
dataTransfer: dt,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as unknown as React.DragEvent, 'archive');
|
||||
} catch (err: unknown) {
|
||||
caught = err;
|
||||
}
|
||||
});
|
||||
|
||||
expect(caught).not.toBeNull();
|
||||
expect((caught as Error).message).toMatch(/moveItems failed 1\/2/);
|
||||
expect((caught as { errors: Array<{ id: string; code: string }> }).errors).toEqual([
|
||||
{ id: 'n2', code: 'IO_ERROR', message: 'disk fail' },
|
||||
]);
|
||||
// 两条 IPC 都发了(累计),不是首条 throw 后停
|
||||
expect(api.notesMove).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('r168:handleTargetDrop 全成功后调 onItemsMoved,带 (oldId → {id, relPath})', async () => {
|
||||
const onItemsMoved = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api, onItemsMoved }),
|
||||
);
|
||||
const dt = makeDataTransfer();
|
||||
act(() => {
|
||||
result.current.startItemsDrag(['n1', 'n2'], {
|
||||
dataTransfer: dt,
|
||||
} as unknown as Parameters<typeof result.current.startItemsDrag>[1]);
|
||||
});
|
||||
api.notesMove.mockImplementation((id: string) => {
|
||||
return Promise.resolve({ ok: true, data: { id: 'new-' + id, relPath: 'archive/' + id + '.md' } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleTargetDrop({
|
||||
dataTransfer: dt,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as unknown as React.DragEvent, 'archive');
|
||||
});
|
||||
|
||||
expect(onItemsMoved).toHaveBeenCalledTimes(1);
|
||||
const remap = onItemsMoved.mock.calls[0]![0] as Map<
|
||||
string,
|
||||
{ id: string; relPath?: string }
|
||||
>;
|
||||
expect(remap.size).toBe(2);
|
||||
expect(remap.get('n1')?.id).toBe('new-n1');
|
||||
expect(remap.get('n1')?.relPath).toBe('archive/n1.md');
|
||||
expect(remap.get('n2')?.id).toBe('new-n2');
|
||||
});
|
||||
|
||||
it('r168:handleTargetDrop 部分失败 → 不调 onItemsMoved(避免半截 state)', async () => {
|
||||
const onItemsMoved = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api, onItemsMoved }),
|
||||
);
|
||||
const dt = makeDataTransfer();
|
||||
act(() => {
|
||||
result.current.startItemsDrag(['n1', 'n2'], {
|
||||
dataTransfer: dt,
|
||||
} as unknown as Parameters<typeof result.current.startItemsDrag>[1]);
|
||||
});
|
||||
api.notesMove.mockImplementation((id: string) => {
|
||||
if (id === 'n2') {
|
||||
return Promise.resolve({ ok: false, code: 'IO_ERROR', message: 'disk fail' });
|
||||
}
|
||||
return Promise.resolve({ ok: true, data: { id, relPath: 'archive/' + id + '.md' } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.handleTargetDrop({
|
||||
dataTransfer: dt,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as unknown as React.DragEvent, 'archive');
|
||||
} catch {
|
||||
// swallow
|
||||
}
|
||||
});
|
||||
expect(onItemsMoved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('r168:onItemsMoved 不传 = 不抛(兼容既有调用)', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
|
||||
);
|
||||
const dt = makeDataTransfer();
|
||||
act(() => {
|
||||
result.current.startItemsDrag(['n1'], {
|
||||
dataTransfer: dt,
|
||||
} as unknown as Parameters<typeof result.current.startItemsDrag>[1]);
|
||||
});
|
||||
api.notesMove.mockResolvedValue({ ok: true, data: { id: 'new-n1', relPath: 'a/n1.md' } });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleTargetDrop({
|
||||
dataTransfer: dt,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as unknown as React.DragEvent, 'a');
|
||||
});
|
||||
// 不抛 + IPC 一次
|
||||
expect(api.notesMove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('r168:moveItems 直接调用也返回 idRemap(caller 拿到后可手动回调 store)', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
|
||||
);
|
||||
api.notesMove.mockResolvedValue({ ok: true, data: { id: 'new-n1', relPath: 'a/n1.md' } });
|
||||
let remap: Map<string, { id: string; relPath?: string }> = new Map();
|
||||
await act(async () => {
|
||||
remap = await result.current.moveItems('a', ['n1']);
|
||||
});
|
||||
expect(remap.size).toBe(1);
|
||||
expect(remap.get('n1')?.id).toBe('new-n1');
|
||||
expect(remap.get('n1')?.relPath).toBe('a/n1.md');
|
||||
});
|
||||
|
||||
// === r173:文件夹(子树)搬移 —— 拖 / 切 / 粘三条入口 ===
|
||||
//
|
||||
// 旧实现的两个死点:
|
||||
// 1) startFolderDrag 写 ids: [] → readMovePayload 拒收(ids 必须非空
|
||||
// 且每项非空串)→ 载荷在读端被丢掉,drop 端永远收不到;
|
||||
// 2) pasteFolderInto / moveFolderNow 是 throw「not yet supported」的占位。
|
||||
it('r173:startFolderDrag 写的载荷能被 readMovePayload 接受(ids 非空且 = relPath)', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
|
||||
);
|
||||
const dt = makeDataTransfer();
|
||||
act(() => {
|
||||
result.current.startFolderDrag(
|
||||
{ relPath: 'sub', name: 'sub', parentRelPath: null },
|
||||
{ dataTransfer: dt } as unknown as Parameters<typeof result.current.startFolderDrag>[1],
|
||||
);
|
||||
});
|
||||
const payload = readMovePayload(dt);
|
||||
expect(payload).not.toBeNull();
|
||||
expect(payload!.type).toBe('folder');
|
||||
expect(payload!.relPaths).toEqual(['sub']);
|
||||
expect(payload!.ids).toEqual(['sub']);
|
||||
});
|
||||
|
||||
it('r173:文件夹拖到目标目录 → 调 api.notesMoveDir(oldRelPath, targetRelPath)', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
|
||||
);
|
||||
const dt = makeDataTransfer();
|
||||
act(() => {
|
||||
result.current.startFolderDrag(
|
||||
{ relPath: 'sub', name: 'sub', parentRelPath: null },
|
||||
{ dataTransfer: dt } as unknown as Parameters<typeof result.current.startFolderDrag>[1],
|
||||
);
|
||||
});
|
||||
api.notesMoveDir.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { relPath: 'archive/sub', idRemap: [] },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleTargetDrop({
|
||||
dataTransfer: dt,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as unknown as React.DragEvent, 'archive');
|
||||
});
|
||||
|
||||
expect(api.notesMoveDir).toHaveBeenCalledTimes(1);
|
||||
expect(api.notesMoveDir).toHaveBeenCalledWith('sub', 'archive');
|
||||
});
|
||||
|
||||
it('r173:文件夹拖到根(ROOT_DROP_ID)→ 目标父目录 = null', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
|
||||
);
|
||||
const dt = makeDataTransfer();
|
||||
act(() => {
|
||||
result.current.startFolderDrag(
|
||||
{ relPath: 'sub', name: 'sub', parentRelPath: null },
|
||||
{ dataTransfer: dt } as unknown as Parameters<typeof result.current.startFolderDrag>[1],
|
||||
);
|
||||
});
|
||||
api.notesMoveDir.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { relPath: 'sub', idRemap: [] },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleTargetDrop({
|
||||
dataTransfer: dt,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as unknown as React.DragEvent, ROOT_DROP_ID);
|
||||
});
|
||||
|
||||
expect(api.notesMoveDir).toHaveBeenCalledWith('sub', null);
|
||||
});
|
||||
|
||||
it('r173:文件夹搬移成功 → onItemsMoved 收到 idRemap(选中态跟到新 id)', async () => {
|
||||
const onItemsMoved = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api, onItemsMoved }),
|
||||
);
|
||||
const dt = makeDataTransfer();
|
||||
act(() => {
|
||||
result.current.startFolderDrag(
|
||||
{ relPath: 'sub', name: 'sub', parentRelPath: null },
|
||||
{ dataTransfer: dt } as unknown as Parameters<typeof result.current.startFolderDrag>[1],
|
||||
);
|
||||
});
|
||||
api.notesMoveDir.mockResolvedValue({
|
||||
ok: true,
|
||||
data: {
|
||||
relPath: 'archive/sub',
|
||||
idRemap: [{ oldId: 'n1', newId: 'n2', relPath: 'archive/sub/n1.md' }],
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleTargetDrop({
|
||||
dataTransfer: dt,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as unknown as React.DragEvent, 'archive');
|
||||
});
|
||||
|
||||
expect(onItemsMoved).toHaveBeenCalledTimes(1);
|
||||
const remap = onItemsMoved.mock.calls[0]![0] as Map<string, { id: string; relPath?: string }>;
|
||||
expect(remap.get('n1')?.id).toBe('n2');
|
||||
expect(remap.get('n1')?.relPath).toBe('archive/sub/n1.md');
|
||||
});
|
||||
|
||||
it('r173:剪切文件夹 → 粘贴到目标目录 → 走 moveDir 且剪贴板清空', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
|
||||
);
|
||||
act(() => {
|
||||
result.current.cutFolder('sub');
|
||||
});
|
||||
expect(result.current.folderClipboard).toBe('sub');
|
||||
|
||||
api.notesMoveDir.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { relPath: 'archive/sub', idRemap: [] },
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.pasteFolderInto('archive');
|
||||
});
|
||||
|
||||
expect(api.notesMoveDir).toHaveBeenCalledWith('sub', 'archive');
|
||||
// 粘贴成功 → 剪贴板清空(再切一次是重新剪切)
|
||||
expect(result.current.folderClipboard).toBeNull();
|
||||
});
|
||||
|
||||
it('r173:粘贴失败 → 抛错且剪贴板保留(可重试)', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
|
||||
);
|
||||
act(() => {
|
||||
result.current.cutFolder('sub');
|
||||
});
|
||||
api.notesMoveDir.mockResolvedValue({
|
||||
ok: false,
|
||||
code: 'IO_ERROR',
|
||||
message: 'disk fail',
|
||||
});
|
||||
|
||||
let caught: unknown = null;
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.pasteFolderInto('archive');
|
||||
} catch (err: unknown) {
|
||||
caught = err;
|
||||
}
|
||||
});
|
||||
expect(caught).not.toBeNull();
|
||||
expect((caught as Error).message).toMatch(/disk fail/);
|
||||
expect(result.current.folderClipboard).toBe('sub');
|
||||
});
|
||||
|
||||
it('r173:moveFolderNow 显式搬移(「移到根目录」入口)→ 目标父目录 null', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
|
||||
);
|
||||
api.notesMoveDir.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { relPath: 'sub', idRemap: [] },
|
||||
});
|
||||
let newRel = '';
|
||||
await act(async () => {
|
||||
newRel = await result.current.moveFolderNow('sub', null);
|
||||
});
|
||||
expect(api.notesMoveDir).toHaveBeenCalledWith('sub', null);
|
||||
expect(newRel).toBe('sub');
|
||||
});
|
||||
});
|
||||
164
tests/unit/renderer/hooks/disk-folder-reload-guard.test.ts
Normal file
164
tests/unit/renderer/hooks/disk-folder-reload-guard.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
// tests/unit/renderer/hooks/disk-folder-reload-guard.test.ts
|
||||
//
|
||||
// 守卫(r175 → r176 树形态):useDiskFolderSidebar 的两处时序/状态防御
|
||||
//
|
||||
// 1. 懒加载的**按目录序号守卫** —— 此前裸 await 后无条件 setSubdirs:
|
||||
// 快速连点文件夹 A → B(间隔小于一次 IPC roundtrip)时,A 的响应若后到,
|
||||
// 会把 A 的子项写进 state —— 面包屑显示 B,列表里却是 A 的子目录。
|
||||
// 守卫:每次请求领 seq,响应回来时 seq 不是最新就丢弃。
|
||||
//
|
||||
// r176 树形态下守卫改成**按目录**计数:同一个目录并发两次拉取
|
||||
// (展开 → 立刻再展开)时,先发的响应后到会覆盖新快照。不同目录
|
||||
// 各算各的 —— 「A 目录的响应」不会污染「B 目录的缓存」。
|
||||
//
|
||||
// 2. 搜索态下 startCreate / startCreateSubfolder **必须 no-op** ——
|
||||
// 搜索态的 inline input 行被 rows 组装侧 gate 掉(!searchActive 才
|
||||
// 挂 input 行),旧实现「进搜索态点 ✚ 按钮」会置 editing='create'
|
||||
// 但没有 input 行渲染:用户看到什么都不发生,且 editing 卡住,清空
|
||||
// 搜索后 input 行突然冒出来。键盘入口早已 gate,这里把守卫收到
|
||||
// hook 内部,让工具栏 / 右键 / 快捷键三条入口行为一致。
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
useDiskFolderSidebar,
|
||||
resetFolderTreeRegistry,
|
||||
} from '../../../../src/renderer/src/hooks/useDiskFolderSidebar';
|
||||
import type { Api } from '../../../../src/shared/api-types';
|
||||
|
||||
function makeApi(): Api {
|
||||
return {
|
||||
notesListDirs: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||
litListDirs: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||
} as unknown as Api;
|
||||
}
|
||||
|
||||
function dir(relPath: string) {
|
||||
return { relPath, name: relPath.split('/').pop() ?? relPath, parentRelPath: null };
|
||||
}
|
||||
|
||||
describe('useDiskFolderSidebar 懒加载序号守卫 + 搜索态 gate', () => {
|
||||
let api: ReturnType<typeof makeApi>;
|
||||
|
||||
beforeEach(() => {
|
||||
api = makeApi();
|
||||
// registry 是模块级(跨 tab 切换要保住展开态)—— 每个用例重来一份。
|
||||
resetFolderTreeRegistry('note');
|
||||
});
|
||||
|
||||
it('同一目录并发两次拉取:慢的旧响应后到时不能覆盖新快照', async () => {
|
||||
// 第一次拉 A(慢),第二次拉 A(快)—— 模拟「展开 → 立刻再展开」
|
||||
let resolveSlow: (v: unknown) => void = () => {};
|
||||
const slow = new Promise((res) => {
|
||||
resolveSlow = res;
|
||||
});
|
||||
let call = 0;
|
||||
(api.notesListDirs as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
(kind: string, rel: string | null) => {
|
||||
if (rel !== 'A') return Promise.resolve({ ok: true, data: [] });
|
||||
call += 1;
|
||||
if (call === 1) return slow;
|
||||
return Promise.resolve({ ok: true, data: [dir('A/fresh')] });
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
|
||||
);
|
||||
|
||||
// 先展开 A(慢),再立刻展开 A(快)
|
||||
await act(async () => {
|
||||
result.current.expandFolder('A');
|
||||
});
|
||||
await act(async () => {
|
||||
result.current.expandFolder('A');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current.childrenByDir.get('A')?.map((d) => d.relPath)).toEqual(['A/fresh']);
|
||||
});
|
||||
|
||||
// 旧响应此刻才到 —— 应当被序号守卫丢弃
|
||||
await act(async () => {
|
||||
resolveSlow({ ok: true, data: [dir('A/stale')] });
|
||||
await slow;
|
||||
});
|
||||
|
||||
expect(result.current.childrenByDir.get('A')?.map((d) => d.relPath)).toEqual(['A/fresh']);
|
||||
});
|
||||
|
||||
it('不同目录各算各的 —— B 目录的响应不会被 A 目录的请求挤掉', async () => {
|
||||
let resolveA: (v: unknown) => void = () => {};
|
||||
const aPromise = new Promise((res) => {
|
||||
resolveA = res;
|
||||
});
|
||||
(api.notesListDirs as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
(kind: string, rel: string | null) => {
|
||||
if (rel === 'A') return aPromise;
|
||||
if (rel === 'B') return Promise.resolve({ ok: true, data: [dir('B/child')] });
|
||||
return Promise.resolve({ ok: true, data: [] });
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.expandFolder('A');
|
||||
});
|
||||
await act(async () => {
|
||||
result.current.expandFolder('B');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current.childrenByDir.get('B')?.map((d) => d.relPath)).toEqual(['B/child']);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveA({ ok: true, data: [dir('A/stale')] });
|
||||
await aPromise;
|
||||
});
|
||||
|
||||
// A 的缓存拿到自己的数据,B 不受影响
|
||||
expect(result.current.childrenByDir.get('A')?.map((d) => d.relPath)).toEqual(['A/stale']);
|
||||
expect(result.current.childrenByDir.get('B')?.map((d) => d.relPath)).toEqual(['B/child']);
|
||||
});
|
||||
|
||||
it('搜索态 startCreate 是 no-op(不置 editing 态)', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: true, api }),
|
||||
);
|
||||
act(() => {
|
||||
result.current.startCreate();
|
||||
});
|
||||
expect(result.current.editing).toBeNull();
|
||||
});
|
||||
|
||||
it('非搜索态 startCreate 正常进入编辑态', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
|
||||
);
|
||||
act(() => {
|
||||
result.current.startCreate();
|
||||
});
|
||||
expect(result.current.editing).toEqual({ mode: 'create', parentRelPath: null, name: '' });
|
||||
});
|
||||
|
||||
// === r176:落点语义 ===
|
||||
it('activateFolder = 设为落点 + 展开(一次手势两件事)', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
|
||||
);
|
||||
act(() => {
|
||||
result.current.activateFolder('A');
|
||||
});
|
||||
expect(result.current.targetRelPath).toBe('A');
|
||||
expect(result.current.expanded.has('A')).toBe(true);
|
||||
|
||||
// 再点一次 = 折叠(落点保持)
|
||||
act(() => {
|
||||
result.current.activateFolder('A');
|
||||
});
|
||||
expect(result.current.targetRelPath).toBe('A');
|
||||
expect(result.current.expanded.has('A')).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
169
tests/unit/renderer/hooks/disk-folder-tree.test.ts
Normal file
169
tests/unit/renderer/hooks/disk-folder-tree.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
// tests/unit/renderer/hooks/disk-folder-tree.test.ts
|
||||
//
|
||||
// 守 r176「左侧栏改树状」的**端到端接线**契约 —— 纯函数(建树)在
|
||||
// folder-tree.test.ts 里锁,本测锁「hook 状态 → 建树输入」这一段:
|
||||
// 挂载 → 拉根目录子项
|
||||
// 点文件夹 → 展开 + 设为落点 + 懒加载该目录子项
|
||||
// 新建文件夹 → 落在**当前落点**下(不是「钻入到的目录」)
|
||||
// 落点处于折叠态 → 新建前先展开(否则 input 行落在看不见的地方)
|
||||
//
|
||||
// 这段是「用户点了文件夹 → 树真的长出子树 → 新建落在点上的文件夹」
|
||||
// 这条主链路的唯一自动化防线 —— 组件层(NotesList / LiteratureList)
|
||||
// 依赖 react-virtuoso,jsdom 容器高度 0 ⇒ 行不渲染,渲染测覆盖不到。
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
useDiskFolderSidebar,
|
||||
resetFolderTreeRegistry,
|
||||
} from '../../../../src/renderer/src/hooks/useDiskFolderSidebar';
|
||||
import { buildTreeRows } from '../../../../src/renderer/src/lib/folder-tree';
|
||||
import type { Api } from '../../../../src/shared/api-types';
|
||||
|
||||
function node(relPath: string, parentRelPath: string | null) {
|
||||
return { relPath, name: relPath.slice(relPath.lastIndexOf('/') + 1), parentRelPath };
|
||||
}
|
||||
|
||||
// 磁盘形状:
|
||||
// root
|
||||
// ├── a
|
||||
// │ └── deep
|
||||
// └── b
|
||||
const TREE: Record<string, ReturnType<typeof node>[]> = {
|
||||
'': [node('a', null), node('b', null)],
|
||||
a: [node('a/deep', 'a')],
|
||||
'a/deep': [],
|
||||
b: [],
|
||||
};
|
||||
|
||||
function makeApi(): Api {
|
||||
return {
|
||||
notesListDirs: vi.fn().mockImplementation((kind: string, rel: string | null) => {
|
||||
const key = rel ?? '';
|
||||
return Promise.resolve({ ok: true, data: TREE[key] ?? [] });
|
||||
}),
|
||||
litListDirs: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||
} as unknown as Api;
|
||||
}
|
||||
|
||||
describe('useDiskFolderSidebar —— 树状接线(r176)', () => {
|
||||
let api: ReturnType<typeof makeApi>;
|
||||
|
||||
beforeEach(() => {
|
||||
api = makeApi();
|
||||
resetFolderTreeRegistry('note');
|
||||
});
|
||||
|
||||
it('挂载时拉一次根目录子项(根 = null)', async () => {
|
||||
renderHook(() => useDiskFolderSidebar({ kind: 'note', searchActive: false, api }));
|
||||
await waitFor(() => {
|
||||
expect((api.notesListDirs as ReturnType<typeof vi.fn>).mock.calls[0]).toEqual([
|
||||
'note',
|
||||
null,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('点文件夹(a)→ 展开 + 设为落点 + 懒加载该目录子项', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(result.current.childrenByDir.get('')?.map((f) => f.relPath)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.activateFolder('a');
|
||||
});
|
||||
|
||||
expect(result.current.targetRelPath).toBe('a');
|
||||
expect(result.current.expanded.has('a')).toBe(true);
|
||||
await waitFor(() => {
|
||||
expect(result.current.childrenByDir.get('a')?.map((f) => f.relPath)).toEqual(['a/deep']);
|
||||
});
|
||||
expect((api.notesListDirs as ReturnType<typeof vi.fn>).mock.calls).toContainEqual([
|
||||
'note',
|
||||
'a',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hook 状态直接喂建树函数 → 展开的目录长出子树', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(result.current.childrenByDir.get('')).toBeDefined();
|
||||
});
|
||||
|
||||
const build = () =>
|
||||
buildTreeRows<{ id: string; relPath: string }>({
|
||||
childrenByDir: result.current.childrenByDir,
|
||||
itemsByDir: new Map(),
|
||||
expanded: result.current.expanded,
|
||||
targetRelPath: result.current.targetRelPath,
|
||||
searchActive: false,
|
||||
});
|
||||
|
||||
expect(build().map((r) => r.id)).toEqual(['folder:a', 'folder:b']);
|
||||
|
||||
await act(async () => {
|
||||
result.current.activateFolder('a');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current.childrenByDir.get('a')).toBeDefined();
|
||||
});
|
||||
expect(build().map((r) => r.id)).toEqual(['folder:a', 'folder:a/deep', 'folder:b']);
|
||||
});
|
||||
|
||||
it('新建文件夹落在**当前落点**下(不是根)', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
|
||||
);
|
||||
await act(async () => {
|
||||
result.current.activateFolder('a');
|
||||
});
|
||||
act(() => {
|
||||
result.current.startCreate();
|
||||
});
|
||||
expect(result.current.editing).toEqual({ mode: 'create', parentRelPath: 'a', name: '' });
|
||||
});
|
||||
|
||||
it('没有落点时新建落在根', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
|
||||
);
|
||||
act(() => {
|
||||
result.current.startCreate();
|
||||
});
|
||||
expect(result.current.editing).toEqual({ mode: 'create', parentRelPath: null, name: '' });
|
||||
});
|
||||
|
||||
it('落点处于折叠态时,新建前先把它展开(input 行才看得见)', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
|
||||
);
|
||||
// 先让落点处于折叠态:activate 一次展开、再一次折叠(落点保持)
|
||||
act(() => {
|
||||
result.current.activateFolder('a');
|
||||
});
|
||||
act(() => {
|
||||
result.current.activateFolder('a');
|
||||
});
|
||||
expect(result.current.targetRelPath).toBe('a');
|
||||
expect(result.current.expanded.has('a')).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.startCreate();
|
||||
});
|
||||
expect(result.current.expanded.has('a')).toBe(true);
|
||||
});
|
||||
|
||||
it('子目录新建:父目录就是被点的那个文件夹', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
|
||||
);
|
||||
act(() => {
|
||||
result.current.startCreateSubfolder(node('a', null));
|
||||
});
|
||||
expect(result.current.editing).toEqual({ mode: 'create', parentRelPath: 'a', name: '' });
|
||||
});
|
||||
});
|
||||
177
tests/unit/renderer/hooks/folder-tree-external-refresh.test.ts
Normal file
177
tests/unit/renderer/hooks/folder-tree-external-refresh.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
// tests/unit/renderer/hooks/folder-tree-external-refresh.test.ts
|
||||
//
|
||||
// 守 r184「磁盘目录结构被**外部**改动后,左侧栏树必须跟着变」契约。
|
||||
//
|
||||
// 背景(用户诉求:「左侧的文献和笔记列表应该和对应的文件夹完全一致」):
|
||||
// 树的数据源 childrenByDir 是**懒加载 + 缓存**的 —— 每个目录一次
|
||||
// listDirs IPC,结果进缓存,之后只靠应用自己的操作(新建 / 改名 /
|
||||
// 删除 / 搬移)去 applyStructuralChange + loadChildren 修正。
|
||||
//
|
||||
// ⇒ 磁盘上**目录结构**被外部改动时(用户在资源管理器里建了夹 / 删了夹 /
|
||||
// 改了名 —— 这在本项目里是常规操作:用户的笔记就放在
|
||||
// <rootPath>/notes/ 下),缓存从来没被碰过:
|
||||
// - 外部新建的夹**永远不出现**(缓存里没有,且 expandFolder 只在
|
||||
// 「没缓存过」时才拉 → 折叠再展开也刷不出来);
|
||||
// - 外部删掉的夹**永远还在**(点进去是空的);
|
||||
// - 外部改名的夹显示**旧名**,里面文件的 relPath 变了 ⇒ 文件行从
|
||||
// 当前目录视图里「消失」—— 用户视角就是「文件凭空不见了」。
|
||||
//
|
||||
// 主进程侧同样有洞:notes / literature 的 chokidar 只监听
|
||||
// add / change / unlink,**不监听 addDir / unlinkDir** ⇒ 光建一个空
|
||||
// 夹(没有文件)时主进程连 'changed' 都不发,renderer 根本不知道。
|
||||
//
|
||||
// 本测锁(渲染层):
|
||||
// 1. 外部新建文件夹 → 树的根子项跟着多出该夹;
|
||||
// 2. 外部删除文件夹 → 树的根子项跟着少掉该夹(且缓存里的孤儿键清掉);
|
||||
// 3. 外部事件不该引发**无谓重渲**(内容没变就不换引用)。
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
useDiskFolderSidebar,
|
||||
resetFolderTreeRegistry,
|
||||
} from '../../../../src/renderer/src/hooks/useDiskFolderSidebar';
|
||||
import type { Api } from '../../../../src/shared/api-types';
|
||||
|
||||
function node(relPath: string, parentRelPath: string | null) {
|
||||
return { relPath, name: relPath.slice(relPath.lastIndexOf('/') + 1), parentRelPath };
|
||||
}
|
||||
|
||||
describe('useDiskFolderSidebar —— 外部目录改动后树跟着变(r184)', () => {
|
||||
let tree: Record<string, ReturnType<typeof node>[]>;
|
||||
let changedHandler: (() => void) | null;
|
||||
let api: Api;
|
||||
let listDirsMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
tree = { '': [node('a', null)], a: [] };
|
||||
changedHandler = null;
|
||||
listDirsMock = vi.fn((kind: string, rel: string | null) =>
|
||||
Promise.resolve({ ok: true, data: tree[rel ?? ''] ?? [] }),
|
||||
);
|
||||
api = {
|
||||
notesListDirs: listDirsMock,
|
||||
litListDirs: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||
onNotesChanged: vi.fn((h: () => void) => {
|
||||
changedHandler = h;
|
||||
return () => {
|
||||
changedHandler = null;
|
||||
};
|
||||
}),
|
||||
onLiteratureChanged: vi.fn(() => () => {}),
|
||||
} as unknown as Api;
|
||||
resetFolderTreeRegistry('note');
|
||||
});
|
||||
|
||||
it('外部新建文件夹 → 树里出现该夹', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(result.current.childrenByDir.get('')?.map((f) => f.relPath)).toEqual(['a']);
|
||||
});
|
||||
|
||||
// 用户在资源管理器里建了 notes/newdir/
|
||||
tree = { '': [node('a', null), node('newdir', null)], a: [] };
|
||||
act(() => {
|
||||
changedHandler?.();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.childrenByDir.get('')?.map((f) => f.relPath)).toEqual([
|
||||
'a',
|
||||
'newdir',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('外部删除文件夹 → 树里少掉该夹,缓存里的孤儿键也清掉', async () => {
|
||||
tree = { '': [node('a', null), node('b', null)], a: [], b: [] };
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(result.current.childrenByDir.get('')?.map((f) => f.relPath)).toEqual(['a', 'b']);
|
||||
});
|
||||
// 展开 a → 缓存里出现 'a' 这个 key
|
||||
await act(async () => {
|
||||
result.current.activateFolder('a');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current.childrenByDir.get('a')).toBeDefined();
|
||||
});
|
||||
|
||||
// 用户在资源管理器里删了 notes/a/
|
||||
tree = { '': [node('b', null)], b: [] };
|
||||
act(() => {
|
||||
changedHandler?.();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.childrenByDir.get('')?.map((f) => f.relPath)).toEqual(['b']);
|
||||
});
|
||||
// 孤儿缓存键(a)不该留着 —— 否则展开态残留 + 再次展开时拉回幽灵子树
|
||||
expect(result.current.childrenByDir.has('a')).toBe(false);
|
||||
});
|
||||
|
||||
it('外部删掉「落点」目录 → 落点回根(否则新建/粘贴会把刚删的夹凭空建回来)', async () => {
|
||||
tree = { '': [node('a', null)], a: [] };
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(result.current.childrenByDir.get('')).toBeDefined();
|
||||
});
|
||||
await act(async () => {
|
||||
result.current.activateFolder('a');
|
||||
});
|
||||
expect(result.current.targetRelPath).toBe('a');
|
||||
|
||||
tree = { '': [] };
|
||||
act(() => {
|
||||
changedHandler?.();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.targetRelPath).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('外部删掉已展开的目录 → 展开态里不再有它', async () => {
|
||||
tree = { '': [node('a', null)], a: [] };
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
|
||||
);
|
||||
await act(async () => {
|
||||
result.current.activateFolder('a');
|
||||
});
|
||||
expect(result.current.expanded.has('a')).toBe(true);
|
||||
|
||||
tree = { '': [] };
|
||||
act(() => {
|
||||
changedHandler?.();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.expanded.has('a')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('内容没变时不换引用(不引发无谓重渲)', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(result.current.childrenByDir.get('')).toBeDefined();
|
||||
});
|
||||
const before = result.current.childrenByDir.get('');
|
||||
|
||||
act(() => {
|
||||
changedHandler?.();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(listDirsMock.mock.calls.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
expect(result.current.childrenByDir.get('')).toBe(before);
|
||||
});
|
||||
});
|
||||
144
tests/unit/renderer/hooks/use-folder-ops.test.ts
Normal file
144
tests/unit/renderer/hooks/use-folder-ops.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
// tests/unit/renderer/hooks/use-folder-ops.test.ts
|
||||
//
|
||||
// 守 r227「文件夹操作 + 粘贴反馈包装」收编契约(收编 ④):
|
||||
// NotesList / LiteratureList 里逐字相同的 runFolderOp(r173)/
|
||||
// commitFolderEdit(r176)/ pasteTo(0007) 三段收进 hooks/use-folder-ops.ts;
|
||||
// 唯一分叉 = log 消息前缀(logTag)与 paste 失败 toast 的 i18n key
|
||||
// (pasteFailedKey)—— 两域 locale 树不同。
|
||||
//
|
||||
// ── 锁两类东西(与 r224-r226 同模式)────────────────────────────────
|
||||
// 1. 行为契约(收编前既有行为,对 HEAD 也必须绿):
|
||||
// op 失败 ⇒ log + 错误 toast,不吞异常;成功 ⇒ 静默;
|
||||
// commitFolderEdit 成功 true / 失败 false(输入框回可编辑态,不卡 busy);
|
||||
// pasteTo 失败用注入的 key 弹 toast;pasteToTarget 落点 = 当前落点文件夹;
|
||||
// 2. 结构契约:组件源码不得再出现 'folder op failed' / 'folder edit failed'
|
||||
// —— 孪生块复活即红(修前红)。
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
// i18n —— t 回显 key,便于断言「用的是哪个 key」。
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: { msg?: string }) => (opts?.msg ? `${key}|${opts.msg}` : 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);
|
||||
},
|
||||
}));
|
||||
|
||||
// logger —— 记录 error 消息(验证 logTag 注入)。
|
||||
const logErrors: { msg: string; err: unknown }[] = [];
|
||||
vi.mock('../../../../src/renderer/src/lib/logger-client', () => ({
|
||||
createLogger: () => ({
|
||||
error: (msg: string, err: unknown) => logErrors.push({ msg, err }),
|
||||
warn: () => {},
|
||||
info: () => {},
|
||||
debug: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { useFolderOps } from '../../../../src/renderer/src/hooks/use-folder-ops';
|
||||
import type { DiskFolderSidebar } from '../../../../src/renderer/src/hooks/useDiskFolderSidebar';
|
||||
|
||||
function setup(over: { pasteFailedKey?: string; logTag?: string } = {}) {
|
||||
const commitEdit = vi.fn().mockResolvedValue(undefined);
|
||||
const folders = {
|
||||
commitEdit,
|
||||
targetRelPath: 'sub/dir',
|
||||
} as unknown as DiskFolderSidebar;
|
||||
const pasteClipboard = vi.fn().mockResolvedValue(undefined);
|
||||
const hook = renderHook(() =>
|
||||
useFolderOps({
|
||||
logTag: over.logTag ?? 'NotesList',
|
||||
pasteFailedKey: over.pasteFailedKey ?? 'toast.pasteNoteFailed',
|
||||
folders,
|
||||
pasteClipboard,
|
||||
}),
|
||||
);
|
||||
return { ...hook, commitEdit, pasteClipboard };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toastCalls.length = 0;
|
||||
logErrors.length = 0;
|
||||
});
|
||||
|
||||
describe('useFolderOps(r227 收编块的行为契约)', () => {
|
||||
it('runFolderOp:成功静默;失败 ⇒ log + 错误 toast(不吞异常)', async () => {
|
||||
const { result } = setup();
|
||||
result.current.runFolderOp(() => Promise.resolve('ok'));
|
||||
await Promise.resolve();
|
||||
expect(toastCalls).toHaveLength(0);
|
||||
expect(logErrors).toHaveLength(0);
|
||||
|
||||
const boom = new Error('boom');
|
||||
result.current.runFolderOp(() => Promise.reject(boom));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(logErrors[0].msg).toBe('NotesList folder op failed');
|
||||
expect(logErrors[0].err).toBe(boom);
|
||||
expect(toastCalls[0].type).toBe('error');
|
||||
expect(toastCalls[0].message).toContain('toast.folderMoveFailed');
|
||||
});
|
||||
|
||||
it('runFolderOp:log 前缀随 logTag 注入(两域各用自己的组件名)', async () => {
|
||||
const { result } = setup({ logTag: 'LiteratureList' });
|
||||
result.current.runFolderOp(() => Promise.reject(new Error('x')));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(logErrors[0].msg).toBe('LiteratureList folder op failed');
|
||||
});
|
||||
|
||||
it('commitFolderEdit:成功 ⇒ true 且无 toast;失败 ⇒ false + 错误 toast', async () => {
|
||||
const { result, commitEdit } = setup();
|
||||
await expect(result.current.commitFolderEdit('newName')).resolves.toBe(true);
|
||||
expect(commitEdit).toHaveBeenCalledWith('newName');
|
||||
expect(toastCalls).toHaveLength(0);
|
||||
|
||||
commitEdit.mockRejectedValueOnce(new Error('bad'));
|
||||
await expect(result.current.commitFolderEdit('x')).resolves.toBe(false);
|
||||
expect(toastCalls[0].type).toBe('error');
|
||||
expect(toastCalls[0].message).toContain('toast.folderMoveFailed');
|
||||
expect(logErrors[0].msg).toBe('NotesList folder edit failed');
|
||||
});
|
||||
|
||||
it('pasteTo:失败用注入的 i18n key 弹 toast;成功静默', async () => {
|
||||
const { result, pasteClipboard } = setup({ pasteFailedKey: 'literature.pasteFailedToast' });
|
||||
pasteClipboard.mockRejectedValueOnce(new Error('nope'));
|
||||
await result.current.pasteTo('target');
|
||||
expect(toastCalls[0].message).toContain('literature.pasteFailedToast');
|
||||
expect(logErrors[0].msg).toBe('NotesList paste failed');
|
||||
|
||||
toastCalls.length = 0;
|
||||
logErrors.length = 0;
|
||||
await result.current.pasteTo('target');
|
||||
expect(toastCalls).toHaveLength(0);
|
||||
expect(logErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('pasteToTarget:落点 = 当前落点文件夹(folders.targetRelPath)', () => {
|
||||
const { result, pasteClipboard } = setup();
|
||||
result.current.pasteToTarget();
|
||||
expect(pasteClipboard).toHaveBeenCalledWith('sub/dir');
|
||||
});
|
||||
});
|
||||
|
||||
describe('r227 结构契约:孪生块不得复活', () => {
|
||||
const ROOT = process.cwd();
|
||||
for (const rel of [
|
||||
'src/renderer/src/components/notes/NotesList.tsx',
|
||||
'src/renderer/src/components/literature/LiteratureList.tsx',
|
||||
]) {
|
||||
it(`${rel} 走 useFolderOps,不再自带 op 反馈包装`, () => {
|
||||
const src = readFileSync(join(ROOT, rel), 'utf-8');
|
||||
expect(src, '文件夹操作/粘贴包装必须走 hooks/use-folder-ops').toContain('useFolderOps');
|
||||
expect(src, '孪生块复活:组件里又出现 runFolderOp 的实现').not.toContain('folder op failed');
|
||||
expect(src, '孪生块复活:组件里又出现 commitFolderEdit 的实现').not.toContain('folder edit failed');
|
||||
});
|
||||
}
|
||||
});
|
||||
304
tests/unit/renderer/hooks/use-listbox-keys.test.ts
Normal file
304
tests/unit/renderer/hooks/use-listbox-keys.test.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
// tests/unit/renderer/hooks/use-listbox-keys.test.ts
|
||||
//
|
||||
// 守 r225「handleListboxKeyDown 同构块收编」契约(收编 ②):
|
||||
// NotesList(153 行)/ LiteratureList(136 行)的容器级键盘处理收进
|
||||
// hooks/use-listbox-keys.ts。差异收敛为注入点(onPasteItems /
|
||||
// onDeleteSelection / 可选 onRenameItem);lit 的 Delete 分支
|
||||
// `!== 'input'` 与 notes 的 `=== 'item'` 在封闭三元并
|
||||
// {'folder','item','input'} 下等价,统一为后者。
|
||||
//
|
||||
// ── 锁两类东西(与 r224 同模式)────────────────────────────────────
|
||||
// 1. 行为契约:输入焦点 gate / mod+c/x/v/a/n / F2(folder + 可选 item)/
|
||||
// Delete(folder + item)/ Enter/Space 容器 commit 与防二次触发 gate /
|
||||
// ←/→ 树导航 —— 这些是收编前两块代码的既有行为,对 HEAD 也必须绿;
|
||||
// 2. 结构契约:两组件源码不得再出现 findFirstChildRow /
|
||||
// `handleListboxKeyDown = useCallback`,孪生块复活即红(修前红)。
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import { useListboxKeys, type UseListboxKeysOptions } from '../../../../src/renderer/src/hooks/use-listbox-keys';
|
||||
import type { TreeRow } from '../../../../src/renderer/src/lib/folder-tree';
|
||||
import type { DiskFolderSidebar } from '../../../../src/renderer/src/hooks/useDiskFolderSidebar';
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
relPath: string;
|
||||
}
|
||||
|
||||
const N1: Row = { id: 'n1', relPath: 'docs/n1.md' };
|
||||
const N2: Row = { id: 'n2', relPath: 'n2.md' };
|
||||
|
||||
function folderRow(relPath: string, expanded: boolean): TreeRow<Row> {
|
||||
return {
|
||||
rowType: 'folder',
|
||||
id: 'folder:' + relPath,
|
||||
folder: { relPath, name: relPath },
|
||||
depth: 0,
|
||||
expanded,
|
||||
hasChildren: true,
|
||||
isTarget: false,
|
||||
parentRelPath: '',
|
||||
posInSet: 1,
|
||||
setSize: 1,
|
||||
} as unknown as TreeRow<Row>;
|
||||
}
|
||||
function itemRow(item: Row, parentRelPath: string): TreeRow<Row> {
|
||||
return {
|
||||
rowType: 'item',
|
||||
id: item.id,
|
||||
item,
|
||||
depth: parentRelPath === '' ? 0 : 1,
|
||||
parentRelPath,
|
||||
posInSet: 1,
|
||||
setSize: 1,
|
||||
} as unknown as TreeRow<Row>;
|
||||
}
|
||||
|
||||
// 0: folder docs(expanded) / 1: item n1 ∈ docs / 2: item n2 ∈ 根 / 3: folder empty(折叠)
|
||||
const ROWS: TreeRow<Row>[] = [
|
||||
folderRow('docs', true),
|
||||
itemRow(N1, 'docs'),
|
||||
itemRow(N2, ''),
|
||||
folderRow('empty', false),
|
||||
];
|
||||
|
||||
function makeFolders() {
|
||||
return {
|
||||
cutFolder: vi.fn(),
|
||||
folderClipboard: null as string | null,
|
||||
pasteFolderInto: vi.fn().mockResolvedValue(undefined),
|
||||
targetRelPath: '',
|
||||
startCreate: vi.fn(),
|
||||
startRename: vi.fn(),
|
||||
requestDelete: vi.fn().mockResolvedValue(undefined),
|
||||
activateFolder: vi.fn(),
|
||||
expandFolder: vi.fn(),
|
||||
collapseFolder: vi.fn(),
|
||||
} as unknown as DiskFolderSidebar;
|
||||
}
|
||||
|
||||
function makeKeyEvent(
|
||||
key: string,
|
||||
opts: { mod?: boolean; shift?: boolean; targetIsInput?: boolean; containerGate?: boolean } = {},
|
||||
) {
|
||||
const container = { tagName: 'DIV', isContentEditable: false };
|
||||
const target = opts.targetIsInput
|
||||
? { tagName: 'INPUT', isContentEditable: false }
|
||||
: container;
|
||||
return {
|
||||
key,
|
||||
ctrlKey: opts.mod ?? false,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: opts.shift ?? false,
|
||||
target,
|
||||
currentTarget: opts.containerGate === false ? { tagName: 'DIV' } : container,
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as ReactKeyboardEvent;
|
||||
}
|
||||
|
||||
function setup(overrides: Partial<UseListboxKeysOptions<Row>> = {}) {
|
||||
const calls = {
|
||||
copySelection: vi.fn(),
|
||||
cutSelection: vi.fn(),
|
||||
onPasteItems: vi.fn(),
|
||||
runFolderOp: vi.fn((op: () => Promise<unknown>) => void op()),
|
||||
selectMany: vi.fn(),
|
||||
onDeleteSelection: vi.fn(),
|
||||
handleSelect: vi.fn(),
|
||||
handleActiveById: vi.fn(),
|
||||
onRenameItem: vi.fn(),
|
||||
};
|
||||
const folders = makeFolders();
|
||||
const opts: UseListboxKeysOptions<Row> = {
|
||||
rows: ROWS,
|
||||
activeId: null,
|
||||
visibleItems: [N1, N2],
|
||||
clipboard: null,
|
||||
searchActive: false,
|
||||
folders,
|
||||
...calls,
|
||||
...overrides,
|
||||
};
|
||||
const hook = renderHook((p: UseListboxKeysOptions<Row>) => useListboxKeys<Row>(p), {
|
||||
initialProps: opts,
|
||||
});
|
||||
return { ...hook, calls, folders };
|
||||
}
|
||||
|
||||
describe('useListboxKeys(r225 收编块的行为契约)', () => {
|
||||
it('输入焦点 gate:目标是 INPUT/TEXTAREA/contentEditable ⇒ 全键让行', () => {
|
||||
const { result, calls } = setup();
|
||||
act(() => result.current(makeKeyEvent('c', { mod: true, targetIsInput: true })));
|
||||
expect(calls.copySelection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mod+c 复制选区;mod+x 在文件夹行剪切文件夹、否则剪切选区', () => {
|
||||
const { result, calls, folders, rerender } = setup({ activeId: 'folder:docs' });
|
||||
act(() => result.current(makeKeyEvent('c', { mod: true })));
|
||||
expect(calls.copySelection).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => result.current(makeKeyEvent('x', { mod: true })));
|
||||
expect(folders.cutFolder).toHaveBeenCalledWith('docs');
|
||||
expect(calls.cutSelection).not.toHaveBeenCalled();
|
||||
|
||||
// active 是 item 行 ⇒ 按选区剪切
|
||||
rerender({ ...setupDefaults(calls, folders), activeId: 'n2' });
|
||||
act(() => result.current(makeKeyEvent('x', { mod: true })));
|
||||
expect(calls.cutSelection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('mod+v:条目剪贴板优先;空则文件夹剪贴板兜底;搜索态不兜底(r131/r134)', () => {
|
||||
const { result, calls, folders, rerender } = setup({ clipboard: { ids: ['n2'] } });
|
||||
act(() => result.current(makeKeyEvent('v', { mod: true })));
|
||||
expect(calls.onPasteItems).toHaveBeenCalledTimes(1);
|
||||
expect(folders.pasteFolderInto).not.toHaveBeenCalled();
|
||||
|
||||
// 条目剪贴板空 + 文件夹剪贴板非空 ⇒ runFolderOp 兜底
|
||||
(folders as { folderClipboard: string | null }).folderClipboard = 'docs';
|
||||
rerender({ ...setupDefaults(calls, folders), clipboard: null });
|
||||
act(() => result.current(makeKeyEvent('v', { mod: true })));
|
||||
expect(calls.runFolderOp).toHaveBeenCalled();
|
||||
expect(folders.pasteFolderInto).toHaveBeenCalledWith('');
|
||||
|
||||
// 搜索态 ⇒ 不兜底
|
||||
calls.runFolderOp.mockClear();
|
||||
(folders.pasteFolderInto as ReturnType<typeof vi.fn>).mockClear();
|
||||
rerender({ ...setupDefaults(calls, folders), clipboard: null, searchActive: true });
|
||||
act(() => result.current(makeKeyEvent('v', { mod: true })));
|
||||
expect(calls.runFolderOp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mod+a 全选当前可见项;mod+Shift+N 搜索态不接收(r134)', () => {
|
||||
const { result, calls, folders, rerender } = setup();
|
||||
act(() => result.current(makeKeyEvent('a', { mod: true, shift: true })));
|
||||
expect(calls.selectMany).toHaveBeenCalledWith(['n1', 'n2']);
|
||||
|
||||
act(() => result.current(makeKeyEvent('n', { mod: true, shift: true })));
|
||||
expect(folders.startCreate).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({ ...setupDefaults(calls, folders), searchActive: true });
|
||||
act(() => result.current(makeKeyEvent('n', { mod: true, shift: true })));
|
||||
expect(folders.startCreate).toHaveBeenCalledTimes(1); // 没有第二次
|
||||
});
|
||||
|
||||
it('F2:文件夹行重命名;item 行仅在传了 onRenameItem 时触发(文献域不传)', () => {
|
||||
const { result, calls, folders, rerender } = setup({ activeId: 'folder:docs' });
|
||||
act(() => result.current(makeKeyEvent('F2')));
|
||||
expect(folders.startRename).toHaveBeenCalledTimes(1);
|
||||
expect(calls.onRenameItem).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ ...setupDefaults(calls, folders), activeId: 'n1' });
|
||||
act(() => result.current(makeKeyEvent('F2')));
|
||||
expect(calls.onRenameItem).toHaveBeenCalledWith('n1');
|
||||
|
||||
// 不传 onRenameItem ⇒ F2 在 item 行什么都不做(文献域原行为)
|
||||
const bare = setup({ activeId: 'n1', onRenameItem: undefined });
|
||||
const e = makeKeyEvent('F2');
|
||||
act(() => bare.result.current(e));
|
||||
expect(bare.calls.onRenameItem).not.toHaveBeenCalled();
|
||||
expect(e.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Delete:文件夹行 requestDelete;item 行 onDeleteSelection', () => {
|
||||
const { result, calls, folders, rerender } = setup({ activeId: 'folder:docs' });
|
||||
act(() => result.current(makeKeyEvent('Delete')));
|
||||
expect(folders.requestDelete).toHaveBeenCalledTimes(1);
|
||||
expect(calls.onDeleteSelection).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ ...setupDefaults(calls, folders), activeId: 'n1' });
|
||||
act(() => result.current(makeKeyEvent('Delete')));
|
||||
expect(calls.onDeleteSelection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Enter/Space 容器级 commit:folder=activate,item=select;行持焦不二次触发(r76)', () => {
|
||||
const { result, calls, folders, rerender } = setup({ activeId: 'folder:docs' });
|
||||
act(() => result.current(makeKeyEvent('Enter')));
|
||||
expect(folders.activateFolder).toHaveBeenCalledWith('docs');
|
||||
|
||||
rerender({ ...setupDefaults(calls, folders), activeId: 'n1' });
|
||||
act(() => result.current(makeKeyEvent(' ')));
|
||||
expect(calls.handleSelect).toHaveBeenCalledWith(N1);
|
||||
|
||||
// gate:target !== currentTarget(行持焦)⇒ 容器不接管
|
||||
calls.handleSelect.mockClear();
|
||||
act(() => result.current(makeKeyEvent('Enter', { containerGate: false })));
|
||||
expect(calls.handleSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('←/→ 树导航:→ 折叠=展开 / 展开=跳首个子行;← 展开=折叠 / 否则跳父行(r176)', () => {
|
||||
const { result, calls, folders, rerender } = setup({ activeId: 'folder:empty' });
|
||||
// → 折叠文件夹 ⇒ 展开
|
||||
act(() => result.current(makeKeyEvent('ArrowRight')));
|
||||
expect(folders.expandFolder).toHaveBeenCalledWith('empty');
|
||||
|
||||
// → 展开文件夹 ⇒ 跳到第一个子行
|
||||
rerender({ ...setupDefaults(calls, folders), activeId: 'folder:docs' });
|
||||
act(() => result.current(makeKeyEvent('ArrowRight')));
|
||||
expect(calls.handleActiveById).toHaveBeenCalledWith('n1');
|
||||
|
||||
// → 文件行 ⇒ 无意义
|
||||
calls.handleActiveById.mockClear();
|
||||
rerender({ ...setupDefaults(calls, folders), activeId: 'n2' });
|
||||
act(() => result.current(makeKeyEvent('ArrowRight')));
|
||||
expect(calls.handleActiveById).not.toHaveBeenCalled();
|
||||
|
||||
// ← 展开文件夹 ⇒ 折叠
|
||||
rerender({ ...setupDefaults(calls, folders), activeId: 'folder:docs' });
|
||||
act(() => result.current(makeKeyEvent('ArrowLeft')));
|
||||
expect(folders.collapseFolder).toHaveBeenCalledWith('docs');
|
||||
|
||||
// ← 子项 ⇒ 跳父文件夹行
|
||||
rerender({ ...setupDefaults(calls, folders), activeId: 'n1' });
|
||||
act(() => result.current(makeKeyEvent('ArrowLeft')));
|
||||
expect(calls.handleActiveById).toHaveBeenCalledWith('folder:docs');
|
||||
});
|
||||
});
|
||||
|
||||
// rerender 需要整棵 opts;把 setup 的内部默认抽出来复用
|
||||
function setupDefaults(
|
||||
calls: ReturnType<typeof makeCalls>,
|
||||
folders: DiskFolderSidebar,
|
||||
): UseListboxKeysOptions<Row> {
|
||||
return {
|
||||
rows: ROWS,
|
||||
activeId: null,
|
||||
visibleItems: [N1, N2],
|
||||
clipboard: null,
|
||||
searchActive: false,
|
||||
folders,
|
||||
...calls,
|
||||
};
|
||||
}
|
||||
function makeCalls() {
|
||||
return {
|
||||
copySelection: vi.fn(),
|
||||
cutSelection: vi.fn(),
|
||||
onPasteItems: vi.fn(),
|
||||
runFolderOp: vi.fn((op: () => Promise<unknown>) => void op()),
|
||||
selectMany: vi.fn(),
|
||||
onDeleteSelection: vi.fn(),
|
||||
handleSelect: vi.fn(),
|
||||
handleActiveById: vi.fn(),
|
||||
onRenameItem: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('r225 结构契约:孪生块不得复活', () => {
|
||||
const ROOT = process.cwd();
|
||||
for (const rel of [
|
||||
'src/renderer/src/components/notes/NotesList.tsx',
|
||||
'src/renderer/src/components/literature/LiteratureList.tsx',
|
||||
]) {
|
||||
it(`${rel} 走 useListboxKeys,不再自带键盘处理块`, () => {
|
||||
const src = readFileSync(join(ROOT, rel), 'utf-8');
|
||||
expect(src, 'listbox 键盘处理必须走 hooks/use-listbox-keys').toContain('useListboxKeys');
|
||||
expect(src, '孪生块复活:组件里又出现树导航 helper').not.toContain('findFirstChildRow');
|
||||
expect(src, '孪生块复活:组件里又出现 useCallback 版键盘块').not.toContain(
|
||||
'handleListboxKeyDown = useCallback',
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
378
tests/unit/renderer/hooks/use-reorder-dnd.test.ts
Normal file
378
tests/unit/renderer/hooks/use-reorder-dnd.test.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
// tests/unit/renderer/hooks/use-reorder-dnd.test.ts
|
||||
//
|
||||
// 守 r224「reorder 拖拽四件套收编」契约:
|
||||
// NotesList / LiteratureList 里逐字同构的 reorder 块(dragOver/dragLeave/
|
||||
// drop/dragEnd + 落点指示线 state)收编进 hooks/use-reorder-dnd.ts。
|
||||
//
|
||||
// ── 锁两类东西 ─────────────────────────────────────────────────────
|
||||
// 1. 行为契约:
|
||||
// dragOver 给 before/after 指示线 + dropEffect='move';
|
||||
// dragLeave 只在真正离开 row 时清(relatedTarget 判);
|
||||
// drop 守卫 = 无 payload / 异 kind / 拖回自身 no-op;跨目录落行
|
||||
// (2026-10-21 起,用户诉求「移出文件夹任意位置都行」)委托
|
||||
// onCrossFolderMove(未注入则维持 r176 静默拒绝);同目录才按
|
||||
// 「filter 掉 moving + before 插位 splice」算新顺序调 reorderByIds;
|
||||
// dragEnd 清指示线;
|
||||
// 2. 结构契约(防孪生块复活):两个组件源码里不得再出现
|
||||
// hasReorderPayload / setReorderIndicator —— 谁把四件套抄回组件里,
|
||||
// 这条立刻红。
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import type { DragEvent as ReactDragEvent } from 'react';
|
||||
import { useReorderDnd } from '../../../../src/renderer/src/hooks/use-reorder-dnd';
|
||||
import { writeReorderPayload } from '../../../../src/renderer/src/lib/folder-dnd';
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
relPath: string;
|
||||
}
|
||||
|
||||
const ROWS: Row[] = [
|
||||
{ id: 'a', relPath: 'a.md' },
|
||||
{ id: 'b', relPath: 'b.md' },
|
||||
{ id: 'c', relPath: 'c.md' },
|
||||
{ id: 'd', relPath: 'sub/d.md' },
|
||||
];
|
||||
const ORDER = { a: 0, b: 1, c: 2, d: 3 };
|
||||
|
||||
function makeDataTransfer() {
|
||||
const types: string[] = [];
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
types,
|
||||
setData(mime: string, val: string) {
|
||||
if (!store.has(mime)) types.push(mime);
|
||||
store.set(mime, val);
|
||||
},
|
||||
getData(mime: string) {
|
||||
return store.get(mime) ?? '';
|
||||
},
|
||||
effectAllowed: 'move',
|
||||
dropEffect: 'move',
|
||||
} as unknown as DataTransfer;
|
||||
}
|
||||
|
||||
function makeDragEvent(dt: DataTransfer, clientY = 10, rect?: Partial<DOMRect>) {
|
||||
const el = {
|
||||
getBoundingClientRect: () =>
|
||||
({ top: 0, height: 20, ...rect }) as DOMRect,
|
||||
contains: () => false,
|
||||
};
|
||||
return {
|
||||
dataTransfer: dt,
|
||||
clientY,
|
||||
currentTarget: el,
|
||||
relatedTarget: null,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
} as unknown as ReactDragEvent;
|
||||
}
|
||||
|
||||
function setup(kind: 'note' | 'literature' = 'note') {
|
||||
const reorderByIds = vi.fn();
|
||||
const hook = renderHook(() =>
|
||||
useReorderDnd<Row>({
|
||||
kind,
|
||||
getItems: () => ROWS,
|
||||
displayOrderById: ORDER,
|
||||
reorderByIds,
|
||||
}),
|
||||
);
|
||||
return { ...hook, reorderByIds };
|
||||
}
|
||||
|
||||
describe('useReorderDnd(r224 收编块的行为契约)', () => {
|
||||
it('dragOver 按鼠标 y 与行中线给 before/after 指示线 + dropEffect=move', () => {
|
||||
const { result } = setup();
|
||||
const dt = makeDataTransfer();
|
||||
writeReorderPayload(dt, { id: 'a', targetId: 'a', before: true, kind: 'note' });
|
||||
|
||||
act(() => {
|
||||
result.current.handleItemReorderDragOver(ROWS[1], makeDragEvent(dt, 15)); // 15 > 中线 10
|
||||
});
|
||||
expect(result.current.reorderIndicator).toEqual({ id: 'b', position: 'after' });
|
||||
expect(dt.dropEffect).toBe('move');
|
||||
|
||||
act(() => {
|
||||
result.current.handleItemReorderDragOver(ROWS[1], makeDragEvent(dt, 3)); // 3 < 中线 10
|
||||
});
|
||||
expect(result.current.reorderIndicator).toEqual({ id: 'b', position: 'before' });
|
||||
});
|
||||
|
||||
it('无 reorder payload 的 dragOver 不接管(不 preventDefault)', () => {
|
||||
const { result } = setup();
|
||||
const e = makeDragEvent(makeDataTransfer());
|
||||
act(() => {
|
||||
result.current.handleItemReorderDragOver(ROWS[1], e);
|
||||
});
|
||||
expect(result.current.reorderIndicator).toBeNull();
|
||||
expect(e.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drop 同目录 after 插入:filter 掉 moving 再 splice,reorderByIds 拿全新顺序', () => {
|
||||
const { result, reorderByIds } = setup();
|
||||
const dt = makeDataTransfer();
|
||||
writeReorderPayload(dt, { id: 'a', targetId: 'a', before: true, kind: 'note' });
|
||||
// 拖 a 到 b 的下半部分(after)→ [b, a, c]
|
||||
act(() => {
|
||||
result.current.handleItemReorderDrop(ROWS[1], makeDragEvent(dt, 15));
|
||||
});
|
||||
expect(reorderByIds).toHaveBeenCalledTimes(1);
|
||||
expect(reorderByIds).toHaveBeenCalledWith(['b', 'a', 'c', 'd']);
|
||||
expect(result.current.reorderIndicator).toBeNull();
|
||||
});
|
||||
|
||||
it('drop 同目录 before 插入:拖 a 到 b 的上半部分 → [a, b, c]', () => {
|
||||
const { result, reorderByIds } = setup();
|
||||
const dt = makeDataTransfer();
|
||||
writeReorderPayload(dt, { id: 'c', targetId: 'c', before: true, kind: 'note' });
|
||||
// 拖 c 到 a 的上半部分(before)→ [c, a, b, d]
|
||||
act(() => {
|
||||
result.current.handleItemReorderDrop(ROWS[0], makeDragEvent(dt, 3));
|
||||
});
|
||||
expect(reorderByIds).toHaveBeenCalledWith(['c', 'a', 'b', 'd']);
|
||||
});
|
||||
|
||||
it('drop 四道守卫:拖回自身 / 跨目录 / 异 kind / 无 payload 全部 no-op', () => {
|
||||
const { result, reorderByIds } = setup();
|
||||
// ① 拖回自身
|
||||
let dt = makeDataTransfer();
|
||||
writeReorderPayload(dt, { id: 'a', targetId: 'a', before: true, kind: 'note' });
|
||||
act(() => {
|
||||
result.current.handleItemReorderDrop(ROWS[0], makeDragEvent(dt, 3));
|
||||
});
|
||||
// ② 跨目录(d 在 sub/,a 在根)→ 未注入 onCrossFolderMove 时维持 r176 拒绝
|
||||
dt = makeDataTransfer();
|
||||
writeReorderPayload(dt, { id: 'd', targetId: 'd', before: true, kind: 'note' });
|
||||
act(() => {
|
||||
result.current.handleItemReorderDrop(ROWS[0], makeDragEvent(dt, 3));
|
||||
});
|
||||
// ③ 异 kind(literature payload 落进 note 列表)
|
||||
dt = makeDataTransfer();
|
||||
writeReorderPayload(dt, { id: 'b', targetId: 'b', before: true, kind: 'literature' });
|
||||
act(() => {
|
||||
result.current.handleItemReorderDrop(ROWS[0], makeDragEvent(dt, 3));
|
||||
});
|
||||
// ④ 无 payload
|
||||
act(() => {
|
||||
result.current.handleItemReorderDrop(ROWS[0], makeDragEvent(makeDataTransfer(), 3));
|
||||
});
|
||||
expect(reorderByIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dragLeave 真正离开才清指示线;dragEnd 无条件清', () => {
|
||||
const { result } = setup();
|
||||
const dt = makeDataTransfer();
|
||||
writeReorderPayload(dt, { id: 'a', targetId: 'a', before: true, kind: 'note' });
|
||||
act(() => {
|
||||
result.current.handleItemReorderDragOver(ROWS[1], makeDragEvent(dt, 15));
|
||||
});
|
||||
expect(result.current.reorderIndicator).not.toBeNull();
|
||||
|
||||
// relatedTarget 在 row 内(contains=true)→ 不清
|
||||
const innerLeave = makeDragEvent(dt, 15);
|
||||
(innerLeave.currentTarget as unknown as { contains: () => boolean }).contains = () => true;
|
||||
(innerLeave as { relatedTarget: unknown }).relatedTarget = {};
|
||||
act(() => {
|
||||
result.current.handleItemReorderDragLeave(ROWS[1], innerLeave);
|
||||
});
|
||||
expect(result.current.reorderIndicator).not.toBeNull();
|
||||
|
||||
// 真正离开 → 清
|
||||
act(() => {
|
||||
result.current.handleItemReorderDragLeave(ROWS[1], makeDragEvent(dt, 15));
|
||||
});
|
||||
expect(result.current.reorderIndicator).toBeNull();
|
||||
|
||||
// 再拖出指示线后 dragEnd → 清
|
||||
act(() => {
|
||||
result.current.handleItemReorderDragOver(ROWS[1], makeDragEvent(dt, 15));
|
||||
});
|
||||
act(() => {
|
||||
result.current.handleItemReorderDragEnd(makeDragEvent(dt));
|
||||
});
|
||||
expect(result.current.reorderIndicator).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('跨目录落行 = 委托 onCrossFolderMove(2026-10-21 「移出文件夹任意位置」)', () => {
|
||||
function setupWithMove() {
|
||||
const reorderByIds = vi.fn();
|
||||
const onCrossFolderMove = vi.fn();
|
||||
const hook = renderHook(() =>
|
||||
useReorderDnd<Row>({
|
||||
kind: 'note',
|
||||
getItems: () => ROWS,
|
||||
displayOrderById: ORDER,
|
||||
reorderByIds,
|
||||
onCrossFolderMove,
|
||||
}),
|
||||
);
|
||||
return { ...hook, reorderByIds, onCrossFolderMove };
|
||||
}
|
||||
|
||||
it('子目录条目 d 落到根行 a ⇒ 委托,targetDir=\'\',不走 reorder', () => {
|
||||
const { result, reorderByIds, onCrossFolderMove } = setupWithMove();
|
||||
const dt = makeDataTransfer();
|
||||
writeReorderPayload(dt, { id: 'd', targetId: 'd', before: true, kind: 'note' });
|
||||
const e = makeDragEvent(dt, 3);
|
||||
act(() => {
|
||||
result.current.handleItemReorderDrop(ROWS[0], e);
|
||||
});
|
||||
expect(onCrossFolderMove).toHaveBeenCalledTimes(1);
|
||||
expect(onCrossFolderMove).toHaveBeenCalledWith('', e);
|
||||
expect(reorderByIds, '跨目录不得走 reorderByIds(视觉错位,r176)').not.toHaveBeenCalled();
|
||||
// 已 preventDefault + stopPropagation:容器空白区 ROOT 落点不得二次处理
|
||||
expect(e.preventDefault).toHaveBeenCalled();
|
||||
expect(e.stopPropagation).toHaveBeenCalled();
|
||||
// 指示线照常清(与 reorder 路径一致)
|
||||
expect(result.current.reorderIndicator).toBeNull();
|
||||
});
|
||||
|
||||
it('根条目 a 落到 sub/ 目录的行 d ⇒ 委托,targetDir=\'sub\'', () => {
|
||||
const { result, reorderByIds, onCrossFolderMove } = setupWithMove();
|
||||
const dt = makeDataTransfer();
|
||||
writeReorderPayload(dt, { id: 'a', targetId: 'a', before: true, kind: 'note' });
|
||||
act(() => {
|
||||
result.current.handleItemReorderDrop(ROWS[3], makeDragEvent(dt, 3));
|
||||
});
|
||||
expect(onCrossFolderMove).toHaveBeenCalledTimes(1);
|
||||
expect(onCrossFolderMove.mock.calls[0]![0]).toBe('sub');
|
||||
expect(reorderByIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('同目录落行 ⇒ 不委托(走原 reorder 路径)', () => {
|
||||
const { result, reorderByIds, onCrossFolderMove } = setupWithMove();
|
||||
const dt = makeDataTransfer();
|
||||
writeReorderPayload(dt, { id: 'a', targetId: 'a', before: true, kind: 'note' });
|
||||
act(() => {
|
||||
result.current.handleItemReorderDrop(ROWS[1], makeDragEvent(dt, 15));
|
||||
});
|
||||
expect(onCrossFolderMove).not.toHaveBeenCalled();
|
||||
expect(reorderByIds).toHaveBeenCalledWith(['b', 'a', 'c', 'd']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('perf r247:拖拽期间的渲染预算(指示线同值不重渲)', () => {
|
||||
/**
|
||||
* 背景(用户诉求「使用流畅,不要有卡顿」):
|
||||
* dragover 是**高频**事件(鼠标一动就发,实测 ~60Hz)。旧实现每次都
|
||||
* `setReorderIndicator({ id, position })` 造一个**新对象** ⇒ React 的
|
||||
* 「新 state = 新引用 ⇒ 必须重渲」判定永远成立 ⇒ 每秒 60 次重渲。
|
||||
* 而重渲的是**整个列表组件**:NotesList / LiteratureList 的 itemContent
|
||||
* useCallback 把 reorderIndicator 列入 deps ⇒ 指示线一变 itemContent
|
||||
* 身份就变 ⇒ react-virtuoso 重新渲染**全部可见行**。
|
||||
* ⇒ 「按住一行拖动」= 整列可见行 60Hz 重渲,大库上必卡。
|
||||
*
|
||||
* 修法:落点语义(id + position)没变 ⇒ **连 setState 都不调**。
|
||||
* 注:曾试过「updater 原样返回 prev ⇒ React 同值 bail-out」,**不成立** ——
|
||||
* 实测 React 18.3.1(updater 返回同一引用)照样重渲一次,官方措辞是
|
||||
* 「React may still need to render that specific component again before
|
||||
* bailing out」。所以只能靠「不发 setState」这一条路。
|
||||
* 本组用例锁「重渲次数」与「指示线对象身份」两个可观测量。
|
||||
*/
|
||||
function setupCounting() {
|
||||
let renders = 0;
|
||||
const hook = renderHook(() => {
|
||||
renders += 1;
|
||||
return useReorderDnd<Row>({
|
||||
kind: 'note',
|
||||
getItems: () => ROWS,
|
||||
displayOrderById: ORDER,
|
||||
reorderByIds: vi.fn(),
|
||||
});
|
||||
});
|
||||
return { ...hook, renders: () => renders };
|
||||
}
|
||||
|
||||
it('同一行同一侧连发 30 次 dragOver ⇒ 零额外重渲,指示线对象身份不变', () => {
|
||||
const { result, renders } = setupCounting();
|
||||
const dt = makeDataTransfer();
|
||||
writeReorderPayload(dt, { id: 'a', targetId: 'a', before: true, kind: 'note' });
|
||||
|
||||
act(() => {
|
||||
result.current.handleItemReorderDragOver(ROWS[1], makeDragEvent(dt, 15)); // 下半部分 ⇒ after
|
||||
});
|
||||
const first = result.current.reorderIndicator;
|
||||
expect(first).toEqual({ id: 'b', position: 'after' });
|
||||
const rendersAfterFirst = renders();
|
||||
|
||||
// 模拟 60Hz 拖拽:指针在同一行内小幅抖动(不跨中线)
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
act(() => {
|
||||
result.current.handleItemReorderDragOver(ROWS[1], makeDragEvent(dt, 15));
|
||||
});
|
||||
}
|
||||
expect(renders(), '同值 dragOver 不得触发任何重渲').toBe(rendersAfterFirst);
|
||||
expect(result.current.reorderIndicator, '指示线对象身份必须保持不变').toBe(first);
|
||||
});
|
||||
|
||||
it('指示线**真的**变了(跨中线 / 换行)⇒ 仍然照常更新并重渲', () => {
|
||||
const { result, renders } = setupCounting();
|
||||
const dt = makeDataTransfer();
|
||||
writeReorderPayload(dt, { id: 'a', targetId: 'a', before: true, kind: 'note' });
|
||||
|
||||
act(() => {
|
||||
result.current.handleItemReorderDragOver(ROWS[1], makeDragEvent(dt, 15)); // after
|
||||
});
|
||||
const after = result.current.reorderIndicator;
|
||||
const r0 = renders();
|
||||
|
||||
// 跨中线 ⇒ before(语义变了 ⇒ 必须更新)
|
||||
act(() => {
|
||||
result.current.handleItemReorderDragOver(ROWS[1], makeDragEvent(dt, 3));
|
||||
});
|
||||
expect(result.current.reorderIndicator).toEqual({ id: 'b', position: 'before' });
|
||||
expect(result.current.reorderIndicator).not.toBe(after);
|
||||
expect(renders(), '语义变化必须重渲(否则指示线不更新)').toBeGreaterThan(r0);
|
||||
|
||||
// 换到另一行 ⇒ id 变了 ⇒ 必须更新
|
||||
const before = result.current.reorderIndicator;
|
||||
act(() => {
|
||||
result.current.handleItemReorderDragOver(ROWS[2], makeDragEvent(dt, 15));
|
||||
});
|
||||
expect(result.current.reorderIndicator).toEqual({ id: 'c', position: 'after' });
|
||||
expect(result.current.reorderIndicator).not.toBe(before);
|
||||
});
|
||||
|
||||
it('一次跨 3 行的拖拽 ⇒ 重渲次数跟「落点变化次数」走(远小于事件数)', () => {
|
||||
const { result, renders } = setupCounting();
|
||||
const dt = makeDataTransfer();
|
||||
writeReorderPayload(dt, { id: 'a', targetId: 'a', before: true, kind: 'note' });
|
||||
|
||||
const r0 = renders();
|
||||
// 现实手势:指针在 3 行之间移动,**每行内 20 次小幅抖动**(不跨中线)。
|
||||
// ⇒ 落点只在「换行」时变(3 次),事件却发了 60 次。
|
||||
// 旧实现 = 60 次重渲;新实现 ≤ 4 次。
|
||||
for (let rowIdx = 0; rowIdx < 3; rowIdx += 1) {
|
||||
const row = ROWS[1 + rowIdx]!;
|
||||
for (let k = 0; k < 20; k += 1) {
|
||||
act(() => {
|
||||
result.current.handleItemReorderDragOver(row, makeDragEvent(dt, 15 + (k % 3)));
|
||||
});
|
||||
}
|
||||
}
|
||||
expect(renders() - r0, '重渲次数必须跟「落点变化次数」走,而不是事件数').toBeLessThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('r224 结构契约:孪生块不得复活', () => {
|
||||
const ROOT = process.cwd();
|
||||
for (const rel of [
|
||||
'src/renderer/src/components/notes/NotesList.tsx',
|
||||
'src/renderer/src/components/literature/LiteratureList.tsx',
|
||||
]) {
|
||||
it(`${rel} 走 useReorderDnd,不再自带四件套`, () => {
|
||||
const src = readFileSync(join(ROOT, rel), 'utf-8');
|
||||
expect(src, 'reorder 四件套必须走 hooks/use-reorder-dnd').toContain('useReorderDnd');
|
||||
expect(src, '孪生块复活:组件里又出现 payload 判读').not.toContain('hasReorderPayload');
|
||||
expect(src, '孪生块复活:组件里又出现指示线 state').not.toContain('setReorderIndicator');
|
||||
// 2026-10-21:跨目录落行委托必须接线(漏接 = 静默退回 r176 拒绝,
|
||||
// 「移出文件夹只能拖底部空白」复发)。根行映射 ROOT_DROP_ID 同守。
|
||||
expect(src, '跨目录落行委托未接线 onCrossFolderMove').toContain('onCrossFolderMove');
|
||||
expect(src, '根行落点必须映射 ROOT_DROP_ID').toContain('ROOT_DROP_ID');
|
||||
});
|
||||
}
|
||||
});
|
||||
168
tests/unit/renderer/hooks/use-resizable-panel-persist.test.ts
Normal file
168
tests/unit/renderer/hooks/use-resizable-panel-persist.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
// tests/unit/renderer/hooks/use-resizable-panel-persist.test.ts
|
||||
//
|
||||
// 守 useResizablePanel 的「调整落盘」契约 —— **松开鼠标后调整必须完成**
|
||||
// (面板显示的宽度 == 用户偏好 == localStorage 里存的值)。
|
||||
//
|
||||
// 背景(2026-10-20 真机实测,Electron + CDP 拖左栏分隔条):
|
||||
// 拖到 720px 后面板显示 720px,但 localStorage 里
|
||||
// 'workbench.listPanel.width.v2' 仍是 **440** —— 用户的调整没存下来。
|
||||
// 重启应用 → 面板又回到 440 ⇒ 「拖了半天,松手后调整没生效」。
|
||||
//
|
||||
// 根因(r142 引入的「夹紧值不落盘」守卫自带的一处泄漏):
|
||||
// 窗口 resize → clampOnResize 算出夹紧后的目标宽度 → 若目标宽度与当前
|
||||
// 宽度**相同**(最常见:用户没拖过,偏好就落在区间内),setState 同值 ⇒
|
||||
// React 不重渲 ⇒ [width] effect 不跑 ⇒ 那句「这一次不要落盘」的标记
|
||||
// (skipPersistRef)**留在原地没人消费**。
|
||||
// 标记是一次性的「跳过下一次落盘」,不是「跳过这个具体值」⇒
|
||||
// 下一次真正的用户调整(拖拽松手)落盘时被它吞掉 —— 宽度变了、界面变了、
|
||||
// 偏好没变 ⇒ 重启后回退。
|
||||
//
|
||||
// 本文件锁三条:
|
||||
// 1. 拖拽松手后 localStorage 必须等于新宽度(泄漏场景 ⇒ 红)。
|
||||
// 2. 窗口夹紧产生的显示宽度**不污染**用户偏好(r142 的原契约 ⇒ 必须绿)。
|
||||
// 3. 泄漏场景之后连续两次拖拽,两次都要落盘(标记不得再吞)。
|
||||
//
|
||||
// 本文件是 .ts(指针指定)⇒ 用 React.createElement 而不是 JSX。
|
||||
import { describe, expect, it, beforeAll, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { render, fireEvent, cleanup } from '@testing-library/react';
|
||||
import { createElement } from 'react';
|
||||
import { useResizablePanel } from '../../../../src/renderer/src/hooks/useResizablePanel';
|
||||
|
||||
// jsdom 没有 PointerEvent 构造器(见 memory jsdom-test-gaps):
|
||||
// 垫一个继承 MouseEvent 的替身,让 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;
|
||||
});
|
||||
|
||||
const KEY = 'test.panel.width';
|
||||
|
||||
// 视口宽度可写(jsdom 默认 1024,夹紧上限 = 60% = 614)。
|
||||
function setViewportWidth(w: number): void {
|
||||
Object.defineProperty(window, 'innerWidth', { value: w, configurable: true, writable: true });
|
||||
}
|
||||
|
||||
interface HarnessProps {
|
||||
min: number;
|
||||
max: number;
|
||||
defaultWidth: number;
|
||||
}
|
||||
|
||||
/** 最小宿主:section 承载视觉宽度(panelRef 必须绑在这层),按钮当手柄。 */
|
||||
function Harness({ min, max, defaultWidth }: HarnessProps): ReturnType<typeof createElement> {
|
||||
const api = useResizablePanel({
|
||||
storageKey: KEY,
|
||||
defaultWidth,
|
||||
min,
|
||||
max,
|
||||
side: 'left',
|
||||
});
|
||||
return createElement(
|
||||
'section',
|
||||
{ ref: api.panelRef, style: { width: `${api.width}px` }, 'data-testid': 'panel' },
|
||||
createElement(
|
||||
'button',
|
||||
{ type: 'button', onPointerDown: api.onResizeStart, 'data-testid': 'handle' },
|
||||
'drag',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function setup(opts?: Partial<HarnessProps>): {
|
||||
panel: HTMLElement;
|
||||
handle: HTMLElement;
|
||||
unmount: () => void;
|
||||
} {
|
||||
const utils = render(
|
||||
createElement(Harness, { min: 200, max: 720, defaultWidth: 240, ...opts }),
|
||||
);
|
||||
return {
|
||||
panel: utils.getByTestId('panel') as HTMLElement,
|
||||
handle: utils.getByTestId('handle') as HTMLElement,
|
||||
unmount: utils.unmount,
|
||||
};
|
||||
}
|
||||
|
||||
/** 模拟一次真实拖拽:按下 → 移动 delta 像素 → 松开。 */
|
||||
function drag(handle: HTMLElement, delta: number): void {
|
||||
// r234:必须全发 pointer 系 —— 真机 Chromium 在 pointerdown preventDefault
|
||||
// 后整个手势不发兼容 mouse 事件(见 panel-resize-drag.test.tsx 头注);
|
||||
// 混合序列(pointerdown + mouseup)在 jsdom 里成立、在真机不成立 ⇒ 假绿。
|
||||
fireEvent.pointerDown(handle, { clientX: 0, button: 0, pointerId: 1 });
|
||||
fireEvent.pointerMove(window, { clientX: delta, pointerId: 1 });
|
||||
fireEvent.pointerUp(window, { clientX: delta, pointerId: 1 });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
setViewportWidth(1024);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useResizablePanel 落盘契约(松开鼠标 ⇒ 调整完成)', () => {
|
||||
it('窗口夹紧(目标宽度 == 当前宽度)之后再拖拽 ⇒ 新宽度必须落盘', () => {
|
||||
// 偏好 400 落在 [min, max] 内 ⇒ 夹紧后的目标宽度 == 当前宽度 ⇒
|
||||
// setState 同值 ⇒ [width] effect 不跑 ⇒ 跳过标记泄漏(本用例的靶心)。
|
||||
localStorage.setItem(KEY, '400');
|
||||
const { panel, handle } = setup();
|
||||
|
||||
expect(panel.style.width).toBe('400px');
|
||||
|
||||
// 窗口缩放 ⇒ 触发 clampOnResize(夹紧值 = 400,同值 ⇒ 不重渲)。
|
||||
setViewportWidth(1024);
|
||||
fireEvent(window, new Event('resize'));
|
||||
expect(panel.style.width).toBe('400px');
|
||||
|
||||
// 用户拖到 500 并松手。
|
||||
drag(handle, 100);
|
||||
|
||||
expect(panel.style.width).toBe('500px');
|
||||
// ⇒ 偏好必须落盘(修前:被泄漏的标记吞掉 ⇒ 这里红)。
|
||||
expect(localStorage.getItem(KEY)).toBe('500');
|
||||
});
|
||||
|
||||
it('窗口夹紧(显示宽度确实变了)不得污染用户偏好', () => {
|
||||
// r142 原契约:夹紧后的显示宽度只影响显示,偏好保持原值。
|
||||
localStorage.setItem(KEY, '600');
|
||||
const { panel } = setup();
|
||||
expect(panel.style.width).toBe('600px');
|
||||
|
||||
// 视口缩到 700 ⇒ 上限 60% = 420 ⇒ 显示宽度被夹到 420。
|
||||
setViewportWidth(700);
|
||||
fireEvent(window, new Event('resize'));
|
||||
|
||||
expect(panel.style.width).toBe('420px');
|
||||
// 偏好仍是 600(没有被夹紧值污染)。
|
||||
expect(localStorage.getItem(KEY)).toBe('600');
|
||||
});
|
||||
|
||||
it('泄漏场景之后连续两次拖拽,两次都要落盘', () => {
|
||||
localStorage.setItem(KEY, '400');
|
||||
const { panel, handle } = setup();
|
||||
|
||||
fireEvent(window, new Event('resize')); // 制造泄漏
|
||||
|
||||
drag(handle, 100);
|
||||
expect(panel.style.width).toBe('500px');
|
||||
expect(localStorage.getItem(KEY)).toBe('500');
|
||||
|
||||
drag(handle, -50);
|
||||
expect(panel.style.width).toBe('450px');
|
||||
expect(localStorage.getItem(KEY)).toBe('450');
|
||||
});
|
||||
});
|
||||
388
tests/unit/renderer/hooks/use-resize-content-freeze.test.tsx
Normal file
388
tests/unit/renderer/hooks/use-resize-content-freeze.test.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
// tests/unit/renderer/hooks/use-resize-content-freeze.test.tsx
|
||||
//
|
||||
// 守 r230「拖动分隔条调宽度时卡顿」的修复契约 —— 拖拽期间被拖动面板的
|
||||
// **内容宽度必须冻结**,拖拽结束才跟上。
|
||||
//
|
||||
// 为什么是行为契约(而不是实现细节):
|
||||
// 拖拽路径本来就是「perf 路径」(useResizablePanel 拖拽期间直写 DOM,
|
||||
// 绕过 React 重渲)⇒ 卡顿不在 JS,在**布局**:面板宽度每帧变 ⇒ 该侧
|
||||
// 所有内容每帧重新折行 / 重排表格。
|
||||
// Electron 真机 + CDP 帧间隔采样(双列 + 笔记列开「渲染显示」,189KB 文档):
|
||||
// 不冻结(修前)→ 拖文献列分隔条:每帧布局 42ms,帧间隔中位 **50ms**,
|
||||
// 60 帧里 39 帧超 50ms(肉眼可见的拖不动);
|
||||
// 拖左侧栏分隔条:每帧布局 45ms,中位 50.1ms。
|
||||
// 冻结(修后) → 每帧布局 ~1ms,帧间隔中位 **16.7ms**(满 60fps)。
|
||||
// 拖左栏时主区宽度同样每帧变 ⇒ 冻结必须**全局**(订阅共享登记簿),
|
||||
// 不能只跟某一个面板的拖拽绑定 —— 这条也是本测的结构契约之一。
|
||||
//
|
||||
// 本轮是**新增能力**(不是收编),故:
|
||||
// - 行为例:对 HEAD 全红(hook 文件不存在)⇒ 修后必须绿;
|
||||
// - 结构例:锁「组件必须挂 hook」「hook 必须走全局登记簿」,
|
||||
// 独立于 hook 自身实现,任何人把 hook 调用删掉 / 退化成 per-panel 即红。
|
||||
import { describe, expect, it, vi, beforeAll, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, render, fireEvent, act, cleanup } from '@testing-library/react';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
// i18n:只关心交互,不关心文案
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
// 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 };
|
||||
});
|
||||
|
||||
// workspace store:主区空态身份
|
||||
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 };
|
||||
});
|
||||
|
||||
// 懒加载的阅读器 / 笔记视图:本测只关心分隔条、列宽与冻结,不关心内容
|
||||
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 { WorkbenchContentPanel } from '../../../../src/renderer/src/components/layout/WorkbenchContentPanel';
|
||||
import { useResizeContentFreeze } from '../../../../src/renderer/src/hooks/use-resize-content-freeze';
|
||||
import {
|
||||
beginResizeDrag,
|
||||
endResizeDrag,
|
||||
isResizeDragActive,
|
||||
resetResizeDragRegistry,
|
||||
} from '../../../../src/renderer/src/lib/resize-drag-registry';
|
||||
|
||||
// jsdom 不做真实布局:getBoundingClientRect 恒为 0 ⇒ 冻结逻辑会因
|
||||
// 「宽度为 0 不钉」而全部跳过。这里用一个可变矩形替身。
|
||||
let rectWidth = 640;
|
||||
|
||||
function makeEl(): HTMLDivElement {
|
||||
const el = document.createElement('div');
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
describe('r230 行为契约:拖拽期间内容宽度冻结', () => {
|
||||
beforeEach(() => {
|
||||
resetResizeDragRegistry();
|
||||
rectWidth = 640;
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
width: rectWidth,
|
||||
height: 800,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: rectWidth,
|
||||
bottom: 800,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetResizeDragRegistry();
|
||||
document.body.innerHTML = '';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('非拖拽期不碰内容根(不预置任何 inline 宽度)', () => {
|
||||
const el = makeEl();
|
||||
const ref = { current: el };
|
||||
renderHook(() => useResizeContentFreeze(ref));
|
||||
expect(el.style.width).toBe('');
|
||||
expect(el.style.overflowX).toBe('');
|
||||
});
|
||||
|
||||
it('拖拽开始 → 内容根宽度被钉成拖拽开始那一刻的像素值', () => {
|
||||
const el = makeEl();
|
||||
const ref = { current: el };
|
||||
renderHook(() => useResizeContentFreeze(ref));
|
||||
|
||||
act(() => {
|
||||
beginResizeDrag();
|
||||
});
|
||||
expect(el.style.width).toBe('640px');
|
||||
});
|
||||
|
||||
it('拖拽中容器变窄 → 内容根宽度不动(不重排)', () => {
|
||||
const el = makeEl();
|
||||
const ref = { current: el };
|
||||
renderHook(() => useResizeContentFreeze(ref));
|
||||
|
||||
act(() => {
|
||||
beginResizeDrag();
|
||||
});
|
||||
rectWidth = 400;
|
||||
expect(el.style.width).toBe('640px');
|
||||
rectWidth = 320;
|
||||
expect(el.style.width).toBe('640px');
|
||||
});
|
||||
|
||||
it('拖拽中横向溢出被裁掉(不出横向滚动条吃掉纵向空间)', () => {
|
||||
const el = makeEl();
|
||||
const ref = { current: el };
|
||||
renderHook(() => useResizeContentFreeze(ref));
|
||||
|
||||
act(() => {
|
||||
beginResizeDrag();
|
||||
});
|
||||
expect(el.style.overflowX).toBe('hidden');
|
||||
});
|
||||
|
||||
it('拖拽结束 → 内容根宽度一次性跟上(冻结不是「永远不更新」)', () => {
|
||||
const el = makeEl();
|
||||
const ref = { current: el };
|
||||
renderHook(() => useResizeContentFreeze(ref));
|
||||
|
||||
act(() => {
|
||||
beginResizeDrag();
|
||||
});
|
||||
rectWidth = 400;
|
||||
act(() => {
|
||||
endResizeDrag();
|
||||
});
|
||||
expect(el.style.width).toBe('');
|
||||
expect(el.style.overflowX).toBe('');
|
||||
});
|
||||
|
||||
it('拖拽前就有的 inline 样式在拖拽结束后原样还原(不是清空)', () => {
|
||||
const el = makeEl();
|
||||
el.style.width = '50%';
|
||||
el.style.overflowX = 'auto';
|
||||
const ref = { current: el };
|
||||
renderHook(() => useResizeContentFreeze(ref));
|
||||
|
||||
act(() => {
|
||||
beginResizeDrag();
|
||||
});
|
||||
expect(el.style.width).toBe('640px');
|
||||
act(() => {
|
||||
endResizeDrag();
|
||||
});
|
||||
expect(el.style.width).toBe('50%');
|
||||
expect(el.style.overflowX).toBe('auto');
|
||||
});
|
||||
|
||||
it('宽度为 0(未布局/隐藏)时不钉 —— 钉 0 会让内容彻底消失', () => {
|
||||
const el = makeEl();
|
||||
rectWidth = 0;
|
||||
const ref = { current: el };
|
||||
renderHook(() => useResizeContentFreeze(ref));
|
||||
|
||||
act(() => {
|
||||
beginResizeDrag();
|
||||
});
|
||||
expect(el.style.width).toBe('');
|
||||
});
|
||||
|
||||
it('嵌套拖拽:计数未归零时保持冻结,归零才还原', () => {
|
||||
const el = makeEl();
|
||||
const ref = { current: el };
|
||||
renderHook(() => useResizeContentFreeze(ref));
|
||||
|
||||
act(() => {
|
||||
beginResizeDrag();
|
||||
beginResizeDrag();
|
||||
});
|
||||
expect(el.style.width).toBe('640px');
|
||||
act(() => {
|
||||
endResizeDrag();
|
||||
});
|
||||
// 还有一个拖拽在途 ⇒ 仍冻结
|
||||
expect(el.style.width).toBe('640px');
|
||||
act(() => {
|
||||
endResizeDrag();
|
||||
});
|
||||
expect(el.style.width).toBe('');
|
||||
});
|
||||
|
||||
it('拖拽中组件卸载 → 还原(不留冻结态 inline 样式)', () => {
|
||||
const el = makeEl();
|
||||
const ref = { current: el };
|
||||
const { unmount } = renderHook(() => useResizeContentFreeze(ref));
|
||||
|
||||
act(() => {
|
||||
beginResizeDrag();
|
||||
});
|
||||
expect(el.style.width).toBe('640px');
|
||||
unmount();
|
||||
expect(el.style.width).toBe('');
|
||||
expect(el.style.overflowX).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// 端到端:真实组件 + 真实事件序列(pointerdown → mousemove → mouseup)。
|
||||
//
|
||||
// 这一组锁的是用户说的那句「鼠标点击放开之后应该完成调整」——
|
||||
// 拖拽期间:两列内容宽度被钉死(不重排);
|
||||
// 松手之后:① 面板宽度提交(DOM + localStorage)② 内容解冻、宽度清空
|
||||
// ⇒ 内容重新跟着面板走 = 调整真正完成,不是「永远冻结」。
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
describe('r230 端到端契约:拖 → 松手 → 完成调整', () => {
|
||||
beforeAll(() => {
|
||||
(window as unknown as { PointerEvent: unknown }).PointerEvent = FakePointerEvent;
|
||||
});
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
resetResizeDragRegistry();
|
||||
// jsdom 无布局:getBoundingClientRect 恒 0 ⇒ 冻结逻辑会因「宽度 0 不钉」
|
||||
// 全部跳过。给一个固定宽度,让冻结路径真正跑起来。
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
width: 640,
|
||||
height: 800,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 640,
|
||||
bottom: 800,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect,
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
resetResizeDragRegistry();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('拖拽期间两列内容宽度被钉死;松手后面板宽度提交 + 内容解冻跟上最终宽度', () => {
|
||||
const { container } = render(<WorkbenchContentPanel />);
|
||||
const separator = container.querySelector('[role="separator"]') as HTMLElement;
|
||||
expect(separator).toBeTruthy();
|
||||
const readerCol = separator.previousElementSibling as HTMLElement;
|
||||
|
||||
// ① 拖拽开始
|
||||
fireEvent.pointerDown(separator, { clientX: 1000, clientY: 100, button: 0, pointerId: 1 });
|
||||
expect(isResizeDragActive()).toBe(true);
|
||||
|
||||
// ② 拖动 100px(向左 = 文献列收窄)
|
||||
// r234:pointer 系 —— 真机 Chromium 在 pointerdown preventDefault 后
|
||||
// 不发兼容 mouse 事件(见 panel-resize-drag.test.tsx 头注)。
|
||||
act(() => {
|
||||
fireEvent.pointerMove(window, { clientX: 900, clientY: 100, pointerId: 1 });
|
||||
});
|
||||
// 面板宽度实时跟手(这一条是 r199 已有的契约,此处作为对照)
|
||||
expect(readerCol.style.width).toBe('540px');
|
||||
// 内容宽度被钉死在拖拽开始时的 640px ⇒ 不重排
|
||||
const frozen = [...container.querySelectorAll('*')].filter(
|
||||
(el) => (el as HTMLElement).style.overflowX === 'hidden',
|
||||
);
|
||||
expect(frozen.length, '两列内容根各被冻结一次').toBe(2);
|
||||
for (const el of frozen) {
|
||||
expect((el as HTMLElement).style.width).toBe('640px');
|
||||
}
|
||||
|
||||
// ③ 松手 —— 调整必须在此完成
|
||||
act(() => {
|
||||
fireEvent.pointerUp(window, { clientX: 900, clientY: 100, pointerId: 1 });
|
||||
});
|
||||
expect(isResizeDragActive()).toBe(false);
|
||||
// ③-a 面板宽度提交(DOM + localStorage)
|
||||
expect(readerCol.style.width).toBe('540px');
|
||||
expect(localStorage.getItem('workbench.readerColumn.width')).toBe('540');
|
||||
// ③-b 内容解冻:inline 宽度清空 ⇒ 内容重新跟着面板走
|
||||
for (const el of frozen) {
|
||||
expect((el as HTMLElement).style.width).toBe('');
|
||||
expect((el as HTMLElement).style.overflowX).toBe('');
|
||||
}
|
||||
const stillFrozen = [...container.querySelectorAll('*')].filter(
|
||||
(el) => (el as HTMLElement).style.overflowX === 'hidden',
|
||||
);
|
||||
expect(stillFrozen.length, '松手后不得残留冻结态').toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('r230 结构契约:冻结必须全局 + 组件必须挂上', () => { const ROOT = process.cwd();
|
||||
|
||||
it('hook 走全局登记簿(任意分隔条在拖都冻结),不是 per-panel', () => {
|
||||
const src = readFileSync(
|
||||
join(ROOT, 'src/renderer/src/hooks/use-resize-content-freeze.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(src, '冻结必须订阅全局登记簿').toContain('subscribeResizeDrag');
|
||||
expect(src, '冻结必须读全局拖拽态').toContain('isResizeDragActive');
|
||||
});
|
||||
|
||||
it('主区两列的内容根都挂了 useResizeContentFreeze', () => {
|
||||
const src = readFileSync(
|
||||
join(ROOT, 'src/renderer/src/components/layout/WorkbenchContentPanel.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
const calls = src.match(/useResizeContentFreeze\(contentRef\);/g) ?? [];
|
||||
expect(calls.length, '笔记列 + 文献列各挂一次冻结').toBe(2);
|
||||
expect(src, '两列内容根都要有 ref').toContain('ref={contentRef}');
|
||||
});
|
||||
|
||||
it('主区不得自己再写一遍冻结(单一真相源在 hook 里)', () => {
|
||||
const src = readFileSync(
|
||||
join(ROOT, 'src/renderer/src/components/layout/WorkbenchContentPanel.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(src, '冻结逻辑不得在组件里内联实现').not.toContain('style.width =');
|
||||
expect(src, '冻结逻辑不得在组件里内联实现').not.toContain('style.overflowX =');
|
||||
});
|
||||
|
||||
it('文献列面板自带 overflow-hidden —— 钉住的内容不得溢出到笔记列', () => {
|
||||
const src = readFileSync(
|
||||
join(ROOT, 'src/renderer/src/components/layout/WorkbenchContentPanel.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
const panelIdx = src.indexOf('ref={readerCol.panelRef}');
|
||||
expect(panelIdx, '文献列面板必须存在').toBeGreaterThan(-1);
|
||||
const panelSnippet = src.slice(panelIdx, panelIdx + 400);
|
||||
expect(panelSnippet, '文献列面板必须裁切溢出的内容').toContain('overflow-hidden');
|
||||
});
|
||||
});
|
||||
173
tests/unit/renderer/hooks/use-sidebar-drop-zone.test.ts
Normal file
173
tests/unit/renderer/hooks/use-sidebar-drop-zone.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
// tests/unit/renderer/hooks/use-sidebar-drop-zone.test.ts
|
||||
//
|
||||
// 守 r226「落点区 DnD 全家桶收编」契约(收编 ③):
|
||||
// NotesList / LiteratureList 里逐字相同的 ① OS 文件拖入导入链
|
||||
// (dragActive + dragCounterRef + dragOver/Enter/Leave/Drop + inert
|
||||
// effect)② 内部 drag 容器落点三件套(ROOT_DROP_ID)③ hidden file
|
||||
// picker 入口,收进 hooks/use-sidebar-drop-zone.ts;唯一分叉 importFiles
|
||||
// 由 caller 注入。
|
||||
//
|
||||
// ── 锁两类东西(与 r224/r225 同模式)────────────────────────────────
|
||||
// 1. 行为契约(收编前既有行为,对 HEAD 也必须绿):
|
||||
// enter 仅 'Files' 类型计数 / 计数法 0↔1 切换 overlay / leave 不负数;
|
||||
// dragOver 内部=move 否则 copy;drop 内部=取消(不导入)否则计数清零
|
||||
// + importFiles;容器三件套 gated on isInternalDrag(非内部不
|
||||
// preventDefault,OS 文件直通 import 链);inert 随 dragActive 增减;
|
||||
// picker change 后清空 value(同文件再选仍触发);
|
||||
// 2. 结构契约:组件源码不得再出现 dragCounterRef / setAttribute('inert'
|
||||
// —— 孪生块复活即红(修前红)。
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import type { ChangeEvent, DragEvent as ReactDragEvent } from 'react';
|
||||
import { useSidebarDropZone } from '../../../../src/renderer/src/hooks/use-sidebar-drop-zone';
|
||||
import { ROOT_DROP_ID, type DiskFolderSidebar } from '../../../../src/renderer/src/hooks/useDiskFolderSidebar';
|
||||
|
||||
function makeFolders(internal: boolean) {
|
||||
return {
|
||||
isInternalDrag: vi.fn(() => internal),
|
||||
handleTargetDragOver: vi.fn(),
|
||||
handleTargetDragLeave: vi.fn(),
|
||||
handleTargetDrop: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as DiskFolderSidebar;
|
||||
}
|
||||
|
||||
function makeDragEvent(opts: { files?: boolean } = {}) {
|
||||
const dt = {
|
||||
types: opts.files ? ['Files'] : ['text/plain'],
|
||||
files: [{ name: 'x.md' }] as unknown as FileList,
|
||||
dropEffect: 'none',
|
||||
effectAllowed: 'all',
|
||||
} as unknown as DataTransfer;
|
||||
return {
|
||||
dataTransfer: dt,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
} as unknown as ReactDragEvent;
|
||||
}
|
||||
|
||||
function setup(internal = false) {
|
||||
const folders = makeFolders(internal);
|
||||
const runFolderOp = vi.fn((op: () => Promise<unknown>) => void op());
|
||||
const importFiles = vi.fn().mockResolvedValue(undefined);
|
||||
const container = document.createElement('div');
|
||||
const listContainerRef = { current: container as HTMLElement };
|
||||
const hook = renderHook(() =>
|
||||
useSidebarDropZone({ folders, runFolderOp, listContainerRef, importFiles }),
|
||||
);
|
||||
return { ...hook, folders, runFolderOp, importFiles, container };
|
||||
}
|
||||
|
||||
describe('useSidebarDropZone(r226 收编块的行为契约)', () => {
|
||||
it('dragEnter 仅 Files 类型计数;计数 0↔1 切 overlay;leave 不为负', () => {
|
||||
const { result } = setup();
|
||||
// 非文件 drag(文本选择 / 内部 reorder)⇒ 不计数不弹层
|
||||
act(() => result.current.handleDragEnter(makeDragEvent({ files: false })));
|
||||
expect(result.current.dragActive).toBe(false);
|
||||
|
||||
// 两次 enter(子元素冒泡)⇒ 仍只弹一次;一次 leave 不关
|
||||
act(() => result.current.handleDragEnter(makeDragEvent({ files: true })));
|
||||
expect(result.current.dragActive).toBe(true);
|
||||
act(() => result.current.handleDragEnter(makeDragEvent({ files: true })));
|
||||
act(() => result.current.handleDragLeave(makeDragEvent({ files: true })));
|
||||
expect(result.current.dragActive).toBe(true);
|
||||
// 第二次 leave 归零 ⇒ 关;再一次 leave 不为负(仍关、不抛)
|
||||
act(() => result.current.handleDragLeave(makeDragEvent({ files: true })));
|
||||
expect(result.current.dragActive).toBe(false);
|
||||
act(() => result.current.handleDragLeave(makeDragEvent({ files: true })));
|
||||
expect(result.current.dragActive).toBe(false);
|
||||
});
|
||||
|
||||
it('dragOver:内部 drag=move(容器 drop 会忽略),OS 文件=copy', () => {
|
||||
const internal = setup(true);
|
||||
const e1 = makeDragEvent();
|
||||
act(() => internal.result.current.handleDragOver(e1));
|
||||
expect(e1.dataTransfer.dropEffect).toBe('move');
|
||||
|
||||
const external = setup(false);
|
||||
const e2 = makeDragEvent();
|
||||
act(() => external.result.current.handleDragOver(e2));
|
||||
expect(e2.dataTransfer.dropEffect).toBe('copy');
|
||||
});
|
||||
|
||||
it('drop:内部 drag 落空白 = 取消(不导入);OS 文件 ⇒ 计数清零 + importFiles', async () => {
|
||||
const internal = setup(true);
|
||||
await act(async () => {
|
||||
await internal.result.current.handleDrop(makeDragEvent({ files: true }));
|
||||
});
|
||||
expect(internal.importFiles).not.toHaveBeenCalled();
|
||||
|
||||
const external = setup(false);
|
||||
act(() => external.result.current.handleDragEnter(makeDragEvent({ files: true })));
|
||||
expect(external.result.current.dragActive).toBe(true);
|
||||
await act(async () => {
|
||||
await external.result.current.handleDrop(makeDragEvent({ files: true }));
|
||||
});
|
||||
expect(external.result.current.dragActive).toBe(false);
|
||||
expect(external.importFiles).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('容器三件套 gated on isInternalDrag:内部 ⇒ ROOT_DROP_ID 落点;非内部直通不拦截', () => {
|
||||
const internal = setup(true);
|
||||
const e1 = makeDragEvent();
|
||||
act(() => {
|
||||
internal.result.current.handleContainerDragOver(e1);
|
||||
internal.result.current.handleContainerDragLeave(e1);
|
||||
internal.result.current.handleContainerDrop(e1);
|
||||
});
|
||||
expect(internal.folders.handleTargetDragOver).toHaveBeenCalledWith(e1, ROOT_DROP_ID);
|
||||
expect(internal.folders.handleTargetDragLeave).toHaveBeenCalledWith(e1, ROOT_DROP_ID);
|
||||
expect(internal.folders.handleTargetDrop).toHaveBeenCalledWith(e1, ROOT_DROP_ID);
|
||||
expect(e1.preventDefault).toHaveBeenCalled();
|
||||
|
||||
// 非内部 ⇒ 全部放行(不 preventDefault —— import overlay 链靠事件未被拦截才生效)
|
||||
const external = setup(false);
|
||||
const e2 = makeDragEvent({ files: true });
|
||||
act(() => {
|
||||
external.result.current.handleContainerDragOver(e2);
|
||||
external.result.current.handleContainerDrop(e2);
|
||||
});
|
||||
expect(external.folders.handleTargetDragOver).not.toHaveBeenCalled();
|
||||
expect(external.folders.handleTargetDrop).not.toHaveBeenCalled();
|
||||
expect(e2.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('inert 随 dragActive 增/删(a11y H4:dragenter 期间底层不可 Tab)', () => {
|
||||
const { result, container } = setup();
|
||||
expect(container.hasAttribute('inert')).toBe(false);
|
||||
act(() => result.current.handleDragEnter(makeDragEvent({ files: true })));
|
||||
expect(container.getAttribute('inert')).toBe('');
|
||||
act(() => result.current.handleDragLeave(makeDragEvent({ files: true })));
|
||||
expect(container.hasAttribute('inert')).toBe(false);
|
||||
});
|
||||
|
||||
it('file picker:change ⇒ importFiles + 清空 value(同文件再选仍触发)', () => {
|
||||
const { result, importFiles } = setup();
|
||||
const input = document.createElement('input');
|
||||
const files = [{ name: 'a.md' }] as unknown as FileList;
|
||||
Object.defineProperty(input, 'files', { value: files, configurable: true });
|
||||
act(() => {
|
||||
result.current.onFilePickerChange({
|
||||
target: input,
|
||||
} as unknown as ChangeEvent<HTMLInputElement>);
|
||||
});
|
||||
expect(importFiles).toHaveBeenCalledWith(files);
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('r226 结构契约:孪生块不得复活', () => {
|
||||
const ROOT = process.cwd();
|
||||
for (const rel of [
|
||||
'src/renderer/src/components/notes/NotesList.tsx',
|
||||
'src/renderer/src/components/literature/LiteratureList.tsx',
|
||||
]) {
|
||||
it(`${rel} 走 useSidebarDropZone,不再自带落点区 DnD 机器`, () => {
|
||||
const src = readFileSync(join(ROOT, rel), 'utf-8');
|
||||
expect(src, '落点区 DnD 必须走 hooks/use-sidebar-drop-zone').toContain('useSidebarDropZone');
|
||||
expect(src, '孪生块复活:组件里又出现 dragCounterRef').not.toContain('dragCounterRef');
|
||||
expect(src, '孪生块复活:组件里又出现 inert 同步').not.toContain("setAttribute('inert'");
|
||||
});
|
||||
}
|
||||
});
|
||||
265
tests/unit/renderer/hooks/use-sidebar-menus.test.ts
Normal file
265
tests/unit/renderer/hooks/use-sidebar-menus.test.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
// tests/unit/renderer/hooks/use-sidebar-menus.test.ts
|
||||
//
|
||||
// 守 r232「侧栏右键菜单两段同构块收编」契约:
|
||||
//
|
||||
// 收编前,NotesList / LiteratureList 各自持有一份**逐字相同**的
|
||||
// ① 文件夹行菜单(folderMenuItems)与 ② 空白区菜单(containerMenuItems)。
|
||||
// 两域的文件夹操作面(设为落点 / 新建子夹 / 重命名 / 剪切 / 粘贴内容 /
|
||||
// 粘贴文件夹 / 移到根 / 删除)是**同一个用户心智**,却有两份实现 ——
|
||||
// 孪生分叉一旦发生,后果是「某一域的某个文件夹操作消失或指向错目标」
|
||||
// (r228 即孪生分叉长成真 bug 的实例)。
|
||||
//
|
||||
// 收编后两域共用 hooks/use-sidebar-menus.ts,差异只剩注入点:
|
||||
// 文件夹菜单零注入;空白区菜单注入「首项」+「粘贴项 label key」。
|
||||
//
|
||||
// ── 锁两类东西(与 r224-r227 同模式)──────────────────────────────
|
||||
// 1. 行为契约(收编前既有行为,对 HEAD 也必须绿):
|
||||
// 文件夹菜单:menu 为空 ⇒ 空;粘贴(内容)看剪贴板;粘贴(文件夹)看
|
||||
// 文件夹剪贴板且排除自粘;移到根看 parentRelPath;删除恒在末尾;
|
||||
// 空白区菜单:首项可注入;搜索态隐藏新建文件夹/粘贴文件夹;全选
|
||||
// 只在有可见条目时出现并把可见 id 全量交给 selectMany;
|
||||
// 2. 结构契约:两个组件必须走 useFolderMenuItems / useContainerMenuItems,
|
||||
// 且不得再内联菜单项字面量 —— 孪生块复活即红(修前红)。
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
// i18n —— t 回显 key,便于断言「用的是哪个 key」。
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
useContainerMenuItems,
|
||||
useFolderMenuItems,
|
||||
} from '../../../../src/renderer/src/hooks/use-sidebar-menus';
|
||||
import type { DiskFolderSidebar } from '../../../../src/renderer/src/hooks/useDiskFolderSidebar';
|
||||
|
||||
interface FolderOpts {
|
||||
folderMenu?: unknown;
|
||||
folderClipboard?: string | null;
|
||||
targetRelPath?: string | null;
|
||||
}
|
||||
|
||||
function makeFolders(over: FolderOpts = {}) {
|
||||
const calls: string[] = [];
|
||||
const folders = {
|
||||
folderMenu:
|
||||
over.folderMenu === undefined
|
||||
? { folder: { relPath: 'a/b', name: 'b', parentRelPath: 'a' }, x: 1, y: 2 }
|
||||
: over.folderMenu,
|
||||
folderClipboard: over.folderClipboard ?? null,
|
||||
targetRelPath: over.targetRelPath ?? null,
|
||||
startCreateSubfolder: vi.fn((f: { relPath: string }) => calls.push(`newSub:${f.relPath}`)),
|
||||
startRename: vi.fn((f: { relPath: string }) => calls.push(`rename:${f.relPath}`)),
|
||||
cutFolder: vi.fn((p: string) => calls.push(`cut:${p}`)),
|
||||
startCreate: vi.fn(() => calls.push('newFolder')),
|
||||
pasteFolderInto: vi.fn(async (p: string) => {
|
||||
calls.push(`pasteFolder:${p}`);
|
||||
return { ok: true };
|
||||
}),
|
||||
moveFolderNow: vi.fn(async (p: string, t: string | null) => {
|
||||
calls.push(`moveToRoot:${p}->${t}`);
|
||||
return { ok: true };
|
||||
}),
|
||||
requestDelete: vi.fn((f: { relPath: string }) => calls.push(`delete:${f.relPath}`)),
|
||||
} as unknown as DiskFolderSidebar;
|
||||
return { folders, calls };
|
||||
}
|
||||
|
||||
function renderFolderMenu(over: FolderOpts = {}, clipboard: unknown = null) {
|
||||
const { folders, calls } = makeFolders(over);
|
||||
const pasteTo = vi.fn(async () => {
|
||||
calls.push('pasteTo');
|
||||
});
|
||||
const runFolderOp = vi.fn((op: () => Promise<unknown>) => {
|
||||
void op();
|
||||
});
|
||||
const { result } = renderHook(() => useFolderMenuItems({ folders, clipboard, pasteTo, runFolderOp }));
|
||||
return { items: result.current, calls, pasteTo, runFolderOp };
|
||||
}
|
||||
|
||||
function renderContainerMenu(
|
||||
over: {
|
||||
folders?: FolderOpts;
|
||||
clipboard?: unknown;
|
||||
searchActive?: boolean;
|
||||
visibleItems?: ReadonlyArray<{ id: string }>;
|
||||
primaryItem?: { id: string; labelKey: string; onSelect: () => void };
|
||||
pasteLabelKey?: string;
|
||||
} = {},
|
||||
) {
|
||||
const { folders, calls } = makeFolders(over.folders ?? {});
|
||||
const pasteToTarget = vi.fn(() => calls.push('pasteToTarget'));
|
||||
const runFolderOp = vi.fn((op: () => Promise<unknown>) => {
|
||||
void op();
|
||||
});
|
||||
const selectMany = vi.fn((ids: string[]) => calls.push(`selectMany:${ids.join(',')}`));
|
||||
const primaryItem = over.primaryItem ?? {
|
||||
id: 'newNote',
|
||||
labelKey: 'notes.listEmptyAction',
|
||||
onSelect: () => calls.push('primary'),
|
||||
};
|
||||
const { result } = renderHook(() =>
|
||||
useContainerMenuItems({
|
||||
folders,
|
||||
clipboard: over.clipboard ?? null,
|
||||
pasteToTarget,
|
||||
runFolderOp,
|
||||
searchActive: over.searchActive ?? false,
|
||||
visibleItems: over.visibleItems ?? [],
|
||||
primaryItem,
|
||||
pasteLabelKey: over.pasteLabelKey ?? 'notes.contextPaste',
|
||||
selectMany,
|
||||
}),
|
||||
);
|
||||
return { items: result.current, calls, selectMany, pasteToTarget, primaryItem };
|
||||
}
|
||||
|
||||
const ids = (items: ReadonlyArray<{ id: string }>) => items.map((i) => i.id);
|
||||
|
||||
describe('useFolderMenuItems(r232 收编块的行为契约)', () => {
|
||||
it('没有打开的文件夹菜单 ⇒ 空数组', () => {
|
||||
const { items } = renderFolderMenu({ folderMenu: null });
|
||||
expect(items).toEqual([]);
|
||||
});
|
||||
|
||||
it('默认项集合:新建子夹 / 重命名 / 剪切 + 末尾的删除(无「设为当前文件夹」—— 点行即设落点)', () => {
|
||||
const { items } = renderFolderMenu();
|
||||
expect(ids(items)).toEqual(['newSub', 'rename', 'cut', 'moveToRoot', 'sep1', 'delete']);
|
||||
// 删除恒在最后一项
|
||||
expect(ids(items).at(-1)).toBe('delete');
|
||||
});
|
||||
|
||||
it('「粘贴(内容)」只在内容剪贴板非空时出现;点击打到 pasteTo(目标 = 该文件夹)', () => {
|
||||
const empty = renderFolderMenu();
|
||||
expect(ids(empty.items)).not.toContain('pasteItems');
|
||||
|
||||
const withClipboard = renderFolderMenu({}, { some: 'payload' });
|
||||
expect(ids(withClipboard.items)).toContain('pasteItems');
|
||||
withClipboard.items.find((i) => i.id === 'pasteItems')?.onSelect?.();
|
||||
expect(withClipboard.pasteTo).toHaveBeenCalledWith('a/b');
|
||||
});
|
||||
|
||||
it('「粘贴(文件夹)」在文件夹剪贴板非空且不是自己时出现(防自粘)', () => {
|
||||
const self = renderFolderMenu({ folderClipboard: 'a/b' });
|
||||
expect(ids(self.items)).not.toContain('pasteFolder');
|
||||
|
||||
const other = renderFolderMenu({ folderClipboard: 'a/c' });
|
||||
expect(ids(other.items)).toContain('pasteFolder');
|
||||
other.items.find((i) => i.id === 'pasteFolder')?.onSelect?.();
|
||||
expect(other.calls).toContain('pasteFolder:a/b');
|
||||
});
|
||||
|
||||
it('「移到根」只在非根目录出现;目标 = 根(null)', () => {
|
||||
const root = renderFolderMenu({
|
||||
folderMenu: { folder: { relPath: 'b', name: 'b', parentRelPath: null }, x: 0, y: 0 },
|
||||
});
|
||||
expect(ids(root.items)).not.toContain('moveToRoot');
|
||||
|
||||
const nested = renderFolderMenu();
|
||||
expect(ids(nested.items)).toContain('moveToRoot');
|
||||
nested.items.find((i) => i.id === 'moveToRoot')?.onSelect?.();
|
||||
expect(nested.calls).toContain('moveToRoot:a/b->null');
|
||||
});
|
||||
|
||||
it('动作接线:新建子夹 / 剪切 / 删除各打到 folders 的对应方法', () => {
|
||||
const { items, calls } = renderFolderMenu();
|
||||
items.find((i) => i.id === 'newSub')?.onSelect?.();
|
||||
items.find((i) => i.id === 'cut')?.onSelect?.();
|
||||
items.find((i) => i.id === 'delete')?.onSelect?.();
|
||||
expect(calls).toEqual(['newSub:a/b', 'cut:a/b', 'delete:a/b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useContainerMenuItems(r232 收编块的行为契约)', () => {
|
||||
it('首项 = 注入的主操作(id 与 label key 都由调用方给)', () => {
|
||||
const { items } = renderContainerMenu({
|
||||
primaryItem: { id: 'import', labelKey: 'literature.listEmptyAction', onSelect: () => {} },
|
||||
});
|
||||
expect(items[0]?.id).toBe('import');
|
||||
expect(items[0]?.label).toBe('literature.listEmptyAction');
|
||||
});
|
||||
|
||||
it('粘贴项 label 走注入的 pasteLabelKey(两域 locale 树不同)', () => {
|
||||
const notes = renderContainerMenu({ clipboard: { x: 1 }, pasteLabelKey: 'notes.contextPaste' });
|
||||
expect(notes.items.find((i) => i.id === 'paste')?.label).toBe('notes.contextPaste');
|
||||
|
||||
const lit = renderContainerMenu({
|
||||
clipboard: { x: 1 },
|
||||
pasteLabelKey: 'literature.contextPaste',
|
||||
primaryItem: { id: 'import', labelKey: 'literature.listEmptyAction', onSelect: () => {} },
|
||||
});
|
||||
expect(lit.items.find((i) => i.id === 'paste')?.label).toBe('literature.contextPaste');
|
||||
});
|
||||
|
||||
it('搜索态:隐藏「新建文件夹」与「粘贴(文件夹)」(落点在搜索视图不可见)', () => {
|
||||
const normal = renderContainerMenu({
|
||||
folders: { folderClipboard: 'x' },
|
||||
clipboard: { x: 1 },
|
||||
});
|
||||
expect(ids(normal.items)).toContain('newFolder');
|
||||
expect(ids(normal.items)).toContain('pasteFolder');
|
||||
|
||||
const searching = renderContainerMenu({
|
||||
folders: { folderClipboard: 'x' },
|
||||
clipboard: { x: 1 },
|
||||
searchActive: true,
|
||||
});
|
||||
expect(ids(searching.items)).not.toContain('newFolder');
|
||||
expect(ids(searching.items)).not.toContain('pasteFolder');
|
||||
// 剪贴板粘贴与主操作仍保留(都影响可见条目,有反馈)
|
||||
expect(ids(searching.items)).toContain('paste');
|
||||
});
|
||||
|
||||
it('「全选」只在有可见条目时出现,并把可见 id 全量交给 selectMany', () => {
|
||||
const empty = renderContainerMenu();
|
||||
expect(ids(empty.items)).not.toContain('selectAll');
|
||||
|
||||
const some = renderContainerMenu({ visibleItems: [{ id: 'n1' }, { id: 'n2' }] });
|
||||
expect(ids(some.items)).toContain('selectAll');
|
||||
some.items.find((i) => i.id === 'selectAll')?.onSelect?.();
|
||||
expect(some.selectMany).toHaveBeenCalledWith(['n1', 'n2']);
|
||||
});
|
||||
|
||||
it('分隔符:粘贴块前 sep1、全选前 sep2', () => {
|
||||
const { items } = renderContainerMenu({
|
||||
clipboard: { x: 1 },
|
||||
visibleItems: [{ id: 'n1' }],
|
||||
});
|
||||
const list = ids(items);
|
||||
// audit-r254:旧写法 `expect(list.indexOf('sep1')).toBeLessThan(list.indexOf('paste'))`
|
||||
// 在 **sep1 缺席**时是**恒真**的 —— indexOf 缺席返回 -1,而 -1 < 任意
|
||||
// 合法下标 ⇒ 分隔符被删掉,守卫照样绿(假绿)。
|
||||
// 这里改成:先断言两者**都在**,再断言先后。
|
||||
const sep1 = list.indexOf('sep1');
|
||||
const paste = list.indexOf('paste');
|
||||
const sep2 = list.indexOf('sep2');
|
||||
const selectAll = list.indexOf('selectAll');
|
||||
expect(sep1, 'sep1 分隔符必须存在').toBeGreaterThanOrEqual(0);
|
||||
expect(paste, 'paste 项必须存在').toBeGreaterThanOrEqual(0);
|
||||
expect(sep2, 'sep2 分隔符必须存在').toBeGreaterThanOrEqual(0);
|
||||
expect(selectAll, 'selectAll 项必须存在').toBeGreaterThanOrEqual(0);
|
||||
expect(sep1, 'sep1 必须在 paste 之前').toBeLessThan(paste);
|
||||
expect(sep2, 'sep2 必须在 selectAll 之前').toBeLessThan(selectAll);
|
||||
});
|
||||
});
|
||||
|
||||
describe('r232 结构契约:孪生块不得复活', () => {
|
||||
const ROOT = process.cwd();
|
||||
for (const rel of [
|
||||
'src/renderer/src/components/notes/NotesList.tsx',
|
||||
'src/renderer/src/components/literature/LiteratureList.tsx',
|
||||
]) {
|
||||
it(`${rel} 走 useFolderMenuItems / useContainerMenuItems`, () => {
|
||||
const src = readFileSync(join(ROOT, rel), 'utf-8');
|
||||
expect(src, '文件夹行菜单必须走 hooks/use-sidebar-menus').toContain('useFolderMenuItems');
|
||||
expect(src, '空白区菜单必须走 hooks/use-sidebar-menus').toContain('useContainerMenuItems');
|
||||
expect(src, '孪生块复活:组件里又内联了文件夹菜单项').not.toContain("id: 'newSub'");
|
||||
expect(src, '孪生块复活:组件里又内联了空白区菜单项').not.toContain("id: 'selectAll'");
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
// tests/unit/renderer/layout/folder-row-callback-stability.test.tsx
|
||||
//
|
||||
// 守 FolderRowOption 的**拖拽四件套回调契约** + **渲染稳定性**
|
||||
// (src/renderer/src/components/common/FolderChrome.tsx r136/r166 头注指向本文件)。
|
||||
//
|
||||
// 契约四条,每条都对应一个**用户可见**的后果:
|
||||
//
|
||||
// 1. dragstart 交出的是**当前** folder 对象。
|
||||
// 退化(闭包捕获了旧对象)⇒ 拖走的还是上一次的目录,落点算错,
|
||||
// 文件被搬进错误的位置。
|
||||
// 2. dragover / dragleave / drop 交出的是 `folder.relPath` —— 它就是
|
||||
// DiskFolderNode 的「id」语义别名(r161 disk-folder 之后没有独立 id 字段)。
|
||||
// 写成 `folder.id` ⇒ 父级收到 undefined ⇒ 落点区收不到任何目录 id,
|
||||
// 拖拽文件夹整条链路静默失效(用户看到「拖了没反应」)。
|
||||
// 3. relPath 变了以后,回调必须跟着变(不许 stale closure)。
|
||||
// 退化(useMemo 依赖写漏)⇒ 文件夹改名 / 搬移后,拖拽仍按旧路径上报,
|
||||
// 文件被搬到一个已不存在的位置,用户「东西不见了」。
|
||||
// 4. props 不变时父级重渲不得让行重渲 —— 行是 memo 组件,每次重渲都要
|
||||
// 重建四个 handler 并让整棵侧栏树重渲(手柄拖拽时尤其明显)。
|
||||
// 退化(去掉 memo / 每次造新 props)⇒ 侧栏滚动与拖拽掉帧。
|
||||
//
|
||||
// 变异思路(下列任一改动会红):
|
||||
// - handleDragOver / handleDragLeave / handleDrop 里把 folder.relPath
|
||||
// 换成 folder.id(⇒ undefined)或 folder.name(⇒ 名字当 id)
|
||||
// - handleDragStart 的 useMemo 依赖去掉 folder
|
||||
// - handleDragOver 等的 useMemo 依赖去掉 folder.relPath
|
||||
// - 去掉外层 memo(⇒ 渲染稳定性用例红)
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, fireEvent, cleanup } from '@testing-library/react';
|
||||
import { FolderRowOption } from '../../../../src/renderer/src/components/common/FolderChrome';
|
||||
import type { DiskFolderNode } from '../../../../src/shared/types';
|
||||
|
||||
// 渲染计数探针:FolderRowOption 每次渲染都会调一次 useTranslation,
|
||||
// 计数变化 = 行真的重渲了(不是「重新渲染了但输出一样」)。
|
||||
const renderProbe = vi.hoisted(() => ({ count: 0 }));
|
||||
|
||||
// i18n:只关心交互,不关心文案;顺带当渲染探针。
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => {
|
||||
renderProbe.count += 1;
|
||||
return { t: (k: string) => k };
|
||||
},
|
||||
}));
|
||||
|
||||
const FOLDER: DiskFolderNode = { relPath: 'a/b', name: 'b', parentRelPath: 'a' };
|
||||
|
||||
type RowProps = React.ComponentProps<typeof FolderRowOption>;
|
||||
|
||||
function makeProps(overrides: Partial<RowProps> = {}): RowProps {
|
||||
return {
|
||||
folder: FOLDER,
|
||||
domId: 'folder-row-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,
|
||||
};
|
||||
}
|
||||
|
||||
function renderRow(props: RowProps) {
|
||||
const utils = render(<FolderRowOption {...props} />);
|
||||
const row = utils.container.querySelector('[data-folder-relpath]') as HTMLElement;
|
||||
return { ...utils, row };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
renderProbe.count = 0;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('FolderRowOption 拖拽四件套:回调载荷契约', () => {
|
||||
it('dragstart 把当前 folder 对象交给父级', () => {
|
||||
const props = makeProps();
|
||||
const { row } = renderRow(props);
|
||||
fireEvent.dragStart(row);
|
||||
expect(props.onDragStart).toHaveBeenCalledTimes(1);
|
||||
expect((props.onDragStart as ReturnType<typeof vi.fn>).mock.calls[0]![0]).toBe(FOLDER);
|
||||
});
|
||||
|
||||
it('dragover / dragleave / drop 收到的是 folder.relPath(不是 name / parentRelPath)', () => {
|
||||
const props = makeProps();
|
||||
const { row } = renderRow(props);
|
||||
|
||||
fireEvent.dragOver(row);
|
||||
fireEvent.dragLeave(row);
|
||||
fireEvent.drop(row);
|
||||
|
||||
expect(props.onDragOver).toHaveBeenCalledTimes(1);
|
||||
expect(props.onDragLeave).toHaveBeenCalledTimes(1);
|
||||
expect(props.onDrop).toHaveBeenCalledTimes(1);
|
||||
for (const cb of [props.onDragOver, props.onDragLeave, props.onDrop]) {
|
||||
const [id] = (cb as ReturnType<typeof vi.fn>).mock.calls[0]!;
|
||||
// DiskFolderNode 没有 id 字段 —— relPath 就是它的 id 语义别名。
|
||||
expect(id).toBe('a/b');
|
||||
expect(id).not.toBeUndefined();
|
||||
expect(id).not.toBe('b'); // name
|
||||
expect(id).not.toBe('a'); // parentRelPath
|
||||
}
|
||||
});
|
||||
|
||||
it('行 DOM 上的 data-folder-relpath 与 folder.relPath 一致(父级靠它定位行)', () => {
|
||||
const { row } = renderRow(makeProps());
|
||||
expect(row.getAttribute('data-folder-relpath')).toBe('a/b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FolderRowOption 拖拽回调:不许 stale closure', () => {
|
||||
it('folder 换成新对象(改名)后,dragstart 交出的是新对象', () => {
|
||||
const props = makeProps();
|
||||
const { row, rerender } = renderRow(props);
|
||||
|
||||
const renamed: DiskFolderNode = { relPath: 'a/b', name: 'b2', parentRelPath: 'a' };
|
||||
rerender(<FolderRowOption {...props} folder={renamed} />);
|
||||
|
||||
fireEvent.dragStart(row);
|
||||
const [f] = (props.onDragStart as ReturnType<typeof vi.fn>).mock.calls[0]!;
|
||||
expect(f).toBe(renamed);
|
||||
expect(f).not.toBe(FOLDER);
|
||||
});
|
||||
|
||||
it('relPath 变了(文件夹被搬移)后,drop 收到的是**新** relPath', () => {
|
||||
const props = makeProps();
|
||||
const { row, rerender } = renderRow(props);
|
||||
|
||||
const moved: DiskFolderNode = { relPath: 'x/c', name: 'c', parentRelPath: 'x' };
|
||||
rerender(<FolderRowOption {...props} folder={moved} />);
|
||||
|
||||
fireEvent.drop(row);
|
||||
expect(props.onDrop).toHaveBeenCalledTimes(1);
|
||||
const [id] = (props.onDrop as ReturnType<typeof vi.fn>).mock.calls[0]!;
|
||||
expect(id).toBe('x/c');
|
||||
});
|
||||
|
||||
it('relPath 变了之后,dragover / dragleave 也跟着走新路径', () => {
|
||||
const props = makeProps();
|
||||
const { row, rerender } = renderRow(props);
|
||||
|
||||
const moved: DiskFolderNode = { relPath: 'x/c', name: 'c', parentRelPath: 'x' };
|
||||
rerender(<FolderRowOption {...props} folder={moved} />);
|
||||
|
||||
fireEvent.dragOver(row);
|
||||
fireEvent.dragLeave(row);
|
||||
expect((props.onDragOver as ReturnType<typeof vi.fn>).mock.calls[0]![0]).toBe('x/c');
|
||||
expect((props.onDragLeave as ReturnType<typeof vi.fn>).mock.calls[0]![0]).toBe('x/c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FolderRowOption 渲染稳定性(memo 命中)', () => {
|
||||
it('props 引用不变时,父级重渲不会让行重渲', () => {
|
||||
const props = makeProps();
|
||||
const { rerender } = renderRow(props);
|
||||
expect(renderProbe.count).toBe(1);
|
||||
|
||||
rerender(<FolderRowOption {...props} />);
|
||||
expect(renderProbe.count).toBe(1);
|
||||
|
||||
rerender(<FolderRowOption {...props} />);
|
||||
expect(renderProbe.count).toBe(1);
|
||||
});
|
||||
|
||||
it('folder 变了时行确实重渲(不能被过度 memo 锁死成旧数据)', () => {
|
||||
const props = makeProps();
|
||||
const { rerender } = renderRow(props);
|
||||
expect(renderProbe.count).toBe(1);
|
||||
|
||||
const renamed: DiskFolderNode = { relPath: 'a/b', name: 'b2', parentRelPath: 'a' };
|
||||
rerender(<FolderRowOption {...props} folder={renamed} />);
|
||||
expect(renderProbe.count).toBe(2);
|
||||
});
|
||||
});
|
||||
238
tests/unit/renderer/layout/library-reader-flex-chain.test.tsx
Normal file
238
tests/unit/renderer/layout/library-reader-flex-chain.test.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
// tests/unit/renderer/layout/library-reader-flex-chain.test.tsx
|
||||
//
|
||||
// 守「文献列 → Reader」的**flex 高度链**契约
|
||||
// (src/renderer/src/components/layout/WorkbenchContentPanel.tsx r120 头注指向本文件)。
|
||||
//
|
||||
// 背景(audit-r120 的真实故障):文献列的承载容器一度是**块级**容器,
|
||||
// 子 reader(PDF / Image / Markdown / Code / Plain)的根节点高度全靠
|
||||
// flex-1 撑 —— 块级父下 flex-1 直接失效,iframe 的 h-full 百分比解析
|
||||
// 失败,回退 UA 默认 300×150 ⇒ PDF 塌成一条 150px 横条并报障。
|
||||
//
|
||||
// 契约(每一层都在用户可见的后果上):
|
||||
//
|
||||
// 1. 承载 Reader 的那层容器必须是 **flex + flex-col**:
|
||||
// - 少 flex ⇒ 子 reader 的 flex-1 不生效(高度塌成内容高)
|
||||
// - 少 flex-col ⇒ flex-1 沿主轴(横轴)分配,高度反而撑不开
|
||||
// 2. 该容器还要带 **flex-1 + min-h-0**:
|
||||
// - 少 flex-1 ⇒ 它不吃掉剩余高度,reader 被挤到容器顶部
|
||||
// - 少 min-h-0 ⇒ overflow 逃逸,PDF 内容把主区撑出滚动条
|
||||
// 3. 从 reader 根往上的**每一层祖先都必须是 flex 容器** ——
|
||||
// 中间任何一层退化成块级,整条链的高度计算就断在那儿。
|
||||
// 4. 双列形态下,文献列容器自身也要是 flex 列(min-h-0 同样不能少)。
|
||||
//
|
||||
// 变异思路(下列任一改动会红):
|
||||
// - 把 LibraryPanel 里 Reader 外层的 className 去掉 flex / flex-col
|
||||
// - 去掉该层的 flex-1 或 min-h-0
|
||||
// - 双列分支里给文献列容器换成块级(去掉 flex flex-col)
|
||||
// - 把单列分支的包裹层换成块级
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, cleanup, screen, act } from '@testing-library/react';
|
||||
|
||||
// i18n:只关心布局链,不关心文案
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
// --- 三个 store 的最小可订阅替身 -------------------------------------------
|
||||
const stores = vi.hoisted(() => {
|
||||
function makeStore<T extends object>(initial: T) {
|
||||
let state = initial;
|
||||
const listeners = new Set<() => void>();
|
||||
return {
|
||||
get: () => state,
|
||||
set: (patch: Partial<T>) => {
|
||||
state = { ...state, ...patch };
|
||||
for (const l of listeners) l();
|
||||
},
|
||||
subscribe: (l: () => void) => {
|
||||
listeners.add(l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
const workspace = makeStore<{ active: 'library' | 'notes' }>({ active: 'library' });
|
||||
const notes = makeStore<{ selectedId: string | null; select: (id: string | null) => void }>({
|
||||
selectedId: null,
|
||||
select: () => {},
|
||||
});
|
||||
notes.set({
|
||||
select: (id: string | null) => {
|
||||
notes.set({ selectedId: id });
|
||||
},
|
||||
});
|
||||
const literature = makeStore<{
|
||||
items: { id: string; absPath: string }[];
|
||||
selectedId: string | null;
|
||||
select: (id: string | null) => void;
|
||||
}>({ items: [], selectedId: null, select: () => {} });
|
||||
literature.set({
|
||||
select: (id: string | null) => {
|
||||
literature.set({ selectedId: id });
|
||||
},
|
||||
});
|
||||
return { workspace, notes, literature };
|
||||
});
|
||||
|
||||
vi.mock('../../../../src/renderer/src/store/workspace', async () => {
|
||||
const { useSyncExternalStore } = await import('react');
|
||||
const store = stores.workspace;
|
||||
const useWorkspaceStore = <T,>(selector: (s: { active: 'library' | 'notes' }) => T): T =>
|
||||
useSyncExternalStore(store.subscribe, () => selector(store.get()));
|
||||
return { useWorkspaceStore };
|
||||
});
|
||||
|
||||
vi.mock('../../../../src/renderer/src/store/notes', async () => {
|
||||
const { useSyncExternalStore } = await import('react');
|
||||
const store = stores.notes;
|
||||
const useNotesStore = <T,>(
|
||||
selector: (s: { selectedId: string | null; select: (id: string | null) => void }) => T,
|
||||
): T => useSyncExternalStore(store.subscribe, () => selector(store.get()));
|
||||
useNotesStore.getState = () => store.get();
|
||||
return { useNotesStore };
|
||||
});
|
||||
|
||||
vi.mock('../../../../src/renderer/src/store/literature', async () => {
|
||||
const { useSyncExternalStore } = await import('react');
|
||||
const store = stores.literature;
|
||||
const useLiteratureStore = <T,>(
|
||||
selector: (s: {
|
||||
items: { id: string; absPath: string }[];
|
||||
selectedId: string | null;
|
||||
select: (id: string | null) => void;
|
||||
}) => T,
|
||||
): T => useSyncExternalStore(store.subscribe, () => selector(store.get()));
|
||||
useLiteratureStore.getState = () => store.get();
|
||||
return { useLiteratureStore };
|
||||
});
|
||||
|
||||
// 阅读器替身:复刻真 PDFReader 的根节点形态 ——
|
||||
// 根: flex-1 flex flex-col (高度靠 flex-1 撑)
|
||||
// 内层: relative flex-1 min-h-0 overflow-hidden (iframe 的定位上下文)
|
||||
vi.mock('../../../../src/renderer/src/components/reader/Reader', () => ({
|
||||
Reader: () => (
|
||||
<div data-testid="reader-root" className="flex-1 flex flex-col overflow-hidden">
|
||||
<div data-testid="reader-stage" className="relative flex-1 min-h-0 overflow-hidden">
|
||||
<iframe data-testid="reader-frame" title="doc" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock('../../../../src/renderer/src/components/notes/NotesView', () => ({
|
||||
NotesView: () => <div data-testid="notes-view" />,
|
||||
}));
|
||||
|
||||
import { WorkbenchContentPanel } from '../../../../src/renderer/src/components/layout/WorkbenchContentPanel';
|
||||
|
||||
const LIT_ITEM = { id: 'l1', absPath: 'C:/papers/a.pdf' };
|
||||
|
||||
async function flushLazy(): Promise<void> {
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
|
||||
function hasClass(el: Element, cls: string): boolean {
|
||||
return el.classList.contains(cls);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stores.workspace.set({ active: 'library' });
|
||||
stores.notes.set({ selectedId: null });
|
||||
stores.literature.set({ items: [], selectedId: null });
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('文献列 → Reader 的 flex 高度链(单列形态)', () => {
|
||||
it('承载 Reader 的容器是 flex 列 + flex-1 + min-h-0', async () => {
|
||||
stores.literature.set({ items: [LIT_ITEM], selectedId: 'l1' });
|
||||
render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
|
||||
const readerRoot = screen.getByTestId('reader-root');
|
||||
const host = readerRoot.parentElement as HTMLElement;
|
||||
|
||||
expect(hasClass(host, 'flex'), '承载 Reader 的容器必须是 flex 容器').toBe(true);
|
||||
expect(hasClass(host, 'flex-col'), '必须是 flex 列 —— 否则子 reader 的 flex-1 撑不开高度').toBe(
|
||||
true,
|
||||
);
|
||||
expect(hasClass(host, 'flex-1'), '必须吃掉剩余高度').toBe(true);
|
||||
expect(hasClass(host, 'min-h-0'), '必须 min-h-0 —— 否则 PDF 内容把主区撑出滚动条').toBe(true);
|
||||
});
|
||||
|
||||
it('从 reader 根往上的每一层祖先都是 flex 容器(链不能断)', async () => {
|
||||
stores.literature.set({ items: [LIT_ITEM], selectedId: 'l1' });
|
||||
const { container } = render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
|
||||
const readerRoot = screen.getByTestId('reader-root');
|
||||
const panelRoot = container.firstElementChild as HTMLElement;
|
||||
|
||||
const chain: HTMLElement[] = [];
|
||||
let cur: HTMLElement | null = readerRoot.parentElement;
|
||||
while (cur) {
|
||||
chain.push(cur);
|
||||
if (cur === panelRoot) break;
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
// 走到了主区根容器(没走丢)
|
||||
expect(chain[chain.length - 1]).toBe(panelRoot);
|
||||
expect(chain.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
for (const el of chain) {
|
||||
expect(hasClass(el, 'flex'), `祖先 <${el.className}> 不是 flex 容器 —— 高度链在此断裂`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('文献列 → Reader 的 flex 高度链(双列形态)', () => {
|
||||
it('双列时文献列容器本身是 flex 列 + min-h-0', async () => {
|
||||
stores.literature.set({ items: [LIT_ITEM], selectedId: 'l1' });
|
||||
stores.notes.set({ selectedId: 'n1' });
|
||||
render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
|
||||
const separator = document.querySelector('[role="separator"]') as HTMLElement;
|
||||
expect(separator).toBeTruthy();
|
||||
const readerCol = separator.previousElementSibling as HTMLElement;
|
||||
|
||||
expect(hasClass(readerCol, 'flex')).toBe(true);
|
||||
expect(hasClass(readerCol, 'flex-col')).toBe(true);
|
||||
expect(hasClass(readerCol, 'min-h-0')).toBe(true);
|
||||
});
|
||||
|
||||
it('双列时承载 Reader 的容器同样是 flex 列 + flex-1 + min-h-0', async () => {
|
||||
stores.literature.set({ items: [LIT_ITEM], selectedId: 'l1' });
|
||||
stores.notes.set({ selectedId: 'n1' });
|
||||
render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
|
||||
const host = screen.getByTestId('reader-root').parentElement as HTMLElement;
|
||||
expect(hasClass(host, 'flex')).toBe(true);
|
||||
expect(hasClass(host, 'flex-col')).toBe(true);
|
||||
expect(hasClass(host, 'flex-1')).toBe(true);
|
||||
expect(hasClass(host, 'min-h-0')).toBe(true);
|
||||
});
|
||||
|
||||
it('双列时笔记列吃掉剩余宽度(flex-1),文献列按固定宽度', async () => {
|
||||
stores.literature.set({ items: [LIT_ITEM], selectedId: 'l1' });
|
||||
stores.notes.set({ selectedId: 'n1' });
|
||||
render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
|
||||
const separator = document.querySelector('[role="separator"]') as HTMLElement;
|
||||
const readerCol = separator.previousElementSibling as HTMLElement;
|
||||
const notesCol = separator.nextElementSibling as HTMLElement;
|
||||
|
||||
expect(hasClass(notesCol, 'flex-1')).toBe(true);
|
||||
expect(hasClass(notesCol, 'min-h-0')).toBe(true);
|
||||
// 文献列宽是持久化的固定值(不是百分比)
|
||||
expect(readerCol.style.width).toBe('640px');
|
||||
});
|
||||
});
|
||||
181
tests/unit/renderer/layout/sidebar-collapse-toggle.test.tsx
Normal file
181
tests/unit/renderer/layout/sidebar-collapse-toggle.test.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
// tests/unit/renderer/layout/sidebar-collapse-toggle.test.tsx
|
||||
//
|
||||
// 守 r242「折叠 / 展开左侧栏」按钮的行为契约。
|
||||
//
|
||||
// 需求:用户要「一个按钮隐藏或展开左侧栏」。本轮的实现落点:
|
||||
// - 按钮在**标题栏右侧**控件簇(r242 起先放左上角品牌区旁,r246 按用户
|
||||
// 反馈「该按钮不应该在左上角吧,放右边怎么样」挪到右侧)—— 折叠后面板
|
||||
// 整个不在 DOM 里,面板内的按钮会跟着消失,用户再也点不回来;标题栏
|
||||
// 是唯一「折叠前后都在」的位置。
|
||||
// - 折叠态存在 workspace store(sidebarCollapsed),持久化到
|
||||
// settings.json 的 workspaceLayout(见 shared/sidebar-collapsed-persistence.test.ts)。
|
||||
// - 快捷键 ⌘B / Ctrl+B(命令 id 'view.toggleSidebar',与主进程 View 菜单
|
||||
// 同一个 id)。
|
||||
// - 折叠的实现是「整个面板 return null」,**不是**「宽度设成 0」——
|
||||
// 宽度 0 会留下一条分隔条 + 边框死白条。
|
||||
//
|
||||
// 本测锁(行为级,不测实现细节):
|
||||
// A. 标题栏按钮存在、aria-pressed 反映折叠态、点击即翻转 store。
|
||||
// B. 折叠 ⇒ 列表面板**整个不进 DOM**;展开 ⇒ 正常渲染。
|
||||
// C. 折叠态会落盘(settings.update 带 sidebarCollapsed)⇒ 重启后仍折叠。
|
||||
// D. 命令注册表有 view.toggleSidebar(mod+B),执行即翻转折叠态。
|
||||
// E. 坏数据(非 boolean)不会把左栏永久藏掉。
|
||||
// F. 按钮在标题栏**右侧**控件簇首位(位置回归防护:曾放左上角)。
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, fireEvent, cleanup, waitFor, act } from '@testing-library/react';
|
||||
|
||||
// i18n:只关心交互与键名,不在乎文案
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
// settings store:workspace store 的 persistLayout 会调 update() 落盘 ——
|
||||
// 换假实现,避免真 IPC(测试环境没有 window.api)。update 是 vi.fn,
|
||||
// 下面的断言直接拿它当 spy 用。
|
||||
vi.mock('../../../../src/renderer/src/store/settings', () => {
|
||||
const update = vi.fn(async () => undefined);
|
||||
const load = vi.fn(async () => undefined);
|
||||
const state = { settings: null, update, load };
|
||||
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 };
|
||||
});
|
||||
|
||||
// 懒加载的列表组件:本测不关心列表内容,换掉避免 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 { TitleBar } from '../../../../src/renderer/src/components/layout/TitleBar';
|
||||
import { WorkbenchListPanel } from '../../../../src/renderer/src/components/layout/WorkbenchListPanel';
|
||||
import { useWorkspaceStore } from '../../../../src/renderer/src/store/workspace';
|
||||
import { useSettingsStore } from '../../../../src/renderer/src/store/settings';
|
||||
import { buildCommands } from '../../../../src/renderer/src/command/registry';
|
||||
import { buildCommandActions } from '../../../../src/renderer/src/command/actions';
|
||||
|
||||
const TOGGLE_LABEL = 'titleBar.toggleSidebar';
|
||||
|
||||
function updateMock() {
|
||||
return useSettingsStore.getState().update as unknown as ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function resetStore() {
|
||||
useWorkspaceStore.setState({ sidebarCollapsed: false });
|
||||
}
|
||||
|
||||
describe('左侧栏折叠 / 展开(r242)', () => {
|
||||
beforeEach(() => {
|
||||
resetStore();
|
||||
updateMock().mockClear();
|
||||
localStorage.clear();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('A1. 标题栏有折叠按钮,标签稳定、aria-pressed 反映当前折叠态', () => {
|
||||
const { container } = render(<TitleBar />);
|
||||
const btn = container.querySelector(`button[aria-label="${TOGGLE_LABEL}"]`) as HTMLButtonElement;
|
||||
expect(btn, '标题栏必须有折叠 / 展开左栏的按钮').toBeTruthy();
|
||||
// 展开态 = 未按下
|
||||
expect(btn.getAttribute('aria-pressed')).toBe('false');
|
||||
});
|
||||
|
||||
it('A2. 点按钮 ⇒ 折叠态翻转(store 是唯一真相源)', () => {
|
||||
const { container } = render(<TitleBar />);
|
||||
const btn = container.querySelector(`button[aria-label="${TOGGLE_LABEL}"]`) as HTMLButtonElement;
|
||||
|
||||
fireEvent.click(btn);
|
||||
expect(useWorkspaceStore.getState().sidebarCollapsed).toBe(true);
|
||||
|
||||
fireEvent.click(btn);
|
||||
expect(useWorkspaceStore.getState().sidebarCollapsed).toBe(false);
|
||||
});
|
||||
|
||||
it('B1. 折叠 ⇒ 列表面板整个不进 DOM(不是宽度设 0)', () => {
|
||||
useWorkspaceStore.setState({ sidebarCollapsed: true });
|
||||
const { container } = render(<WorkbenchListPanel />);
|
||||
expect(container.querySelector('section'), '折叠后不应留下宽度 0 的空壳 section').toBeNull();
|
||||
expect(container.querySelector('[role="tablist"]')).toBeNull();
|
||||
expect(container.querySelector('[role="separator"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('B2. 展开 ⇒ 面板正常渲染(折叠不是单向死路)', async () => {
|
||||
useWorkspaceStore.setState({ sidebarCollapsed: false });
|
||||
// list 是 lazy 组件(React.lazy + Suspense)—— act 里等它 resolve,
|
||||
// 否则 React 报「suspended resource finished loading outside act」。
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
({ container } = render(<WorkbenchListPanel />));
|
||||
});
|
||||
expect(container.querySelector('section')).toBeTruthy();
|
||||
expect(container.querySelector('[role="tablist"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('C. 折叠态会落盘(settings.update 带 sidebarCollapsed)⇒ 重启后仍折叠', async () => {
|
||||
const { container } = render(<TitleBar />);
|
||||
const btn = container.querySelector(`button[aria-label="${TOGGLE_LABEL}"]`) as HTMLButtonElement;
|
||||
|
||||
fireEvent.click(btn);
|
||||
|
||||
// persistLayout 是 200ms trailing debounce
|
||||
await waitFor(() => expect(updateMock()).toHaveBeenCalled(), { timeout: 2000 });
|
||||
const lastCall = updateMock().mock.calls.at(-1);
|
||||
expect(lastCall?.[0]).toMatchObject({
|
||||
workspaceLayout: expect.objectContaining({ sidebarCollapsed: true }),
|
||||
});
|
||||
});
|
||||
|
||||
it('D. 命令注册表有 view.toggleSidebar(⌘B),执行即翻转折叠态', () => {
|
||||
const cmds = buildCommands(buildCommandActions());
|
||||
const cmd = cmds.find((c) => c.id === 'view.toggleSidebar');
|
||||
expect(cmd, '命令注册表缺少 view.toggleSidebar(主进程菜单会 invoke 不到)').toBeTruthy();
|
||||
expect(cmd?.shortcut).toBe('mod+B');
|
||||
|
||||
cmd?.run();
|
||||
expect(useWorkspaceStore.getState().sidebarCollapsed).toBe(true);
|
||||
cmd?.run();
|
||||
expect(useWorkspaceStore.getState().sidebarCollapsed).toBe(false);
|
||||
});
|
||||
|
||||
it('E. 坏数据(非 boolean)不会把左栏永久藏掉', () => {
|
||||
useWorkspaceStore.getState().hydrateLayout({
|
||||
sidebarCollapsed: 'yes' as unknown as boolean,
|
||||
});
|
||||
expect(useWorkspaceStore.getState().sidebarCollapsed).toBe(false);
|
||||
|
||||
useWorkspaceStore.getState().hydrateLayout({ sidebarCollapsed: true });
|
||||
expect(useWorkspaceStore.getState().sidebarCollapsed).toBe(true);
|
||||
});
|
||||
|
||||
it('F. 按钮在标题栏右侧控件簇首位(位置回归防护:曾放左上角品牌区旁)', () => {
|
||||
const { container } = render(<TitleBar />);
|
||||
const btn = container.querySelector(`button[aria-label="${TOGGLE_LABEL}"]`) as HTMLButtonElement;
|
||||
const banner = container.querySelector('[role="banner"]') as HTMLElement;
|
||||
const brand = container.querySelector('#app-title-h1')?.parentElement;
|
||||
|
||||
expect(brand, '找不到品牌区(徽标 + 应用名)').toBeTruthy();
|
||||
expect(
|
||||
brand?.contains(btn),
|
||||
'折叠开关不该待在品牌区(左上角)—— 那里是「我是谁」,开关是「操作」',
|
||||
).toBe(false);
|
||||
|
||||
// 标题栏的子节点:品牌区 | 弹性拖拽区 | 右侧控件簇 ⇒ 按钮要在最后一簇
|
||||
const clusters = [...banner.children] as HTMLElement[];
|
||||
const rightCluster = clusters[clusters.length - 1];
|
||||
expect(rightCluster.contains(btn), '折叠开关应在标题栏最右侧的控件簇里').toBe(true);
|
||||
// 且是该簇首位(紧邻拖拽区)—— Win 的最小化/最大化/关闭要保持贴右成组
|
||||
expect(
|
||||
rightCluster.firstElementChild,
|
||||
'折叠开关应紧邻拖拽区,不与窗口三按钮混排',
|
||||
).toBe(btn);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
// tests/unit/renderer/layout/workbench-content-decoupled-columns.test.tsx
|
||||
//
|
||||
// 守 WorkbenchContentPanel 的**主区 / 左栏 Tab 解耦**契约
|
||||
// (src/renderer/src/components/layout/WorkbenchContentPanel.tsx 头注指向本文件)。
|
||||
//
|
||||
// 契约(2026-10-18 改造后的形态):
|
||||
//
|
||||
// 1. 主区**不跟左栏 Tab 切换**,只跟两个模块各自的「打开态」
|
||||
// (literature.selectedId / notes.selectedId)走:
|
||||
// 两边都没开 → 空态(身份才取 active)
|
||||
// 开一个 → 该模块单列占满
|
||||
// 两个都开 → 双列(文献在左 ┃ NotesView 在右)
|
||||
// 退化(又用 active 门控 panel 分支)⇒ 用户开着文献去点笔记 Tab,
|
||||
// 文献被顶掉、读者位置丢失 —— 这正是这次改造要消灭的现象。
|
||||
//
|
||||
// 2. **有打开态时,切 active 一律不许动主区**。
|
||||
// 退化 ⇒ 切 Tab 把正在读的东西顶掉(用户明确报障的现象)。
|
||||
//
|
||||
// 3. 关列按钮只清**本模块**的打开态,不动 active、也不动另一列:
|
||||
// 双列关一列 → 另一列自动占满
|
||||
// 单列关列 → 主区落回空态(身份取当前 Tab)
|
||||
//
|
||||
// 变异思路(下列任一改动会红):
|
||||
// - 把 litFile !== null && selectedId 的分支改回看 active
|
||||
// - 在「有打开态」的分支里读 active(例如按 active 决定渲染哪列)
|
||||
// - closePanel 里额外改 active / 额外清另一模块的 selectedId
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, fireEvent, cleanup, screen, act } from '@testing-library/react';
|
||||
|
||||
// i18n:只关心结构,不关心文案(键原样返回即可)
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
// --- 三个 store 的最小可订阅替身 -------------------------------------------
|
||||
// 真 zustand store 有订阅语义(状态一变,订阅该 selector 的组件重渲)。
|
||||
// 这里用 useSyncExternalStore 复刻这一点 —— 否则「切 active 后主区不动」
|
||||
// 根本测不出来(不重渲 ⇒ DOM 当然一样,变成空过)。
|
||||
const stores = vi.hoisted(() => {
|
||||
function makeStore<T extends object>(initial: T) {
|
||||
let state = initial;
|
||||
const listeners = new Set<() => void>();
|
||||
return {
|
||||
get: () => state,
|
||||
set: (patch: Partial<T>) => {
|
||||
state = { ...state, ...patch };
|
||||
for (const l of listeners) l();
|
||||
},
|
||||
subscribe: (l: () => void) => {
|
||||
listeners.add(l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
const workspace = makeStore<{ active: 'library' | 'notes' }>({ active: 'library' });
|
||||
const notes = makeStore<{ selectedId: string | null; select: (id: string | null) => void }>({
|
||||
selectedId: null,
|
||||
select: () => {},
|
||||
});
|
||||
notes.set({
|
||||
select: (id: string | null) => {
|
||||
notes.set({ selectedId: id });
|
||||
},
|
||||
});
|
||||
const literature = makeStore<{
|
||||
items: { id: string; absPath: string }[];
|
||||
selectedId: string | null;
|
||||
select: (id: string | null) => void;
|
||||
}>({ items: [], selectedId: null, select: () => {} });
|
||||
literature.set({
|
||||
select: (id: string | null) => {
|
||||
literature.set({ selectedId: id });
|
||||
},
|
||||
});
|
||||
return { workspace, notes, literature };
|
||||
});
|
||||
|
||||
vi.mock('../../../../src/renderer/src/store/workspace', async () => {
|
||||
const { useSyncExternalStore } = await import('react');
|
||||
const store = stores.workspace;
|
||||
const useWorkspaceStore = <T,>(selector: (s: { active: 'library' | 'notes' }) => T): T =>
|
||||
useSyncExternalStore(store.subscribe, () => selector(store.get()));
|
||||
return { useWorkspaceStore };
|
||||
});
|
||||
|
||||
vi.mock('../../../../src/renderer/src/store/notes', async () => {
|
||||
const { useSyncExternalStore } = await import('react');
|
||||
const store = stores.notes;
|
||||
const useNotesStore = <T,>(
|
||||
selector: (s: { selectedId: string | null; select: (id: string | null) => void }) => T,
|
||||
): T => useSyncExternalStore(store.subscribe, () => selector(store.get()));
|
||||
useNotesStore.getState = () => store.get();
|
||||
return { useNotesStore };
|
||||
});
|
||||
|
||||
vi.mock('../../../../src/renderer/src/store/literature', async () => {
|
||||
const { useSyncExternalStore } = await import('react');
|
||||
const store = stores.literature;
|
||||
const useLiteratureStore = <T,>(
|
||||
selector: (s: {
|
||||
items: { id: string; absPath: string }[];
|
||||
selectedId: string | null;
|
||||
select: (id: string | null) => void;
|
||||
}) => T,
|
||||
): T => useSyncExternalStore(store.subscribe, () => selector(store.get()));
|
||||
useLiteratureStore.getState = () => store.get();
|
||||
return { useLiteratureStore };
|
||||
});
|
||||
|
||||
// 懒加载的阅读器 / 笔记视图:本测只关心主区结构,换掉避免 IPC / CodeMirror 噪声
|
||||
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 { WorkbenchContentPanel } from '../../../../src/renderer/src/components/layout/WorkbenchContentPanel';
|
||||
|
||||
const LIT_ITEM = { id: 'l1', absPath: 'C:/papers/a.pdf' };
|
||||
|
||||
// Reader / NotesView 都是 React.lazy —— 首次渲染只出 Suspense 占位,
|
||||
// 组件本体要等动态 import 的 promise 落地才出现。断言「不存在」之前
|
||||
// 也必须 flush,否则「还没加载出来」会被误当成「不存在」(假绿)。
|
||||
// 改 store 状态会触发订阅组件重渲 —— 必须包在 act 里,
|
||||
// 否则 React 报「update not wrapped in act」并且重渲落在断言之后。
|
||||
function setActive(active: 'library' | 'notes'): void {
|
||||
act(() => {
|
||||
stores.workspace.set({ active });
|
||||
});
|
||||
}
|
||||
|
||||
async function flushLazy(): Promise<void> {
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stores.workspace.set({ active: 'library' });
|
||||
stores.notes.set({ selectedId: null });
|
||||
stores.literature.set({ items: [], selectedId: null });
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('主区形态由「打开态」决定,不由左栏 Tab 决定', () => {
|
||||
it('文献开着、笔记没开 ⇒ 单列文献(没有笔记列)', async () => {
|
||||
stores.literature.set({ items: [LIT_ITEM], selectedId: 'l1' });
|
||||
render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
|
||||
expect(screen.getByTestId('reader')).toBeTruthy();
|
||||
expect(screen.queryByTestId('notes-view')).toBeNull();
|
||||
expect(document.querySelector('[role="separator"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('笔记开着、文献没开 ⇒ 单列笔记(没有文献列)', async () => {
|
||||
stores.notes.set({ selectedId: 'n1' });
|
||||
render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
|
||||
expect(screen.getByTestId('notes-view')).toBeTruthy();
|
||||
expect(screen.queryByTestId('reader')).toBeNull();
|
||||
expect(document.querySelector('[role="separator"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('两边都开着 ⇒ 双列 + 分隔条', async () => {
|
||||
stores.literature.set({ items: [LIT_ITEM], selectedId: 'l1' });
|
||||
stores.notes.set({ selectedId: 'n1' });
|
||||
render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
|
||||
expect(screen.getByTestId('reader')).toBeTruthy();
|
||||
expect(screen.getByTestId('notes-view')).toBeTruthy();
|
||||
expect(document.querySelector('[role="separator"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('文献 selectedId 命中不到 items ⇒ 按「没打开」处理(冷挂载竞态)', async () => {
|
||||
stores.literature.set({ items: [], selectedId: 'l1' });
|
||||
render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
|
||||
expect(screen.queryByTestId('reader')).toBeNull();
|
||||
expect(screen.queryByTestId('notes-view')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('有打开态时切 active:主区必须原样不动', () => {
|
||||
it('文献开着时切到笔记 Tab ⇒ 仍然是文献列(DOM 一字不变)', async () => {
|
||||
stores.literature.set({ items: [LIT_ITEM], selectedId: 'l1' });
|
||||
const { container } = render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
const before = container.innerHTML;
|
||||
|
||||
setActive('notes');
|
||||
|
||||
expect(container.innerHTML).toBe(before);
|
||||
expect(screen.getByTestId('reader')).toBeTruthy();
|
||||
expect(screen.queryByTestId('notes-view')).toBeNull();
|
||||
});
|
||||
|
||||
it('笔记开着时切到文献 Tab ⇒ 仍然是笔记列(DOM 一字不变)', async () => {
|
||||
stores.notes.set({ selectedId: 'n1' });
|
||||
const { container } = render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
const before = container.innerHTML;
|
||||
|
||||
setActive('library');
|
||||
|
||||
expect(container.innerHTML).toBe(before);
|
||||
expect(screen.getByTestId('notes-view')).toBeTruthy();
|
||||
expect(screen.queryByTestId('reader')).toBeNull();
|
||||
});
|
||||
|
||||
it('双列时切 active ⇒ 仍是双列(DOM 一字不变)', async () => {
|
||||
stores.literature.set({ items: [LIT_ITEM], selectedId: 'l1' });
|
||||
stores.notes.set({ selectedId: 'n1' });
|
||||
const { container } = render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
const before = container.innerHTML;
|
||||
|
||||
setActive('notes');
|
||||
|
||||
expect(container.innerHTML).toBe(before);
|
||||
expect(screen.getByTestId('reader')).toBeTruthy();
|
||||
expect(screen.getByTestId('notes-view')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('空态身份只在「都没打开」时跟随左栏 Tab', () => {
|
||||
it('都没打开 + active=notes ⇒ 笔记空态', async () => {
|
||||
setActive('notes');
|
||||
render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
expect(screen.getByText('notes.viewEmptyTitle')).toBeTruthy();
|
||||
expect(screen.queryByText('literature.viewEmptyTitle')).toBeNull();
|
||||
});
|
||||
|
||||
it('都没打开 + active=library ⇒ 文献空态', async () => {
|
||||
setActive('library');
|
||||
render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
expect(screen.getByText('literature.viewEmptyTitle')).toBeTruthy();
|
||||
expect(screen.queryByText('notes.viewEmptyTitle')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('关列按钮:只关本列,不动 active,不动另一列', () => {
|
||||
it('双列关掉文献列 ⇒ 笔记列仍在,主区回到单列', async () => {
|
||||
stores.literature.set({ items: [LIT_ITEM], selectedId: 'l1' });
|
||||
stores.notes.set({ selectedId: 'n1' });
|
||||
render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('literature.closePanelAria'));
|
||||
|
||||
expect(stores.literature.get().selectedId).toBeNull();
|
||||
expect(screen.queryByTestId('reader')).toBeNull();
|
||||
expect(screen.getByTestId('notes-view')).toBeTruthy();
|
||||
expect(document.querySelector('[role="separator"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('双列关掉笔记列 ⇒ 文献列仍在', async () => {
|
||||
stores.literature.set({ items: [LIT_ITEM], selectedId: 'l1' });
|
||||
stores.notes.set({ selectedId: 'n1' });
|
||||
render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('notes.closePanelAria'));
|
||||
|
||||
expect(stores.notes.get().selectedId).toBeNull();
|
||||
expect(screen.getByTestId('reader')).toBeTruthy();
|
||||
expect(screen.queryByTestId('notes-view')).toBeNull();
|
||||
});
|
||||
|
||||
it('关列不会顺手切走左栏 Tab(active 不变)', async () => {
|
||||
setActive('notes');
|
||||
stores.literature.set({ items: [LIT_ITEM], selectedId: 'l1' });
|
||||
stores.notes.set({ selectedId: 'n1' });
|
||||
render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('notes.closePanelAria'));
|
||||
|
||||
expect(stores.workspace.get().active).toBe('notes');
|
||||
});
|
||||
|
||||
it('单列关掉 ⇒ 主区落回空态(身份取当前 Tab)', async () => {
|
||||
setActive('notes');
|
||||
stores.notes.set({ selectedId: 'n1' });
|
||||
render(<WorkbenchContentPanel />);
|
||||
await flushLazy();
|
||||
expect(screen.getByTestId('notes-view')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('notes.closePanelAria'));
|
||||
|
||||
expect(screen.queryByTestId('notes-view')).toBeNull();
|
||||
expect(screen.getByText('notes.viewEmptyTitle')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
337
tests/unit/renderer/layout/workbench-content-empty-state.test.ts
Normal file
337
tests/unit/renderer/layout/workbench-content-empty-state.test.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
// tests/unit/renderer/layout/workbench-content-empty-state.test.ts
|
||||
//
|
||||
// 守 WorkbenchContentPanel 的**空态契约**
|
||||
// (src/renderer/src/components/layout/WorkbenchContentPanel.tsx 头注指向本文件)。
|
||||
//
|
||||
// 契约两条:
|
||||
//
|
||||
// 1. 【行为级】两个模块都没打开时,主区给空态,身份跟随左栏 active:
|
||||
// active='notes' → 笔记空态(notes.viewEmptyTitle / notes.viewEmptyDesc)
|
||||
// active='library' → 文献空态(literature.viewEmptyTitle / …)
|
||||
// 空态是 role="status" + aria-live="polite"(读屏用户听得见「未选中」,
|
||||
// 但不打断当前播报 —— 旧状态若变成 assertive 会在用户读文献时插播)。
|
||||
// 视觉身份跟随模块色(notes 蓝 / library 紫),用户一眼知道该点左边哪一栏。
|
||||
//
|
||||
// 2. 【源码顺序】文件里**第一个** <EmptyState … liveRegion="polite" />
|
||||
// 必须属于 NotesEmptyState(第二个才是 LibraryEmptyState)。
|
||||
// 退化(把两个 EmptyState 的定义顺序对调 / 把 library 的搬前面)⇒
|
||||
// 顺序型守卫取样会打到 library 块上,「笔记空态先播报」的约定被打偏,
|
||||
// 而且后续任何按「第一个 EmptyState」取样的工具都会静默取错。
|
||||
//
|
||||
// 变异思路(下列任一改动会红):
|
||||
// - 把 LibraryEmptyState 的定义搬到 NotesEmptyState 前面
|
||||
// - 把 liveRegion="polite" 改成 "assertive" / 去掉
|
||||
// - 把 notes 空态的 title / description 换成 literature 的键
|
||||
// - 把 accent="notes" 改成 accent="library"(模块色身份串了)
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, cleanup, screen, act } from '@testing-library/react';
|
||||
import { createElement } from 'react';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
// i18n:键原样返回 —— 空态文案本身就是契约的一部分(直接断言键名)
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
// --- 三个 store 的最小可订阅替身 -------------------------------------------
|
||||
const stores = vi.hoisted(() => {
|
||||
function makeStore<T extends object>(initial: T) {
|
||||
let state = initial;
|
||||
const listeners = new Set<() => void>();
|
||||
return {
|
||||
get: () => state,
|
||||
set: (patch: Partial<T>) => {
|
||||
state = { ...state, ...patch };
|
||||
for (const l of listeners) l();
|
||||
},
|
||||
subscribe: (l: () => void) => {
|
||||
listeners.add(l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
const workspace = makeStore<{ active: 'library' | 'notes' }>({ active: 'library' });
|
||||
const notes = makeStore<{ selectedId: string | null; select: (id: string | null) => void }>({
|
||||
selectedId: null,
|
||||
select: () => {},
|
||||
});
|
||||
notes.set({
|
||||
select: (id: string | null) => {
|
||||
notes.set({ selectedId: id });
|
||||
},
|
||||
});
|
||||
const literature = makeStore<{
|
||||
items: { id: string; absPath: string }[];
|
||||
selectedId: string | null;
|
||||
select: (id: string | null) => void;
|
||||
}>({ items: [], selectedId: null, select: () => {} });
|
||||
literature.set({
|
||||
select: (id: string | null) => {
|
||||
literature.set({ selectedId: id });
|
||||
},
|
||||
});
|
||||
return { workspace, notes, literature };
|
||||
});
|
||||
|
||||
vi.mock('../../../../src/renderer/src/store/workspace', async () => {
|
||||
const { useSyncExternalStore } = await import('react');
|
||||
const store = stores.workspace;
|
||||
const useWorkspaceStore = <T,>(selector: (s: { active: 'library' | 'notes' }) => T): T =>
|
||||
useSyncExternalStore(store.subscribe, () => selector(store.get()));
|
||||
return { useWorkspaceStore };
|
||||
});
|
||||
|
||||
vi.mock('../../../../src/renderer/src/store/notes', async () => {
|
||||
const { useSyncExternalStore } = await import('react');
|
||||
const store = stores.notes;
|
||||
const useNotesStore = <T,>(
|
||||
selector: (s: { selectedId: string | null; select: (id: string | null) => void }) => T,
|
||||
): T => useSyncExternalStore(store.subscribe, () => selector(store.get()));
|
||||
useNotesStore.getState = () => store.get();
|
||||
return { useNotesStore };
|
||||
});
|
||||
|
||||
vi.mock('../../../../src/renderer/src/store/literature', async () => {
|
||||
const { useSyncExternalStore } = await import('react');
|
||||
const store = stores.literature;
|
||||
const useLiteratureStore = <T,>(
|
||||
selector: (s: {
|
||||
items: { id: string; absPath: string }[];
|
||||
selectedId: string | null;
|
||||
select: (id: string | null) => void;
|
||||
}) => T,
|
||||
): T => useSyncExternalStore(store.subscribe, () => selector(store.get()));
|
||||
useLiteratureStore.getState = () => store.get();
|
||||
return { useLiteratureStore };
|
||||
});
|
||||
|
||||
// 本文件是 .ts(指针指定)⇒ 不用 JSX,mock 组件用 createElement 造。
|
||||
vi.mock('../../../../src/renderer/src/components/reader/Reader', async () => {
|
||||
const { createElement } = await import('react');
|
||||
return { Reader: () => createElement('div', { 'data-testid': 'reader' }) };
|
||||
});
|
||||
vi.mock('../../../../src/renderer/src/components/notes/NotesView', async () => {
|
||||
const { createElement } = await import('react');
|
||||
return { NotesView: () => createElement('div', { 'data-testid': 'notes-view' }) };
|
||||
});
|
||||
|
||||
import { WorkbenchContentPanel } from '../../../../src/renderer/src/components/layout/WorkbenchContentPanel';
|
||||
|
||||
const SRC_PATH = resolve(
|
||||
process.cwd(),
|
||||
'src/renderer/src/components/layout/WorkbenchContentPanel.tsx',
|
||||
);
|
||||
|
||||
/** 去掉注释(行注释 + 块注释),保留字符串字面量。
|
||||
* 源码头部注释里写着「取样只认裸名字」,但注释一旦写到同样的字面组合
|
||||
* 就会把取样起点带偏 —— 与其指望人不写,不如让取样器自己跳过注释。 */
|
||||
function stripComments(src: string): string {
|
||||
let out = '';
|
||||
let state: 'code' | 'line' | 'block' | 'sq' | 'dq' = 'code';
|
||||
let i = 0;
|
||||
while (i < src.length) {
|
||||
const c = src[i]!;
|
||||
const next = src[i + 1];
|
||||
if (state === 'code') {
|
||||
if (c === '/' && next === '/') {
|
||||
state = 'line';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === '/' && next === '*') {
|
||||
state = 'block';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === "'") {
|
||||
state = 'sq';
|
||||
} else if (c === '"') {
|
||||
state = 'dq';
|
||||
}
|
||||
out += c;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (state === 'line') {
|
||||
if (c === '\n') {
|
||||
state = 'code';
|
||||
out += '\n';
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (state === 'block') {
|
||||
if (c === '*' && next === '/') {
|
||||
state = 'code';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// 字符串内部
|
||||
if (c === '\\') {
|
||||
out += src.slice(i, i + 2);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if ((state === 'sq' && c === "'") || (state === 'dq' && c === '"')) state = 'code';
|
||||
out += c;
|
||||
i += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 从 <EmptyState 开始取到**配平的**自闭合标签。
|
||||
* 属性里的 icon={<NotebookPen … />} 自带一个 '/>' —— 直接找第一个 '/>'
|
||||
* 会在 icon 处截断,取不到 title / liveRegion。 */
|
||||
function extractJsxElement(src: string, start: number): string {
|
||||
let curly = 0;
|
||||
let angle = 0;
|
||||
let paren = 0;
|
||||
for (let i = start; i < src.length; i += 1) {
|
||||
const c = src[i]!;
|
||||
if (c === '{') curly += 1;
|
||||
else if (c === '}') curly -= 1;
|
||||
else if (c === '<') angle += 1;
|
||||
else if (c === '>') angle -= 1;
|
||||
else if (c === '(') paren += 1;
|
||||
else if (c === ')') paren -= 1;
|
||||
else if (
|
||||
c === '/' &&
|
||||
src[i + 1] === '>' &&
|
||||
curly === 0 &&
|
||||
angle === 0 &&
|
||||
paren === 0
|
||||
) {
|
||||
return src.slice(start, i + 2);
|
||||
}
|
||||
}
|
||||
return src.slice(start);
|
||||
}
|
||||
|
||||
function extractEmptyStateBlocks(src: string): string[] {
|
||||
const blocks: string[] = [];
|
||||
const re = /<EmptyState\b/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(src)) !== null) {
|
||||
blocks.push(extractJsxElement(src, m.index));
|
||||
re.lastIndex = m.index + 1;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// NotesView / Reader 都是 React.lazy —— 组件本体要等动态 import 落地。
|
||||
// 「断言不存在」之前也必须 flush,否则「还没加载出来」会被误当成「不存在」。
|
||||
async function flushLazy(): Promise<void> {
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
|
||||
function setActive(active: 'library' | 'notes'): void {
|
||||
act(() => {
|
||||
stores.workspace.set({ active });
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stores.workspace.set({ active: 'library' });
|
||||
stores.notes.set({ selectedId: null });
|
||||
stores.literature.set({ items: [], selectedId: null });
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('空态:身份跟随左栏 Tab(两个模块都没打开时)', () => {
|
||||
it('active=notes ⇒ 笔记空态', () => {
|
||||
setActive('notes');
|
||||
render(createElement(WorkbenchContentPanel));
|
||||
expect(screen.getByText('notes.viewEmptyTitle')).toBeTruthy();
|
||||
expect(screen.getByText('notes.viewEmptyDesc')).toBeTruthy();
|
||||
expect(screen.queryByText('literature.viewEmptyTitle')).toBeNull();
|
||||
});
|
||||
|
||||
it('active=library ⇒ 文献空态', () => {
|
||||
setActive('library');
|
||||
render(createElement(WorkbenchContentPanel));
|
||||
expect(screen.getByText('literature.viewEmptyTitle')).toBeTruthy();
|
||||
expect(screen.getByText('literature.viewEmptyDesc')).toBeTruthy();
|
||||
expect(screen.queryByText('notes.viewEmptyTitle')).toBeNull();
|
||||
});
|
||||
|
||||
it('空态是 polite live region(读屏播报但不打断)', () => {
|
||||
setActive('notes');
|
||||
render(createElement(WorkbenchContentPanel));
|
||||
const status = screen.getByRole('status');
|
||||
expect(status.getAttribute('aria-live')).toBe('polite');
|
||||
});
|
||||
|
||||
it('空态视觉身份跟随模块色(notes 蓝 / library 紫)', () => {
|
||||
setActive('notes');
|
||||
const { unmount } = render(createElement(WorkbenchContentPanel));
|
||||
const notesIconBox = screen.getByRole('status').firstElementChild as HTMLElement;
|
||||
expect(notesIconBox.getAttribute('style')).toContain('--module-notes-rgb');
|
||||
unmount();
|
||||
|
||||
setActive('library');
|
||||
render(createElement(WorkbenchContentPanel));
|
||||
const libIconBox = screen.getByRole('status').firstElementChild as HTMLElement;
|
||||
expect(libIconBox.getAttribute('style')).toContain('--module-library-rgb');
|
||||
});
|
||||
|
||||
it('有打开态时不出现空态', async () => {
|
||||
stores.notes.set({ selectedId: 'n1' });
|
||||
render(createElement(WorkbenchContentPanel));
|
||||
await flushLazy();
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
expect(screen.queryByText('notes.viewEmptyTitle')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('源码顺序:第一个 polite EmptyState 必须是笔记空态', () => {
|
||||
const source = readFileSync(SRC_PATH, 'utf8');
|
||||
const blocks = extractEmptyStateBlocks(stripComments(source));
|
||||
|
||||
it('文件里恰好两个 EmptyState', () => {
|
||||
expect(blocks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('两个都声明 liveRegion="polite"', () => {
|
||||
for (const b of blocks) expect(b).toContain('liveRegion="polite"');
|
||||
});
|
||||
|
||||
it('第一个是笔记空态(title/desc/accent 都是 notes)', () => {
|
||||
expect(blocks[0]).toContain('notes.viewEmptyTitle');
|
||||
expect(blocks[0]).toContain('notes.viewEmptyDesc');
|
||||
expect(blocks[0]).toContain('accent="notes"');
|
||||
expect(blocks[0]).not.toContain('literature.viewEmptyTitle');
|
||||
});
|
||||
|
||||
it('第二个是文献空态(title/desc/accent 都是 library)', () => {
|
||||
expect(blocks[1]).toContain('literature.viewEmptyTitle');
|
||||
expect(blocks[1]).toContain('literature.viewEmptyDesc');
|
||||
expect(blocks[1]).toContain('accent="library"');
|
||||
expect(blocks[1]).not.toContain('notes.viewEmptyTitle');
|
||||
});
|
||||
|
||||
it('取样器不会被注释里的同名字面量带偏(自检)', () => {
|
||||
// 构造一份「注释里先出现 polite EmptyState」的样本 —— 真源码也可能
|
||||
// 以后被人加注释写进同样的字面量,取样器必须只认代码里的。
|
||||
const sample = `
|
||||
// <EmptyState liveRegion="polite" title={'library.viewEmptyTitle'} />
|
||||
function NotesEmptyState() {
|
||||
return <EmptyState liveRegion="polite" title={t('notes.viewEmptyTitle')} />;
|
||||
}
|
||||
`;
|
||||
const sampled = extractEmptyStateBlocks(stripComments(sample));
|
||||
expect(sampled).toHaveLength(1);
|
||||
expect(sampled[0]).toContain('notes.viewEmptyTitle');
|
||||
expect(sampled[0]).not.toContain('library.viewEmptyTitle');
|
||||
});
|
||||
});
|
||||
102
tests/unit/renderer/lib/bootstrap-apply-once.test.ts
Normal file
102
tests/unit/renderer/lib/bootstrap-apply-once.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// tests/unit/renderer/lib/bootstrap-apply-once.test.ts
|
||||
//
|
||||
// 守卫(r206,2026-10-20):bootstrap 结果「应用且仅应用一次」的单点契约。
|
||||
//
|
||||
// 起因(r87):r85 的单飞修了「重复 IPC」,没修「重复应用」—— 两个 awaiter
|
||||
// 拿到同一结果后**各自** applyBootstrapSettings + setState({paths,loaded})。
|
||||
// pendingLayout 的消费在 applyPendingLayoutFallback 里
|
||||
// `void api.updateSettings(...).then(removeItem)` —— key 清理是异步的,
|
||||
// 同帧双 apply ⇒ 双 updateSettings 写(同值,LOW)。修:apply 收口到
|
||||
// lib/bootstrap.ts 的 applyBootstrapOnce 单点。
|
||||
//
|
||||
// 契约三分支:
|
||||
// - r.ok 且尚未应用 → 'applied':applyBootstrapSettings + setState({paths, loaded:true});
|
||||
// - r.ok 但已应用(另一 continuation 先落地 / 测试直接置 loaded)
|
||||
// → 'already-applied':**零 store 写、零 IPC** —— 首落地值不被覆写;
|
||||
// - !r.ok → 'ipc-failed':**纯判定不碰 state**。调用方按各自职责处理
|
||||
// (main:log.warn + loaded=true 标记「本轮启动尝试过」;App:legacy
|
||||
// settings/paths pair 修复)。两个调用方都会拿到同一个 'ipc-failed',
|
||||
// 修复只由 App 执行一次 —— 这是有意分工,不是守卫缺失。
|
||||
//
|
||||
// 隔离:本文件**不**用 vi.resetModules() —— applyBootstrapOnce 读写的
|
||||
// paths store 是模块级 zustand 单例,resetModules 会造出**另一份**实例,
|
||||
// 令「apply 写进去的 loaded」读不到(本轮第一版就踩了这个坑)。
|
||||
// 改为每例显式 setState 复位 loaded。
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { BootstrapPayload, IpcResult } from '@shared/types';
|
||||
|
||||
const { applyBootstrapSettings } = vi.hoisted(() => ({ applyBootstrapSettings: vi.fn() }));
|
||||
vi.mock('@renderer/store/settings', () => ({ applyBootstrapSettings }));
|
||||
|
||||
const PAYLOAD = {
|
||||
settings: { themePreset: 'default' } as unknown as BootstrapPayload['settings'],
|
||||
paths: { rootPath: '/tmp/x' } as unknown as BootstrapPayload['paths'],
|
||||
} as BootstrapPayload;
|
||||
|
||||
const OK = { ok: true, data: PAYLOAD } as IpcResult<BootstrapPayload>;
|
||||
const FAIL = { ok: false, code: 'INTERNAL', message: 'boom' } as IpcResult<BootstrapPayload>;
|
||||
|
||||
describe('bootstrap apply-once(r206)', () => {
|
||||
beforeEach(async () => {
|
||||
applyBootstrapSettings.mockReset();
|
||||
const { usePathsStore } = await import('@renderer/store/paths');
|
||||
usePathsStore.setState({ loaded: false, paths: null as never });
|
||||
});
|
||||
|
||||
it('r.ok 且未加载 → applied,并写 settings + paths + loaded', async () => {
|
||||
const { applyBootstrapOnce } = await import('@renderer/lib/bootstrap');
|
||||
const { usePathsStore } = await import('@renderer/store/paths');
|
||||
|
||||
const out = applyBootstrapOnce(OK);
|
||||
|
||||
expect(out).toBe('applied');
|
||||
expect(applyBootstrapSettings).toHaveBeenCalledTimes(1);
|
||||
expect(applyBootstrapSettings).toHaveBeenCalledWith(PAYLOAD.settings);
|
||||
const st = usePathsStore.getState();
|
||||
expect(st.loaded).toBe(true);
|
||||
expect(st.paths).toEqual(PAYLOAD.paths);
|
||||
});
|
||||
|
||||
it('已应用(loaded=true)→ already-applied,零 store 写', async () => {
|
||||
const { applyBootstrapOnce } = await import('@renderer/lib/bootstrap');
|
||||
const { usePathsStore } = await import('@renderer/store/paths');
|
||||
usePathsStore.setState({ loaded: true, paths: { rootPath: '/keep' } as never });
|
||||
|
||||
const out = applyBootstrapOnce(OK);
|
||||
|
||||
expect(out).toBe('already-applied');
|
||||
expect(applyBootstrapSettings).not.toHaveBeenCalled();
|
||||
// 首落地的值不被覆写
|
||||
expect(usePathsStore.getState().paths).toEqual({ rootPath: '/keep' });
|
||||
});
|
||||
|
||||
it('!r.ok → ipc-failed,且**不碰 state**(loaded 仍为 false)', async () => {
|
||||
const { applyBootstrapOnce } = await import('@renderer/lib/bootstrap');
|
||||
const { usePathsStore } = await import('@renderer/store/paths');
|
||||
|
||||
const out = applyBootstrapOnce(FAIL);
|
||||
|
||||
expect(out).toBe('ipc-failed');
|
||||
expect(applyBootstrapSettings).not.toHaveBeenCalled();
|
||||
expect(usePathsStore.getState().loaded).toBe(false);
|
||||
});
|
||||
|
||||
it('连续两次 apply 同一个结果:第二次是 already-applied(只写一次)', async () => {
|
||||
const { applyBootstrapOnce } = await import('@renderer/lib/bootstrap');
|
||||
|
||||
expect(applyBootstrapOnce(OK)).toBe('applied');
|
||||
expect(applyBootstrapOnce(OK)).toBe('already-applied');
|
||||
expect(applyBootstrapSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ipc-failed 不写 loaded —— 「本轮启动尝试过」由调用方自己置', async () => {
|
||||
// main.tsx 的语义是「失败也置 loaded=true」,但那是**调用方**的动作,
|
||||
// 不是 applyBootstrapOnce 的动作。本例锁住这个分工边界。
|
||||
const { applyBootstrapOnce } = await import('@renderer/lib/bootstrap');
|
||||
const { usePathsStore } = await import('@renderer/store/paths');
|
||||
|
||||
applyBootstrapOnce(FAIL);
|
||||
|
||||
expect(usePathsStore.getState().loaded).toBe(false);
|
||||
});
|
||||
});
|
||||
139
tests/unit/renderer/lib/bootstrap-single-flight.test.ts
Normal file
139
tests/unit/renderer/lib/bootstrap-single-flight.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
// tests/unit/renderer/lib/bootstrap-single-flight.test.ts
|
||||
//
|
||||
// 守卫(r206,2026-10-20):bootstrap 的**单飞**(single-flight)契约。
|
||||
//
|
||||
// 起因(r85 recon):main.tsx 后台 IIFE 第一行发 bootstrap,App.tsx passive
|
||||
// effect 又「幂等兜底」地看 usePathsStore.loaded 决定要不要再发 —— 但
|
||||
// React passive effect flush(<16ms)永远早于 bootstrap IPC 往返(50-200ms),
|
||||
// loaded 此刻必然还是 false,所以**每次冷启都并发两次 bootstrap IPC**
|
||||
// (dev StrictMode 三次)。main.tsx 里那句「loaded 守卫保证不会跑 legacy
|
||||
// fallback」是**不生效的假守卫**。
|
||||
//
|
||||
// 契约(本文件逐条钉):
|
||||
// - 并发调用命中同一 promise ⇒ 只发**一次** IPC;
|
||||
// - 成功结果(**含 ok:false 的合法回复**)被缓存 ⇒ 不重发 —— 与
|
||||
// main.tsx「失败后置 loaded=true」的「本轮启动尝试过」语义一致;
|
||||
// - rejection(桥未就绪 / IPC 抛)清缓存 ⇒ 瞬时 race 之后重调可再试
|
||||
// (这才是原注释「幂等兜底」想表达却没做到的行为);
|
||||
// - 桥缺失(api.bootstrap 不是函数)转成 **rejection** 而不是同步 throw
|
||||
// —— 符合 r83 契约(调用点带同线 typeof guard,Proxy 属性读安全)。
|
||||
//
|
||||
// 隔离手段:bootstrap.ts 的 inflight 是**模块级**变量 ⇒ 每例
|
||||
// vi.resetModules() + 动态 import 拿全新模块实例。
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { BootstrapPayload, IpcResult } from '@shared/types';
|
||||
|
||||
const OK_PAYLOAD = {
|
||||
settings: {} as BootstrapPayload['settings'],
|
||||
paths: {} as BootstrapPayload['paths'],
|
||||
} as BootstrapPayload;
|
||||
|
||||
async function freshBootstrap() {
|
||||
vi.resetModules();
|
||||
return await import('@renderer/lib/bootstrap');
|
||||
}
|
||||
|
||||
function setBridge(value: unknown): void {
|
||||
(window as unknown as { api?: unknown }).api = value;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setBridge(undefined);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('bootstrap 单飞(r206)', () => {
|
||||
it('并发调用只发一次 IPC', async () => {
|
||||
const mod = await freshBootstrap();
|
||||
const bootstrap = vi.fn().mockResolvedValue({ ok: true, data: OK_PAYLOAD } as IpcResult<BootstrapPayload>);
|
||||
setBridge({ bootstrap });
|
||||
|
||||
const [a, b] = await Promise.all([mod.bootstrapOnce(), mod.bootstrapOnce()]);
|
||||
|
||||
expect(bootstrap).toHaveBeenCalledTimes(1);
|
||||
expect(a).toBe(b); // 同一个 promise ⇒ 同一个结果对象
|
||||
});
|
||||
|
||||
it('三个并发调用仍只发一次', async () => {
|
||||
const mod = await freshBootstrap();
|
||||
const bootstrap = vi.fn().mockResolvedValue({ ok: true, data: OK_PAYLOAD } as IpcResult<BootstrapPayload>);
|
||||
setBridge({ bootstrap });
|
||||
|
||||
await Promise.all([mod.bootstrapOnce(), mod.bootstrapOnce(), mod.bootstrapOnce()]);
|
||||
|
||||
expect(bootstrap).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('成功结果被缓存:后续调用不再发 IPC', async () => {
|
||||
const mod = await freshBootstrap();
|
||||
const bootstrap = vi.fn().mockResolvedValue({ ok: true, data: OK_PAYLOAD } as IpcResult<BootstrapPayload>);
|
||||
setBridge({ bootstrap });
|
||||
|
||||
await mod.bootstrapOnce();
|
||||
await mod.bootstrapOnce();
|
||||
|
||||
expect(bootstrap).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ok:false 的合法回复也缓存(不重发)', async () => {
|
||||
const mod = await freshBootstrap();
|
||||
const bootstrap = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: false, code: 'INTERNAL', message: 'x' } as IpcResult<BootstrapPayload>);
|
||||
setBridge({ bootstrap });
|
||||
|
||||
const first = await mod.bootstrapOnce();
|
||||
const second = await mod.bootstrapOnce();
|
||||
|
||||
expect(bootstrap).toHaveBeenCalledTimes(1);
|
||||
expect(first).toBe(second);
|
||||
expect(first.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejection 清缓存:之后重调能再试(幂等兜底真的兜住了)', async () => {
|
||||
const mod = await freshBootstrap();
|
||||
const bootstrap = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('bridge race'))
|
||||
.mockResolvedValueOnce({ ok: true, data: OK_PAYLOAD } as IpcResult<BootstrapPayload>);
|
||||
setBridge({ bootstrap });
|
||||
|
||||
await expect(mod.bootstrapOnce()).rejects.toThrow('bridge race');
|
||||
const retry = await mod.bootstrapOnce();
|
||||
|
||||
expect(bootstrap).toHaveBeenCalledTimes(2);
|
||||
expect(retry.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('桥缺失 ⇒ 返回 rejected promise(不是同步 throw)', async () => {
|
||||
const mod = await freshBootstrap();
|
||||
setBridge(undefined);
|
||||
|
||||
// 同步 throw 会让调用点的 try/catch 之外代码炸掉;契约要求是 rejection。
|
||||
let promise: Promise<IpcResult<BootstrapPayload>> | null = null;
|
||||
expect(() => {
|
||||
promise = mod.bootstrapOnce();
|
||||
}).not.toThrow();
|
||||
await expect(promise!).rejects.toThrow(/bridge missing/);
|
||||
});
|
||||
|
||||
it('桥在但方法不是函数 ⇒ 同样转成 rejection', async () => {
|
||||
const mod = await freshBootstrap();
|
||||
setBridge({ bootstrap: 'not-a-function' });
|
||||
|
||||
await expect(mod.bootstrapOnce()).rejects.toThrow(/bridge missing/);
|
||||
});
|
||||
|
||||
it('单飞是**跨调用**的:第二个 awaiter 拿到的还是第一次的结果', async () => {
|
||||
const mod = await freshBootstrap();
|
||||
const bootstrap = vi.fn().mockResolvedValue({ ok: true, data: OK_PAYLOAD } as IpcResult<BootstrapPayload>);
|
||||
setBridge({ bootstrap });
|
||||
|
||||
const p1 = mod.bootstrapOnce();
|
||||
const p2 = mod.bootstrapOnce();
|
||||
expect(p1).toBe(p2); // 连 promise 引用都相同
|
||||
await p1;
|
||||
await p2;
|
||||
expect(bootstrap).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
125
tests/unit/renderer/lib/click-select-route.test.ts
Normal file
125
tests/unit/renderer/lib/click-select-route.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
// tests/unit/renderer/lib/click-select-route.test.ts
|
||||
//
|
||||
// 守 r228「修饰键点击路由单一真相源」契约:
|
||||
//
|
||||
// 旧状态(实测分叉):NotesList / LiteratureList 的 handleSelect 各写一份
|
||||
// mode 分支。文献域的 toggle / range 分支会 setActiveId(让 listbox 本地
|
||||
// 键盘光标 / aria-activedescendant 跟手),**笔记域漏了** ⇒ 笔记侧
|
||||
// Ctrl+点击后光标仍停在旧行,紧接着按 F2 会重命名**旧行**而不是刚点的那
|
||||
// 行(r160 B7 那次「click 后把焦点搬到 listbox」的修复只进了文献侧)。
|
||||
//
|
||||
// 修法不是给笔记补一行 setActiveId(下次还会分叉),而是把路由本体上收到
|
||||
// lib/multi-select.applyClickSelectRoute,两域共用一份 ⇒ 分叉不可能再长
|
||||
// 出来(同 isReallyGone 上收 utils/file-check.ts 的做法)。
|
||||
//
|
||||
// 锁两类东西:
|
||||
// 1. 行为契约(applyClickSelectRoute):
|
||||
// toggle ⇒ toggleSelect + setActiveId(光标跟手),不动主选中相关动作;
|
||||
// range ⇒ selectRange + setActiveId,同上;
|
||||
// single ⇒ selectOnly + select(设主选中),**不**调 setActiveId
|
||||
// (两域的 activeId 靠「selectedId 变化 → effect 同步」跟随,
|
||||
// 在路由里再写一次属重复);
|
||||
// 2. 端到端一条:修饰键 → 模式 → 路由动作串起来(防「模式解析与路由
|
||||
// 各改各的」)。
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
applyClickSelectRoute,
|
||||
resolveClickSelectMode,
|
||||
type ClickSelectMode,
|
||||
} from '../../../../src/renderer/src/lib/multi-select';
|
||||
|
||||
function makeActions() {
|
||||
const calls: string[] = [];
|
||||
// audit-r229:参数类型放宽到 `string | null` —— 路由契约里 setActiveId 的
|
||||
// 签名是 `(id: TId | null) => void`,而 applyClickSelectRoute 的 TId
|
||||
// 由字面量 id 推断成 'n1' / 'n2' 这类**窄字面量类型** ⇒ mock 若只写
|
||||
// `(id: string) => void`,把 mock 传给路由时 TS2345 报错
|
||||
// (peer 在途文件的 typecheck 红,本轮顺手收口)。函数参数逆变 ⇒
|
||||
// 宽参类型可赋给窄参类型,行为一字未改。
|
||||
const rec = (name: string) => (id: string | null) => {
|
||||
calls.push(`${name}:${id}`);
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
actions: {
|
||||
toggleSelect: rec('toggle'),
|
||||
selectRange: rec('range'),
|
||||
selectOnly: rec('only'),
|
||||
select: rec('select'),
|
||||
setActiveId: rec('active'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('applyClickSelectRoute(r228 路由单一真相源)', () => {
|
||||
it('toggle:切多选成员 + 键盘光标跟手;不动主选中', () => {
|
||||
const { calls, actions } = makeActions();
|
||||
applyClickSelectRoute('toggle', 'n1', actions);
|
||||
expect(calls).toEqual(['toggle:n1', 'active:n1']);
|
||||
});
|
||||
|
||||
it('range:范围选择 + 键盘光标跟手;不动主选中', () => {
|
||||
const { calls, actions } = makeActions();
|
||||
applyClickSelectRoute('range', 'n2', actions);
|
||||
expect(calls).toEqual(['range:n2', 'active:n2']);
|
||||
});
|
||||
|
||||
it('single:作用集收成该行 + 设主选中;不调 setActiveId', () => {
|
||||
const { calls, actions } = makeActions();
|
||||
applyClickSelectRoute('single', 'n3', actions);
|
||||
expect(calls).toEqual(['only:n3', 'select:n3']);
|
||||
});
|
||||
|
||||
it('端到端:修饰键 → 模式 → 动作(Ctrl=toggle / Shift=range / 其它=single)', () => {
|
||||
const run = (e: { ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }) => {
|
||||
const { calls, actions } = makeActions();
|
||||
applyClickSelectRoute(resolveClickSelectMode(e), 'x', actions);
|
||||
return calls;
|
||||
};
|
||||
expect(run({ ctrlKey: true, metaKey: false, shiftKey: false })).toEqual([
|
||||
'toggle:x',
|
||||
'active:x',
|
||||
]);
|
||||
expect(run({ ctrlKey: false, metaKey: true, shiftKey: false })).toEqual([
|
||||
'toggle:x',
|
||||
'active:x',
|
||||
]);
|
||||
expect(run({ ctrlKey: false, metaKey: false, shiftKey: true })).toEqual([
|
||||
'range:x',
|
||||
'active:x',
|
||||
]);
|
||||
expect(run({ ctrlKey: false, metaKey: false, shiftKey: false })).toEqual([
|
||||
'only:x',
|
||||
'select:x',
|
||||
]);
|
||||
});
|
||||
|
||||
it('模式解析的取值域就是路由的三分支(新增模式必须同时改路由)', () => {
|
||||
const modes: ClickSelectMode[] = ['single', 'toggle', 'range'];
|
||||
for (const m of modes) {
|
||||
const { calls, actions } = makeActions();
|
||||
applyClickSelectRoute(m, 'id', actions);
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('r228 结构契约:两域必须共用同一份路由(孪生分支不得复活)', () => {
|
||||
const ROOT = process.cwd();
|
||||
const FILES = [
|
||||
'src/renderer/src/components/notes/NotesList.tsx',
|
||||
'src/renderer/src/components/literature/LiteratureList.tsx',
|
||||
];
|
||||
for (const rel of FILES) {
|
||||
it(`${rel} 走 lib/multi-select.applyClickSelectRoute`, () => {
|
||||
const src = readFileSync(join(ROOT, rel), 'utf-8');
|
||||
expect(src, '点击路由必须走 lib/multi-select 的单一真相源').toContain('applyClickSelectRoute');
|
||||
expect(
|
||||
src,
|
||||
'孪生块复活:组件里又内联了 mode 分支(两域会再次分叉)',
|
||||
).not.toContain("if (mode === 'toggle')");
|
||||
});
|
||||
}
|
||||
});
|
||||
170
tests/unit/renderer/lib/confirm.test.ts
Normal file
170
tests/unit/renderer/lib/confirm.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
// tests/unit/renderer/lib/confirm.test.ts
|
||||
//
|
||||
// 守卫(r206,2026-10-20):应用内 ConfirmDialog(lib/confirm.ts)的行为契约 +
|
||||
// 「renderer 不许再用原生阻塞式对话框」的结构不变式。
|
||||
//
|
||||
// 起因(r206 recon):删除文件夹走的是**原生 window.confirm**,文案还硬编码
|
||||
// 英文(`Delete folder "X" and all its contents?`)—— 中文用户在 zh-CN 界面
|
||||
// 点「删除文件夹」弹出来的是英文原生对话框,而且 window.confirm 会**阻塞
|
||||
// 渲染进程**、完全脱离应用主题(项目自己在 SettingsPage 里写过这条结论)。
|
||||
// r206 把它换成应用内 ConfirmDialog(lib/confirm.ts)+ i18n 文案。
|
||||
//
|
||||
// 三条不变式:
|
||||
// ① confirm() 是 Promise 风格:open → active 有值;close(true/false)
|
||||
// 分别 resolve(true/false);
|
||||
// ② 并发:第二个 confirm **入队**(不静默 resolve(false) —— 那会让首调用
|
||||
// 方误以为用户点了「取消」而继续走「用户说不要」分支);close 后下一个
|
||||
// 提到 active;
|
||||
// ③ 队列上限 1:第三个 confirm 走 toast 提示 + resolve(false)(用户不会
|
||||
// 被永久卡在弹窗里)。
|
||||
//
|
||||
// ④ 结构不变式:renderer 源码里**零** window.confirm / window.alert /
|
||||
// window.prompt(注释里的历史说明字面量不算)。
|
||||
//
|
||||
// ⑤ 层级不变式:renderer 的 hooks/ 与 lib/ 不得 **import 值** components/*
|
||||
// (反向依赖 = 层级倒挂的前兆,r206 正是因此把 confirm store 从
|
||||
// components/common/ 搬到 lib/)。type-only import 例外(见 workspace.ts)。
|
||||
import { readFileSync, readdirSync, statSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { confirm, useConfirmStore } from '@renderer/lib/confirm';
|
||||
import { useToast } from '@renderer/lib/toast';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const RENDERER = join(ROOT, 'src/renderer/src');
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) out.push(...walk(full));
|
||||
else if (/\.tsx?$/.test(entry)) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 去掉行注释与块注释,避免「注释里提到 window.confirm」被误判 */
|
||||
function stripComments(src: string): string {
|
||||
return src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.split('\n')
|
||||
.filter((l) => !l.trimStart().startsWith('//'))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
describe('lib/confirm(r206)', () => {
|
||||
beforeEach(() => {
|
||||
// 复位模块级 store(confirm 队列跨用例存活)
|
||||
useConfirmStore.setState({ active: null, resolve: null, queue: [] });
|
||||
useToast.setState({ toasts: [] });
|
||||
});
|
||||
|
||||
it('confirm() 会把对话框置为 active', () => {
|
||||
void confirm({ title: 'T1' });
|
||||
expect(useConfirmStore.getState().active).toEqual({ title: 'T1' });
|
||||
});
|
||||
|
||||
it('close(true) ⇒ promise resolve(true)', async () => {
|
||||
const p = confirm({ title: 'T1' });
|
||||
useConfirmStore.getState().close(true);
|
||||
await expect(p).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('close(false) ⇒ promise resolve(false)', async () => {
|
||||
const p = confirm({ title: 'T1' });
|
||||
useConfirmStore.getState().close(false);
|
||||
await expect(p).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('第二个 confirm 入队(不静默 resolve false)', async () => {
|
||||
const p1 = confirm({ title: 'A' });
|
||||
const p2 = confirm({ title: 'B' });
|
||||
|
||||
expect(useConfirmStore.getState().active?.title).toBe('A');
|
||||
expect(useConfirmStore.getState().queue).toHaveLength(1);
|
||||
// 关键:第二个还没被裁决
|
||||
let settled = false;
|
||||
void p2.then(() => (settled = true));
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
|
||||
useConfirmStore.getState().close(true);
|
||||
await expect(p1).resolves.toBe(true);
|
||||
// close 后第二个被提到 active
|
||||
expect(useConfirmStore.getState().active?.title).toBe('B');
|
||||
useConfirmStore.getState().close(false);
|
||||
await expect(p2).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('队列上限 1:第三个 confirm 走 toast + resolve(false)', async () => {
|
||||
const p1 = confirm({ title: 'A' });
|
||||
const p2 = confirm({ title: 'B' });
|
||||
const p3 = confirm({ title: 'C' });
|
||||
|
||||
await expect(p3).resolves.toBe(false);
|
||||
// 队列没被撑爆
|
||||
expect(useConfirmStore.getState().queue).toHaveLength(1);
|
||||
// 用户得到了明确反馈(不是静默吞掉)
|
||||
expect(useToast.getState().toasts.length).toBeGreaterThan(0);
|
||||
expect(useToast.getState().toasts[0].type).toBe('info');
|
||||
|
||||
useConfirmStore.getState().close(true);
|
||||
await expect(p1).resolves.toBe(true);
|
||||
useConfirmStore.getState().close(true);
|
||||
await expect(p2).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('close 后 active/resolve 被清空(不留悬挂态)', () => {
|
||||
void confirm({ title: 'T1' });
|
||||
useConfirmStore.getState().close(true);
|
||||
const st = useConfirmStore.getState();
|
||||
expect(st.active).toBeNull();
|
||||
expect(st.resolve).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderer 原生对话框禁令(r206)', () => {
|
||||
it('renderer 源码里零 window.confirm / window.alert / window.prompt', () => {
|
||||
const hits: string[] = [];
|
||||
for (const file of walk(RENDERER)) {
|
||||
const code = stripComments(readFileSync(file, 'utf8'));
|
||||
for (const re of [/window\s*\.\s*confirm\b/g, /window\s*\.\s*alert\b/g, /window\s*\.\s*prompt\b/g]) {
|
||||
re.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(code)) !== null) {
|
||||
hits.push(`${file.slice(ROOT.length + 1)} → ${m[0]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(hits, `renderer 不许用原生阻塞式对话框:\n${hits.join('\n')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('hooks/ 与 lib/ 不得 import components/* 的**值**(type-only 除外)', () => {
|
||||
const hits: string[] = [];
|
||||
for (const dir of ['hooks', 'lib']) {
|
||||
const base = join(RENDERER, dir);
|
||||
for (const file of walk(base)) {
|
||||
const code = stripComments(readFileSync(file, 'utf8'));
|
||||
// 值 import:import { x } from '../components/...' / import x from ...
|
||||
const re = /^import\s+(?!type\b)(?!\{[^}]*\btype\b)[^;]*?from\s+['"][^'"]*\/components\/[^'"]*['"]/gm;
|
||||
re.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(code)) !== null) {
|
||||
hits.push(`${file.slice(ROOT.length + 1)} → ${m[0].trim()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(
|
||||
hits,
|
||||
`renderer 的 hooks/ 与 lib/ 不得反向依赖 components/(层级倒挂):\n${hits.join('\n')}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('useDiskFolderSidebar 的删除确认走应用内 confirm + i18n key', () => {
|
||||
const src = readFileSync(join(RENDERER, 'hooks/useDiskFolderSidebar.ts'), 'utf8');
|
||||
expect(src).toMatch(/await confirm\(\{/);
|
||||
// 文案必须走 t(...) 而不是硬编码英文
|
||||
expect(src).toMatch(/t\('folders\.deleteConfirmTitle'\)/);
|
||||
expect(src).toMatch(/t\('folders\.deleteConfirmBody',\s*\{\s*name:/);
|
||||
});
|
||||
});
|
||||
71
tests/unit/renderer/lib/drop-bridge-missing.test.ts
Normal file
71
tests/unit/renderer/lib/drop-bridge-missing.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// tests/unit/renderer/lib/drop-bridge-missing.test.ts
|
||||
//
|
||||
// 守 drop.ts 的降级契约(2026-10-20,r197):
|
||||
//
|
||||
// 「拿不到 OS 绝对路径就返 '' / 退回 f.path,绝不抛」。
|
||||
//
|
||||
// 桥缺失有两种形态,旧实现只挡了第一种:
|
||||
// ① 桥**不存在** → api Proxy.get 返 undefined → `api.getPathForFile(file)`
|
||||
// 抛 TypeError(not a function)。r81 已 guard 住「属性读取」。
|
||||
// ② 桥**存在但调用抛异常**(preload 实现变化 / 测试替身 / 第三方注入)
|
||||
// → 异常一路逃出 getDroppedFilePath → 下面的 legacy f.path 兜底
|
||||
// 永远走不到,整条 drop 流程炸掉。r197 补上这层。
|
||||
//
|
||||
// 不 mock api 模块:必须用真实 Proxy(旧 drop.test.ts 的 vi.mock 恰好绕开了
|
||||
// 这条路径,才让缺口活到今天)。这里直接改写 window.api —— api Proxy 在
|
||||
// 每次属性读取时实时取 window.api 的当前值。
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import type { Api } from '@shared/api-types';
|
||||
|
||||
import { getDroppedFilePath, collectDroppedPaths } from '../../../../src/renderer/src/lib/drop';
|
||||
|
||||
const originalApi = (globalThis as { window?: Window }).window?.api;
|
||||
|
||||
function fakeFile(name: string, legacyPath?: string): File {
|
||||
const f = { name } as unknown as File;
|
||||
if (legacyPath !== undefined) {
|
||||
(f as unknown as { path?: string }).path = legacyPath;
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
describe('getDroppedFilePath 桥异常降级(r197)', () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as unknown as { window: { api: Api } }).window.api = {
|
||||
getPathForFile: () => {
|
||||
throw new Error('bridge exploded');
|
||||
},
|
||||
} as unknown as Api;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as unknown as { window: { api: Api } }).window.api = originalApi as Api;
|
||||
});
|
||||
|
||||
it('桥调用抛异常时退回 legacy file.path(不抛)', () => {
|
||||
const f = fakeFile('note.md', '/legacy/note.md');
|
||||
expect(() => getDroppedFilePath(f)).not.toThrow();
|
||||
expect(getDroppedFilePath(f)).toBe('/legacy/note.md');
|
||||
});
|
||||
|
||||
it('桥调用抛异常且无 legacy path → 返空串(跳过此 file)', () => {
|
||||
expect(getDroppedFilePath(fakeFile('note.md'))).toBe('');
|
||||
});
|
||||
|
||||
it('桥方法缺失(属性是 undefined)→ 同样走 legacy 兜底', () => {
|
||||
(globalThis as unknown as { window: { api: Api } }).window.api = {} as unknown as Api;
|
||||
expect(getDroppedFilePath(fakeFile('note.md', '/legacy/note.md'))).toBe('/legacy/note.md');
|
||||
expect(getDroppedFilePath(fakeFile('note.md'))).toBe('');
|
||||
});
|
||||
|
||||
it('collectDroppedPaths:整批降级不炸,空路径被过滤掉', () => {
|
||||
const files = [fakeFile('a.md', '/legacy/a.md'), fakeFile('b.md'), fakeFile('c.md', '/legacy/c.md')];
|
||||
expect(collectDroppedPaths(files)).toEqual(['/legacy/a.md', '/legacy/c.md']);
|
||||
});
|
||||
|
||||
it('桥调用抛异常时,legacy path 优先于抛异常(顺序契约不变)', () => {
|
||||
// 主路径优先级:webUtils > f.path。桥异常时 webUtils 视为「拿不到」。
|
||||
const f = fakeFile('note.md', '/legacy/note.md');
|
||||
expect(getDroppedFilePath(f)).toBe('/legacy/note.md');
|
||||
});
|
||||
});
|
||||
180
tests/unit/renderer/lib/drop.test.ts
Normal file
180
tests/unit/renderer/lib/drop.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
// tests/unit/renderer/lib/drop.test.ts
|
||||
//
|
||||
// 守 src/renderer/src/lib/drop.ts 的三条契约(指针原在
|
||||
// src/renderer/src/components/notes/NotesList.tsx 的「🔒(守卫缺失)」标记里,
|
||||
// 本文件补齐后该标记已摘除 —— 见 tests/unit/main/lint/stale-test-reference.test.ts
|
||||
// 的反向守卫:引用的测试已存在却还留着 🔒 标记 ⇒ 红)。
|
||||
//
|
||||
// A. getDroppedFilePath —— 路径提取的**优先级**与「拿不到返空串」:
|
||||
// webUtils.getPathForFile 优先 > legacy File.path 兜底 > ''
|
||||
// B. collectDroppedPaths —— 顺序保留(与原 FileList 下标对得上)、过滤空串
|
||||
// C. classifyDroppedNotesFiles —— 笔记面板**三桶**分类:
|
||||
// paths / invalidTypeNames / noPathNames 严格互斥,
|
||||
// 「看起来是 .md 但拿不到 OS 路径」必须**独立成桶**(不能被「非 .md」吞掉)
|
||||
//
|
||||
// 与既有 drop-bridge-missing.test.ts 的分工:那一份专门锁「桥缺失/桥抛异常」
|
||||
// 的降级路径(不 mock api 模块);本文件锁**正常路径**下的语义与优先级。
|
||||
// 两份都不 vi.mock api 模块 —— 直接改写 window.api,api Proxy 每次属性
|
||||
// 读取实时取 window.api 当前值(见 src/renderer/src/lib/api.ts 头注)。
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import type { Api } from '@shared/api-types';
|
||||
|
||||
import {
|
||||
classifyDroppedNotesFiles,
|
||||
collectDroppedPaths,
|
||||
getDroppedFilePath,
|
||||
} from '../../../../src/renderer/src/lib/drop';
|
||||
|
||||
const originalApi = (globalThis as { window?: Window }).window?.api;
|
||||
|
||||
/** 造一个只有 name(+ 可选 legacy path)的 File 替身。 */
|
||||
function fakeFile(name: string, legacyPath?: string): File {
|
||||
const f = { name } as unknown as File;
|
||||
if (legacyPath !== undefined) {
|
||||
(f as unknown as { path?: string }).path = legacyPath;
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
/** 用 map 装一个 getPathForFile:命中返回路径,未命中返回 null。 */
|
||||
function installBridge(paths: Record<string, string | null>): void {
|
||||
(globalThis as unknown as { window: { api: Api } }).window.api = {
|
||||
getPathForFile: (file: File) => paths[file.name] ?? null,
|
||||
} as unknown as Api;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
installBridge({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as unknown as { window: { api: Api } }).window.api = originalApi as Api;
|
||||
});
|
||||
|
||||
describe('getDroppedFilePath 路径提取优先级', () => {
|
||||
it('优先用 webUtils.getPathForFile', () => {
|
||||
installBridge({ 'a.md': '/from/webutils/a.md' });
|
||||
const f = fakeFile('a.md', '/legacy/a.md');
|
||||
expect(getDroppedFilePath(f)).toBe('/from/webutils/a.md');
|
||||
});
|
||||
|
||||
it('webUtils 返回空串 → 退回 legacy file.path', () => {
|
||||
installBridge({ 'a.md': '' });
|
||||
expect(getDroppedFilePath(fakeFile('a.md', '/legacy/a.md'))).toBe('/legacy/a.md');
|
||||
});
|
||||
|
||||
it('webUtils 返回 null → 退回 legacy file.path', () => {
|
||||
installBridge({ 'a.md': null });
|
||||
expect(getDroppedFilePath(fakeFile('a.md', '/legacy/a.md'))).toBe('/legacy/a.md');
|
||||
});
|
||||
|
||||
it('webUtils 桥缺失 → 退回 legacy file.path', () => {
|
||||
(globalThis as unknown as { window: { api: Api } }).window.api = {} as unknown as Api;
|
||||
expect(getDroppedFilePath(fakeFile('a.md', '/legacy/a.md'))).toBe('/legacy/a.md');
|
||||
});
|
||||
|
||||
it('两路都拿不到 → 返空串(绝不抛,也绝不返回 undefined)', () => {
|
||||
expect(getDroppedFilePath(fakeFile('a.md'))).toBe('');
|
||||
});
|
||||
|
||||
it('legacy path 是空串 → 同样视为拿不到', () => {
|
||||
expect(getDroppedFilePath(fakeFile('a.md', ''))).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectDroppedPaths 批量版', () => {
|
||||
it('顺序保留(与原 FileList 下标对得上)', () => {
|
||||
installBridge({
|
||||
'a.md': '/p/a.md',
|
||||
'b.md': '/p/b.md',
|
||||
'c.md': '/p/c.md',
|
||||
});
|
||||
const paths = collectDroppedPaths([fakeFile('a.md'), fakeFile('b.md'), fakeFile('c.md')]);
|
||||
expect(paths).toEqual(['/p/a.md', '/p/b.md', '/p/c.md']);
|
||||
});
|
||||
|
||||
it('过滤掉拿不到路径的文件,其余顺序不变', () => {
|
||||
installBridge({ 'a.md': '/p/a.md', 'c.md': '/p/c.md' });
|
||||
const paths = collectDroppedPaths([fakeFile('a.md'), fakeFile('b.md'), fakeFile('c.md')]);
|
||||
expect(paths).toEqual(['/p/a.md', '/p/c.md']);
|
||||
});
|
||||
|
||||
it('空数组 → 空数组', () => {
|
||||
expect(collectDroppedPaths([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyDroppedNotesFiles 三桶分类', () => {
|
||||
it('.md → paths 桶', () => {
|
||||
installBridge({ 'a.md': '/p/a.md' });
|
||||
expect(classifyDroppedNotesFiles([fakeFile('a.md')])).toEqual({
|
||||
paths: ['/p/a.md'],
|
||||
invalidTypeNames: [],
|
||||
noPathNames: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('.markdown 也算合法后缀', () => {
|
||||
installBridge({ 'a.markdown': '/p/a.markdown' });
|
||||
expect(classifyDroppedNotesFiles([fakeFile('a.markdown')]).paths).toEqual(['/p/a.markdown']);
|
||||
});
|
||||
|
||||
it('后缀大小写不敏感(.MD / .MarkDown 都算 .md)', () => {
|
||||
installBridge({ 'A.MD': '/p/A.MD', 'B.MarkDown': '/p/B.MarkDown' });
|
||||
const r = classifyDroppedNotesFiles([fakeFile('A.MD'), fakeFile('B.MarkDown')]);
|
||||
expect(r.paths).toEqual(['/p/A.MD', '/p/B.MarkDown']);
|
||||
expect(r.invalidTypeNames).toEqual([]);
|
||||
expect(r.noPathNames).toEqual([]);
|
||||
});
|
||||
|
||||
it('非 .md/.markdown → invalidTypeNames 桶(不查路径)', () => {
|
||||
installBridge({ 'a.txt': '/p/a.txt' });
|
||||
const r = classifyDroppedNotesFiles([fakeFile('a.txt')]);
|
||||
expect(r.paths).toEqual([]);
|
||||
expect(r.invalidTypeNames).toEqual(['a.txt']);
|
||||
expect(r.noPathNames).toEqual([]);
|
||||
});
|
||||
|
||||
it('.md 但拿不到 OS 路径 → noPathNames 桶(独立成桶,不被「非 .md」吞掉)', () => {
|
||||
installBridge({}); // 桥给不出路径
|
||||
const r = classifyDroppedNotesFiles([fakeFile('lost.md')]);
|
||||
expect(r.paths).toEqual([]);
|
||||
expect(r.invalidTypeNames).toEqual([]);
|
||||
expect(r.noPathNames).toEqual(['lost.md']);
|
||||
});
|
||||
|
||||
it('三桶互斥:一个文件只落一个桶', () => {
|
||||
installBridge({ 'ok.md': '/p/ok.md' });
|
||||
const r = classifyDroppedNotesFiles([
|
||||
fakeFile('ok.md'),
|
||||
fakeFile('bad.txt'),
|
||||
fakeFile('lost.md'),
|
||||
]);
|
||||
expect(r).toEqual({
|
||||
paths: ['/p/ok.md'],
|
||||
invalidTypeNames: ['bad.txt'],
|
||||
noPathNames: ['lost.md'],
|
||||
});
|
||||
// 三桶元素总数 = 输入文件数 ⇒ 无重复归属
|
||||
expect(r.paths.length + r.invalidTypeNames.length + r.noPathNames.length).toBe(3);
|
||||
});
|
||||
|
||||
it('空输入 → 三桶全空', () => {
|
||||
expect(classifyDroppedNotesFiles([])).toEqual({
|
||||
paths: [],
|
||||
invalidTypeNames: [],
|
||||
noPathNames: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('空名字文件按「非 .md」处理(名字为空 ⇒ 不匹配 .md 后缀)', () => {
|
||||
// 源码里 `noPathNames.push(f.name || '')` 的 `|| ''` 是**防御性**兜底:
|
||||
// noPath 桶的前提是后缀已是 .md ⇒ 名字必然非空 ⇒ 该分支实际不可达。
|
||||
// 这里锁「空名字不落 noPath 桶」这一侧,免得哪天有人把兜底挪到
|
||||
// invalidType 桶导致 toast 渲染出空行。
|
||||
installBridge({});
|
||||
const r = classifyDroppedNotesFiles([fakeFile('')]);
|
||||
expect(r.noPathNames).toEqual([]);
|
||||
expect(r.invalidTypeNames).toEqual(['']);
|
||||
});
|
||||
});
|
||||
67
tests/unit/renderer/lib/error-errmsg.test.ts
Normal file
67
tests/unit/renderer/lib/error-errmsg.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
// tests/unit/renderer/lib/error-errmsg.test.ts
|
||||
//
|
||||
// 守 renderer lib/error.ts 的 errMsg 契约(r217):
|
||||
//
|
||||
// 1. **永不 throw** —— errMsg 跑在 catch 块里(30+ 处 toast 翻译),自己
|
||||
// 炸了会把真实故障盖掉。回归:r217 前 renderer 版镜像的是 main 版
|
||||
// r124 修复前的旧口径 `String(err)`,`String(Object.create(null))`
|
||||
// 在 V8 直接 TypeError;现与 main 共用 @shared/display-string。
|
||||
// 2. Error 实例取 message;字符串原样;其余走 toDisplayString 降级链。
|
||||
//
|
||||
// 变异实证:r217 修复前(旧实现 `String(err)`)跑本文件,「null-proto 对象」
|
||||
// 与「throwing toString」两例红(TypeError 逃出),修复后全绿。
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { errMsg } from '../../../../src/renderer/src/lib/error';
|
||||
import { toDisplayString } from '../../../../src/shared/display-string';
|
||||
|
||||
describe('renderer errMsg — 永不 throw 的 catch 翻译官(r217)', () => {
|
||||
it('Error 实例 → message', () => {
|
||||
expect(errMsg(new Error('boom'))).toBe('boom');
|
||||
expect(errMsg(new TypeError('bad type'))).toBe('bad type');
|
||||
});
|
||||
|
||||
it('字符串原样返回', () => {
|
||||
expect(errMsg('plain failure')).toBe('plain failure');
|
||||
});
|
||||
|
||||
it('null / undefined / number → 显示字符串', () => {
|
||||
expect(errMsg(null)).toBe('null');
|
||||
expect(errMsg(undefined)).toBe('undefined');
|
||||
expect(errMsg(42)).toBe('42');
|
||||
});
|
||||
|
||||
it('null-proto 对象不 throw(r124 口径,renderer 曾缺)', () => {
|
||||
const nullProto = Object.create(null) as object;
|
||||
expect(() => errMsg(nullProto)).not.toThrow();
|
||||
expect(errMsg(nullProto)).toBe('[object Object]');
|
||||
});
|
||||
|
||||
it('throwing toString 的对象不 throw,走降级链', () => {
|
||||
const evil = {
|
||||
toString(): string {
|
||||
throw new Error('toString exploded');
|
||||
},
|
||||
valueOf(): never {
|
||||
throw new Error('valueOf exploded');
|
||||
},
|
||||
};
|
||||
expect(() => errMsg(evil)).not.toThrow();
|
||||
expect(errMsg(evil)).toBe('[object Object]');
|
||||
});
|
||||
|
||||
it('message 为 undefined 的 Error → undefined 不透出(toast 不渲染 "undefined")', () => {
|
||||
// Error 分支直取 message —— 该分支契约:调用方拿到的是 err.message 本身,
|
||||
// 这里锁的是「非 Error 路径永不给 undefined」。
|
||||
expect(errMsg(Object.create(null))).not.toBe('undefined');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shared toDisplayString — 两侧共用同一份实现(r217)', () => {
|
||||
it('renderer errMsg 与 shared toDisplayString 对非 Error 输入口径一致', () => {
|
||||
const inputs: unknown[] = [null, undefined, 42, 's', {}, Object.create(null)];
|
||||
for (const v of inputs) {
|
||||
expect(errMsg(v)).toBe(toDisplayString(v));
|
||||
}
|
||||
});
|
||||
});
|
||||
250
tests/unit/renderer/lib/folder-tree.test.ts
Normal file
250
tests/unit/renderer/lib/folder-tree.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
// tests/unit/renderer/lib/folder-tree.test.ts
|
||||
//
|
||||
// 守 r176「左侧栏改树状」的行合成契约。
|
||||
//
|
||||
// 树行合成是纯函数(无 IO / 无 React)—— 放在 lib/folder-tree.ts,本测
|
||||
// 直接单测,不必起 renderer。锁的契约:
|
||||
//
|
||||
// 1. 顺序:同目录下**文件夹 → input 行 → 文件**(VS Code 资源管理器同款);
|
||||
// 2. 折叠的文件夹不递归,展开的才把子树插在它下面;
|
||||
// 3. depth 从 0 起,子节点 = 父 + 1;
|
||||
// 4. aria-posinset / aria-setsize 按**同级**算(不是全局序号);
|
||||
// 5. 搜索态:扁平返回命中项,不渲染文件夹行;
|
||||
// 6. 落点标记 isTarget 只打在 targetRelPath 那一个文件夹行上;
|
||||
// 7. 树键盘辅助:← 找父文件夹行、→ 找第一个子行。
|
||||
//
|
||||
// 这套契约锁死了「树」与「钻入式列表」的本质差别 —— 钻入式只需要
|
||||
// 「当前目录的一层」,树需要层级、展开态、落点三者同时正确。
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildTreeRows,
|
||||
findFirstChildRow,
|
||||
findParentFolderRow,
|
||||
groupItemsByDir,
|
||||
NEW_FOLDER_ROW_ID,
|
||||
type TreeFolderRow,
|
||||
type TreeRow,
|
||||
} from '../../../../src/renderer/src/lib/folder-tree';
|
||||
import type { DiskFolderNode } from '../../../../src/shared/types';
|
||||
|
||||
function node(relPath: string, parentRelPath: string | null): DiskFolderNode {
|
||||
return {
|
||||
relPath,
|
||||
name: relPath.slice(relPath.lastIndexOf('/') + 1),
|
||||
parentRelPath,
|
||||
};
|
||||
}
|
||||
|
||||
interface Item {
|
||||
id: string;
|
||||
relPath: string;
|
||||
}
|
||||
|
||||
const ITEMS: Item[] = [
|
||||
{ id: 'f1', relPath: 'top.md' },
|
||||
{ id: 'f2', relPath: 'a/one.md' },
|
||||
{ id: 'f3', relPath: 'a/b/two.md' },
|
||||
{ id: 'f4', relPath: 'a/b/three.md' },
|
||||
{ id: 'f5', relPath: 'z.md' },
|
||||
];
|
||||
|
||||
// 磁盘形状:
|
||||
// root
|
||||
// ├── a
|
||||
// │ ├── one.md
|
||||
// │ └── b
|
||||
// │ ├── two.md
|
||||
// │ └── three.md
|
||||
// ├── top.md
|
||||
// └── z.md
|
||||
const CHILDREN = new Map<string, DiskFolderNode[]>([
|
||||
['', [node('a', null)]],
|
||||
['a', [node('a/b', 'a')]],
|
||||
['a/b', []],
|
||||
]);
|
||||
|
||||
function rows(opts: {
|
||||
expanded?: string[];
|
||||
targetRelPath?: string | null;
|
||||
pendingInput?: { parentRelPath: string | null } | null;
|
||||
searchActive?: boolean;
|
||||
}): TreeRow<Item>[] {
|
||||
return buildTreeRows<Item>({
|
||||
childrenByDir: CHILDREN,
|
||||
itemsByDir: groupItemsByDir(ITEMS),
|
||||
expanded: new Set(opts.expanded ?? []),
|
||||
targetRelPath: opts.targetRelPath ?? null,
|
||||
searchActive: opts.searchActive ?? false,
|
||||
searchItems: opts.searchActive ? ITEMS : undefined,
|
||||
pendingInput: opts.pendingInput ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
/** 取某个文件夹行(找不到 / 不是文件夹行 → 直接 fail,免得断言被 any 吞掉)。 */
|
||||
function folderRow(r: readonly TreeRow<Item>[], id: string): TreeFolderRow {
|
||||
const hit = r.find((x) => x.id === id);
|
||||
if (!hit || hit.rowType !== 'folder') throw new Error(`not a folder row: ${id}`);
|
||||
return hit;
|
||||
}
|
||||
|
||||
describe('buildTreeRows —— 树行合成(r176)', () => {
|
||||
it('折叠:只出根层文件夹 + 根层文件(子目录内容不露头)', () => {
|
||||
const r = rows({});
|
||||
expect(r.map((x) => x.id)).toEqual(['folder:a', 'f1', 'f5']);
|
||||
});
|
||||
|
||||
it('展开:子行紧跟在父文件夹行下面,depth 递增', () => {
|
||||
const r = rows({ expanded: ['a'] });
|
||||
// a 展开后:它的子文件夹 a/b 在前,a 直属的 one.md 在后(文件夹在前)
|
||||
expect(r.map((x) => x.id)).toEqual(['folder:a', 'folder:a/b', 'f2', 'f1', 'f5']);
|
||||
const byId = new Map(r.map((x) => [x.id, x]));
|
||||
expect(byId.get('folder:a')?.depth).toBe(0);
|
||||
expect(byId.get('f2')?.depth).toBe(1);
|
||||
expect(byId.get('folder:a/b')?.depth).toBe(1);
|
||||
});
|
||||
|
||||
it('两层全展开:三层 depth = 0 / 1 / 2', () => {
|
||||
const r = rows({ expanded: ['a', 'a/b'] });
|
||||
expect(r.map((x) => x.id)).toEqual([
|
||||
'folder:a',
|
||||
'folder:a/b',
|
||||
'f3',
|
||||
'f4',
|
||||
'f2',
|
||||
'f1',
|
||||
'f5',
|
||||
]);
|
||||
const byId = new Map(r.map((x) => [x.id, x]));
|
||||
expect(byId.get('folder:a')?.depth).toBe(0);
|
||||
expect(byId.get('folder:a/b')?.depth).toBe(1);
|
||||
expect(byId.get('f3')?.depth).toBe(2);
|
||||
expect(byId.get('f4')?.depth).toBe(2);
|
||||
});
|
||||
|
||||
it('顺序:同目录下文件夹在前、文件在后', () => {
|
||||
const r = rows({ expanded: ['a', 'a/b'] });
|
||||
const ids = r.map((x) => x.id);
|
||||
expect(ids.indexOf('folder:a')).toBeLessThan(ids.indexOf('f1'));
|
||||
expect(ids.indexOf('folder:a/b')).toBeLessThan(ids.indexOf('f2'));
|
||||
});
|
||||
|
||||
it('aria-posinset / aria-setsize 按同级算(不是全局序号)', () => {
|
||||
const r = rows({ expanded: ['a'] });
|
||||
const byId = new Map(r.map((x) => [x.id, x]));
|
||||
// 根层:文件夹 a + 文件 top.md + 文件 z.md = 3 个兄弟
|
||||
expect(byId.get('folder:a')?.posInSet).toBe(1);
|
||||
expect(byId.get('folder:a')?.setSize).toBe(3);
|
||||
expect(byId.get('f1')?.posInSet).toBe(2);
|
||||
expect(byId.get('f5')?.posInSet).toBe(3);
|
||||
// a 目录里:子文件夹 b + 文件 one.md = 2 个兄弟(文件夹在前)
|
||||
expect(byId.get('folder:a/b')?.posInSet).toBe(1);
|
||||
expect(byId.get('folder:a/b')?.setSize).toBe(2);
|
||||
expect(byId.get('f2')?.posInSet).toBe(2);
|
||||
expect(byId.get('f2')?.setSize).toBe(2);
|
||||
});
|
||||
|
||||
it('搜索态:扁平返回命中项,不渲染文件夹行', () => {
|
||||
const r = rows({ searchActive: true });
|
||||
expect(r.map((x) => x.id)).toEqual(['f1', 'f2', 'f3', 'f4', 'f5']);
|
||||
expect(r.every((x) => x.rowType === 'item')).toBe(true);
|
||||
expect(r.every((x) => x.depth === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('落点标记只打在 targetRelPath 那一个文件夹行上', () => {
|
||||
const r = rows({ expanded: ['a'], targetRelPath: 'a/b' });
|
||||
expect(folderRow(r, 'folder:a/b').isTarget).toBe(true);
|
||||
expect(folderRow(r, 'folder:a').isTarget).toBe(false);
|
||||
});
|
||||
|
||||
it('inline input 行挂在目标目录下、文件夹之后文件之前', () => {
|
||||
const r = rows({ expanded: ['a'], pendingInput: { parentRelPath: 'a' } });
|
||||
const ids = r.map((x) => x.id);
|
||||
expect(ids).toContain(NEW_FOLDER_ROW_ID);
|
||||
// a 目录的集合 = 子文件夹 b + input + 文件 one.md
|
||||
const byId = new Map(r.map((x) => [x.id, x]));
|
||||
expect(byId.get(NEW_FOLDER_ROW_ID)?.depth).toBe(1);
|
||||
expect(byId.get(NEW_FOLDER_ROW_ID)?.setSize).toBe(3);
|
||||
expect(byId.get(NEW_FOLDER_ROW_ID)?.posInSet).toBe(2);
|
||||
expect(ids.indexOf('folder:a/b')).toBeLessThan(ids.indexOf(NEW_FOLDER_ROW_ID));
|
||||
expect(ids.indexOf(NEW_FOLDER_ROW_ID)).toBeLessThan(ids.indexOf('f2'));
|
||||
});
|
||||
|
||||
it('在**根目录**新建文件夹时 input 行也要渲染(parentRelPath = null)', () => {
|
||||
// r176 初版把「根目录」和「没有 pendingInput」都归一成 null,
|
||||
// 于是根目录建夹的 input 行永远不渲染(点 ✚ 什么都不发生)。
|
||||
const r = rows({ pendingInput: { parentRelPath: null } });
|
||||
const input = r.find((x) => x.rowType === 'input');
|
||||
expect(input).toBeDefined();
|
||||
expect(input?.id).toBe(NEW_FOLDER_ROW_ID);
|
||||
expect(input?.depth).toBe(0);
|
||||
});
|
||||
|
||||
it('NEW_FOLDER_ROW_ID 不与真名为 "new" 的文件夹撞 id', () => {
|
||||
const children = new Map<string, DiskFolderNode[]>([['', [node('new', null)]]]);
|
||||
const r = buildTreeRows<Item>({
|
||||
childrenByDir: children,
|
||||
itemsByDir: groupItemsByDir([]),
|
||||
expanded: new Set(),
|
||||
targetRelPath: null,
|
||||
searchActive: false,
|
||||
pendingInput: { parentRelPath: null },
|
||||
});
|
||||
const ids = r.map((x) => x.id);
|
||||
expect(ids).toContain('folder:new');
|
||||
expect(ids).toContain(NEW_FOLDER_ROW_ID);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('hasChildren:未加载的目录乐观为 true(画 chevron),已加载的空目录为 false', () => {
|
||||
const children = new Map<string, DiskFolderNode[]>([
|
||||
['', [node('empty', null), node('unknown', null)]],
|
||||
['empty', []],
|
||||
]);
|
||||
const r = buildTreeRows<Item>({
|
||||
childrenByDir: children,
|
||||
itemsByDir: groupItemsByDir([]),
|
||||
expanded: new Set(),
|
||||
targetRelPath: null,
|
||||
searchActive: false,
|
||||
});
|
||||
// empty 已 listDirs 过且为空 → 不画 chevron
|
||||
expect(folderRow(r, 'folder:empty').hasChildren).toBe(false);
|
||||
// unknown 没 listDirs 过 → 乐观画 chevron(展开后由缓存纠正)
|
||||
expect(folderRow(r, 'folder:unknown').hasChildren).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupItemsByDir —— 按 dirname(relPath) 分组', () => {
|
||||
it('根层 / 各子目录各归各位', () => {
|
||||
const byDir = groupItemsByDir(ITEMS);
|
||||
expect(byDir.get('')?.map((x) => x.id)).toEqual(['f1', 'f5']);
|
||||
expect(byDir.get('a')?.map((x) => x.id)).toEqual(['f2']);
|
||||
expect(byDir.get('a/b')?.map((x) => x.id)).toEqual(['f3', 'f4']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('树键盘辅助:← 找父行、→ 找子行', () => {
|
||||
it('findParentFolderRow:子行找得到它的父文件夹行', () => {
|
||||
const r = rows({ expanded: ['a', 'a/b'] });
|
||||
const child = r.find((x) => x.id === 'f3')!;
|
||||
const parent = findParentFolderRow(r, child);
|
||||
expect(parent?.id).toBe('folder:a/b');
|
||||
});
|
||||
|
||||
it('findParentFolderRow:根层行没有父文件夹行 → null', () => {
|
||||
const r = rows({});
|
||||
const rootRow = r.find((x) => x.id === 'f1')!;
|
||||
expect(findParentFolderRow(r, rootRow)).toBeNull();
|
||||
});
|
||||
|
||||
it('findFirstChildRow:展开的文件夹 → 下一行;折叠的 → null', () => {
|
||||
const r = rows({ expanded: ['a'] });
|
||||
const idx = r.findIndex((x) => x.id === 'folder:a');
|
||||
// a 的第一个子节点是子文件夹 a/b(文件夹在前)
|
||||
expect(findFirstChildRow(r, idx)?.id).toBe('folder:a/b');
|
||||
// 折叠态
|
||||
const collapsed = rows({});
|
||||
const cIdx = collapsed.findIndex((x) => x.id === 'folder:a');
|
||||
expect(findFirstChildRow(collapsed, cIdx)).toBeNull();
|
||||
});
|
||||
});
|
||||
123
tests/unit/renderer/lib/literature-folder-view.test.ts
Normal file
123
tests/unit/renderer/lib/literature-folder-view.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
// tests/unit/renderer/lib/literature-folder-view.test.ts
|
||||
//
|
||||
// 守 renderer 侧「文献行按文件夹分组」(r172)。
|
||||
//
|
||||
// visibleItemRows 靠 dirname(relPath) 分组(根 = '')。literature 行以前
|
||||
// **没有 relPath**(LiteratureResource 无此字段)⇒ dirname 恒为 '' ⇒ 文献行
|
||||
// 永远只在根视图可见,子目录视图恒空 —— 「粘贴进文件夹」后文件还在根。
|
||||
//
|
||||
// r172 起 LiteratureService 给每行填真实 relPath(相对 literature 根),
|
||||
// renderer 这层只需把它交给分组器(旧代码在这里做的是假 cast:注释声称
|
||||
// 「把 absPath 当 relPath 走」,实际一行映射都没有)。本用例锁住分组
|
||||
// 契约,防止再出现「文献行永远只在根」。
|
||||
//
|
||||
// r176(树状改造):visibleItemRows(钻入式单层过滤)整体下线,分组职责搬到
|
||||
// lib/folder-tree.ts 的 groupItemsByDir + buildTreeRows(树形行合成)。
|
||||
// 本测同步改成锁**树形**下的同款契约:文献行按 relPath 归到对应目录,
|
||||
// 折叠的目录不露出子行,展开才露出。
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { dirnameOfRelPath } from '../../../../src/renderer/src/lib/folder-view';
|
||||
import {
|
||||
buildTreeRows,
|
||||
groupItemsByDir,
|
||||
type TreeRow,
|
||||
} from '../../../../src/renderer/src/lib/folder-tree';
|
||||
import type { LiteratureResource } from '../../../../src/shared/types';
|
||||
|
||||
function lit(id: string, relPath: string): LiteratureResource {
|
||||
return {
|
||||
id: id as unknown as LiteratureResource['id'],
|
||||
kind: 'literature',
|
||||
absPath: `C:/lib/${relPath}`,
|
||||
relPath,
|
||||
rootId: null,
|
||||
extractStatus: 'ok',
|
||||
title: relPath,
|
||||
mtime: 0,
|
||||
size: 0,
|
||||
indexedAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const ITEMS = [lit('a', 'top.pdf'), lit('b', 'sub/inner.pdf'), lit('c', 'sub/deep/deeper.pdf')];
|
||||
|
||||
const EMPTY_CHILDREN = new Map<string, never[]>();
|
||||
|
||||
function treeRows(
|
||||
items: readonly LiteratureResource[],
|
||||
expanded: readonly string[] = [],
|
||||
): TreeRow<LiteratureResource>[] {
|
||||
return buildTreeRows<LiteratureResource>({
|
||||
childrenByDir: EMPTY_CHILDREN,
|
||||
itemsByDir: groupItemsByDir(items),
|
||||
expanded: new Set(expanded),
|
||||
targetRelPath: null,
|
||||
searchActive: false,
|
||||
});
|
||||
}
|
||||
|
||||
describe('literature 行按 relPath 分组(r172 / r176 树形态)', () => {
|
||||
it('子目录里的文献不在根层出现(粘贴进文件夹后不再显示在根)', () => {
|
||||
const byDir = groupItemsByDir(ITEMS);
|
||||
expect(byDir.get('')?.map((r) => r.id)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('子目录里的文献归到自己的目录', () => {
|
||||
const byDir = groupItemsByDir(ITEMS);
|
||||
expect(byDir.get('sub')?.map((r) => r.id)).toEqual(['b']);
|
||||
expect(byDir.get('sub/deep')?.map((r) => r.id)).toEqual(['c']);
|
||||
});
|
||||
|
||||
it('搜索态返回全量(不分文件夹)', () => {
|
||||
const rows = buildTreeRows<LiteratureResource>({
|
||||
childrenByDir: EMPTY_CHILDREN,
|
||||
itemsByDir: groupItemsByDir(ITEMS),
|
||||
expanded: new Set(),
|
||||
targetRelPath: null,
|
||||
searchActive: true,
|
||||
searchItems: ITEMS,
|
||||
});
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows.every((r) => r.rowType === 'item')).toBe(true);
|
||||
});
|
||||
|
||||
it('dirnameOfRelPath:relPath 末段之前的部分即归属(根 = 空串)', () => {
|
||||
expect(dirnameOfRelPath({ relPath: 'top.pdf' })).toBe('');
|
||||
expect(dirnameOfRelPath({ relPath: 'sub/inner.pdf' })).toBe('sub');
|
||||
expect(dirnameOfRelPath({ relPath: 'sub/deep/deeper.pdf' })).toBe('sub/deep');
|
||||
// 缺 relPath 兜底到根(老行 / DB 直读路径)
|
||||
expect(dirnameOfRelPath({})).toBe('');
|
||||
});
|
||||
|
||||
// === r176:树形行合成 ===
|
||||
it('没有子文件夹缓存时,文献行只出根层那一条', () => {
|
||||
const rows = treeRows(ITEMS);
|
||||
expect(rows.map((r) => r.id)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('展开子目录后,目录内的文献行才出现(depth 随层级递增)', () => {
|
||||
const children = new Map<string, { relPath: string; name: string; parentRelPath: string | null }[]>([
|
||||
['', [{ relPath: 'sub', name: 'sub', parentRelPath: null }]],
|
||||
['sub', [{ relPath: 'sub/deep', name: 'deep', parentRelPath: 'sub' }]],
|
||||
]);
|
||||
const rows = buildTreeRows<LiteratureResource>({
|
||||
childrenByDir: children,
|
||||
itemsByDir: groupItemsByDir(ITEMS),
|
||||
expanded: new Set(['sub', 'sub/deep']),
|
||||
targetRelPath: null,
|
||||
searchActive: false,
|
||||
});
|
||||
expect(rows.map((r) => r.id)).toEqual([
|
||||
'folder:sub',
|
||||
'folder:sub/deep',
|
||||
'c', // sub/deep 里的文献
|
||||
'b', // sub 里的文献
|
||||
'a', // 根层文献
|
||||
]);
|
||||
const byId = new Map(rows.map((r) => [r.id, r]));
|
||||
expect(byId.get('folder:sub')?.depth).toBe(0);
|
||||
expect(byId.get('folder:sub/deep')?.depth).toBe(1);
|
||||
expect(byId.get('b')?.depth).toBe(1);
|
||||
expect(byId.get('a')?.depth).toBe(0);
|
||||
});
|
||||
});
|
||||
98
tests/unit/renderer/lib/markdown-sanitize.test.ts
Normal file
98
tests/unit/renderer/lib/markdown-sanitize.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
// tests/unit/renderer/lib/markdown-sanitize.test.ts
|
||||
//
|
||||
// 守 r197「净化器不许吃正文」契约。
|
||||
//
|
||||
// 背景(用户可见症状):markdown-it 配 `html: false` ⇒ 用户正文里的 '<'
|
||||
// 被转义成 '<' ⇒ **正文中的 'onclick=alert(1)' 落在文本节点里,不是属性**。
|
||||
// 旧实现却对**整串 HTML** 跑 `on[a-z]+\s*=\s*...` 正则,不看自己在不在标签
|
||||
// 内部 ⇒ 文本节点里的 "onclick=alert(1)" 被当事件属性**删掉**。
|
||||
//
|
||||
// 实锤(旧实现):
|
||||
// 输入 `<code>onclick=alert(1)</code>` → 输出 `<code></code>`
|
||||
// —— 代码片段凭空消失,读者看到的是「代码被清空了」。
|
||||
//
|
||||
// 更糟的是 CodeReader:hljs 输出的 `<span class="hljs-attr">` 与用户代码里
|
||||
// 的 "style = {" 撞上,style 正则把 hljs 自己的 <span 标签吃掉 ⇒
|
||||
// 高亮结构被破坏(用户看到裸的 class="hljs-attr">a)。
|
||||
//
|
||||
// 本测锁的契约:
|
||||
// 1. **文本节点原样保留**(标签外的 on*/style 一律不动);
|
||||
// 2. **标签内部**的 on* / style 属性仍被剥除(defense-in-depth 不能松);
|
||||
// 3. <a href> 危险 scheme 仍替换为 about:blank;
|
||||
// 4. isSafeHref 对 C0 控制字符前缀的 javascript: 判定为 false。
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isSafeHref, sanitizeMarkdownHtml } from '../../../../src/renderer/src/lib/markdown-html-sanitize';
|
||||
|
||||
describe('sanitizeMarkdownHtml(r197)', () => {
|
||||
it('正文里的 onclick=…(文本节点)必须原样保留 —— 旧实现整串正则把它删了', () => {
|
||||
const html = '<p>调用 <code>onclick=alert(1)</code> 触发</p>';
|
||||
const out = sanitizeMarkdownHtml(html);
|
||||
expect(out).toContain('onclick=alert(1)');
|
||||
});
|
||||
|
||||
it('正文里的 onerror=… / style="…" 也必须保留(不在标签内 ⇒ 不是属性)', () => {
|
||||
const out = sanitizeMarkdownHtml('<p>文本 onerror=boom 结尾</p>');
|
||||
expect(out).toContain('onerror=boom');
|
||||
const out2 = sanitizeMarkdownHtml('<p>属性 style="color:red" 出现</p>');
|
||||
expect(out2).toContain('style="color:red"');
|
||||
});
|
||||
|
||||
it('CodeReader 场景:hljs 的 span 标签不得被 style 正则吃掉', () => {
|
||||
const html =
|
||||
'<span class="hljs-keyword">const</span> style = {<span class="hljs-attr">a</span>:1};';
|
||||
const out = sanitizeMarkdownHtml(html);
|
||||
expect(out).toContain('<span class="hljs-attr">a</span>');
|
||||
expect(out).toContain('const');
|
||||
});
|
||||
|
||||
it('标签内部的 on* 事件属性仍被剥除(defense-in-depth 不松)', () => {
|
||||
const out = sanitizeMarkdownHtml('<img src="x.png" onerror="alert(1)">');
|
||||
expect(out).not.toContain('onerror');
|
||||
expect(out).toContain('src="x.png"');
|
||||
});
|
||||
|
||||
it('标签内部的 style 属性仍被剥除', () => {
|
||||
const out = sanitizeMarkdownHtml('<p style="background:url(javascript:alert(1))">x</p>');
|
||||
expect(out).not.toContain('style=');
|
||||
expect(out).toContain('>x</p>');
|
||||
});
|
||||
|
||||
it('<a href> 危险 scheme 仍替换为 about:blank', () => {
|
||||
const out = sanitizeMarkdownHtml('<a href="javascript:alert(1)">点我</a>');
|
||||
expect(out).toContain('about:blank');
|
||||
expect(out).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
it('<a href> 正常 http(s) 链接不受影响', () => {
|
||||
const out = sanitizeMarkdownHtml('<a href="https://example.com/a">链接</a>');
|
||||
expect(out).toContain('https://example.com/a');
|
||||
});
|
||||
|
||||
it('空串 / 无 tag 的纯文本原样返回', () => {
|
||||
expect(sanitizeMarkdownHtml('')).toBe('');
|
||||
expect(sanitizeMarkdownHtml('普通文本')).toBe('普通文本');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSafeHref(r197)', () => {
|
||||
it('C0 控制字符前缀的 javascript: 必须判为不安全', () => {
|
||||
// WHATWG URL 解析会先删掉所有前导 C0 控制符 ⇒ '\x01javascript:' 的
|
||||
// protocol 仍是 'javascript:'。旧实现只删 \t\n\r ⇒ 漏判。
|
||||
expect(isSafeHref('\x01javascript:alert(1)')).toBe(false);
|
||||
expect(isSafeHref('\x08javascript:alert(1)')).toBe(false);
|
||||
expect(isSafeHref('\x0bjavascript:alert(1)')).toBe(false);
|
||||
});
|
||||
|
||||
it('常规 http(s) / mailto / 相对路径仍安全', () => {
|
||||
expect(isSafeHref('https://example.com')).toBe(true);
|
||||
expect(isSafeHref('mailto:a@b.com')).toBe(true);
|
||||
expect(isSafeHref('#anchor')).toBe(true);
|
||||
expect(isSafeHref('notes/a.md')).toBe(true);
|
||||
});
|
||||
|
||||
it('javascript: / data:text/html / file: 仍判为不安全', () => {
|
||||
expect(isSafeHref('javascript:alert(1)')).toBe(false);
|
||||
expect(isSafeHref('data:text/html,<script>alert(1)</script>')).toBe(false);
|
||||
expect(isSafeHref('file:///C:/Windows/System32')).toBe(false);
|
||||
});
|
||||
});
|
||||
51
tests/unit/renderer/lib/move-remap.test.ts
Normal file
51
tests/unit/renderer/lib/move-remap.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// tests/unit/renderer/lib/move-remap.test.ts
|
||||
//
|
||||
// 守卫(r175):lib/move-remap.ts —— 搬移后 id 重映射的两个纯函数。
|
||||
//
|
||||
// 这是 notes / literature 两域**共用**的一份实现(此前两个 store 各写
|
||||
// 一份字节级相同的循环)。纯函数、无副作用 → 单测不必起 React 渲染,
|
||||
// 直接测输入输出。
|
||||
//
|
||||
// 契约:
|
||||
// remapSelectedIds —— 只换落在映射里的成员,其余原样;未命中返回 null
|
||||
// remapSelectedId —— 单数选中态换 id;没命中返回 null
|
||||
//
|
||||
// r177:landedIdsAfterMove 已随「导入后不自动打开」退役(唯一生产调用方
|
||||
// 是 NotesList.importFiles 的自动选中)。
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
remapSelectedId,
|
||||
remapSelectedIds,
|
||||
type MoveRemap,
|
||||
} from '../../../../src/renderer/src/lib/move-remap';
|
||||
|
||||
function remap(pairs: Array<[string, string]>): MoveRemap {
|
||||
const m: MoveRemap = new Map();
|
||||
for (const [oldId, newId] of pairs) m.set(oldId, { id: newId });
|
||||
return m;
|
||||
}
|
||||
|
||||
describe('move-remap 纯函数(r175)', () => {
|
||||
it('remapSelectedIds:命中成员换新 id,未命中的原样保留', () => {
|
||||
const sel = new Set(['a', 'b', 'z']);
|
||||
const next = remapSelectedIds(sel, remap([['a', 'a2'], ['b', 'b2']]));
|
||||
expect(next).not.toBeNull();
|
||||
expect([...(next as Set<string>)].sort()).toEqual(['a2', 'b2', 'z']);
|
||||
// 入参不能被就地改
|
||||
expect([...sel].sort()).toEqual(['a', 'b', 'z']);
|
||||
});
|
||||
|
||||
it('remapSelectedIds:没有命中时返回 null(调用方据此跳过 setState)', () => {
|
||||
expect(remapSelectedIds(new Set(['x']), remap([['a', 'a2']]))).toBeNull();
|
||||
});
|
||||
|
||||
it('remapSelectedIds:映射表为空时返回 null', () => {
|
||||
expect(remapSelectedIds(new Set(['a']), new Map())).toBeNull();
|
||||
});
|
||||
|
||||
it('remapSelectedId:命中返回新 id,未命中 / 空值返回 null', () => {
|
||||
expect(remapSelectedId('a', remap([['a', 'a2']]))).toBe('a2');
|
||||
expect(remapSelectedId('b', remap([['a', 'a2']]))).toBeNull();
|
||||
expect(remapSelectedId(null, remap([['a', 'a2']]))).toBeNull();
|
||||
});
|
||||
});
|
||||
122
tests/unit/renderer/lib/multi-select-live-ids.test.ts
Normal file
122
tests/unit/renderer/lib/multi-select-live-ids.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// tests/unit/renderer/lib/multi-select-live-ids.test.ts
|
||||
//
|
||||
// 守 r193「删除 / 移入回收站只对**活着的** id 动手」契约。
|
||||
//
|
||||
// 现象(用户可复现):
|
||||
// 1. 侧栏 Ctrl 多选 5 篇笔记;
|
||||
// 2. 其中 1 篇在别处被删了(另一个窗口删 / 网盘同步删 / 回收站清理);
|
||||
// 3. 回到应用,侧栏仍显示 5 篇被选中(那一行还在,因为列表还没刷新);
|
||||
// 4. 按 Delete / 右键「移入回收站」⇒ 抛 NOT_FOUND ⇒ **串行循环中断** ⇒
|
||||
// 只删了 3 篇,后面 2 篇没删,还弹一个「移入回收站失败」toast。
|
||||
//
|
||||
// 现象(两侧不对称版):
|
||||
// 同样的操作在**文献侧**是好的 —— literature 侧删除前用
|
||||
// `items.filter(r => selectedIds.has(r.id))` 过滤,死 id 被静默跳过;
|
||||
// 笔记侧直接拿 selectedIds 全集去删 ⇒ 一边好用一边不好用。
|
||||
//
|
||||
// 本测锁:
|
||||
// 1. liveSelectedIds:只返回列表里还活着的成员,按**列表顺序**;
|
||||
// 2. 空选择集 → 空结果(不返回 undefined / 不抛);
|
||||
// 3. 全部死 id → 空结果(而不是把死 id 原样吐出去);
|
||||
// 4. 源码契约:两个列表组件的删除回调都必须经过 liveSelectedIds,
|
||||
// 不得直接用 selectedIds 全集(防止以后又写回不对称的老写法)。
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { liveSelectedIds } from '../../../../src/renderer/src/lib/multi-select';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const NOTES = 'src/renderer/src/components/notes/NotesList.tsx';
|
||||
const LIT = 'src/renderer/src/components/literature/LiteratureList.tsx';
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出某个 `useCallback(` 回调的**函数体**。
|
||||
*
|
||||
* audit-r254:旧实现用 `src.slice(src.indexOf(anchor))` +
|
||||
* `src.slice(0, src.indexOf(endAnchor))` 切片 —— **锚点缺失时 indexOf
|
||||
* 返回 -1**,而 `slice(-1)` 取的是**最后一个字符**、`slice(0, -1)` 取的是
|
||||
* 「除末字符外的全文」⇒ 切片范围**静默漂移**:
|
||||
* - 起始锚点丢了(回调改名/删除)⇒ 拿到空串 ⇒ 断言红(报错信息难读);
|
||||
* - 结束锚点丢了(**依赖清单一改就会丢**,最常见的改动)⇒ 范围悄悄放大
|
||||
* 成「从回调一直到文件尾」⇒ 断言 `toContain('liveSelectedIds(')` 可以
|
||||
* 被**函数体之外**的同名文本满足 ⇒ 回调真的不用 liveSelectedIds 了,
|
||||
* 守卫照样绿(假绿)。
|
||||
*
|
||||
* 这里改成:锚点缺失 / 顺序不对 ⇒ **直接失败**,让守卫真的失效;
|
||||
* 结束锚点只认 useCallback 的收尾形态 `}, [`(不写死依赖清单,免得依赖项
|
||||
* 一改守卫就假红 —— 那种红是「守卫跟不上实现」,不是「实现有病」)。
|
||||
*/
|
||||
function sliceCallbackBody(src: string, startAnchor: string): string {
|
||||
const s = src.indexOf(startAnchor);
|
||||
expect(s, `起始锚点缺失:${startAnchor}`).toBeGreaterThanOrEqual(0);
|
||||
const e = src.indexOf('\n }, [', s);
|
||||
expect(e, `结束锚点缺失(未找到 useCallback 收尾):${startAnchor}`).toBeGreaterThan(s);
|
||||
return src.slice(s, e);
|
||||
}
|
||||
|
||||
/**
|
||||
* 剥掉注释 —— 断言必须落在**真代码**上。
|
||||
*
|
||||
* 实证(旧守卫的第二个假绿面):LiteratureList.tsx 的删除回调里有一行**注释**
|
||||
* // r193:与 notes 侧共用 liveSelectedIds(死 id 静默跳过,别让
|
||||
* 这行注释里含字面量 `liveSelectedIds(` ⇒ 旧守卫的
|
||||
* `expect(body).toContain('liveSelectedIds(')` 可以**被注释满足** ⇒
|
||||
* 把真正的调用换成 `Array.from(st.selectedIds)` 也不红。
|
||||
*/
|
||||
function stripComments(code: string): string {
|
||||
return code.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
|
||||
}
|
||||
|
||||
describe('liveSelectedIds:作用集只含活着的成员(r193)', () => {
|
||||
it('只返回列表里还活着的成员,按列表顺序', () => {
|
||||
const items: Row[] = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
|
||||
// 选择集里有 'b'(活着)+ 'zz'(不在列表里 ⇒ 死 id)
|
||||
const out = liveSelectedIds(items, new Set(['b', 'zz']));
|
||||
expect(out.map((x) => x.id)).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('选择集顺序与列表顺序不一致时,以**列表顺序**为准', () => {
|
||||
const items: Row[] = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
|
||||
const out = liveSelectedIds(items, new Set(['c', 'a']));
|
||||
expect(out.map((x) => x.id)).toEqual(['a', 'c']);
|
||||
});
|
||||
|
||||
it('空选择集 → 空结果', () => {
|
||||
expect(liveSelectedIds([{ id: 'a' }], new Set<string>())).toEqual([]);
|
||||
});
|
||||
|
||||
it('全是死 id → 空结果(不得把死 id 吐出去)', () => {
|
||||
const out = liveSelectedIds([{ id: 'a' }], new Set(['x', 'y']));
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
|
||||
it('源码契约:两个域的删除回调都必须过滤成「活着的 id」', () => {
|
||||
const notesSrc = readFileSync(join(ROOT, NOTES), 'utf8');
|
||||
const litSrc = readFileSync(join(ROOT, LIT), 'utf8');
|
||||
|
||||
// notes 侧:handleTrashSelection
|
||||
const notesTrashBody = stripComments(
|
||||
sliceCallbackBody(notesSrc, 'const handleTrashSelection = useCallback('),
|
||||
);
|
||||
expect(notesTrashBody, 'notes 侧删除必须用 liveSelectedIds 过滤').toContain('liveSelectedIds(');
|
||||
expect(
|
||||
notesTrashBody,
|
||||
'notes 侧不得再直接用 selectedIds 全集([...selectedIds])',
|
||||
).not.toMatch(/\[\.\.\.\s*(?:useNotesStore\.getState\(\)\.)?selectedIds\]/);
|
||||
|
||||
// literature 侧:handleDeleteSelection
|
||||
const litDeleteBody = stripComments(
|
||||
sliceCallbackBody(litSrc, 'const handleDeleteSelection = useCallback('),
|
||||
);
|
||||
expect(litDeleteBody, 'literature 侧删除必须用 liveSelectedIds 过滤').toContain('liveSelectedIds(');
|
||||
expect(
|
||||
litDeleteBody,
|
||||
'literature 侧也不得直接用 selectedIds 全集([...selectedIds])',
|
||||
).not.toMatch(/\[\.\.\.\s*(?:useLiteratureStore\.getState\(\)\.)?selectedIds\]/);
|
||||
});
|
||||
});
|
||||
108
tests/unit/renderer/lib/order-persistence-localstorage.test.ts
Normal file
108
tests/unit/renderer/lib/order-persistence-localstorage.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
// tests/unit/renderer/lib/order-persistence-localstorage.test.ts
|
||||
//
|
||||
// 守 order-persistence.ts 的「永不抛」契约(2026-10-20,r197)。
|
||||
//
|
||||
// store/notes.ts + store/literature.ts 的初始 state 里有
|
||||
// `displayOrderById: loadOrder('note')` —— **模块 init 时**就调 loadOrder。
|
||||
// 旧实现的 `typeof localStorage === 'undefined'` 探测在 try/catch **外面**:
|
||||
//
|
||||
// - Chromium 对不透明 origin(file:// / 隐私模式 / 站点数据被禁)访问
|
||||
// localStorage 会抛 SecurityError,而 `typeof` 只挡 ReferenceError,
|
||||
// **不挡 getter 抛异常** → 探测本身就炸。
|
||||
// - localStorage.getItem 也在 try/catch 外面 → quota / 禁用 → 同样炸。
|
||||
//
|
||||
// 后果不是「排序没保存」这种小痛:store 模块 init 抛 ⇒ 整个 store 模块
|
||||
// 加载失败 ⇒ 整个侧栏/列表白屏,且用户完全不知道原因。
|
||||
//
|
||||
// store 里的注释已经写着「任何 throw 都被 loadOrder 内部防御吞掉,返回 {}
|
||||
// 兜底」—— 那条不变式此前**代码并没有兑现**。本测把注释变成可执行契约。
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { loadOrder, saveOrder } from '../../../../src/renderer/src/lib/order-persistence';
|
||||
|
||||
type G = { localStorage?: Storage };
|
||||
|
||||
function setLocalStorage(value: unknown): void {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('order-persistence localStorage 不可用(r197)', () => {
|
||||
let original: PropertyDescriptor | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
original = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (original) {
|
||||
Object.defineProperty(globalThis, 'localStorage', original);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis as G, 'localStorage');
|
||||
}
|
||||
});
|
||||
|
||||
it('访问 localStorage 本身抛异常 → loadOrder 返 {} 不抛', () => {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
get() {
|
||||
throw new Error('SecurityError: localStorage is disabled');
|
||||
},
|
||||
});
|
||||
expect(() => loadOrder('note')).not.toThrow();
|
||||
expect(loadOrder('note')).toEqual({});
|
||||
});
|
||||
|
||||
it('访问 localStorage 本身抛异常 → saveOrder 不抛', () => {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
get() {
|
||||
throw new Error('SecurityError: localStorage is disabled');
|
||||
},
|
||||
});
|
||||
expect(() => saveOrder('note', { a: 1 })).not.toThrow();
|
||||
});
|
||||
|
||||
it('getItem 抛异常(配额/禁用)→ loadOrder 返 {} 不抛', () => {
|
||||
setLocalStorage({
|
||||
getItem: () => {
|
||||
throw new Error('quota exceeded');
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error('quota exceeded');
|
||||
},
|
||||
} as unknown as Storage);
|
||||
expect(() => loadOrder('literature')).not.toThrow();
|
||||
expect(loadOrder('literature')).toEqual({});
|
||||
});
|
||||
|
||||
it('setItem 抛异常 → saveOrder 静默吞掉', () => {
|
||||
setLocalStorage({
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
throw new Error('quota exceeded');
|
||||
},
|
||||
} as unknown as Storage);
|
||||
expect(() => saveOrder('note', { a: 1 })).not.toThrow();
|
||||
});
|
||||
|
||||
it('localStorage 完全不存在 → 仍返 {} / 不抛', () => {
|
||||
Reflect.deleteProperty(globalThis as G, 'localStorage');
|
||||
expect(loadOrder('note')).toEqual({});
|
||||
expect(() => saveOrder('note', { a: 1 })).not.toThrow();
|
||||
});
|
||||
|
||||
it('正常路径不变:读回写进去的排序', () => {
|
||||
const store = new Map<string, string>();
|
||||
setLocalStorage({
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
} as unknown as Storage);
|
||||
saveOrder('note', { 'n1': 1, 'n2': 2 });
|
||||
expect(loadOrder('note')).toEqual({ n1: 1, n2: 2 });
|
||||
});
|
||||
});
|
||||
110
tests/unit/renderer/lib/text-stats.test.ts
Normal file
110
tests/unit/renderer/lib/text-stats.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
// tests/unit/renderer/lib/text-stats.test.ts
|
||||
//
|
||||
// 守卫(r206,2026-10-20):StatusBar 中区的字数 / 字符数 / 行数统计。
|
||||
//
|
||||
// 为什么值得锁:这三个数字是**用户唯一能看到的"我写了多少"反馈**,
|
||||
// 算错不会报错、只会静默给一个错的数。历史上已经出过一次系统性偏差
|
||||
// (r82):旧 wordCount 把「任何非空白非 CJK 字符」都起一个词,于是
|
||||
// '你好,世界' 数出 5(全角逗号 +1)—— 中文写作几乎每个逗号/句号都虚增。
|
||||
//
|
||||
// 覆盖边界(诚实声明):charCount 用的是 String.length,**是 UTF-16 码元数**,
|
||||
// 不是 Unicode 字位数 —— 表情符号(代理对)算 2。这是**当前实现的既定
|
||||
// 语义**,本守卫把它钉住;若将来改成 [...text].length,这条会红,届时
|
||||
// 记得同步改注释与 CHANGELOG。
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { charCount, lineCount, wordCount } from '@renderer/lib/text-stats';
|
||||
|
||||
describe('text-stats(r206)', () => {
|
||||
describe('charCount', () => {
|
||||
it('空串 → 0', () => {
|
||||
expect(charCount('')).toBe(0);
|
||||
});
|
||||
|
||||
it('按 UTF-16 码元计长(代理对算 2)', () => {
|
||||
expect(charCount('abc')).toBe(3);
|
||||
expect(charCount('你好')).toBe(2);
|
||||
expect(charCount('😀')).toBe(2);
|
||||
});
|
||||
|
||||
it('含空白也算进字符数', () => {
|
||||
expect(charCount('a b\nc')).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wordCount', () => {
|
||||
it('空串 → 0', () => {
|
||||
expect(wordCount('')).toBe(0);
|
||||
});
|
||||
|
||||
it('CJK 每个字算 1', () => {
|
||||
expect(wordCount('你好世界')).toBe(4);
|
||||
});
|
||||
|
||||
it('英文按空格分词', () => {
|
||||
expect(wordCount('hello world')).toBe(2);
|
||||
});
|
||||
|
||||
it('中英混排:CJK 逐字 + 英文按词', () => {
|
||||
// 注:源码 doc 注释原写「'你好 hello' → 2(1 字 + 1 词)」—— 错的,
|
||||
// 「你好」是**两个**汉字 ⇒ 2 + 1 = 3。r206 更正注释,本例钉住实际行为。
|
||||
expect(wordCount('你好 hello')).toBe(3);
|
||||
});
|
||||
|
||||
it('标点/符号不起词、也不断词(r82 修的核心)', () => {
|
||||
// 旧实现:全角逗号 +1 ⇒ '你好,世界' 数出 5
|
||||
expect(wordCount('你好,世界')).toBe(4);
|
||||
expect(wordCount('...')).toBe(0);
|
||||
expect(wordCount('!!! ???')).toBe(0);
|
||||
});
|
||||
|
||||
it('撇号/连字符不断词(既有契约)', () => {
|
||||
expect(wordCount("don't")).toBe(1);
|
||||
expect(wordCount('mixed-language')).toBe(1);
|
||||
});
|
||||
|
||||
it('字母与数字连写算 1 词', () => {
|
||||
expect(wordCount('a1')).toBe(1);
|
||||
expect(wordCount('2024')).toBe(1);
|
||||
});
|
||||
|
||||
it('CJK 扩展 B-G 增补平面按字计(不是并成 1 词)', () => {
|
||||
// 𠀀 𠀁 —— 旧实现缺这段,会被并成 1 词
|
||||
expect(wordCount('𠀀𠀁')).toBe(2);
|
||||
});
|
||||
|
||||
it('CJK 兼容表意文字(旧字形 U+F900-U+FAFF)按字计', () => {
|
||||
expect(wordCount('豈')).toBe(1);
|
||||
});
|
||||
|
||||
it('假名 / 谚文按字计', () => {
|
||||
expect(wordCount('ひらがな')).toBe(4);
|
||||
expect(wordCount('カタカナ')).toBe(4);
|
||||
expect(wordCount('한글')).toBe(2);
|
||||
});
|
||||
|
||||
it('空白字符结束当前词', () => {
|
||||
expect(wordCount('a\tb')).toBe(2);
|
||||
expect(wordCount('a\nb')).toBe(2);
|
||||
expect(wordCount('a b')).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lineCount', () => {
|
||||
it('空串 → 0', () => {
|
||||
expect(lineCount('')).toBe(0);
|
||||
});
|
||||
|
||||
it('单行无换行 → 1', () => {
|
||||
expect(lineCount('a')).toBe(1);
|
||||
});
|
||||
|
||||
it('按 \\n 分割', () => {
|
||||
expect(lineCount('a\nb')).toBe(2);
|
||||
expect(lineCount('a\nb\nc')).toBe(3);
|
||||
});
|
||||
|
||||
it('末尾有换行时,末尾空行也算 1 行(split 语义)', () => {
|
||||
expect(lineCount('a\n')).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
55
tests/unit/renderer/lib/toast-duration-contract.test.ts
Normal file
55
tests/unit/renderer/lib/toast-duration-contract.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// tests/unit/renderer/lib/toast-duration-contract.test.ts
|
||||
//
|
||||
// 守 audit r252:toast 的**自动消失时长**不得被「带着 key 的 undefined」
|
||||
// 打穿(否则 toast 0 毫秒闪没,用户根本来不及看)。
|
||||
//
|
||||
// 旧实现的坑(实测复现):
|
||||
// store 里 `{ id: newId(), duration: DEFAULT_DURATION[t.type], ...t }`
|
||||
// —— `...t` 排在**后面** ⇒ 只要调用方**带着 duration 这个 key**(哪怕值是
|
||||
// undefined),默认值就被覆盖成 undefined。
|
||||
// 而 IPC 那条路恰好如此:ToastPushPayload.duration 是**可选**字段 →
|
||||
// preload 守卫放行 undefined → renderer 侧再写一遍
|
||||
// `duration: payload.duration`(key 存在、值 undefined)
|
||||
// ⇒ ToastItem 里 `toast.duration <= 0` 对 undefined 求值为 false(不早退)
|
||||
// ⇒ `setTimeout(dismiss, undefined)` ≈ 0ms ⇒ **toast 一闪就没**。
|
||||
//
|
||||
// 本测锁三条:
|
||||
// 1. 不传 duration ⇒ 走该类型的默认时长;
|
||||
// 2. 传 `duration: undefined`(key 在!)⇒ **仍**走默认时长(本轮的 bug);
|
||||
// 3. 传 `duration: 0` ⇒ 保持 0(「不自动消失」的合法值,不能用 `||` 兜底)。
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import { useToast } from '../../../../src/renderer/src/lib/toast';
|
||||
|
||||
describe('toast 时长契约(r252)', () => {
|
||||
beforeEach(() => {
|
||||
useToast.setState({ toasts: [] });
|
||||
});
|
||||
|
||||
it('不传 duration ⇒ 走该类型的默认时长', () => {
|
||||
useToast.getState().show({ type: 'error', message: 'm' });
|
||||
useToast.getState().show({ type: 'info', message: 'm' });
|
||||
const [err, info] = useToast.getState().toasts;
|
||||
expect(err?.duration).toBe(6000);
|
||||
expect(info?.duration).toBe(3500);
|
||||
});
|
||||
|
||||
it('传 duration: undefined(key 在!)⇒ 默认时长必须存活(旧实现这里被覆盖成 undefined)', () => {
|
||||
// 这就是 IPC 那条路:payload.duration 可选 ⇒ undefined ⇒ key 存在。
|
||||
const payload: { type: 'error'; message: string; duration?: number } = {
|
||||
type: 'error',
|
||||
message: '打开失败',
|
||||
duration: undefined,
|
||||
};
|
||||
useToast.getState().show(payload);
|
||||
const t = useToast.getState().toasts[0];
|
||||
expect(t?.duration, '默认时长不得被 undefined 打穿(否则 toast 0ms 闪没)').toBe(6000);
|
||||
});
|
||||
|
||||
it('显式 duration 生效;duration=0 表示「不自动消失」,必须原样保留', () => {
|
||||
useToast.getState().show({ type: 'info', message: 'm', duration: 1234 });
|
||||
useToast.getState().show({ type: 'info', message: 'm', duration: 0 });
|
||||
const [a, b] = useToast.getState().toasts;
|
||||
expect(a?.duration).toBe(1234);
|
||||
expect(b?.duration, '0 是合法值(不自动消失)⇒ 不能用 || 兜底成默认值').toBe(0);
|
||||
});
|
||||
});
|
||||
227
tests/unit/renderer/notes/editor-preview-toggle.test.tsx
Normal file
227
tests/unit/renderer/notes/editor-preview-toggle.test.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
// tests/unit/renderer/notes/editor-preview-toggle.test.tsx
|
||||
//
|
||||
// 守 audit-r133「原始文本 ↔ 渲染显示」双模式切换的**用户可见**行为契约
|
||||
// (src/renderer/src/components/notes/StandaloneNoteEditorCore.tsx 头注
|
||||
// 指向本文件,原先标 🔒(守卫缺失))。
|
||||
//
|
||||
// 背景:2026-09-06 的 Typora 原地渲染(装饰层自动隐藏非活动行标记)被用户报障
|
||||
// 「显示存在很大问题」(符号忽隐忽现 / 内容跳动 / 表格任务列表装饰缺失)⇒
|
||||
// 装饰层整体删除,改成「默认原始文本 + 头栏按钮切到只读渲染视图」。
|
||||
//
|
||||
// 本测锁的契约(全部从用户可见结果出发):
|
||||
// 1. 默认是**原始文本**编辑器,不是渲染视图;
|
||||
// 2. 头栏 toggle 按一次 → 只读渲染视图(markdown-it 真渲染);再按 → 回原始文本;
|
||||
// 3. **切换往返不丢内容**:body 由 Core 持有,不经过编辑器实例 ⇒ 往返后
|
||||
// 正文一字不差,「未保存」状态也还在;
|
||||
// 4. 预览显示的是 Core 持有的**最新** body,不是 note.body 快照;
|
||||
// 5. 新建 / 拖入笔记(initialFocus='body')时,即便上一轮停在渲染视图,
|
||||
// 也要切回原始文本 —— 否则用户没有地方打字。
|
||||
//
|
||||
// 退化后的用户可见后果:
|
||||
// - 3 退化 ⇒ 用户切一次视图,刚写的一段字消失(丢字级别);
|
||||
// - 4 退化 ⇒ 预览显示旧正文,用户以为改动没生效;
|
||||
// - 5 退化 ⇒ 新建笔记后光标无处落,只能再手动点一次 toggle。
|
||||
//
|
||||
// 变异思路(哪几处改动会红):
|
||||
// - previewOn 初值改 true / toggle 语义反转 ⇒ 例 1、2 红;
|
||||
// - <MarkdownReader source={body}> 改成 source={note?.body} ⇒ 例 4 红;
|
||||
// - toggle 时 setBody('') 或每次重挂编辑器实例 ⇒ 例 3 红;
|
||||
// - 删掉 initialFocus effect 里的 setPreviewOn(false) ⇒ 例 5 红。
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { render, fireEvent, screen, cleanup, waitFor } from '@testing-library/react';
|
||||
|
||||
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 前查「旧笔记还在不在列表里」;
|
||||
// 2) ⌘S 通道读 selectedId(本测不走)。
|
||||
vi.mock('../../../../src/renderer/src/store/notes', () => {
|
||||
const state = {
|
||||
notes: [{ id: 'n1', relPath: 'a.md' }],
|
||||
selectedId: 'n1',
|
||||
};
|
||||
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 BODY = '# 标题\n\n正文内容';
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染视图**真的渲染完了** —— Suspense + lazy import + markdown-it 动态加载
|
||||
* 都是异步的。注意:article 元素在 markdown-it 到位**之前**就已经挂载
|
||||
* (此时正文区是空的),所以不能只等 article 存在,必须等到 markdown 渲染出的
|
||||
* 标题元素出现(:not(.sr-only) 排除 reader 给 SR 用的那个隐藏 h1)。
|
||||
*/
|
||||
async function waitForPreview() {
|
||||
await waitFor(
|
||||
() => expect(document.querySelector('article h1:not(.sr-only)')).not.toBeNull(),
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
}
|
||||
|
||||
function editorValue(): string {
|
||||
return (screen.getByTestId('body-editor') as HTMLTextAreaElement).value;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('StandaloneNoteEditorCore:原始文本 ↔ 渲染显示 双模式(audit-r133)', () => {
|
||||
it('1. 默认是原始文本编辑器,不是渲染视图', () => {
|
||||
render(<StandaloneNoteEditorCore note={note('n1', 'a.md', BODY)} onSave={async () => {}} titleEditable />);
|
||||
|
||||
// 源码编辑器在场
|
||||
expect(screen.getByTestId('body-editor')).toBeTruthy();
|
||||
expect(editorValue()).toBe(BODY);
|
||||
// 渲染视图不在场(它是 lazy 的,默认根本不加载)
|
||||
expect(document.querySelector('article')).toBeNull();
|
||||
// toggle 按钮此刻的语义是「切到渲染显示」
|
||||
expect(screen.getByTestId('note-mode-toggle').getAttribute('aria-label')).toBe(
|
||||
'notes.modePreviewAria',
|
||||
);
|
||||
});
|
||||
|
||||
it('2. 按 toggle 切到只读渲染视图,再按一次切回原始文本', async () => {
|
||||
render(<StandaloneNoteEditorCore note={note('n1', 'a.md', BODY)} onSave={async () => {}} titleEditable />);
|
||||
const toggle = screen.getByTestId('note-mode-toggle');
|
||||
|
||||
fireEvent.click(toggle);
|
||||
await waitForPreview();
|
||||
|
||||
// 渲染视图在场:markdown 被真渲染成 h1
|
||||
expect(document.querySelector('article h1:not(.sr-only)')!.textContent).toBe('标题');
|
||||
// 原始文本编辑器不在场(两模式互斥,不是分屏)
|
||||
expect(screen.queryByTestId('body-editor')).toBeNull();
|
||||
// 按钮语义反转
|
||||
expect(screen.getByTestId('note-mode-toggle').getAttribute('aria-label')).toBe(
|
||||
'notes.modeSourceAria',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('note-mode-toggle'));
|
||||
expect(screen.getByTestId('body-editor')).toBeTruthy();
|
||||
expect(document.querySelector('article')).toBeNull();
|
||||
});
|
||||
|
||||
it('3. 切换往返不丢内容:正文一字不差', async () => {
|
||||
render(<StandaloneNoteEditorCore note={note('n1', 'a.md', BODY)} onSave={async () => {}} titleEditable />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('note-mode-toggle'));
|
||||
await waitForPreview();
|
||||
fireEvent.click(screen.getByTestId('note-mode-toggle'));
|
||||
|
||||
expect(editorValue()).toBe(BODY);
|
||||
});
|
||||
|
||||
it('4. 预览显示的是编辑后的最新正文,不是 note.body 快照', async () => {
|
||||
render(<StandaloneNoteEditorCore note={note('n1', 'a.md', BODY)} onSave={async () => {}} titleEditable />);
|
||||
|
||||
// 改一段正文
|
||||
fireEvent.change(screen.getByTestId('body-editor'), {
|
||||
target: { value: '# 标题\n\n改过的正文' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId('note-mode-toggle'));
|
||||
await waitForPreview();
|
||||
|
||||
expect(document.querySelector('article')!.textContent).toContain('改过的正文');
|
||||
expect(document.querySelector('article')!.textContent).not.toContain('正文内容');
|
||||
});
|
||||
|
||||
it('5. 切换往返不改变保存状态:未保存标记仍在', async () => {
|
||||
render(<StandaloneNoteEditorCore note={note('n1', 'a.md', BODY)} onSave={async () => {}} titleEditable />);
|
||||
|
||||
// 初始:已保存(进入笔记时 Core 标 'saved')
|
||||
expect(screen.getByRole('status').textContent).toContain('notes.saveIdle');
|
||||
|
||||
// 改一个字 ⇒ 未保存
|
||||
fireEvent.change(screen.getByTestId('body-editor'), { target: { value: 'X' } });
|
||||
expect(screen.getByRole('status').textContent).toContain('notes.saveDirty');
|
||||
|
||||
// 来回切一次视图 ⇒ 仍然是未保存(切换本身不该落盘,也不该谎报已保存)。
|
||||
// 刻意**不** await 预览挂载:autosave 防抖 1500ms 一旦 fire,指示器会
|
||||
// 正常地翻成「已保存」,那不是本例要断言的东西(保存语义另有守卫)。
|
||||
fireEvent.click(screen.getByTestId('note-mode-toggle'));
|
||||
fireEvent.click(screen.getByTestId('note-mode-toggle'));
|
||||
|
||||
expect(screen.getByRole('status').textContent).toContain('notes.saveDirty');
|
||||
});
|
||||
|
||||
it('6. 换笔记且要求聚焦正文时,即便停在渲染视图也切回原始文本', async () => {
|
||||
const { rerender } = render(
|
||||
<StandaloneNoteEditorCore
|
||||
note={note('n1', 'a.md', BODY)}
|
||||
onSave={async () => {}}
|
||||
titleEditable
|
||||
/>,
|
||||
);
|
||||
|
||||
// 用户停在渲染视图
|
||||
fireEvent.click(screen.getByTestId('note-mode-toggle'));
|
||||
await waitForPreview();
|
||||
expect(document.querySelector('article')).not.toBeNull();
|
||||
|
||||
// 父组件新建 / 拖入后要求光标落正文
|
||||
rerender(
|
||||
<StandaloneNoteEditorCore
|
||||
note={note('n2', 'b.md', '新笔记正文')}
|
||||
onSave={async () => {}}
|
||||
titleEditable
|
||||
initialFocus="body"
|
||||
/>,
|
||||
);
|
||||
|
||||
// 必须回到原始文本 —— 否则用户没有地方打字
|
||||
expect(screen.getByTestId('body-editor')).toBeTruthy();
|
||||
expect(document.querySelector('article')).toBeNull();
|
||||
expect(editorValue()).toBe('新笔记正文');
|
||||
});
|
||||
});
|
||||
223
tests/unit/renderer/notes/note-undo-redo.test.tsx
Normal file
223
tests/unit/renderer/notes/note-undo-redo.test.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
// tests/unit/renderer/notes/note-undo-redo.test.tsx
|
||||
//
|
||||
// 守「笔记编辑有撤销 / 重做按钮」这条**用户可见**契约
|
||||
// (src/renderer/src/components/notes/StandaloneNoteEditorCore.tsx 头栏)。
|
||||
//
|
||||
// 背景:编辑器是 CodeMirror 6(history 扩展 + historyKeymap ⇒ ⌘Z/⌘Y 早就能用),
|
||||
// 但**没有按钮** —— 键盘用户能用,鼠标用户完全不知道有这回事;而且
|
||||
// 「能不能撤」这件事用户看不到(不知道当前这篇还能不能撤)。
|
||||
//
|
||||
// 本测锁的契约(从用户可见结果出发):
|
||||
// 1. 头栏有「撤销 / 重做」两个按钮,带可访问名(屏幕阅读器用户能找到);
|
||||
// 2. 初始(还没编辑过)两个按钮**禁用** —— 没有可撤的东西就别摆个能点的
|
||||
// 按钮(点了没反应比没有更误导);
|
||||
// 3. 编辑器上报「能撤 / 能重做」后,按钮相应启用;
|
||||
// 4. 点按钮 ⇒ 真的调到编辑器的撤销 / 重做命令(不是摆设);
|
||||
// 5. 换笔记 ⇒ 按钮回到禁用态(新文档没有可撤的编辑)且文档 token 递增
|
||||
// (编辑器据此清空上一篇的撤销栈 —— 见 markdown-editor-history.test.tsx);
|
||||
// 6. 「渲染显示」模式下按钮禁用(那儿没有可撤的编辑:编辑器根本没挂载)。
|
||||
//
|
||||
// 变异思路(哪几处改动会红):
|
||||
// - 删掉两个按钮 ⇒ 例 1、3、4 红;
|
||||
// - disabled 恒 false(不管 canUndo/canRedo)⇒ 例 2、5 红;
|
||||
// - onClick 不调 editorApi ⇒ 例 4 红;
|
||||
// - 换笔记时不重置 canUndo/canRedo、不递增 docToken ⇒ 例 5 红。
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { render, fireEvent, screen, cleanup, waitFor, act } from '@testing-library/react';
|
||||
|
||||
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 前查「旧笔记还在不在列表里」;
|
||||
// 2) ⌘S 通道读 selectedId(本测不走)。
|
||||
vi.mock('../../../../src/renderer/src/store/notes', () => {
|
||||
const state = {
|
||||
notes: [{ id: 'n1', relPath: 'a.md' }],
|
||||
selectedId: 'n1',
|
||||
};
|
||||
return {
|
||||
useNotesStore: {
|
||||
getState: () => state,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// MarkdownEditor 是 CodeMirror(jsdom 里不可测)⇒ 换成普通 textarea,
|
||||
// 并把「命令式句柄」按真实实现的契约挂上去(真实实现在 effect 里挂,
|
||||
// 这里在 render 里挂 —— 测试只需要父级能拿到它)。
|
||||
const editorMock = vi.hoisted(() => ({
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
props: null as null | {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
docToken?: number;
|
||||
onHistoryChange?: (f: { canUndo: boolean; canRedo: boolean }) => void;
|
||||
apiRef?: { current: unknown };
|
||||
},
|
||||
reset() {
|
||||
editorMock.undo.mockClear();
|
||||
editorMock.redo.mockClear();
|
||||
editorMock.props = null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../../src/renderer/src/components/editor/MarkdownEditor', () => ({
|
||||
MarkdownEditor: (props: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
docToken?: number;
|
||||
onHistoryChange?: (f: { canUndo: boolean; canRedo: boolean }) => void;
|
||||
apiRef?: { current: unknown };
|
||||
}) => {
|
||||
editorMock.props = props;
|
||||
if (props.apiRef) {
|
||||
props.apiRef.current = {
|
||||
undo: editorMock.undo,
|
||||
redo: editorMock.redo,
|
||||
canUndo: () => false,
|
||||
canRedo: () => false,
|
||||
};
|
||||
}
|
||||
return (
|
||||
<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 BODY = '# 标题\n\n正文内容';
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function undoBtn(): HTMLButtonElement {
|
||||
return screen.getByTestId('note-undo') as HTMLButtonElement;
|
||||
}
|
||||
function redoBtn(): HTMLButtonElement {
|
||||
return screen.getByTestId('note-redo') as HTMLButtonElement;
|
||||
}
|
||||
|
||||
/** 编辑器上报能力位(真实实现里由 CodeMirror 的 history 栈深驱动)。 */
|
||||
function reportHistory(canUndo: boolean, canRedo: boolean): void {
|
||||
act(() => {
|
||||
editorMock.props?.onHistoryChange?.({ canUndo, canRedo });
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
editorMock.reset();
|
||||
});
|
||||
|
||||
describe('StandaloneNoteEditorCore:撤销 / 重做按钮', () => {
|
||||
it('1. 头栏有「撤销 / 重做」按钮,且带可访问名', () => {
|
||||
render(<StandaloneNoteEditorCore note={note('n1', 'a.md', BODY)} onSave={async () => {}} titleEditable />);
|
||||
|
||||
expect(undoBtn()).toBeTruthy();
|
||||
expect(redoBtn()).toBeTruthy();
|
||||
// 可访问名 = i18n key(测试里 t() 返回 key 本身)
|
||||
expect(undoBtn().getAttribute('aria-label')).toBe('notes.undo');
|
||||
expect(redoBtn().getAttribute('aria-label')).toBe('notes.redo');
|
||||
});
|
||||
|
||||
it('2. 初始没有可撤的编辑 ⇒ 两个按钮都禁用', () => {
|
||||
render(<StandaloneNoteEditorCore note={note('n1', 'a.md', BODY)} onSave={async () => {}} titleEditable />);
|
||||
|
||||
expect(undoBtn().disabled, '还没编辑过就不该有一个能点的「撤销」').toBe(true);
|
||||
expect(redoBtn().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('3. 编辑器上报能力位后 ⇒ 按钮相应启用', () => {
|
||||
render(<StandaloneNoteEditorCore note={note('n1', 'a.md', BODY)} onSave={async () => {}} titleEditable />);
|
||||
|
||||
reportHistory(true, false);
|
||||
expect(undoBtn().disabled, '能撤了就必须能点').toBe(false);
|
||||
expect(redoBtn().disabled, '还没重做过就不该能点').toBe(true);
|
||||
|
||||
reportHistory(true, true);
|
||||
expect(redoBtn().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('4. 点按钮 ⇒ 真的调到编辑器的撤销 / 重做命令', () => {
|
||||
render(<StandaloneNoteEditorCore note={note('n1', 'a.md', BODY)} onSave={async () => {}} titleEditable />);
|
||||
|
||||
reportHistory(true, true);
|
||||
fireEvent.click(undoBtn());
|
||||
expect(editorMock.undo).toHaveBeenCalledTimes(1);
|
||||
expect(editorMock.redo).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(redoBtn());
|
||||
expect(editorMock.redo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('5. 换笔记 ⇒ 按钮回到禁用态 + 文档 token 递增(撤销栈归零)', async () => {
|
||||
const { rerender } = render(
|
||||
<StandaloneNoteEditorCore note={note('n1', 'a.md', BODY)} onSave={async () => {}} titleEditable />,
|
||||
);
|
||||
|
||||
reportHistory(true, true);
|
||||
expect(undoBtn().disabled).toBe(false);
|
||||
const tokenBefore = editorMock.props?.docToken;
|
||||
|
||||
rerender(
|
||||
<StandaloneNoteEditorCore
|
||||
note={note('n2', 'b.md', '第二篇')}
|
||||
onSave={async () => {}}
|
||||
titleEditable
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(undoBtn().disabled).toBe(true));
|
||||
expect(redoBtn().disabled, '新文档没有可重做的编辑').toBe(true);
|
||||
expect(
|
||||
editorMock.props?.docToken,
|
||||
'换文档必须递增 token —— 编辑器据此清空上一篇的撤销栈',
|
||||
).toBeGreaterThan(tokenBefore ?? -1);
|
||||
});
|
||||
|
||||
it('6. 「渲染显示」模式下按钮禁用(编辑器没挂载,没有可撤的编辑)', () => {
|
||||
render(<StandaloneNoteEditorCore note={note('n1', 'a.md', BODY)} onSave={async () => {}} titleEditable />);
|
||||
|
||||
reportHistory(true, true);
|
||||
expect(undoBtn().disabled).toBe(false);
|
||||
|
||||
fireEvent.click(screen.getByTestId('note-mode-toggle'));
|
||||
|
||||
expect(undoBtn().disabled, '渲染视图只读,没有可撤的编辑').toBe(true);
|
||||
expect(redoBtn().disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
196
tests/unit/renderer/reader/markdown-reader.test.tsx
Normal file
196
tests/unit/renderer/reader/markdown-reader.test.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
// tests/unit/renderer/reader/markdown-reader.test.tsx
|
||||
//
|
||||
// 守 MarkdownReader 的行为契约
|
||||
// (src/renderer/src/components/reader/MarkdownReader.tsx 头注指向本文件,
|
||||
// 原先标 🔒(守卫缺失);其中「安全前置」一条是源码注释**点名**要守的)。
|
||||
//
|
||||
// 组件做什么:markdown-it 渲染 → 字符串层净化 → dangerouslySetInnerHTML。
|
||||
// 用户可见的结果分三块:
|
||||
// 1. 渲染 —— 标题 / 列表 / 粗体 / 代码块 / 表格都变成真的 HTML 元素;
|
||||
// 文档第一行的 # 标题额外给 SR 一个隐藏 h1;容器带 filename 的 aria-label。
|
||||
// 2. 【安全前置】—— markdown-it 实例必须带 `html: false`:
|
||||
// 正文里的原始 HTML **不被解析**(只作为文本显示),
|
||||
// 否则 <script> 会经 dangerouslySetInnerHTML 直接进 DOM;
|
||||
// 这一层之上还有字符串净化(markdown-html-sanitize):
|
||||
// 危险 scheme(javascript: / data: / vbscript:)的链接一律被中和。
|
||||
// 退化后果:用户笔记里的 `<script>` 或 `javascript:` 链接在 renderer
|
||||
// 进程里可执行 ⇒ 经 window.api 间接调用所有 IPC ⇒ 用户数据 RCE。
|
||||
// (实测:markdown-it 自己的 validateLink 已经先挡掉 markdown 链接里的
|
||||
// javascript:,本测锁的是**端到端用户可见**的结果 —— 渲染结果里
|
||||
// 永远没有危险 scheme 的可点链接,且原文不被静默吞掉。)
|
||||
// 3. 空态 / 失败态 —— 空正文给明确空态提示(而不是空 <p>);
|
||||
// markdown-it chunk 加载失败给错误态(而不是永久白屏)。
|
||||
//
|
||||
// 变异思路(哪几处改动会红):
|
||||
// - markdown-it 选项 html: false → true ⇒ 「正文里的 <script> 是文本」红;
|
||||
// - 去掉 sanitizeMarkdownHtml 调用(或放宽 scheme 白名单)⇒ 危险链接红;
|
||||
// - linkify: true → false ⇒ 裸 URL 自动成链红;
|
||||
// - 去掉 fence/code_block 的尾换行裁剪 ⇒ 代码块文本多一个 \n 红;
|
||||
// - 空内容分支改成渲染空 article ⇒ 空态红;
|
||||
// - 动态 import 的 catch 分支去掉 ⇒ 加载失败变白屏红。
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
import { MarkdownReader } from '../../../../src/renderer/src/components/reader/MarkdownReader';
|
||||
|
||||
/**
|
||||
* markdown-it 是**动态 import** —— 等它真正渲染完。
|
||||
* 注意:article 元素在 markdown-it 到位**之前**就已挂载(正文区是空的),
|
||||
* 且组件还会额外渲染一个给 SR 用的隐藏 h1 —— 所以「article 里有字」并不代表
|
||||
* markdown 渲染完了。真正的判据是 markdown 输出容器(article > div)非空。
|
||||
*/
|
||||
async function waitForRendered(container: HTMLElement) {
|
||||
await waitFor(
|
||||
() => {
|
||||
const md = container.querySelector('article > div');
|
||||
expect(md).not.toBeNull();
|
||||
expect(md!.innerHTML.trim().length).toBeGreaterThan(0);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('MarkdownReader:markdown 渲染', () => {
|
||||
it('标题 / 列表 / 粗体 / 行内代码都变成真的 HTML 元素', async () => {
|
||||
const { container } = render(
|
||||
<MarkdownReader
|
||||
source={'# 一级标题\n\n- 甲\n- 乙\n\n这是**粗体**与 `code`\n'}
|
||||
filename="a.md"
|
||||
/>,
|
||||
);
|
||||
await waitForRendered(container);
|
||||
|
||||
expect(container.querySelector('article h1')!.textContent).toBe('一级标题');
|
||||
expect(container.querySelectorAll('article li')).toHaveLength(2);
|
||||
expect(container.querySelector('article strong')!.textContent).toBe('粗体');
|
||||
expect(container.querySelector('article code')!.textContent).toBe('code');
|
||||
});
|
||||
|
||||
it('代码块内容末尾没有多余换行', async () => {
|
||||
const { container } = render(
|
||||
<MarkdownReader source={'```js\nconst a = 1;\n```\n'} filename="a.md" />,
|
||||
);
|
||||
await waitForRendered(container);
|
||||
|
||||
const code = container.querySelector('article pre code')!;
|
||||
expect(code.textContent).toBe('const a = 1;');
|
||||
expect(code.textContent!.endsWith('\n')).toBe(false);
|
||||
});
|
||||
|
||||
it('文档第一行的 # 标题额外给 SR 一个隐藏 h1;容器带 filename 的 aria-label', async () => {
|
||||
const { container } = render(
|
||||
<MarkdownReader source={'# 主题\n\n正文\n'} filename="研究笔记.md" />,
|
||||
);
|
||||
await waitForRendered(container);
|
||||
|
||||
const srHeading = container.querySelector('article h1.sr-only');
|
||||
expect(srHeading).not.toBeNull();
|
||||
expect(srHeading!.textContent).toBe('主题');
|
||||
// 容器对外暴露可访问名
|
||||
expect(container.querySelector('[aria-label="研究笔记.md"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('[[wiki 链接]] 渲染为纯文本,不是可点锚点', async () => {
|
||||
const { container } = render(
|
||||
<MarkdownReader source={'参见 [[另一篇笔记]] 的说明'} filename="a.md" />,
|
||||
);
|
||||
await waitForRendered(container);
|
||||
|
||||
expect(container.querySelector('article')!.textContent).toContain('[[另一篇笔记]]');
|
||||
expect(container.querySelector('article a')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownReader:安全前置(html:false + scheme 白名单)', () => {
|
||||
it('正文里的原始 HTML 只作为文本显示,不进 DOM', async () => {
|
||||
const { container } = render(
|
||||
<MarkdownReader
|
||||
source={'<script>alert(1)</script>\n\n<img src=x onerror="alert(2)">\n'}
|
||||
filename="a.md"
|
||||
/>,
|
||||
);
|
||||
await waitForRendered(container);
|
||||
|
||||
// DOM 里不得出现脚本 / 图片元素
|
||||
expect(container.querySelector('script')).toBeNull();
|
||||
expect(container.querySelector('img')).toBeNull();
|
||||
// 字面量以文本形式完整保留(用户能看见自己写的东西)
|
||||
expect(container.textContent).toContain('<script>alert(1)</script>');
|
||||
expect(container.textContent).toContain('onerror');
|
||||
});
|
||||
|
||||
it('危险 scheme 不会变成可点链接,原文仍可见', async () => {
|
||||
const { container } = render(
|
||||
<MarkdownReader
|
||||
source={
|
||||
'[点我](javascript:alert(1))\n\n访问 https://example.com\n\n裸的 javascript:alert(2) 也在正文里\n'
|
||||
}
|
||||
filename="a.md"
|
||||
/>,
|
||||
);
|
||||
await waitForRendered(container);
|
||||
|
||||
// 渲染结果里只有**安全**的那个链接(markdown-it 的 validateLink 先挡一道,
|
||||
// 字符串净化再兜一道 —— 两道都在 ⇒ 用户笔记里的危险链接永远点不动)
|
||||
const hrefs = Array.from(container.querySelectorAll('article a')).map((a) =>
|
||||
(a.getAttribute('href') ?? '').toLowerCase(),
|
||||
);
|
||||
expect(hrefs).toEqual(['https://example.com']);
|
||||
for (const href of hrefs) {
|
||||
expect(href).not.toMatch(/^(javascript|vbscript|data|file):/);
|
||||
}
|
||||
// 且原文没有被静默吞掉(用户能看到自己写了什么)
|
||||
expect(container.textContent).toContain('javascript:alert(1)');
|
||||
expect(container.textContent).toContain('javascript:alert(2)');
|
||||
});
|
||||
|
||||
it('裸 URL 被自动链接化,http(s) 链接保持原样', async () => {
|
||||
const { container } = render(
|
||||
<MarkdownReader source={'访问 https://example.com 看看'} filename="a.md" />,
|
||||
);
|
||||
await waitForRendered(container);
|
||||
|
||||
const anchor = container.querySelector('article a')!;
|
||||
expect(anchor.getAttribute('href')).toBe('https://example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownReader:空态与失败态', () => {
|
||||
it('空白正文 → 明确空态提示,而不是一个空 article', async () => {
|
||||
const { container } = render(<MarkdownReader source={' \n\n'} filename="空.md" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('reader.emptyDoc')).toBeTruthy());
|
||||
expect(container.querySelector('article')).toBeNull();
|
||||
});
|
||||
|
||||
it('markdown-it 初始化失败 → 错误态提示,不是永久白屏', async () => {
|
||||
// 真实故障形态:chunk 到了但实例化炸了(asar hash 不符 / 沙盒环境)。
|
||||
// 工厂返回「构造即抛」的类,错误信息能原样传到 UI 上。
|
||||
vi.doMock('markdown-it', () => ({
|
||||
default: class {
|
||||
constructor() {
|
||||
throw new Error('chunk load failed');
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const { container } = render(<MarkdownReader source={'# 标题'} filename="a.md" />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('reader.errorMarkdownLoaderReaderTitle')).toBeTruthy(),
|
||||
);
|
||||
expect(container.querySelector('article')).toBeNull();
|
||||
// 错误原因对用户可见(可截图报障),不是空壳
|
||||
expect(container.textContent).toContain('chunk load failed');
|
||||
|
||||
vi.doUnmock('markdown-it');
|
||||
});
|
||||
});
|
||||
204
tests/unit/renderer/reader/reader-budget.test.tsx
Normal file
204
tests/unit/renderer/reader/reader-budget.test.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
// tests/unit/renderer/reader/reader-budget.test.tsx
|
||||
//
|
||||
// 守 perf r259「大文件阅读不许把渲染进程主线程堵死」这条契约 ——
|
||||
// 阅读器的重管线(语法高亮 / markdown 渲染 + HTML 净化)是**同步**的,
|
||||
// 跑在 renderer 主线程上。实测(2026-10-21):
|
||||
//
|
||||
// 代码文件 12.3MB → hljs.highlight 3191ms + 净化 592ms(净化后 HTML 57.8MB)
|
||||
// markdown 5.6MB → md.render 889ms + 净化 1943ms
|
||||
//
|
||||
// main 侧阅读器上限 READER_MAX_SIZE_BYTES = 10MB ⇒ 这些量级用户真能碰到
|
||||
// ⇒ 窗口「未响应」数秒。修法 = **降级**而不是截断:超预算的部分不再走
|
||||
// 重管线,但**内容一字不丢**(以纯文本呈现)。
|
||||
//
|
||||
// ── 本测锁五件事 ───────────────────────────────────────────────────
|
||||
// A. splitByBudget 的三条不变量:
|
||||
// ① head + tail === 原文(**内容零丢失**);
|
||||
// ② 未超预算 ⇒ tail 为空、truncated 为 false;
|
||||
// ③ 超预算 ⇒ 切点落在**行边界**(不把一行劈成两半)。
|
||||
// B. CodeReader:超大文件 ⇒ 只有预算内的一段被高亮(高亮 span 数有界),
|
||||
// 但**尾部内容仍在 DOM 里**(用户看得见完整文件),且出现降级提示条。
|
||||
// C. MarkdownReader:超大文件 ⇒ 预算外的部分以 <pre> 纯文本呈现
|
||||
// (**不被 markdown 排版**),内容完整,并出现降级提示条。
|
||||
// D. 小文件**不受影响**:照常高亮 / 照常排版,且**没有**提示条。
|
||||
// E. 提示条上的「在系统应用打开」出口存在(想要完整高亮的用户有去处)。
|
||||
//
|
||||
// 变异思路(哪几处改动会红):
|
||||
// - splitByBudget 改成「截断丢弃尾巴」⇒ A① / B / C 红(内容丢);
|
||||
// - 预算调回 Infinity(不做降级)⇒ B / C 红(高亮 span 数无界 / 无提示条);
|
||||
// - 尾巴忘了接上(head+tail 少一段)⇒ B / C 红;
|
||||
// - 提示条不渲染 ⇒ B / C 红。
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
import { CodeReader } from '../../../../src/renderer/src/components/reader/CodeReader';
|
||||
import { MarkdownReader } from '../../../../src/renderer/src/components/reader/MarkdownReader';
|
||||
import {
|
||||
READER_HIGHLIGHT_BUDGET_CHARS,
|
||||
READER_MARKDOWN_BUDGET_CHARS,
|
||||
splitByBudget,
|
||||
} from '../../../../src/renderer/src/lib/reader-budget';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
/** 造一份「行数足够多、尾部有唯一标记」的源码。 */
|
||||
function makeSource(totalChars: number, marker: string): string {
|
||||
const line = 'def f(x):\n return x + 1 # comment\n';
|
||||
let s = '';
|
||||
while (s.length < totalChars) s += line;
|
||||
s += marker;
|
||||
return s;
|
||||
}
|
||||
|
||||
describe('splitByBudget:内容零丢失的不变量', () => {
|
||||
it('A1 未超预算 ⇒ head 就是全文,tail 为空,truncated 为 false', () => {
|
||||
const text = 'abc\ndef\n';
|
||||
const r = splitByBudget(text, 1024);
|
||||
expect(r.truncated).toBe(false);
|
||||
expect(r.tail).toBe('');
|
||||
expect(r.head).toBe(text);
|
||||
expect(r.headChars).toBe(text.length);
|
||||
});
|
||||
|
||||
it('A2 超预算 ⇒ head + tail 拼回原文(一个字符都不丢)', () => {
|
||||
const text = makeSource(5000, 'TAIL-MARKER\n');
|
||||
const r = splitByBudget(text, 1000);
|
||||
expect(r.truncated).toBe(true);
|
||||
expect(r.head.length).toBeLessThanOrEqual(1000);
|
||||
expect(r.head + r.tail).toBe(text);
|
||||
expect(r.tail).toContain('TAIL-MARKER');
|
||||
});
|
||||
|
||||
it('A3 超预算 ⇒ 切点落在行边界(不把一行劈成两半)', () => {
|
||||
const text = makeSource(5000, 'TAIL-MARKER\n');
|
||||
const r = splitByBudget(text, 1000);
|
||||
expect(r.head.endsWith('\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('A4 整段没有换行的超长单行 ⇒ 硬切在预算处,内容仍零丢失', () => {
|
||||
const text = 'x'.repeat(5000);
|
||||
const r = splitByBudget(text, 1000);
|
||||
expect(r.truncated).toBe(true);
|
||||
expect(r.head.length).toBe(1000);
|
||||
expect(r.head + r.tail).toBe(text);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodeReader:大文件降级(内容不丢,高亮有界)', () => {
|
||||
it('B1 超大文件 ⇒ 只有预算内的一段被高亮,尾部内容仍在 DOM 里', async () => {
|
||||
// 标记用「一定会被 hljs 包成 span」的形态(def = python 关键字):
|
||||
// 若整份文件都被高亮,这个标记的文本节点就会挂在 span 下面;
|
||||
// 降级后它是 <code> 的**直接文本子节点** ⇒ 断言文本节点的父元素。
|
||||
const marker = 'def zzz_tail_marker():\n return 1\n';
|
||||
const source = makeSource(READER_HIGHLIGHT_BUDGET_CHARS + 20000, marker);
|
||||
const { container } = render(<CodeReader source={source} filename="big.py" />);
|
||||
|
||||
// 等 hljs chunk 到位(动态 import)—— 高亮 span 出现即算到位。
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll('code span').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ① 内容零丢失:尾部标记在 DOM 里(纯文本呈现)
|
||||
expect(container.textContent).toContain('zzz_tail_marker');
|
||||
// ② 尾部**没有被高亮**:标记所在的文本节点直接挂在 <code> 下
|
||||
const code = container.querySelector('code')!;
|
||||
const walker = document.createTreeWalker(code, NodeFilter.SHOW_TEXT);
|
||||
let node = walker.nextNode();
|
||||
let parentTag = '';
|
||||
while (node) {
|
||||
if (node.textContent?.includes('zzz_tail_marker')) {
|
||||
parentTag = (node.parentElement?.tagName ?? '').toUpperCase();
|
||||
break;
|
||||
}
|
||||
node = walker.nextNode();
|
||||
}
|
||||
expect(
|
||||
parentTag,
|
||||
'尾部被高亮了 ⇒ 渲染预算没生效(整份文件仍在跑 hljs,主线程会卡数秒)',
|
||||
).toBe('CODE');
|
||||
});
|
||||
|
||||
it('B2 出现降级提示条(用户有权知道「后面那段没高亮」)', async () => {
|
||||
const source = makeSource(READER_HIGHLIGHT_BUDGET_CHARS + 20000, 'TAIL\n');
|
||||
render(<CodeReader source={source} filename="big.py" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('status')).toBeTruthy());
|
||||
expect(screen.getByRole('status').textContent).toContain('reader.largeCodeNotice');
|
||||
});
|
||||
|
||||
it('B3 提示条带「在系统应用打开」出口(想要完整高亮有去处)', async () => {
|
||||
const source = makeSource(READER_HIGHLIGHT_BUDGET_CHARS + 20000, 'TAIL\n');
|
||||
const onOpenExternal = vi.fn();
|
||||
render(<CodeReader source={source} filename="big.py" onOpenExternal={onOpenExternal} />);
|
||||
|
||||
const btn = await screen.findByRole('button', { name: 'reader.openExternal' });
|
||||
btn.click();
|
||||
expect(onOpenExternal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('D1 小文件 ⇒ 照常高亮,且**没有**降级提示条', async () => {
|
||||
const source = 'def f(x):\n return x + 1\n';
|
||||
const { container } = render(<CodeReader source={source} filename="small.py" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll('code span').length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownReader:大文件降级(排版有界,内容不丢)', () => {
|
||||
it('C1 超大文件 ⇒ 预算外的部分以纯文本呈现,不被 markdown 排版', async () => {
|
||||
const marker = 'TAIL-MARKER-MD **not-bold**';
|
||||
const head = '# Title\n\nsome **bold** text\n\n';
|
||||
let source = head;
|
||||
while (source.length < READER_MARKDOWN_BUDGET_CHARS + 20000) source += 'line of text\n\n';
|
||||
source += marker + '\n';
|
||||
|
||||
const { container } = render(<MarkdownReader source={source} filename="big.md" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('article > div')!.innerHTML.trim().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ① 内容零丢失:尾部标记在 DOM 里(以 <pre> 纯文本呈现)
|
||||
expect(container.textContent).toContain('TAIL-MARKER-MD');
|
||||
// ② 预算外的部分**没有**进 markdown 渲染产物 —— 这是本测的关键断言:
|
||||
// markdown 输出容器里不得出现尾部标记(否则说明整份文件仍在走
|
||||
// md.render,主线程照样被堵数秒)。
|
||||
const mdOut = container.querySelector('article > div')!;
|
||||
expect(
|
||||
mdOut.textContent,
|
||||
'尾部被 markdown 渲染了 ⇒ 渲染预算没生效(整份文件仍在跑 md.render)',
|
||||
).not.toContain('TAIL-MARKER-MD');
|
||||
// ③ 预算内的部分照常排版(只有一个 strong,来自正文)
|
||||
expect(mdOut.querySelectorAll('strong')).toHaveLength(1);
|
||||
expect(container.querySelector('article strong')!.textContent).toBe('bold');
|
||||
expect(container.textContent).toContain('**not-bold**');
|
||||
});
|
||||
|
||||
it('C2 出现降级提示条', async () => {
|
||||
let source = '# Title\n\n';
|
||||
while (source.length < READER_MARKDOWN_BUDGET_CHARS + 20000) source += 'line of text\n\n';
|
||||
render(<MarkdownReader source={source} filename="big.md" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('status')).toBeTruthy());
|
||||
expect(screen.getByRole('status').textContent).toContain('reader.largeMarkdownNotice');
|
||||
});
|
||||
|
||||
it('D2 小文件 ⇒ 照常排版,且没有降级提示条', async () => {
|
||||
const { container } = render(
|
||||
<MarkdownReader source={'# 标题\n\n正文**粗体**\n'} filename="small.md" />,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('article strong')!.textContent).toBe('粗体');
|
||||
});
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
});
|
||||
});
|
||||
289
tests/unit/renderer/resize-handle-keyboard.test.ts
Normal file
289
tests/unit/renderer/resize-handle-keyboard.test.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
// tests/unit/renderer/resize-handle-keyboard.test.ts
|
||||
//
|
||||
// 守 VerticalResizeHandle 的**键盘操作契约**与**方向约定**
|
||||
// (src/renderer/src/components/layout/VerticalResizeHandle.tsx 头注指向本文件)。
|
||||
//
|
||||
// 契约(每条都对应一个用户可见的后果):
|
||||
//
|
||||
// 1. 方向约定 —— 手柄永远在面板的「内侧」:
|
||||
// side='left' (手柄在面板右沿):ArrowLeft = 面板收窄(-step)
|
||||
// side='right' (手柄在面板左沿):ArrowLeft = 面板加宽(+step)
|
||||
// 退化(两个 side 的 ± 号写反)⇒ 键盘用户按左键面板反而变宽,
|
||||
// 与鼠标拖拽方向正好相反(鼠标往左拖就是收窄)⇒ 用户完全失去方向感。
|
||||
// 2. 步长:普通键 10px,Shift 加速 50px。
|
||||
// 退化 ⇒ 长按加速失效 / 加速步长变成 10px,细调与粗调分不出来。
|
||||
// 3. Home = 跳到 min,End = 跳到 max。
|
||||
// 退化 ⇒ 键盘用户没法一键到边界,只能长按几十次。
|
||||
// 4. 方向键必须 preventDefault —— 否则浏览器跟着横向滚动整页。
|
||||
// 5. a11y 数值必须对得上:role=separator / aria-orientation=vertical /
|
||||
// aria-valuenow / valuemin / valuemax / aria-valuetext / title。
|
||||
// px 模式读出 "320 px",pct 模式读出 "32%"(读屏用户不靠像素猜宽度)。
|
||||
// 6. 双击手柄 = 复位到 defaultWidth;按下 = 开始拖拽(必须是 pointer 事件,
|
||||
// 只有 pointer 事件能 setPointerCapture,拖出窗口松手才不卡死)。
|
||||
//
|
||||
// 本文件是 .ts(指针指定)⇒ 用 React.createElement 而不是 JSX。
|
||||
//
|
||||
// 变异思路(下列任一改动会红):
|
||||
// - 把 arrowLeftAddsWidth 的判定反过来 / 把两个 case 的 ± 写反
|
||||
// - SHIFT_STEP 改回 NORMAL_STEP
|
||||
// - 去掉方向键分支里的 e.preventDefault()
|
||||
// - Home / End 传错 min / max
|
||||
// - fmt 里的 pct 分支去掉(百分比面板读出 "0.32 px")
|
||||
// - onDoubleClick / onPointerDown 摘掉
|
||||
import { describe, expect, it, vi, beforeAll, beforeEach, afterEach } from 'vitest';
|
||||
import { render, fireEvent, cleanup } from '@testing-library/react';
|
||||
import { createElement } from 'react';
|
||||
import { VerticalResizeHandle } from '../../../src/renderer/src/components/layout/VerticalResizeHandle';
|
||||
import { useResizablePanel } from '../../../src/renderer/src/hooks/useResizablePanel';
|
||||
import type { ResizablePanelApi } from '../../../src/renderer/src/hooks/useResizablePanel';
|
||||
|
||||
// jsdom 没有 PointerEvent 构造器(见 memory jsdom-test-gaps)。
|
||||
// 垫一个继承 MouseEvent 的替身,让 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;
|
||||
});
|
||||
|
||||
const NORMAL_STEP = 10;
|
||||
const SHIFT_STEP = 50;
|
||||
|
||||
function makeApi(overrides: Partial<ResizablePanelApi> = {}): ResizablePanelApi {
|
||||
return {
|
||||
width: 300,
|
||||
unit: 'px',
|
||||
dragging: false,
|
||||
panelRef: () => {},
|
||||
onResizeStart: vi.fn(),
|
||||
onResizeReset: vi.fn(),
|
||||
setWidth: vi.fn(),
|
||||
bumpWidth: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderHandle(
|
||||
api: ResizablePanelApi,
|
||||
side: 'left' | 'right' = 'left',
|
||||
min = 100,
|
||||
max = 500,
|
||||
): { handle: HTMLElement; container: HTMLElement; unmount: () => void } {
|
||||
const utils = render(
|
||||
createElement(VerticalResizeHandle, { api, side, min, max, ariaLabel: 'resize handle' }),
|
||||
);
|
||||
const handle = utils.container.querySelector('[role="separator"]') as HTMLElement;
|
||||
return { handle, container: utils.container, unmount: utils.unmount };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('VerticalResizeHandle 方向约定(键盘 ± step)', () => {
|
||||
it("side='left'(手柄在右沿):ArrowLeft 收窄 / ArrowRight 加宽", () => {
|
||||
const api = makeApi();
|
||||
const { handle } = renderHandle(api, 'left');
|
||||
|
||||
fireEvent.keyDown(handle, { key: 'ArrowLeft' });
|
||||
expect(api.bumpWidth).toHaveBeenCalledWith(-NORMAL_STEP);
|
||||
|
||||
fireEvent.keyDown(handle, { key: 'ArrowRight' });
|
||||
expect(api.bumpWidth).toHaveBeenCalledWith(NORMAL_STEP);
|
||||
});
|
||||
|
||||
it("side='right'(手柄在左沿):ArrowLeft 加宽 / ArrowRight 收窄(与 left 相反)", () => {
|
||||
const api = makeApi();
|
||||
const { handle } = renderHandle(api, 'right');
|
||||
|
||||
fireEvent.keyDown(handle, { key: 'ArrowLeft' });
|
||||
expect(api.bumpWidth).toHaveBeenCalledWith(NORMAL_STEP);
|
||||
|
||||
fireEvent.keyDown(handle, { key: 'ArrowRight' });
|
||||
expect(api.bumpWidth).toHaveBeenCalledWith(-NORMAL_STEP);
|
||||
});
|
||||
|
||||
it('Shift = 加速步长 50(普通 10)', () => {
|
||||
const api = makeApi();
|
||||
const { handle } = renderHandle(api, 'left');
|
||||
|
||||
fireEvent.keyDown(handle, { key: 'ArrowLeft', shiftKey: true });
|
||||
expect(api.bumpWidth).toHaveBeenCalledWith(-SHIFT_STEP);
|
||||
|
||||
fireEvent.keyDown(handle, { key: 'ArrowRight', shiftKey: true });
|
||||
expect(api.bumpWidth).toHaveBeenCalledWith(SHIFT_STEP);
|
||||
});
|
||||
|
||||
it('方向键 preventDefault(否则整页横向滚动)', () => {
|
||||
const api = makeApi();
|
||||
const { handle } = renderHandle(api, 'left');
|
||||
// fireEvent 返回 dispatchEvent 的结果:被 preventDefault 过就是 false
|
||||
expect(fireEvent.keyDown(handle, { key: 'ArrowLeft' })).toBe(false);
|
||||
expect(fireEvent.keyDown(handle, { key: 'ArrowRight' })).toBe(false);
|
||||
});
|
||||
|
||||
it('Home = min,End = max', () => {
|
||||
const api = makeApi();
|
||||
const { handle } = renderHandle(api, 'left', 100, 500);
|
||||
|
||||
fireEvent.keyDown(handle, { key: 'Home' });
|
||||
expect(api.setWidth).toHaveBeenCalledWith(100);
|
||||
|
||||
fireEvent.keyDown(handle, { key: 'End' });
|
||||
expect(api.setWidth).toHaveBeenCalledWith(500);
|
||||
});
|
||||
|
||||
it('无关按键不触发任何宽度变更,也不拦默认行为', () => {
|
||||
const api = makeApi();
|
||||
const { handle } = renderHandle(api, 'left');
|
||||
|
||||
expect(fireEvent.keyDown(handle, { key: 'a' })).toBe(true);
|
||||
expect(fireEvent.keyDown(handle, { key: 'ArrowUp' })).toBe(true);
|
||||
expect(fireEvent.keyDown(handle, { key: 'Tab' })).toBe(true);
|
||||
expect(api.bumpWidth).not.toHaveBeenCalled();
|
||||
expect(api.setWidth).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('VerticalResizeHandle a11y 数值', () => {
|
||||
it('separator 语义与 aria 数值都对得上当前宽度', () => {
|
||||
const api = makeApi({ width: 320 });
|
||||
const { handle } = renderHandle(api, 'left', 100, 500);
|
||||
|
||||
expect(handle.getAttribute('role')).toBe('separator');
|
||||
expect(handle.getAttribute('aria-orientation')).toBe('vertical');
|
||||
expect(handle.getAttribute('aria-valuemin')).toBe('100');
|
||||
expect(handle.getAttribute('aria-valuemax')).toBe('500');
|
||||
expect(handle.getAttribute('aria-valuenow')).toBe('320');
|
||||
expect(handle.getAttribute('aria-valuetext')).toBe('320 px');
|
||||
expect(handle.getAttribute('title')).toBe('320 px');
|
||||
expect(handle.getAttribute('aria-label')).toBe('resize handle');
|
||||
});
|
||||
|
||||
it('pct 模式读出百分比而不是像素', () => {
|
||||
const api = makeApi({ width: 0.32, unit: 'pct' });
|
||||
const { handle } = renderHandle(api, 'left', 0, 1);
|
||||
expect(handle.getAttribute('aria-valuetext')).toBe('32%');
|
||||
expect(handle.getAttribute('title')).toBe('32%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('VerticalResizeHandle 鼠标交互', () => {
|
||||
it('双击手柄 = 复位', () => {
|
||||
const api = makeApi();
|
||||
const { handle } = renderHandle(api, 'left');
|
||||
fireEvent.doubleClick(handle);
|
||||
expect(api.onResizeReset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('按下手柄用 pointer 事件(才能 setPointerCapture,拖出窗口不卡死)', () => {
|
||||
const api = makeApi();
|
||||
const { handle } = renderHandle(api, 'left');
|
||||
fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 100, clientY: 50 });
|
||||
expect(api.onResizeStart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VerticalResizeHandle 接真 hook:键盘真的改变面板宽度', () => {
|
||||
function Harness({
|
||||
side,
|
||||
min = 100,
|
||||
max = 500,
|
||||
defaultWidth = 300,
|
||||
}: {
|
||||
side: 'left' | 'right';
|
||||
min?: number;
|
||||
max?: number;
|
||||
defaultWidth?: number;
|
||||
}) {
|
||||
const api = useResizablePanel({
|
||||
storageKey: 'test.resize.handle.width',
|
||||
defaultWidth,
|
||||
min,
|
||||
max,
|
||||
side,
|
||||
unit: 'px',
|
||||
});
|
||||
return createElement(
|
||||
'div',
|
||||
null,
|
||||
createElement('div', {
|
||||
ref: api.panelRef,
|
||||
'data-testid': 'panel',
|
||||
style: { width: `${api.width}px` },
|
||||
}),
|
||||
createElement(VerticalResizeHandle, { api, side, min, max, ariaLabel: 'resize handle' }),
|
||||
);
|
||||
}
|
||||
|
||||
function panelWidth(): string {
|
||||
return (document.querySelector('[data-testid="panel"]') as HTMLElement).style.width;
|
||||
}
|
||||
|
||||
it("side='left':ArrowLeft 让面板真的收窄 10px", () => {
|
||||
const { container } = render(createElement(Harness, { side: 'left' }));
|
||||
const handle = container.querySelector('[role="separator"]') as HTMLElement;
|
||||
expect(panelWidth()).toBe('300px');
|
||||
|
||||
fireEvent.keyDown(handle, { key: 'ArrowLeft' });
|
||||
expect(panelWidth()).toBe('290px');
|
||||
|
||||
fireEvent.keyDown(handle, { key: 'ArrowRight' });
|
||||
expect(panelWidth()).toBe('300px');
|
||||
});
|
||||
|
||||
it("side='right':ArrowLeft 让面板真的加宽 10px(与 left 相反)", () => {
|
||||
const { container } = render(createElement(Harness, { side: 'right' }));
|
||||
const handle = container.querySelector('[role="separator"]') as HTMLElement;
|
||||
|
||||
fireEvent.keyDown(handle, { key: 'ArrowLeft' });
|
||||
expect(panelWidth()).toBe('310px');
|
||||
});
|
||||
|
||||
it('连续按键逐步推进,不会被夹死在 min / max 之外', () => {
|
||||
const { container } = render(createElement(Harness, { side: 'left' }));
|
||||
const handle = container.querySelector('[role="separator"]') as HTMLElement;
|
||||
|
||||
for (let i = 0; i < 40; i += 1) fireEvent.keyDown(handle, { key: 'ArrowLeft' });
|
||||
expect(panelWidth()).toBe('100px'); // min
|
||||
|
||||
for (let i = 0; i < 60; i += 1) fireEvent.keyDown(handle, { key: 'ArrowRight' });
|
||||
expect(panelWidth()).toBe('500px'); // max
|
||||
});
|
||||
|
||||
it('Home / End 直接落到边界', () => {
|
||||
const { container } = render(createElement(Harness, { side: 'left' }));
|
||||
const handle = container.querySelector('[role="separator"]') as HTMLElement;
|
||||
|
||||
fireEvent.keyDown(handle, { key: 'End' });
|
||||
expect(panelWidth()).toBe('500px');
|
||||
|
||||
fireEvent.keyDown(handle, { key: 'Home' });
|
||||
expect(panelWidth()).toBe('100px');
|
||||
});
|
||||
|
||||
it('双击手柄 = 复位到 defaultWidth', () => {
|
||||
const { container } = render(createElement(Harness, { side: 'left', defaultWidth: 300 }));
|
||||
const handle = container.querySelector('[role="separator"]') as HTMLElement;
|
||||
|
||||
fireEvent.keyDown(handle, { key: 'ArrowRight' });
|
||||
expect(panelWidth()).toBe('310px');
|
||||
|
||||
fireEvent.doubleClick(handle);
|
||||
expect(panelWidth()).toBe('300px');
|
||||
});
|
||||
});
|
||||
313
tests/unit/renderer/startup.test.ts
Normal file
313
tests/unit/renderer/startup.test.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
// tests/unit/renderer/startup.test.ts
|
||||
//
|
||||
// 守卫:启动期进度覆盖层状态机(src/renderer/src/store/startup.ts)。
|
||||
// 源码头注指向本文件(audit-r75 登记,audit-r229 补齐 —— 原先标 🔒(守卫缺失))。
|
||||
//
|
||||
// 这个 store 很小但**语义载体很重**:它是应用启动时覆盖整个主界面的那层
|
||||
// 遮罩。写错了用户直接看到「界面卡住不动」或「遮罩闪一下又消失」:
|
||||
//
|
||||
// 1. done=true ⇒ visible 必须 false —— 否则遮罩退不掉,主界面永远点不动;
|
||||
// 2. applyEvent('all-done') ⇒ 立刻 done + 退场;
|
||||
// 3. phase-start ⇒ 该 phase running + 成为 activePhase(后启动者覆盖);
|
||||
// 4. phase-done ⇒ 该 phase done;若它正是 activePhase ⇒ 回退到下一个
|
||||
// running 的 phase(优先级:finalizing > literature > notes-resync > notes);
|
||||
// 5. phase-done(ok=false, error) ⇒ lastError 与该 phase 的 error 都要落;
|
||||
// 6. hydrateFromSnapshot 是**合并**而非覆盖 —— snapshot 可能比 listener
|
||||
// 累积的状态更早,直接覆盖会把已 done 的 phase 打回 pending(UI 闪一次
|
||||
// 「进行中」);已 forceFinish(30s 兜底)时 snapshot 到达也不能把遮罩
|
||||
// 闪回来;
|
||||
// 7. **phase 名单三方一致**(r51 回归类):共享类型 StartupPhase union、
|
||||
// store 的 freshPhases 初始化表、mergePhases 的覆盖表、优先级表
|
||||
// 必须同全集 —— 主进程加一个 phase 而 renderer 漏一处 ⇒
|
||||
// `s.phases[phase] === undefined` ⇒ applyEvent 直接抛 TypeError。
|
||||
//
|
||||
// 变异思路:去掉 done⇒visible 联动 / mergePhases 改成无条件覆盖 / 优先级
|
||||
// 表漏一项 / freshPhases 漏一个 key —— 逐条会红。
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import type { StartupPhase, StartupProgressSnapshot } from '@shared/types';
|
||||
import { useStartupStore } from '../../../src/renderer/src/store/startup';
|
||||
|
||||
const ALL_PHASES: readonly StartupPhase[] = ['notes', 'notes-resync', 'literature', 'finalizing'];
|
||||
|
||||
/** 优先级表(源码 pickNextRunningPhase 里的 priority 数组顺序)。 */
|
||||
const PRIORITY: readonly StartupPhase[] = ['finalizing', 'literature', 'notes-resync', 'notes'];
|
||||
|
||||
function freshPhaseMap(): Record<string, { state: 'pending' | 'running' | 'done' }> {
|
||||
const out: Record<string, { state: 'pending' | 'running' | 'done' }> = {};
|
||||
for (const p of ALL_PHASES) out[p] = { state: 'pending' };
|
||||
return out;
|
||||
}
|
||||
|
||||
function resetStore() {
|
||||
useStartupStore.setState({
|
||||
visible: true,
|
||||
done: false,
|
||||
activePhase: null,
|
||||
phases: freshPhaseMap() as never,
|
||||
lastError: null,
|
||||
});
|
||||
}
|
||||
|
||||
function snapshot(over: Partial<StartupProgressSnapshot> = {}): StartupProgressSnapshot {
|
||||
return {
|
||||
done: false,
|
||||
startedAt: 1,
|
||||
currentPhase: null,
|
||||
phases: freshPhaseMap() as never,
|
||||
lastError: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetStore();
|
||||
});
|
||||
|
||||
describe('启动覆盖层状态机', () => {
|
||||
it('初始状态:遮罩可见、未完成、无活跃 phase、全部 phase pending', () => {
|
||||
const s = useStartupStore.getState();
|
||||
expect(s.visible).toBe(true);
|
||||
expect(s.done).toBe(false);
|
||||
expect(s.activePhase).toBeNull();
|
||||
expect(s.lastError).toBeNull();
|
||||
for (const p of ALL_PHASES) {
|
||||
expect(s.phases[p].state, `phase ${p}`).toBe('pending');
|
||||
}
|
||||
});
|
||||
|
||||
it('all-done ⇒ done=true + 遮罩退场 + 活跃 phase 清空', () => {
|
||||
useStartupStore.getState().applyEvent({ type: 'all-done' });
|
||||
const s = useStartupStore.getState();
|
||||
expect(s.done).toBe(true);
|
||||
expect(s.visible, 'done 后遮罩必须退场,否则主界面永远点不动').toBe(false);
|
||||
expect(s.activePhase).toBeNull();
|
||||
});
|
||||
|
||||
it('phase-start ⇒ 该 phase 转 running + 记 total + 成为活跃 phase', () => {
|
||||
useStartupStore.getState().applyEvent({ type: 'phase-start', phase: 'notes', total: 42 });
|
||||
const s = useStartupStore.getState();
|
||||
expect(s.phases.notes.state).toBe('running');
|
||||
expect(s.phases.notes.total).toBe(42);
|
||||
expect(s.activePhase).toBe('notes');
|
||||
expect(s.visible).toBe(true);
|
||||
});
|
||||
|
||||
it('phase-start 不带 total ⇒ 不写 total 字段(不凭空造数)', () => {
|
||||
useStartupStore.getState().applyEvent({ type: 'phase-start', phase: 'literature' });
|
||||
expect(useStartupStore.getState().phases.literature.total).toBeUndefined();
|
||||
});
|
||||
|
||||
it('后启动的 phase 覆盖活跃 phase(last-started wins)', () => {
|
||||
const st = useStartupStore.getState();
|
||||
st.applyEvent({ type: 'phase-start', phase: 'notes' });
|
||||
st.applyEvent({ type: 'phase-start', phase: 'literature' });
|
||||
expect(useStartupStore.getState().activePhase).toBe('literature');
|
||||
});
|
||||
|
||||
it('phase-done(ok) ⇒ 该 phase done,活跃 phase 回退到下一个 running', () => {
|
||||
const st = useStartupStore.getState();
|
||||
st.applyEvent({ type: 'phase-start', phase: 'notes' });
|
||||
st.applyEvent({ type: 'phase-start', phase: 'finalizing' });
|
||||
expect(useStartupStore.getState().activePhase).toBe('finalizing');
|
||||
st.applyEvent({ type: 'phase-done', phase: 'finalizing', ok: true });
|
||||
const s = useStartupStore.getState();
|
||||
expect(s.phases.finalizing.state).toBe('done');
|
||||
expect(s.activePhase, 'finalizing 完成后应回退到还在跑的 notes').toBe('notes');
|
||||
});
|
||||
|
||||
it('phase-done(失败) ⇒ 记 lastError + 该 phase 的 error', () => {
|
||||
const st = useStartupStore.getState();
|
||||
st.applyEvent({ type: 'phase-start', phase: 'notes-resync' });
|
||||
st.applyEvent({ type: 'phase-done', phase: 'notes-resync', ok: false, error: 'resync boom' });
|
||||
const s = useStartupStore.getState();
|
||||
expect(s.phases['notes-resync'].state).toBe('done');
|
||||
expect(s.phases['notes-resync'].error).toBe('resync boom');
|
||||
expect(s.lastError).toBe('resync boom');
|
||||
});
|
||||
|
||||
it('phase-done(成功) ⇒ 不写 lastError(成功不该留下错误文案)', () => {
|
||||
const st = useStartupStore.getState();
|
||||
st.applyEvent({ type: 'phase-start', phase: 'notes' });
|
||||
st.applyEvent({ type: 'phase-done', phase: 'notes', ok: true });
|
||||
expect(useStartupStore.getState().lastError).toBeNull();
|
||||
});
|
||||
|
||||
it('活跃 phase 完成后无其它 running ⇒ 活跃 phase 变 null', () => {
|
||||
const st = useStartupStore.getState();
|
||||
st.applyEvent({ type: 'phase-start', phase: 'notes' });
|
||||
st.applyEvent({ type: 'phase-done', phase: 'notes', ok: true });
|
||||
expect(useStartupStore.getState().activePhase).toBeNull();
|
||||
});
|
||||
|
||||
it('forceFinish ⇒ done + 退场;重复调用幂等(不抛不改)', () => {
|
||||
useStartupStore.getState().forceFinish();
|
||||
const after1 = useStartupStore.getState();
|
||||
expect(after1.done).toBe(true);
|
||||
expect(after1.visible).toBe(false);
|
||||
useStartupStore.getState().forceFinish();
|
||||
const after2 = useStartupStore.getState();
|
||||
expect(after2.done).toBe(true);
|
||||
expect(after2.visible).toBe(false);
|
||||
});
|
||||
|
||||
it('已 done 后收到普通 phase 事件 ⇒ 忽略(不复活遮罩)', () => {
|
||||
const st = useStartupStore.getState();
|
||||
st.applyEvent({ type: 'all-done' });
|
||||
st.applyEvent({ type: 'phase-start', phase: 'notes' });
|
||||
const s = useStartupStore.getState();
|
||||
expect(s.done).toBe(true);
|
||||
expect(s.visible).toBe(false);
|
||||
expect(s.phases.notes.state, '已收尾后不该有 phase 回到 running').toBe('pending');
|
||||
});
|
||||
|
||||
it('未知事件 type ⇒ 静默忽略,不抛(union 扩展时的防御)', () => {
|
||||
useStartupStore.getState().applyEvent({ type: 'who-knows' } as never);
|
||||
expect(useStartupStore.getState().done).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapshot 合并(不回退 + 不闪回)', () => {
|
||||
it('snap.done=true ⇒ 直接收尾(不挂遮罩)', () => {
|
||||
useStartupStore.getState().hydrateFromSnapshot(snapshot({ done: true }));
|
||||
const s = useStartupStore.getState();
|
||||
expect(s.done).toBe(true);
|
||||
expect(s.visible).toBe(false);
|
||||
expect(s.activePhase).toBeNull();
|
||||
});
|
||||
|
||||
it('已 forceFinish 时 snapshot 到达 ⇒ no-op(遮罩不闪回)', () => {
|
||||
useStartupStore.getState().forceFinish();
|
||||
useStartupStore.getState().hydrateFromSnapshot(snapshot({ done: false }));
|
||||
const s = useStartupStore.getState();
|
||||
expect(s.done, '30s 兜底已收尾,snapshot 不得把它打回未完成').toBe(true);
|
||||
expect(s.visible, '遮罩不得在已渲染好的主界面上闪回').toBe(false);
|
||||
});
|
||||
|
||||
it('listener 已把 phase 推到 done,snapshot 还是 pending ⇒ 保持 done(不回退)', () => {
|
||||
const st = useStartupStore.getState();
|
||||
st.applyEvent({ type: 'phase-start', phase: 'notes' });
|
||||
st.applyEvent({ type: 'phase-done', phase: 'notes', ok: true });
|
||||
st.hydrateFromSnapshot(snapshot());
|
||||
expect(useStartupStore.getState().phases.notes.state).toBe('done');
|
||||
});
|
||||
|
||||
it('listener 已 running,snapshot 还是 pending ⇒ 保持 running', () => {
|
||||
const st = useStartupStore.getState();
|
||||
st.applyEvent({ type: 'phase-start', phase: 'notes' });
|
||||
st.hydrateFromSnapshot(snapshot());
|
||||
expect(useStartupStore.getState().phases.notes.state).toBe('running');
|
||||
});
|
||||
|
||||
it('snapshot 更靠后(pending→done)⇒ 采用 snapshot 的 state', () => {
|
||||
const st = useStartupStore.getState();
|
||||
st.applyEvent({ type: 'phase-start', phase: 'notes' });
|
||||
const snap = snapshot();
|
||||
snap.phases.notes = { state: 'done' };
|
||||
st.hydrateFromSnapshot(snap);
|
||||
expect(useStartupStore.getState().phases.notes.state).toBe('done');
|
||||
});
|
||||
|
||||
it('两边都 running ⇒ 保留 listener 的 total(snapshot 可能是更早的值)', () => {
|
||||
const st = useStartupStore.getState();
|
||||
st.applyEvent({ type: 'phase-start', phase: 'notes', total: 7 });
|
||||
const snap = snapshot();
|
||||
snap.phases.notes = { state: 'running', total: 3 };
|
||||
st.hydrateFromSnapshot(snap);
|
||||
expect(useStartupStore.getState().phases.notes.total).toBe(7);
|
||||
});
|
||||
|
||||
it('listener 已有活跃 phase ⇒ 优先于 snapshot 的 currentPhase', () => {
|
||||
const st = useStartupStore.getState();
|
||||
st.applyEvent({ type: 'phase-start', phase: 'literature' });
|
||||
st.hydrateFromSnapshot(snapshot({ currentPhase: 'notes' }));
|
||||
expect(useStartupStore.getState().activePhase).toBe('literature');
|
||||
});
|
||||
|
||||
it('两边都没有活跃 phase ⇒ 回退到合并后的第一个 running phase', () => {
|
||||
const st = useStartupStore.getState();
|
||||
const snap = snapshot();
|
||||
snap.phases.literature = { state: 'running' };
|
||||
st.hydrateFromSnapshot(snap);
|
||||
expect(useStartupStore.getState().activePhase).toBe('literature');
|
||||
});
|
||||
|
||||
it('lastError:snapshot 有值取 snapshot,snapshot 为 null 则保留 listener 累积值', () => {
|
||||
const st = useStartupStore.getState();
|
||||
st.applyEvent({ type: 'phase-done', phase: 'notes', ok: false, error: 'cur err' });
|
||||
st.hydrateFromSnapshot(snapshot({ lastError: null }));
|
||||
expect(useStartupStore.getState().lastError).toBe('cur err');
|
||||
st.hydrateFromSnapshot(snapshot({ lastError: 'snap err' }));
|
||||
expect(useStartupStore.getState().lastError).toBe('snap err');
|
||||
});
|
||||
});
|
||||
|
||||
describe('phase 名单三方一致(r51 回归类:主进程加 phase,renderer 漏一处就崩)', () => {
|
||||
/** 从 src/shared/types.ts 抽出 StartupPhase union 的成员(单一真相源)。 */
|
||||
function unionMembers(): string[] {
|
||||
const src = readFileSync(join(process.cwd(), 'src/shared/types.ts'), 'utf-8');
|
||||
const m = /export type StartupPhase =([^;]+);/.exec(src);
|
||||
expect(m, 'src/shared/types.ts 里应能解析出 StartupPhase union').not.toBeNull();
|
||||
const members = [...m![1].matchAll(/'([^']+)'/g)].map((x) => x[1]!);
|
||||
expect(members.length, 'StartupPhase union 不应为空').toBeGreaterThan(0);
|
||||
return members;
|
||||
}
|
||||
|
||||
it('共享 union 与 store 初始化表(freshPhases)同全集', () => {
|
||||
const union = unionMembers().sort();
|
||||
const init = Object.keys(useStartupStore.getState().phases).sort();
|
||||
expect(init, 'store 初始化漏了 union 里的 phase ⇒ applyEvent 会撞 undefined').toEqual(union);
|
||||
});
|
||||
|
||||
it('共享 union 与优先级表同全集', () => {
|
||||
const union = unionMembers().sort();
|
||||
expect([...PRIORITY].sort(), '优先级表漏了 union 里的 phase ⇒ 该 phase 跑起来时活跃文案空').toEqual(
|
||||
union,
|
||||
);
|
||||
});
|
||||
|
||||
it('逐 phase:mergePhases 覆盖 union 全集(漏一个 ⇒ 该 phase 被 snapshot 覆盖回退)', () => {
|
||||
for (const p of unionMembers()) {
|
||||
resetStore();
|
||||
const st = useStartupStore.getState();
|
||||
st.applyEvent({ type: 'phase-start', phase: p as StartupPhase });
|
||||
st.applyEvent({ type: 'phase-done', phase: p as StartupPhase, ok: true });
|
||||
st.hydrateFromSnapshot(snapshot());
|
||||
expect(
|
||||
useStartupStore.getState().phases[p as StartupPhase].state,
|
||||
`phase ${p} 被 snapshot 打回 pending`,
|
||||
).toBe('done');
|
||||
}
|
||||
});
|
||||
|
||||
it('逐 phase:优先级表能挑出每个 running 的 phase(漏一个 ⇒ 活跃文案空)', () => {
|
||||
for (const p of unionMembers()) {
|
||||
resetStore();
|
||||
const st = useStartupStore.getState();
|
||||
st.applyEvent({ type: 'phase-start', phase: p as StartupPhase });
|
||||
st.applyEvent({ type: 'phase-done', phase: p as StartupPhase, ok: true });
|
||||
expect(useStartupStore.getState().activePhase, `phase ${p} 完成后没有回退目标`).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('两个 phase 同时 running ⇒ 回退挑优先级更高的那个', () => {
|
||||
for (let i = 0; i < PRIORITY.length; i++) {
|
||||
for (let j = 0; j < PRIORITY.length; j++) {
|
||||
if (i === j) continue;
|
||||
const hi = PRIORITY[i]!;
|
||||
const lo = PRIORITY[j]!;
|
||||
resetStore();
|
||||
const st = useStartupStore.getState();
|
||||
st.applyEvent({ type: 'phase-start', phase: hi });
|
||||
st.applyEvent({ type: 'phase-start', phase: lo });
|
||||
// 让 lo 成为当前活跃 phase,再完成它 ⇒ 应回退到优先级更高的 hi
|
||||
st.applyEvent({ type: 'phase-done', phase: lo, ok: true });
|
||||
expect(
|
||||
useStartupStore.getState().activePhase,
|
||||
`${hi} 与 ${lo} 同时 running 时,${lo} 完成后应回退到 ${hi}`,
|
||||
).toBe(hi);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
94
tests/unit/renderer/store/failed-drafts.test.ts
Normal file
94
tests/unit/renderer/store/failed-drafts.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
// tests/unit/renderer/store/failed-drafts.test.ts
|
||||
//
|
||||
// 守卫(r206,2026-10-20):笔记保存失败时的草稿缓冲(store/failed-drafts.ts)。
|
||||
//
|
||||
// 为什么值得锁:这个 Map 是**用户丢字前的最后一道保险** —— 保存失败
|
||||
// (CONFLICT / IPC 断 / 磁盘满)时把 body/title 快照进来,用户重开面板时
|
||||
// 还能捞回来。静默丢字是这类应用最不可接受的故障,且**不会报错**
|
||||
// (失败路径本来就静默)。
|
||||
//
|
||||
// 两条硬不变式:
|
||||
// ① **模块级单例**(不是 ref):ref 随组件 unmount 消失,而「关面板 →
|
||||
// 重开」正是最需要草稿活着的场景;组件级持有还会形成
|
||||
// store/notes → components/StandaloneNoteEditorCore → store/notes 的
|
||||
// 循环依赖(r35 就是为这个把它抽到零依赖叶子模块的)。
|
||||
// ② **零 import**:本文件不许 import 任何东西 —— 一旦 import 了
|
||||
// store/ 之类的东西,循环依赖就可能重新长回来(而 layering 守卫只管
|
||||
// 进程边界,管不到 renderer 内部)。这是 r206 加的**结构守卫**。
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
clearFailedDraft,
|
||||
getFailedDraft,
|
||||
putFailedDraft,
|
||||
} from '@renderer/store/failed-drafts';
|
||||
|
||||
const SRC = join(process.cwd(), 'src/renderer/src/store/failed-drafts.ts');
|
||||
|
||||
describe('failed-drafts(r206)', () => {
|
||||
beforeEach(() => {
|
||||
// 清理上一例残留(模块级 Map 跨用例存活)
|
||||
for (const id of ['a', 'b', 'c']) clearFailedDraft(id);
|
||||
});
|
||||
|
||||
it('put 之后 get 得到同一个草稿', () => {
|
||||
putFailedDraft('a', { body: 'body-1', title: 'title-1' });
|
||||
expect(getFailedDraft('a')).toEqual({ body: 'body-1', title: 'title-1' });
|
||||
});
|
||||
|
||||
it('get 未暂存的 id → undefined', () => {
|
||||
expect(getFailedDraft('never-put')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('同一 id 重复 put 覆盖旧值(不追加、不排队)', () => {
|
||||
putFailedDraft('a', { body: 'old', title: 'old' });
|
||||
putFailedDraft('a', { body: 'new', title: 'new' });
|
||||
expect(getFailedDraft('a')).toEqual({ body: 'new', title: 'new' });
|
||||
});
|
||||
|
||||
it('不同 id 各自独立', () => {
|
||||
putFailedDraft('a', { body: 'A', title: 'A' });
|
||||
putFailedDraft('b', { body: 'B', title: 'B' });
|
||||
expect(getFailedDraft('a')?.body).toBe('A');
|
||||
expect(getFailedDraft('b')?.body).toBe('B');
|
||||
});
|
||||
|
||||
it('clear 之后 get → undefined', () => {
|
||||
putFailedDraft('a', { body: 'x', title: 'y' });
|
||||
clearFailedDraft('a');
|
||||
expect(getFailedDraft('a')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clear 未暂存的 id 不抛(幂等)', () => {
|
||||
expect(() => clearFailedDraft('never-put')).not.toThrow();
|
||||
});
|
||||
|
||||
it('空字符串 body / title 也照常存取(不因 falsy 被吞)', () => {
|
||||
putFailedDraft('a', { body: '', title: '' });
|
||||
expect(getFailedDraft('a')).toEqual({ body: '', title: '' });
|
||||
});
|
||||
|
||||
it('草稿是**同一个对象引用**(不被悄悄拷贝/规范化)', () => {
|
||||
const draft = { body: 'B', title: 'T' };
|
||||
putFailedDraft('a', draft);
|
||||
expect(getFailedDraft('a')).toBe(draft);
|
||||
});
|
||||
|
||||
it('模块级单例:重复 import 拿到同一份 Map', async () => {
|
||||
const again = await import('@renderer/store/failed-drafts');
|
||||
putFailedDraft('c', { body: 'C', title: 'C' });
|
||||
expect(again.getFailedDraft('c')).toEqual({ body: 'C', title: 'C' });
|
||||
});
|
||||
|
||||
it('结构不变式:本文件零 import(不许长回循环依赖)', () => {
|
||||
const src = readFileSync(SRC, 'utf8');
|
||||
// 去掉注释行再找 import —— 注释里提到 import 是允许的
|
||||
const code = src
|
||||
.split('\n')
|
||||
.filter((l) => !l.trimStart().startsWith('//'))
|
||||
.join('\n');
|
||||
const imports = code.match(/^\s*import\s.*$/gm) ?? [];
|
||||
expect(imports, `failed-drafts.ts 不许有 import(现状):\n${imports.join('\n')}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
101
tests/unit/renderer/store/hmr-listener-dispose.test.ts
Normal file
101
tests/unit/renderer/store/hmr-listener-dispose.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
// tests/unit/renderer/store/hmr-listener-dispose.test.ts
|
||||
//
|
||||
// 守「模块级订阅 onXxxChanged 的 store 必须有 import.meta.hot 自卸载」这条
|
||||
// 约定(r213)。
|
||||
//
|
||||
// ── 被守卫的缺陷(不是风格问题,是 dev 态的监听器泄漏)─────────────────
|
||||
// 这些 store 在**模块顶层**订阅主进程推送(`_ensureInit()` 里
|
||||
// `_unsubscribe = window.api.onNotesChanged(...)`,模块一加载就订,不等
|
||||
// React mount)。dev 模式下 vite-plugin-react 改**任意** source file 都会
|
||||
// 触发本模块的 HMR 重求值,模块顶层副作用会**再跑一次** —— 于是旧
|
||||
// listener(指向旧模块闭包)没被摘掉、新 listener 又订上一份。
|
||||
//
|
||||
// 后果:N 次热更后监听器集合单调增长到 N 份,一次 NOTES_CHANGED /
|
||||
// LITERATURE_CHANGED 广播触发 **N 次并发** listNotes / listLiterature IPC
|
||||
// 与 N 轮全列表重渲,而且这些旧监听器**永远无法回收**(teardown 函数只有
|
||||
// 测试调用,生产路径从不摘)。
|
||||
//
|
||||
// 修法 = 在每个这种 store 里加 `import.meta.hot?.dispose(() => { …摘监听… })`
|
||||
// —— 在 HMR 替换本模块**之前**跑,摘掉旧 listener,让新一轮 `_ensureInit`
|
||||
// 重订一次。生产 build `import.meta.hot === undefined`,自然不挂这条逻辑。
|
||||
// 对照:`store/literature.ts`(r136 M7)与 `store/workspace.ts` 都有这条;
|
||||
// `store/notes.ts` **漏了**(r213 补上)。
|
||||
//
|
||||
// ── 判定方式 ───────────────────────────────────────────────────────
|
||||
// 静态扫 src/renderer/src/store/*.ts:凡是出现**模块级订阅**形态
|
||||
// `window.api.on…Changed(` 的文件,必须同时出现 `import.meta.hot`
|
||||
// (dispose 钩子的标记)。这是结构守卫 —— 它锁「该有 dispose 的文件不许漏」,
|
||||
// 而不是去模拟 HMR 运行时(jsdom 没法重放 Vite 的模块替换)。
|
||||
import { readdirSync, readFileSync, statSync } from 'fs';
|
||||
import { join, relative } from 'path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const STORE_DIR = join(ROOT, 'src', 'renderer', 'src', 'store');
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) out.push(...walk(full));
|
||||
else if (/\.ts$/.test(entry)) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function relOf(abs: string): string {
|
||||
return relative(ROOT, abs).split('\\').join('/');
|
||||
}
|
||||
|
||||
/** 模块级推送订阅的形态(window.api.on<…>Changed( —— 含 notes / literature 等) */
|
||||
const PUSH_SUBSCRIBE_RE = /window\.api\.on\w*Changed\(/;
|
||||
/** HMR 自卸载的标记 */
|
||||
const HMR_DISPOSE_RE = /import\.meta\.hot/;
|
||||
|
||||
describe('模块级推送订阅必须有 HMR 自卸载(r213)', () => {
|
||||
it('每个订阅 onXxxChanged 的 store 都有 import.meta.hot dispose', () => {
|
||||
const violations: string[] = [];
|
||||
for (const file of walk(STORE_DIR)) {
|
||||
const src = readFileSync(file, 'utf-8');
|
||||
if (!PUSH_SUBSCRIBE_RE.test(src)) continue; // 不订阅推送的 store 不适用
|
||||
if (HMR_DISPOSE_RE.test(src)) continue; // 有 dispose ⇒ 通过
|
||||
violations.push(relOf(file));
|
||||
}
|
||||
|
||||
expect(
|
||||
violations,
|
||||
violations.length > 0
|
||||
? `这些 store 在模块顶层订阅了 onXxxChanged,却没有 import.meta.hot dispose:\n` +
|
||||
violations.map((v) => ` ${v}`).join('\n') +
|
||||
`\n后果:dev 态每次 HMR 都泄漏一个 listener,N 次热更后一次广播触发 N 次\n` +
|
||||
`并发 IPC 与 N 轮全列表重渲,旧 listener 永远回收不掉。\n` +
|
||||
`修法:照 store/literature.ts 的 r136 M7 模式补一段 ——\n` +
|
||||
` if (typeof import.meta.hot !== 'undefined') {\n` +
|
||||
` import.meta.hot?.dispose(() => { /* _unsubscribe(); _initialized=false */ });\n` +
|
||||
` }`
|
||||
: '',
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('守卫本身是有效的(变异对照:有订阅无 dispose ⇒ 抓得到)', () => {
|
||||
// fake 用**正则源文本**(字符串)表达,避免 import.meta.hot 字面量被
|
||||
// esbuild 在测试构建时静态处理掉(它会让断言依赖构建期行为,不可靠)。
|
||||
const disposeMarker = new RegExp(HMR_DISPOSE_RE.source);
|
||||
const fake = [
|
||||
"const _unsub = window.api.onNotesChanged(() => { /* … */ });",
|
||||
'// 这个文件没有任何 HMR dispose',
|
||||
].join('\n');
|
||||
expect(PUSH_SUBSCRIBE_RE.test(fake)).toBe(true);
|
||||
expect(disposeMarker.test(fake)).toBe(false); // ⇒ 这条 fake 会被主守卫抓到
|
||||
|
||||
// 反向:有订阅 + 有 dispose ⇒ 通过
|
||||
const good = fake + '\n' + 'if (import.meta.hot) { import.meta.hot.dispose(() => {}); }';
|
||||
expect(PUSH_SUBSCRIBE_RE.test(good)).toBe(true);
|
||||
expect(disposeMarker.test(good)).toBe(true);
|
||||
});
|
||||
|
||||
it('非订阅文件(纯逻辑 store)不适用本约定(对照)', () => {
|
||||
const pure = "export const x = 1;\nif (import.meta.hot) { /* 别的用途 */ }";
|
||||
expect(PUSH_SUBSCRIBE_RE.test(pure)).toBe(false); // ⇒ 不在本守卫的作用集里
|
||||
});
|
||||
});
|
||||
258
tests/unit/renderer/store/lit-cut-paste.test.ts
Normal file
258
tests/unit/renderer/store/lit-cut-paste.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
// tests/unit/renderer/store/lit-cut-paste.test.ts
|
||||
//
|
||||
// 守卫:literature store 的 cut + paste(cut) + moveToFolder 在 r167 必须
|
||||
// 走 api.litMove IPC,失败累计 + 首个 throw。以及 r167 落地的 selectedId
|
||||
// 重映射 —— 跨目录搬移改 id 后若编辑器选中该资源,selectedId 必须跳到
|
||||
// 新 id,否则后续读 absPath / 重开 PDF 会撞 NOT_FOUND。
|
||||
//
|
||||
// 与 notes-cut-paste.test.ts 同源,只换 api.litMove / literature store。
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../../src/renderer/src/store/workspace', () => ({
|
||||
useWorkspaceStore: {
|
||||
getState: () => ({
|
||||
pendingFocusNote: { hasPending: false, payload: null },
|
||||
consumeFocusNoteRequest: () => {},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../../src/renderer/src/lib/toast', () => ({
|
||||
useToast: {
|
||||
getState: () => ({ show: () => {} }),
|
||||
},
|
||||
}));
|
||||
|
||||
import { useLiteratureStore } from '../../../../src/renderer/src/store/literature';
|
||||
import type { LiteratureResource } from '../../../../src/shared/types';
|
||||
|
||||
interface MockApi {
|
||||
litMove: ReturnType<typeof vi.fn>;
|
||||
listLiterature: ReturnType<typeof vi.fn>;
|
||||
importLiteratureFromFile: ReturnType<typeof vi.fn>;
|
||||
onLiteratureChanged: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function makeItem(id: string, absPath: string): LiteratureResource {
|
||||
return {
|
||||
id: id as unknown as LiteratureResource['id'],
|
||||
kind: 'literature',
|
||||
absPath,
|
||||
rootId: null,
|
||||
extractStatus: 'ok',
|
||||
title: '',
|
||||
mtime: 0,
|
||||
size: 0,
|
||||
indexedAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function setupMockApi(): MockApi {
|
||||
const api: MockApi = {
|
||||
litMove: vi.fn(),
|
||||
listLiterature: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||
importLiteratureFromFile: vi.fn(),
|
||||
onLiteratureChanged: vi.fn().mockReturnValue(() => {}),
|
||||
};
|
||||
(globalThis as unknown as { window: { api: MockApi } }).window = { api };
|
||||
useLiteratureStore.setState({
|
||||
items: [],
|
||||
selectedIds: new Set<string>(),
|
||||
selectedId: null,
|
||||
anchorId: null,
|
||||
clipboard: null,
|
||||
lastError: null,
|
||||
displayOrderById: {},
|
||||
});
|
||||
return api;
|
||||
}
|
||||
|
||||
describe('literature store cut/paste/moveToFolder', () => {
|
||||
beforeEach(() => setupMockApi());
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it('cut 写剪贴板,不立即调 IPC', () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
useLiteratureStore.setState({
|
||||
items: [makeItem('l1', 'l1.pdf'), makeItem('l2', 'l2.pdf')],
|
||||
selectedIds: new Set(['l1', 'l2']),
|
||||
});
|
||||
useLiteratureStore.getState().cut();
|
||||
expect(useLiteratureStore.getState().clipboard).toEqual({
|
||||
ids: ['l1', 'l2'],
|
||||
mode: 'cut',
|
||||
});
|
||||
expect(api.litMove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('paste(cut) 调 litMove 每个 id 用目标 relPath', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
useLiteratureStore.setState({
|
||||
items: [makeItem('l1', 'l1.pdf'), makeItem('l2', 'l2.pdf')],
|
||||
selectedIds: new Set(['l1', 'l2']),
|
||||
});
|
||||
useLiteratureStore.getState().cut();
|
||||
api.litMove.mockResolvedValue({ ok: true, data: { id: 'l1', relPath: 'foo/l1.pdf' } });
|
||||
await useLiteratureStore.getState().paste('foo');
|
||||
expect(api.litMove).toHaveBeenCalledTimes(2);
|
||||
expect(api.litMove).toHaveBeenNthCalledWith(1, 'l1', 'foo');
|
||||
expect(api.litMove).toHaveBeenNthCalledWith(2, 'l2', 'foo');
|
||||
});
|
||||
|
||||
it('paste(cut) 部分失败 throw,剪贴板保留可重试', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
useLiteratureStore.setState({
|
||||
items: [makeItem('l1', 'l1.pdf'), makeItem('l2', 'l2.pdf')],
|
||||
selectedIds: new Set(['l1', 'l2']),
|
||||
});
|
||||
useLiteratureStore.getState().cut();
|
||||
api.litMove.mockImplementation((id: string) => {
|
||||
if (id === 'l2') {
|
||||
return Promise.resolve({ ok: false, code: 'IO_ERROR', message: 'disk fail' });
|
||||
}
|
||||
return Promise.resolve({ ok: true, data: { id, relPath: 'foo/' + id + '.pdf' } });
|
||||
});
|
||||
await expect(useLiteratureStore.getState().paste('foo')).rejects.toThrowError(/failed 1\/2/);
|
||||
expect(useLiteratureStore.getState().clipboard).toEqual({
|
||||
ids: ['l1', 'l2'],
|
||||
mode: 'cut',
|
||||
});
|
||||
});
|
||||
|
||||
it('paste(cut) 成功后被搬移的 selectedId 重映射到 IPC 响应的新 id', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
const NEW_ID = 'new-l1';
|
||||
const NEW_REL = 'foo/l1.pdf';
|
||||
useLiteratureStore.setState({
|
||||
items: [makeItem('l1', 'l1.pdf')],
|
||||
selectedId: 'l1' as unknown as null,
|
||||
selectedIds: new Set(['l1']),
|
||||
});
|
||||
useLiteratureStore.getState().cut();
|
||||
api.litMove.mockResolvedValue({ ok: true, data: { id: NEW_ID, relPath: NEW_REL } });
|
||||
await useLiteratureStore.getState().paste('foo');
|
||||
expect(useLiteratureStore.getState().selectedId).toBe(NEW_ID);
|
||||
});
|
||||
|
||||
it('moveToFolder 0 ids 是 no-op', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
await useLiteratureStore.getState().moveToFolder([], 'x');
|
||||
expect(api.litMove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('moveToFolder 失败 throw,剪贴板不动', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
useLiteratureStore.setState({
|
||||
items: [makeItem('l1', 'l1.pdf')],
|
||||
selectedIds: new Set(['l1']),
|
||||
});
|
||||
useLiteratureStore.getState().cut();
|
||||
api.litMove.mockResolvedValue({ ok: false, code: 'NOT_FOUND', message: 'gone' });
|
||||
await expect(useLiteratureStore.getState().moveToFolder(['l1'], 'x')).rejects.toThrowError(
|
||||
/failed/,
|
||||
);
|
||||
expect(useLiteratureStore.getState().clipboard).toEqual({ ids: ['l1'], mode: 'cut' });
|
||||
});
|
||||
|
||||
it('r168:applyMoveRemap 是 store public action —— LiteratureList 回调回写', () => {
|
||||
useLiteratureStore.setState({
|
||||
selectedId: 'l1' as unknown as null,
|
||||
});
|
||||
const remap = new Map<string, { id: string; relPath?: string; absPath?: string }>();
|
||||
remap.set('l1', { id: 'new-l1', absPath: 'foo/l1.pdf' });
|
||||
useLiteratureStore.getState().applyMoveRemap(remap);
|
||||
expect(useLiteratureStore.getState().selectedId).toBe('new-l1');
|
||||
});
|
||||
|
||||
it('r168:applyMoveRemap 空 map / id 不匹配 都是 no-op', () => {
|
||||
useLiteratureStore.setState({ selectedId: 'l1' as unknown as null });
|
||||
useLiteratureStore.getState().applyMoveRemap(new Map());
|
||||
expect(useLiteratureStore.getState().selectedId).toBe('l1');
|
||||
const remap = new Map<string, { id: string; relPath?: string; absPath?: string }>();
|
||||
remap.set('other', { id: 'new-other', absPath: 'foo/other.pdf' });
|
||||
useLiteratureStore.getState().applyMoveRemap(remap);
|
||||
expect(useLiteratureStore.getState().selectedId).toBe('l1');
|
||||
});
|
||||
});
|
||||
|
||||
// r170:literature copy 分支两处修正。
|
||||
//
|
||||
// 1. **搬移后 id 会变** —— lit id = 'lit:'+toPosix(absPath),搬进子目录
|
||||
// absPath 变 → id 变。旧实现只判 `r.ok` 把 r.data.id 丢掉,末尾
|
||||
// `set({ selectedIds: new Set(newIds) })` 存的是**搬移前**的 id →
|
||||
// 选中的行在 UI 里不存在(点不开 / Reader 拿不到)。这是「复制粘贴进
|
||||
// 文件夹后选不中」的直接原因。
|
||||
// 2. 失败可见 —— 旧实现在 store 内部调 `api.pushToast` 弹部分失败 toast,
|
||||
// 但 **preload 的 Api 接口里没有 pushToast**(grep 零命中)→ 生产恒走
|
||||
// else 分支只 log.warn,用户零感知。改成累计 errors 后抛聚合错,由
|
||||
// LiteratureList.pasteTo 的 catch 统一 toast(literature.pasteFailedToast)。
|
||||
describe('literature store paste(copy) —— 搬移后的 id 与失败可见(r170)', () => {
|
||||
beforeEach(() => setupMockApi());
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it('搬进目标目录后 selectedIds 用搬后的 id(不是 import 时的 id)', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
useLiteratureStore.setState({
|
||||
items: [makeItem('l1', 'C:/lib/l1.pdf')],
|
||||
selectedIds: new Set(['l1']),
|
||||
});
|
||||
useLiteratureStore.getState().copy();
|
||||
// import 落在 literature 根,id = 'lit:'+根下绝对路径
|
||||
api.importLiteratureFromFile.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { id: 'lit:root/l1.pdf', absPath: 'C:/lib/l1.pdf' },
|
||||
});
|
||||
// 搬进子目录后 absPath 变 → id 变
|
||||
api.litMove.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { id: 'lit:root/foo/l1.pdf', absPath: 'C:/lib/foo/l1.pdf' },
|
||||
});
|
||||
await useLiteratureStore.getState().paste('foo');
|
||||
expect(api.litMove).toHaveBeenCalledWith('lit:root/l1.pdf', 'foo');
|
||||
expect([...useLiteratureStore.getState().selectedIds]).toEqual(['lit:root/foo/l1.pdf']);
|
||||
});
|
||||
|
||||
it('目标 = 根(null)时不调 litMove(import 本来就落根),选中 import id', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
useLiteratureStore.setState({
|
||||
items: [makeItem('l1', 'C:/lib/l1.pdf')],
|
||||
selectedIds: new Set(['l1']),
|
||||
});
|
||||
useLiteratureStore.getState().copy();
|
||||
api.importLiteratureFromFile.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { id: 'lit:root/l1.pdf', absPath: 'C:/lib/l1.pdf' },
|
||||
});
|
||||
await useLiteratureStore.getState().paste(null);
|
||||
expect(api.litMove).not.toHaveBeenCalled();
|
||||
expect([...useLiteratureStore.getState().selectedIds]).toEqual(['lit:root/l1.pdf']);
|
||||
});
|
||||
|
||||
it('import 失败 → 聚合错抛出,不调 litMove,剪贴板保留可重试', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
useLiteratureStore.setState({
|
||||
items: [makeItem('l1', 'C:/lib/l1.pdf')],
|
||||
selectedIds: new Set(['l1']),
|
||||
});
|
||||
useLiteratureStore.getState().copy();
|
||||
api.importLiteratureFromFile.mockRejectedValue(new Error('io boom'));
|
||||
await expect(useLiteratureStore.getState().paste('foo')).rejects.toThrowError(/failed 1\/1/);
|
||||
expect(api.litMove).not.toHaveBeenCalled();
|
||||
expect(useLiteratureStore.getState().clipboard).toEqual({ ids: ['l1'], mode: 'copy' });
|
||||
});
|
||||
|
||||
it('搬移失败 → 聚合错抛出,副本仍留在选中集(它还在根,id 未变)', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
useLiteratureStore.setState({
|
||||
items: [makeItem('l1', 'C:/lib/l1.pdf')],
|
||||
selectedIds: new Set(['l1']),
|
||||
});
|
||||
useLiteratureStore.getState().copy();
|
||||
api.importLiteratureFromFile.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { id: 'lit:root/l1.pdf', absPath: 'C:/lib/l1.pdf' },
|
||||
});
|
||||
api.litMove.mockResolvedValue({ ok: false, code: 'VALIDATION', message: 'target exists' });
|
||||
await expect(useLiteratureStore.getState().paste('foo')).rejects.toThrowError(/failed 1\/1/);
|
||||
expect([...useLiteratureStore.getState().selectedIds]).toEqual(['lit:root/l1.pdf']);
|
||||
});
|
||||
});
|
||||
204
tests/unit/renderer/store/move-remap.test.ts
Normal file
204
tests/unit/renderer/store/move-remap.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
// tests/unit/renderer/store/move-remap.test.ts
|
||||
//
|
||||
// 守卫(r175):搬移之后 **id 重映射必须覆盖 selectedIds(复数选中集)**,
|
||||
// 且 moveToFolder 必须把 idRemap 交回给 caller。
|
||||
//
|
||||
// 背景 —— 两处真实缺陷:
|
||||
//
|
||||
// 1. applyMoveRemapImpl / applyLitMoveRemapImpl 只刷单数 selectedId,
|
||||
// 复数 selectedIds 里留着**已不存在的旧 id**(fs.rename 后 id 随
|
||||
// relPath / absPath 重算)。窗口期(NOTES_CHANGED 推送触发 load()
|
||||
// 清空 selectedIds 之前,约 50~500ms)用户按 Delete / Ctrl+X:
|
||||
// - cut/copy:ids = notes.filter(n => selectedIds.has(n.id)) → 空
|
||||
// → 剪贴板不落,「按了没反应」
|
||||
// - trash:按 selectedIds 过滤 → 一条都删不掉(静默)
|
||||
//
|
||||
// 2. moveToFolder 原来返回 void,caller 拿不到 oldId → newId 映射。
|
||||
// NotesList.importFiles 因此在「导入到子文件夹」后用**搬移前**的
|
||||
// createdIds 去 select —— 选到一个死 id,右侧 NotesView 报 NOT_FOUND,
|
||||
// 用户看到「导入成功却打不开」。文献侧同款路径早已用搬移后的 id。
|
||||
//
|
||||
// 契约锁定:
|
||||
// - moveToFolder 全成功 → 返回完整 idRemap Map
|
||||
// - moveToFolder 部分失败 → throw,但 error.idRemap 带**成功那部分**的
|
||||
// 映射,且成功那部分的重映射同样已经落到 store(已搬走的 id 确实变了)
|
||||
// - applyMoveRemap / moveToFolder 两条路径都把 selectedIds 里的旧 id
|
||||
// 换成新 id
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../../src/renderer/src/store/workspace', () => ({
|
||||
useWorkspaceStore: {
|
||||
getState: () => ({
|
||||
pendingFocusNote: { hasPending: false, payload: null },
|
||||
consumeFocusNoteRequest: () => {},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../../src/renderer/src/lib/toast', () => ({
|
||||
useToast: {
|
||||
getState: () => ({ show: () => {} }),
|
||||
},
|
||||
}));
|
||||
|
||||
import { useNotesStore } from '../../../../src/renderer/src/store/notes';
|
||||
import { useLiteratureStore } from '../../../../src/renderer/src/store/literature';
|
||||
import type { ResourceId } from '../../../../src/shared/types';
|
||||
|
||||
interface MockApi {
|
||||
notesMove: ReturnType<typeof vi.fn>;
|
||||
litMove: ReturnType<typeof vi.fn>;
|
||||
listNotes: ReturnType<typeof vi.fn>;
|
||||
listLiterature: ReturnType<typeof vi.fn>;
|
||||
onNotesChanged: ReturnType<typeof vi.fn>;
|
||||
onLiteratureChanged: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function setupMockApi(): MockApi {
|
||||
const api: MockApi = {
|
||||
notesMove: vi.fn(),
|
||||
litMove: vi.fn(),
|
||||
listNotes: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||
listLiterature: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||
onNotesChanged: vi.fn().mockReturnValue(() => {}),
|
||||
onLiteratureChanged: vi.fn().mockReturnValue(() => {}),
|
||||
};
|
||||
(globalThis as unknown as { window: { api: MockApi } }).window = { api };
|
||||
useNotesStore.setState({
|
||||
notes: [],
|
||||
selectedIds: new Set<string>(),
|
||||
selectedId: null,
|
||||
activeNoteFull: null,
|
||||
anchorId: null,
|
||||
clipboard: null,
|
||||
lastError: null,
|
||||
displayOrderById: {},
|
||||
});
|
||||
useLiteratureStore.setState({
|
||||
items: [],
|
||||
selectedIds: new Set<string>(),
|
||||
selectedId: null,
|
||||
anchorId: null,
|
||||
clipboard: null,
|
||||
lastError: null,
|
||||
displayOrderById: {},
|
||||
});
|
||||
return api;
|
||||
}
|
||||
|
||||
describe('搬移后的 id 重映射(r175)', () => {
|
||||
beforeEach(() => {
|
||||
setupMockApi();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('notes moveToFolder 全成功 → 返回 oldId→newId 映射', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
api.notesMove.mockImplementation((id: string) =>
|
||||
Promise.resolve({ ok: true, data: { id: `${id}-new`, relPath: `sub/${id}.md` } }),
|
||||
);
|
||||
const remap = await useNotesStore.getState().moveToFolder(['a', 'b'], 'sub');
|
||||
expect(remap.get('a')).toEqual({ id: 'a-new', relPath: 'sub/a.md' });
|
||||
expect(remap.get('b')).toEqual({ id: 'b-new', relPath: 'sub/b.md' });
|
||||
});
|
||||
|
||||
it('notes moveToFolder 把 selectedIds 里的旧 id 换成新 id', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
api.notesMove.mockImplementation((id: string) =>
|
||||
Promise.resolve({ ok: true, data: { id: `${id}-new`, relPath: `sub/${id}.md` } }),
|
||||
);
|
||||
useNotesStore.setState({
|
||||
notes: [
|
||||
{ id: 'a', relPath: 'a.md', filename: 'a.md', mtime: 0, tags: [], title: null },
|
||||
{ id: 'b', relPath: 'b.md', filename: 'b.md', mtime: 0, tags: [], title: null },
|
||||
],
|
||||
selectedIds: new Set(['a', 'b']),
|
||||
selectedId: 'a',
|
||||
});
|
||||
await useNotesStore.getState().moveToFolder(['a', 'b'], 'sub');
|
||||
const st = useNotesStore.getState();
|
||||
// 单数:编辑器打开态跟到新 id
|
||||
expect(st.selectedId).toBe('a-new');
|
||||
// 复数:多选集合里**不能**留着已不存在的旧 id
|
||||
expect(st.selectedIds.has('a')).toBe(false);
|
||||
expect(st.selectedIds.has('b')).toBe(false);
|
||||
expect(st.selectedIds.has('a-new')).toBe(true);
|
||||
expect(st.selectedIds.has('b-new')).toBe(true);
|
||||
});
|
||||
|
||||
it('notes moveToFolder 部分失败 → throw,但 error.idRemap 带成功那部分', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
api.notesMove.mockImplementation((id: string) => {
|
||||
if (id === 'b') {
|
||||
return Promise.resolve({ ok: false, code: 'IO_ERROR', message: 'disk fail' });
|
||||
}
|
||||
return Promise.resolve({ ok: true, data: { id: `${id}-new`, relPath: `sub/${id}.md` } });
|
||||
});
|
||||
useNotesStore.setState({
|
||||
selectedIds: new Set(['a', 'b']),
|
||||
selectedId: null,
|
||||
});
|
||||
let caught: unknown = null;
|
||||
try {
|
||||
await useNotesStore.getState().moveToFolder(['a', 'b'], 'sub');
|
||||
} catch (err: unknown) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
const remap = (caught as { idRemap?: Map<string, { id: string }> }).idRemap;
|
||||
expect(remap).toBeInstanceOf(Map);
|
||||
expect(remap?.get('a')?.id).toBe('a-new');
|
||||
expect(remap?.has('b')).toBe(false);
|
||||
// 成功搬走的那条,复数选中集也要跟着换
|
||||
expect(useNotesStore.getState().selectedIds.has('a-new')).toBe(true);
|
||||
expect(useNotesStore.getState().selectedIds.has('a')).toBe(false);
|
||||
});
|
||||
|
||||
it('notes applyMoveRemap(drag 路径)同样把 selectedIds 的旧 id 换成新 id', () => {
|
||||
useNotesStore.setState({
|
||||
selectedIds: new Set(['a', 'z']),
|
||||
selectedId: 'a',
|
||||
});
|
||||
useNotesStore.getState().applyMoveRemap(
|
||||
new Map([['a', { id: 'a-new', relPath: 'sub/a.md' }]]),
|
||||
);
|
||||
const st = useNotesStore.getState();
|
||||
expect(st.selectedId).toBe('a-new');
|
||||
expect(st.selectedIds.has('a')).toBe(false);
|
||||
expect(st.selectedIds.has('a-new')).toBe(true);
|
||||
// 不在搬移集合内的成员保持原样
|
||||
expect(st.selectedIds.has('z')).toBe(true);
|
||||
});
|
||||
|
||||
it('literature moveToFolder 把 selectedIds 里的旧 id 换成新 id', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
api.litMove.mockImplementation((id: string) =>
|
||||
Promise.resolve({ ok: true, data: { id: `${id}-new`, absPath: `/root/sub/${id}.pdf` } }),
|
||||
);
|
||||
useLiteratureStore.setState({
|
||||
items: [],
|
||||
selectedIds: new Set(['a', 'b']),
|
||||
selectedId: 'a' as ResourceId,
|
||||
});
|
||||
await useLiteratureStore.getState().moveToFolder(['a', 'b'], 'sub');
|
||||
const st = useLiteratureStore.getState();
|
||||
expect(st.selectedId).toBe('a-new');
|
||||
expect(st.selectedIds.has('a')).toBe(false);
|
||||
expect(st.selectedIds.has('b')).toBe(false);
|
||||
expect(st.selectedIds.has('a-new')).toBe(true);
|
||||
expect(st.selectedIds.has('b-new')).toBe(true);
|
||||
});
|
||||
|
||||
it('literature applyMoveRemap(drag 路径)同样把 selectedIds 的旧 id 换成新 id', () => {
|
||||
useLiteratureStore.setState({
|
||||
selectedIds: new Set(['a']),
|
||||
selectedId: 'a' as ResourceId,
|
||||
});
|
||||
useLiteratureStore.getState().applyMoveRemap(new Map([['a', { id: 'a-new' }]]));
|
||||
const st = useLiteratureStore.getState();
|
||||
expect(st.selectedId).toBe('a-new');
|
||||
expect(st.selectedIds.has('a')).toBe(false);
|
||||
expect(st.selectedIds.has('a-new')).toBe(true);
|
||||
});
|
||||
});
|
||||
176
tests/unit/renderer/store/notes-create-in-folder.test.ts
Normal file
176
tests/unit/renderer/store/notes-create-in-folder.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
// tests/unit/renderer/store/notes-create-in-folder.test.ts
|
||||
//
|
||||
// 守 r175「新建笔记必须落在当前文件夹」契约。
|
||||
//
|
||||
// 旧实现:`create: async (filename, folderId) => { void folderId; … }` ——
|
||||
// 目标目录被整个丢弃,笔记**永远**建在 notes 根。后果:
|
||||
//
|
||||
// 1. 在子文件夹视图里按「+ 新建」→ 文件落在 notes 根;
|
||||
// 2. sidebar 的 visibleItemRows 按 dirname(relPath) === currentRelPath
|
||||
// 过滤 → 当前文件夹里**看不到**刚建的笔记(用户视角 =「新建坏了」);
|
||||
// 3. 文件落点与 UI 展示的文件夹不一致 —— 直接违反用户诉求
|
||||
// 「左侧列表要和对应文件夹完全一致」。
|
||||
//
|
||||
// 本测锁:
|
||||
// A. folderId = 'sub' → createNote 收到 'sub/<name>.md';
|
||||
// B. folderId = null / undefined / '' (根) → createNote 收到裸文件名;
|
||||
// C. DUPLICATE_NAME 重试换名后,目录前缀**仍然带**(不能退到根);
|
||||
// D. 越界目录('.', '..')→ 归一到根,不把路径拼出 notes 子树。
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../../src/renderer/src/store/workspace', () => ({
|
||||
useWorkspaceStore: {
|
||||
getState: () => ({
|
||||
pendingFocusNote: { hasPending: false, payload: null },
|
||||
consumeFocusNoteRequest: () => {},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../../src/renderer/src/lib/toast', () => ({
|
||||
useToast: {
|
||||
getState: () => ({ show: () => {} }),
|
||||
},
|
||||
}));
|
||||
|
||||
import { useNotesStore } from '../../../../src/renderer/src/store/notes';
|
||||
|
||||
interface MockApi {
|
||||
notesMove: ReturnType<typeof vi.fn>;
|
||||
litMove: ReturnType<typeof vi.fn>;
|
||||
listNotes: ReturnType<typeof vi.fn>;
|
||||
readNote: ReturnType<typeof vi.fn>;
|
||||
createNote: ReturnType<typeof vi.fn>;
|
||||
writeNote: ReturnType<typeof vi.fn>;
|
||||
onNotesChanged: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function setupMockApi(): MockApi {
|
||||
const api: MockApi = {
|
||||
notesMove: vi.fn(),
|
||||
litMove: vi.fn(),
|
||||
listNotes: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||
readNote: vi.fn(),
|
||||
createNote: vi.fn(),
|
||||
writeNote: vi.fn(),
|
||||
onNotesChanged: vi.fn().mockReturnValue(() => {}),
|
||||
};
|
||||
(globalThis as unknown as { window: { api: MockApi } }).window = { api };
|
||||
useNotesStore.setState({
|
||||
notes: [],
|
||||
selectedIds: new Set<string>(),
|
||||
selectedId: null,
|
||||
activeNoteFull: null,
|
||||
anchorId: null,
|
||||
clipboard: null,
|
||||
lastError: null,
|
||||
displayOrderById: {},
|
||||
});
|
||||
return api;
|
||||
}
|
||||
|
||||
const created = (relPath: string) => ({
|
||||
id: 'id-1',
|
||||
relPath,
|
||||
filename: relPath.split('/').pop() ?? relPath,
|
||||
body: '',
|
||||
mtime: 0,
|
||||
tags: null,
|
||||
title: null,
|
||||
});
|
||||
|
||||
describe('notes store create —— 落点跟随当前文件夹(r175)', () => {
|
||||
beforeEach(() => {
|
||||
setupMockApi();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('folderId = sub → createNote 收到 sub/<name>.md(旧实现丢弃目录,笔记落根)', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
api.createNote.mockResolvedValue({ ok: true, data: created('sub/未命名.md') });
|
||||
|
||||
await useNotesStore.getState().create('未命名.md', 'sub');
|
||||
|
||||
expect(api.createNote).toHaveBeenCalledTimes(1);
|
||||
expect(api.createNote).toHaveBeenCalledWith('sub/未命名.md');
|
||||
});
|
||||
|
||||
it('folderId = null(根)→ createNote 收到裸文件名,不拼目录', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
api.createNote.mockResolvedValue({ ok: true, data: created('未命名.md') });
|
||||
|
||||
await useNotesStore.getState().create('未命名.md', null);
|
||||
|
||||
expect(api.createNote).toHaveBeenCalledWith('未命名.md');
|
||||
});
|
||||
|
||||
it('folderId = undefined / 空串(根)→ 同样不拼目录', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
api.createNote.mockResolvedValue({ ok: true, data: created('未命名.md') });
|
||||
|
||||
await useNotesStore.getState().create('未命名.md', undefined);
|
||||
await useNotesStore.getState().create('未命名.md', '');
|
||||
|
||||
expect(api.createNote).toHaveBeenNthCalledWith(1, '未命名.md');
|
||||
expect(api.createNote).toHaveBeenNthCalledWith(2, '未命名.md');
|
||||
});
|
||||
|
||||
it('多级目录(sub/deep)→ 整条路径都带上', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
api.createNote.mockResolvedValue({ ok: true, data: created('sub/deep/未命名.md') });
|
||||
|
||||
await useNotesStore.getState().create('未命名.md', 'sub/deep');
|
||||
|
||||
expect(api.createNote).toHaveBeenCalledWith('sub/deep/未命名.md');
|
||||
});
|
||||
|
||||
it('DUPLICATE_NAME 重试换名后,目录前缀仍然带(不能退到根)', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
api.createNote
|
||||
.mockResolvedValueOnce({ ok: false, code: 'DUPLICATE_NAME', message: 'exists' })
|
||||
.mockResolvedValueOnce({ ok: true, data: created('sub/未命名 1-xxxx.md') });
|
||||
|
||||
await useNotesStore.getState().create('未命名.md', 'sub');
|
||||
|
||||
expect(api.createNote).toHaveBeenCalledTimes(2);
|
||||
expect(api.createNote).toHaveBeenNthCalledWith(1, 'sub/未命名.md');
|
||||
// 第二次换名后的路径仍带目录前缀
|
||||
expect(String(api.createNote.mock.calls[1]![0])).toMatch(/^sub\//);
|
||||
});
|
||||
|
||||
it('越界目录(., ..)→ 归一到根,绝不把路径拼出 notes 子树', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
api.createNote.mockResolvedValue({ ok: true, data: created('未命名.md') });
|
||||
|
||||
await useNotesStore.getState().create('未命名.md', '../evil');
|
||||
|
||||
expect(api.createNote).toHaveBeenCalledWith('未命名.md');
|
||||
});
|
||||
|
||||
it('越界段的各种写法(混合 / 中缀 / 单独)→ 一律归一到根', async () => {
|
||||
// '.', '..' 段整个丢掉是错的(会把 'sub/./x' 误降级到根,但拼出去也
|
||||
// 不安全);这里统一**整条归一到根** —— 越界时宁可落在根,也不能拼出
|
||||
// notes 子树之外的路径。sidebar 给的 currentId 永远是合法 relPath,
|
||||
// 这条只是防御性兜底。
|
||||
for (const target of ['../evil', 'sub/../../evil', '.', './x', 'sub/./x']) {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
api.createNote.mockResolvedValue({ ok: true, data: created('未命名.md') });
|
||||
|
||||
await useNotesStore.getState().create('未命名.md', target);
|
||||
|
||||
const called = String(api.createNote.mock.calls[0]![0]);
|
||||
expect(called).not.toContain('..');
|
||||
expect(called.endsWith('未命名.md')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('返回的是主进程回的那份 note(含目录的 relPath)', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
api.createNote.mockResolvedValue({ ok: true, data: created('sub/未命名.md') });
|
||||
|
||||
const note = await useNotesStore.getState().create('未命名.md', 'sub');
|
||||
|
||||
expect(note.relPath).toBe('sub/未命名.md');
|
||||
});
|
||||
});
|
||||
512
tests/unit/renderer/store/notes-cut-paste.test.ts
Normal file
512
tests/unit/renderer/store/notes-cut-paste.test.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
// tests/unit/renderer/store/notes-cut-paste.test.ts
|
||||
//
|
||||
// 守卫:notes store 的 cut + paste(cut) + moveToFolder 三个 action 必须
|
||||
// 真的发 IPC(api.notesMove),targetRelPath 取正确语义(空串 = 根;
|
||||
// null / undefined 视为根),失败累计 + 首个 throw。直接复用 r161+
|
||||
// 链路 —— 在 r166 之前这些 action 走得是 placeholder, 这次行为级
|
||||
// 接管后必须守住契约, 防止后续 r167+ 静默回退成「clear clipboard only」。
|
||||
//
|
||||
// 与 useFolderSidebar shim 的 moveItems 对齐 —— disk hook 直接走 IPC,
|
||||
// store path 不绕 disk hook(shim 的 onMoveItems 入口可选)以保证单元
|
||||
// 测不必启动 React 渲染。
|
||||
//
|
||||
// 间接验证用户反馈「文件拖入文件夹 + 剪切粘贴有问题」:
|
||||
//
|
||||
// 1. cut 后不调 IPC, 只写 clipboard —— 这是设计(真正的搬移落在 paste,
|
||||
// 类似 VS Code 的 cut-then-paste)。
|
||||
// 2. paste(targetRelPath === '') 默认语义是把 rows 移到「根」。
|
||||
// 3. paste(targetRelPath === null) 同 '' 语义,与 disk hook 对齐(
|
||||
// null = 根, store path 落库写 '' 是搬迁细节)。
|
||||
// 4. paste(cut) 的逐条循环不会因头一条失败而吞后续(累计 errors 抛首条)。
|
||||
// 5. paste(copy) 副本**直接建在目标目录**(createNote 一次到位,r170),
|
||||
// copy 模式剪贴板不消费。
|
||||
// 6. moveToFolder 走 cut 的同一 IPC, 失败 throw + 不消费剪贴板;
|
||||
// 成功消费(cut 模式下)。
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// store 顶层 import i18next;测试环境 i18next 不挂全局实例会被 init 调用
|
||||
// 牵动 chrome → settings → toast 等重模块。mock 掉这两个 hook,store 本体
|
||||
// 仍能跑(r166+ 不依赖 chrome 的实际形态,只 import 类型)。
|
||||
vi.mock('../../../../src/renderer/src/store/workspace', () => ({
|
||||
useWorkspaceStore: {
|
||||
getState: () => ({
|
||||
pendingFocusNote: { hasPending: false, payload: null },
|
||||
consumeFocusNoteRequest: () => {},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../../src/renderer/src/lib/toast', () => ({
|
||||
useToast: {
|
||||
getState: () => ({ show: () => {} }),
|
||||
},
|
||||
}));
|
||||
|
||||
import { useNotesStore } from '../../../../src/renderer/src/store/notes';
|
||||
|
||||
interface MockApi {
|
||||
notesMove: ReturnType<typeof vi.fn>;
|
||||
litMove: ReturnType<typeof vi.fn>;
|
||||
listNotes: ReturnType<typeof vi.fn>;
|
||||
readNote: ReturnType<typeof vi.fn>;
|
||||
createNote: ReturnType<typeof vi.fn>;
|
||||
writeNote: ReturnType<typeof vi.fn>;
|
||||
onNotesChanged: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function setupMockApi(): MockApi {
|
||||
const api: MockApi = {
|
||||
notesMove: vi.fn(),
|
||||
litMove: vi.fn(),
|
||||
listNotes: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||
readNote: vi.fn(),
|
||||
createNote: vi.fn(),
|
||||
writeNote: vi.fn(),
|
||||
onNotesChanged: vi.fn().mockReturnValue(() => {}),
|
||||
};
|
||||
(globalThis as unknown as { window: { api: MockApi } }).window = {
|
||||
api,
|
||||
};
|
||||
// reset store 状态:cut afterEach 把 notes/selectedIds/clipboard 重置;再
|
||||
// 显式重置 selectedId/activeNoteFull 以防上一用例残留。
|
||||
useNotesStore.setState({
|
||||
notes: [],
|
||||
selectedIds: new Set<string>(),
|
||||
selectedId: null,
|
||||
activeNoteFull: null,
|
||||
anchorId: null,
|
||||
clipboard: null,
|
||||
lastError: null,
|
||||
displayOrderById: {},
|
||||
});
|
||||
return api;
|
||||
}
|
||||
|
||||
describe('notes store cut/paste/moveToFolder', () => {
|
||||
beforeEach(() => {
|
||||
setupMockApi();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('cut 写剪贴板,不立即调 IPC(等同 VS Code cut 语义)', () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
useNotesStore.setState({
|
||||
notes: [
|
||||
{ id: 'a', relPath: 'a.md', filename: 'a.md', mtime: 0, tags: [], title: null },
|
||||
{ id: 'b', relPath: 'b.md', filename: 'b.md', mtime: 0, tags: [], title: null },
|
||||
],
|
||||
selectedIds: new Set(['a', 'b']),
|
||||
});
|
||||
useNotesStore.getState().cut();
|
||||
expect(useNotesStore.getState().clipboard).toEqual({ ids: ['a', 'b'], mode: 'cut' });
|
||||
expect(api.notesMove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('paste(cut) 调 notesMove 每个 id 用目标 relPath,全成功清剪贴板', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
useNotesStore.setState({
|
||||
notes: [
|
||||
{ id: 'a', relPath: 'a.md', filename: 'a.md', mtime: 0, tags: [], title: null },
|
||||
{ id: 'b', relPath: 'b.md', filename: 'b.md', mtime: 0, tags: [], title: null },
|
||||
],
|
||||
selectedIds: new Set(['a', 'b']),
|
||||
});
|
||||
useNotesStore.getState().cut();
|
||||
api.notesMove.mockResolvedValue({ ok: true, data: { id: 'a', relPath: 'a.md' } });
|
||||
await useNotesStore.getState().paste('foo');
|
||||
expect(api.notesMove).toHaveBeenCalledTimes(2);
|
||||
expect(api.notesMove).toHaveBeenNthCalledWith(1, 'a', 'foo');
|
||||
expect(api.notesMove).toHaveBeenNthCalledWith(2, 'b', 'foo');
|
||||
expect(useNotesStore.getState().clipboard).toBeNull();
|
||||
});
|
||||
|
||||
it('paste(cut) targetRelPath=null 视为根(空串) — 与 disk hook 对齐', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
useNotesStore.setState({
|
||||
notes: [{ id: 'a', relPath: 'a.md', filename: 'a.md', mtime: 0, tags: [], title: null }],
|
||||
selectedIds: new Set(['a']),
|
||||
});
|
||||
useNotesStore.getState().cut();
|
||||
api.notesMove.mockResolvedValue({ ok: true, data: { id: 'a', relPath: 'a.md' } });
|
||||
await useNotesStore.getState().paste(null);
|
||||
expect(api.notesMove).toHaveBeenCalledWith('a', '');
|
||||
});
|
||||
|
||||
it('paste(cut) 部分失败 → errors 累计并 throw,剪贴板保留', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
useNotesStore.setState({
|
||||
notes: [
|
||||
{ id: 'a', relPath: 'a.md', filename: 'a.md', mtime: 0, tags: [], title: null },
|
||||
{ id: 'b', relPath: 'b.md', filename: 'b.md', mtime: 0, tags: [], title: null },
|
||||
],
|
||||
selectedIds: new Set(['a', 'b']),
|
||||
});
|
||||
useNotesStore.getState().cut();
|
||||
api.notesMove.mockImplementation((id: string) => {
|
||||
if (id === 'b') {
|
||||
return Promise.resolve({ ok: false, code: 'IO_ERROR', message: 'disk fail' });
|
||||
}
|
||||
return Promise.resolve({ ok: true, data: { id, relPath: `${id}.md` } });
|
||||
});
|
||||
await expect(useNotesStore.getState().paste('foo')).rejects.toThrowError(/failed 1\/2/);
|
||||
// 剪贴板未被消费(用户可重试)
|
||||
expect(useNotesStore.getState().clipboard).toEqual({ ids: ['a', 'b'], mode: 'cut' });
|
||||
// 两条 IPC 都发了(累计)
|
||||
expect(api.notesMove).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('moveToFolder 全成功调 notesMove,成功消费剪贴板(cut)', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
useNotesStore.setState({
|
||||
notes: [
|
||||
{ id: 'a', relPath: 'a.md', filename: 'a.md', mtime: 0, tags: [], title: null },
|
||||
{ id: 'b', relPath: 'b.md', filename: 'b.md', mtime: 0, tags: [], title: null },
|
||||
],
|
||||
selectedIds: new Set(['a', 'b']),
|
||||
});
|
||||
useNotesStore.getState().cut();
|
||||
api.notesMove.mockResolvedValue({ ok: true, data: { id: 'a', relPath: 'a.md' } });
|
||||
await useNotesStore.getState().moveToFolder(['a', 'b'], 'sub');
|
||||
expect(api.notesMove).toHaveBeenCalledTimes(2);
|
||||
expect(api.notesMove).toHaveBeenNthCalledWith(1, 'a', 'sub');
|
||||
expect(api.notesMove).toHaveBeenNthCalledWith(2, 'b', 'sub');
|
||||
expect(useNotesStore.getState().clipboard).toBeNull();
|
||||
});
|
||||
|
||||
it('moveToFolder 0 ids 是 no-op,不调 IPC', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
await useNotesStore.getState().moveToFolder([], 'x');
|
||||
expect(api.notesMove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('moveToFolder 失败 throw,剪贴板不动', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
useNotesStore.setState({
|
||||
notes: [{ id: 'a', relPath: 'a.md', filename: 'a.md', mtime: 0, tags: [], title: null }],
|
||||
selectedIds: new Set(['a']),
|
||||
});
|
||||
useNotesStore.getState().cut();
|
||||
api.notesMove.mockResolvedValue({ ok: false, code: 'NOT_FOUND', message: 'gone' });
|
||||
await expect(useNotesStore.getState().moveToFolder(['a'], 'x')).rejects.toThrowError(/failed/);
|
||||
expect(useNotesStore.getState().clipboard).toEqual({ ids: ['a'], mode: 'cut' });
|
||||
});
|
||||
|
||||
it('paste(cut) 成功后,被搬移的 selectedId + activeNoteFull 重映射到 IPC 响应给的 (新 id, 新 relPath)', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
// 模拟「编辑中的笔记被搬移」:cut 取的 ids 由 selectedIds 在 notes[] 里
|
||||
// 的交集决定,所以 notes 必须含旧 id 'a',否则 cut() 早返。
|
||||
const NEW_ID = 'new-a';
|
||||
const NEW_REL = 'foo/a.md';
|
||||
const oldFull: import('../../../../src/shared/types').StandaloneNote = {
|
||||
id: 'a',
|
||||
relPath: 'a.md',
|
||||
filename: 'a.md',
|
||||
body: 'old content',
|
||||
mtime: 0,
|
||||
tags: null,
|
||||
title: null,
|
||||
};
|
||||
useNotesStore.setState({
|
||||
notes: [{ id: 'a', relPath: 'a.md', filename: 'a.md', mtime: 0, tags: [], title: null }],
|
||||
selectedId: 'a',
|
||||
activeNoteFull: oldFull,
|
||||
selectedIds: new Set(['a']),
|
||||
});
|
||||
useNotesStore.getState().cut();
|
||||
api.notesMove.mockResolvedValue({ ok: true, data: { id: NEW_ID, relPath: NEW_REL } });
|
||||
await useNotesStore.getState().paste('foo');
|
||||
const cur = useNotesStore.getState();
|
||||
// 重映射契约:被搬移的笔记被编辑中,selectedId 必须跳到新 id,
|
||||
// activeNoteFull 同步刷 id/relPath/filename(body 不变);
|
||||
// 不重映射会让后续 save 撞 NOT_FOUND(这是用户报告的隐性回归)。
|
||||
expect(cur.selectedId).toBe(NEW_ID);
|
||||
expect(cur.activeNoteFull?.id).toBe(NEW_ID);
|
||||
expect(cur.activeNoteFull?.relPath).toBe(NEW_REL);
|
||||
expect(cur.activeNoteFull?.filename).toBe('a.md'); // basename('foo/a.md')
|
||||
expect(cur.activeNoteFull?.body).toBe('old content'); // body 不变
|
||||
});
|
||||
|
||||
it('paste(cut) 成功后,未参与搬移的 selectedId 不动(id 不在 idRemap 内)', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
useNotesStore.setState({
|
||||
notes: [{ id: 'b', relPath: 'b.md', filename: 'b.md', mtime: 0, tags: [], title: null }],
|
||||
selectedId: 'b', // 不在 clip.ids 里
|
||||
activeNoteFull: null,
|
||||
selectedIds: new Set(['a']), // 多选 a 但 selectedId 单选 b
|
||||
});
|
||||
useNotesStore.getState().cut();
|
||||
api.notesMove.mockResolvedValue({ ok: true, data: { id: 'new-a', relPath: 'foo/a.md' } });
|
||||
await useNotesStore.getState().paste('foo');
|
||||
expect(useNotesStore.getState().selectedId).toBe('b');
|
||||
});
|
||||
|
||||
it('r168:applyMoveRemap 是 store public action —— drag-into-folder 路径回调用', () => {
|
||||
// 守卫 store 公开 surface,防止后续误把 applyMoveRemap 改 private 让
|
||||
// NotesList.tsx 的 handleItemsMoved 断路径(typecheck 会捕,行为级也锁)。
|
||||
const oldFull: import('../../../../src/shared/types').StandaloneNote = {
|
||||
id: 'a',
|
||||
relPath: 'a.md',
|
||||
filename: 'a.md',
|
||||
body: 'old content',
|
||||
mtime: 0,
|
||||
tags: null,
|
||||
title: null,
|
||||
};
|
||||
useNotesStore.setState({
|
||||
selectedId: 'a',
|
||||
activeNoteFull: oldFull,
|
||||
});
|
||||
const remap = new Map<string, { id: string; relPath?: string; absPath?: string }>();
|
||||
remap.set('a', { id: 'new-a', relPath: 'foo/a.md' });
|
||||
useNotesStore.getState().applyMoveRemap(remap);
|
||||
const cur = useNotesStore.getState();
|
||||
expect(cur.selectedId).toBe('new-a');
|
||||
expect(cur.activeNoteFull?.id).toBe('new-a');
|
||||
expect(cur.activeNoteFull?.relPath).toBe('foo/a.md');
|
||||
expect(cur.activeNoteFull?.filename).toBe('a.md');
|
||||
});
|
||||
|
||||
it('r168:applyMoveRemap 收到空 map = no-op', () => {
|
||||
useNotesStore.setState({ selectedId: 'a' });
|
||||
useNotesStore.getState().applyMoveRemap(new Map());
|
||||
expect(useNotesStore.getState().selectedId).toBe('a');
|
||||
});
|
||||
|
||||
it('r168:applyMoveRemap id 不在 map 内 = no-op(selectedId 不动)', () => {
|
||||
useNotesStore.setState({ selectedId: 'a' });
|
||||
const remap = new Map<string, { id: string; relPath?: string; absPath?: string }>();
|
||||
remap.set('other', { id: 'new-other', relPath: 'foo/other.md' });
|
||||
useNotesStore.getState().applyMoveRemap(remap);
|
||||
expect(useNotesStore.getState().selectedId).toBe('a');
|
||||
});
|
||||
|
||||
it('r168:applyMoveRemap relPath 缺失 → 只刷 selectedId,不刷 activeFull(防御 miswire)', () => {
|
||||
const oldFull: import('../../../../src/shared/types').StandaloneNote = {
|
||||
id: 'a',
|
||||
relPath: 'a.md',
|
||||
filename: 'a.md',
|
||||
body: 'old',
|
||||
mtime: 0,
|
||||
tags: null,
|
||||
title: null,
|
||||
};
|
||||
useNotesStore.setState({
|
||||
selectedId: 'a',
|
||||
activeNoteFull: oldFull,
|
||||
});
|
||||
const remap = new Map<string, { id: string; relPath?: string; absPath?: string }>();
|
||||
remap.set('a', { id: 'new-a' }); // relPath 缺失
|
||||
useNotesStore.getState().applyMoveRemap(remap);
|
||||
const cur = useNotesStore.getState();
|
||||
expect(cur.selectedId).toBe('new-a');
|
||||
// activeFull 不动(避免把 undefined 写进去)
|
||||
expect(cur.activeNoteFull?.id).toBe('a');
|
||||
});
|
||||
});
|
||||
|
||||
// r170:copy 分支重写 —— 副本**直接建在目标目录**(一次 createNote)。
|
||||
//
|
||||
// 用户报的「粘贴功能有问题」在复制路径上是**必现**故障:旧实现副本名是
|
||||
// 「X (copy).md」,圆括号被 filename-validator 拒 → 每一次复制粘贴都抛
|
||||
// ValidationError,副本一个都建不出来。旧实现另有三处连带缺陷:
|
||||
// - base 取 stripExt(src.relPath) 保留目录前缀 → 副本建在源目录(嵌套)
|
||||
// - 目标 = 根时整段 notesMove 被跳过 → 副本留在源目录
|
||||
// - move 之后 frontmatter id 重算 → newIds 变 stale,选中指向死 id
|
||||
// 新契约(本块逐条锁):
|
||||
// 1. createNote 直接收到 '<targetDir>/<base> (copy).md'
|
||||
// 2. 副本名不含源目录前缀
|
||||
// 3. 目标 = 根(null/undefined)同样直接建在根
|
||||
// 4. 撞名避让只看目标目录内既有文件
|
||||
// 5. 主进程 DUPLICATE_NAME → 自动换下一个候选名重试
|
||||
// 6. 单条失败隔离:其余副本照常落地 + 选中,聚合错抛出,剪贴板保留
|
||||
describe('notes store paste(copy) —— 副本直接落在目标目录(r170)', () => {
|
||||
beforeEach(() => {
|
||||
setupMockApi();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const summary = (id: string, relPath: string, filename: string) => ({
|
||||
id,
|
||||
relPath,
|
||||
filename,
|
||||
mtime: 0,
|
||||
tags: [] as string[],
|
||||
title: null,
|
||||
});
|
||||
|
||||
const full = (id: string, relPath: string, filename: string) => ({
|
||||
id,
|
||||
relPath,
|
||||
filename,
|
||||
body: 'BODY',
|
||||
mtime: 0,
|
||||
tags: null,
|
||||
title: 'TITLE',
|
||||
});
|
||||
|
||||
it('副本直接建在目标目录:createNote 收到 <target>/<name>.md,不再走 notesMove', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
useNotesStore.setState({
|
||||
notes: [summary('a', 'a.md', 'a.md')],
|
||||
selectedIds: new Set(['a']),
|
||||
});
|
||||
useNotesStore.getState().copy();
|
||||
api.readNote.mockResolvedValue({ ok: true, data: full('a', 'a.md', 'a.md') });
|
||||
api.createNote.mockResolvedValue({
|
||||
ok: true,
|
||||
data: full('new-a', 'sub/a (copy).md', 'a (copy).md'),
|
||||
});
|
||||
api.writeNote.mockResolvedValue({
|
||||
ok: true,
|
||||
data: full('new-a', 'sub/a (copy).md', 'a (copy).md'),
|
||||
});
|
||||
await useNotesStore.getState().paste('sub');
|
||||
// 一次 createNote 落在目标目录 —— 不再 create(根)→ move(目标)两段
|
||||
expect(api.createNote).toHaveBeenCalledTimes(1);
|
||||
expect(api.createNote).toHaveBeenCalledWith('sub/a (copy).md');
|
||||
expect(api.notesMove).not.toHaveBeenCalled();
|
||||
// 副本 id 就是最终 id(没搬移 ⇒ frontmatter id 不重算)
|
||||
expect([...useNotesStore.getState().selectedIds]).toEqual(['new-a']);
|
||||
});
|
||||
|
||||
it('副本名不含源目录前缀(源在 src/ 下,目标 dst/ → dst/a (copy).md)', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
useNotesStore.setState({
|
||||
notes: [summary('a', 'src/a.md', 'a.md')],
|
||||
selectedIds: new Set(['a']),
|
||||
});
|
||||
useNotesStore.getState().copy();
|
||||
api.readNote.mockResolvedValue({ ok: true, data: full('a', 'src/a.md', 'a.md') });
|
||||
api.createNote.mockResolvedValue({
|
||||
ok: true,
|
||||
data: full('new-a', 'dst/a (copy).md', 'a (copy).md'),
|
||||
});
|
||||
api.writeNote.mockResolvedValue({
|
||||
ok: true,
|
||||
data: full('new-a', 'dst/a (copy).md', 'a (copy).md'),
|
||||
});
|
||||
await useNotesStore.getState().paste('dst');
|
||||
expect(api.createNote).toHaveBeenCalledWith('dst/a (copy).md');
|
||||
});
|
||||
|
||||
it('目标 = 根(null)时副本也直接建在根(旧实现整段 move 被跳过,副本留在源目录)', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
useNotesStore.setState({
|
||||
notes: [summary('a', 'src/a.md', 'a.md')],
|
||||
selectedIds: new Set(['a']),
|
||||
});
|
||||
useNotesStore.getState().copy();
|
||||
api.readNote.mockResolvedValue({ ok: true, data: full('a', 'src/a.md', 'a.md') });
|
||||
api.createNote.mockResolvedValue({
|
||||
ok: true,
|
||||
data: full('new-a', 'a (copy).md', 'a (copy).md'),
|
||||
});
|
||||
api.writeNote.mockResolvedValue({
|
||||
ok: true,
|
||||
data: full('new-a', 'a (copy).md', 'a (copy).md'),
|
||||
});
|
||||
await useNotesStore.getState().paste(null);
|
||||
expect(api.createNote).toHaveBeenCalledWith('a (copy).md');
|
||||
expect(api.notesMove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('撞名避让只看目标目录:目标已有 a (copy).md → 副本名退到 a (copy 2).md', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
useNotesStore.setState({
|
||||
notes: [
|
||||
summary('a', 'a.md', 'a.md'),
|
||||
// 目标目录里已有一份副本
|
||||
summary('x', 'sub/a (copy).md', 'a (copy).md'),
|
||||
// 别的目录的同名文件不该干扰
|
||||
summary('y', 'other/a (copy).md', 'a (copy).md'),
|
||||
],
|
||||
selectedIds: new Set(['a']),
|
||||
});
|
||||
useNotesStore.getState().copy();
|
||||
api.readNote.mockResolvedValue({ ok: true, data: full('a', 'a.md', 'a.md') });
|
||||
api.createNote.mockResolvedValue({
|
||||
ok: true,
|
||||
data: full('new-a', 'sub/a (copy 2).md', 'a (copy 2).md'),
|
||||
});
|
||||
api.writeNote.mockResolvedValue({
|
||||
ok: true,
|
||||
data: full('new-a', 'sub/a (copy 2).md', 'a (copy 2).md'),
|
||||
});
|
||||
await useNotesStore.getState().paste('sub');
|
||||
expect(api.createNote).toHaveBeenCalledWith('sub/a (copy 2).md');
|
||||
});
|
||||
|
||||
it('主进程 DUPLICATE_NAME → 自动换下一个候选名重试(本地 taken 是 stale 快照)', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
useNotesStore.setState({
|
||||
notes: [summary('a', 'a.md', 'a.md')],
|
||||
selectedIds: new Set(['a']),
|
||||
});
|
||||
useNotesStore.getState().copy();
|
||||
api.readNote.mockResolvedValue({ ok: true, data: full('a', 'a.md', 'a.md') });
|
||||
api.createNote
|
||||
.mockResolvedValueOnce({ ok: false, code: 'DUPLICATE_NAME', message: 'exists' })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
data: full('new-a', 'a (copy 2).md', 'a (copy 2).md'),
|
||||
});
|
||||
api.writeNote.mockResolvedValue({
|
||||
ok: true,
|
||||
data: full('new-a', 'a (copy 2).md', 'a (copy 2).md'),
|
||||
});
|
||||
await useNotesStore.getState().paste('');
|
||||
expect(api.createNote).toHaveBeenCalledTimes(2);
|
||||
expect(api.createNote).toHaveBeenNthCalledWith(1, 'a (copy).md');
|
||||
expect(api.createNote).toHaveBeenNthCalledWith(2, 'a (copy 2).md');
|
||||
});
|
||||
|
||||
it('单条失败隔离:一条读失败,另一条照常落地并被选中;聚合错抛出,剪贴板保留', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
useNotesStore.setState({
|
||||
notes: [summary('a', 'a.md', 'a.md'), summary('b', 'b.md', 'b.md')],
|
||||
selectedIds: new Set(['a', 'b']),
|
||||
});
|
||||
useNotesStore.getState().copy();
|
||||
api.readNote.mockImplementation((id: string) => {
|
||||
if (id === 'a') return Promise.reject(new Error('read boom'));
|
||||
return Promise.resolve({ ok: true, data: full('b', 'b.md', 'b.md') });
|
||||
});
|
||||
api.createNote.mockResolvedValue({
|
||||
ok: true,
|
||||
data: full('new-b', 'b (copy).md', 'b (copy).md'),
|
||||
});
|
||||
api.writeNote.mockResolvedValue({
|
||||
ok: true,
|
||||
data: full('new-b', 'b (copy).md', 'b (copy).md'),
|
||||
});
|
||||
await expect(useNotesStore.getState().paste('')).rejects.toThrowError(/failed 1\/2/);
|
||||
// 成功的那条照常落地 + 选中
|
||||
expect(api.createNote).toHaveBeenCalledTimes(1);
|
||||
expect([...useNotesStore.getState().selectedIds]).toEqual(['new-b']);
|
||||
// copy 模式剪贴板保留(可重试)
|
||||
expect(useNotesStore.getState().clipboard).toEqual({ ids: ['a', 'b'], mode: 'copy' });
|
||||
});
|
||||
|
||||
it('copy 模式不消费剪贴板(可重复粘贴)', async () => {
|
||||
const api = (globalThis as unknown as { window: { api: MockApi } }).window.api;
|
||||
useNotesStore.setState({
|
||||
notes: [summary('a', 'a.md', 'a.md')],
|
||||
selectedIds: new Set(['a']),
|
||||
});
|
||||
useNotesStore.getState().copy();
|
||||
api.readNote.mockResolvedValue({ ok: true, data: full('a', 'a.md', 'a.md') });
|
||||
api.createNote.mockResolvedValue({
|
||||
ok: true,
|
||||
data: full('new-a', 'a (copy).md', 'a (copy).md'),
|
||||
});
|
||||
api.writeNote.mockResolvedValue({
|
||||
ok: true,
|
||||
data: full('new-a', 'a (copy).md', 'a (copy).md'),
|
||||
});
|
||||
await useNotesStore.getState().paste('');
|
||||
expect(useNotesStore.getState().clipboard).toEqual({ ids: ['a'], mode: 'copy' });
|
||||
});
|
||||
});
|
||||
91
tests/unit/renderer/store/notes-list-ttl.test.ts
Normal file
91
tests/unit/renderer/store/notes-list-ttl.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
// tests/unit/renderer/store/notes-list-ttl.test.ts
|
||||
//
|
||||
// 守 r193「notes 列表其实被缓存了 1 秒」契约。
|
||||
//
|
||||
// 现象(用户可复现):
|
||||
// 1. 侧栏打开着笔记列表(第一次 load 已完成);
|
||||
// 2. 1 秒内,磁盘上**新增/改动**了笔记 —— 另一个窗口保存、chokidar 事件、
|
||||
// 网盘同步落盘…… ⇒ 主进程广播 NOTES_CHANGED ⇒ renderer 走 load();
|
||||
// 3. 侧栏列表**不变**:新笔记不出现、已删笔记还在,直到用户再点一次别的
|
||||
// 刷新才「突然跳」到正确内容。
|
||||
//
|
||||
// 根因(旧实现):`loadOnce` 在 loader 成功时**无条件**写 `cache.ts =
|
||||
// Date.now()`。notesCache 声明上虽然没有 `ts` 字段(TypeScript 层面看不见),
|
||||
// 但运行时第一次 load 之后它**凭空多出一个 ts 属性** ⇒ loadOnce 的 TTL
|
||||
// 分支从**第二次** load 起就成立 ⇒ 笔记列表被实打实地缓存 1 秒,
|
||||
// 1 秒内的 NOTES_CHANGED 推送**连 IPC 都不发**,直接拿回上一次的旧数组。
|
||||
//
|
||||
// r218 补记:`loadOnce`(lib/store-helpers.ts)已整体下线 —— 它唯一的生产
|
||||
// 消费方就是 notes store,序号守卫(r218,见 notes-load-seq-guard.test.ts)
|
||||
// 落地后成了零调用点死代码。「不缓存」从 helper 的字段约定升级为结构事实:
|
||||
// 每次 load 直发新 IPC,没有缓存对象可写。本测原有两条 loadOnce 单元用例
|
||||
// (TTL 显式 opt-in / 不凭空补 ts)随被测对象一并删除,保留的这条是
|
||||
// **store 行为级**契约,与被删的 helper 无关,必须继续守。
|
||||
//
|
||||
// 本测锁:
|
||||
// 1 秒内连续两次 load() ⇒ **两次都真发 IPC**(listNotes 调用 2 次),
|
||||
// 且第二次拿到的是**新列表**。
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../../src/renderer/src/store/workspace', () => ({
|
||||
useWorkspaceStore: {
|
||||
getState: () => ({
|
||||
pendingFocusNote: { hasPending: false, payload: null },
|
||||
consumeFocusNoteRequest: () => {},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../../src/renderer/src/lib/toast', () => ({
|
||||
useToast: {
|
||||
getState: () => ({ show: () => {} }),
|
||||
},
|
||||
}));
|
||||
|
||||
import { useNotesStore } from '../../../../src/renderer/src/store/notes';
|
||||
import type { StandaloneNoteSummary } from '../../../../src/shared/types';
|
||||
|
||||
function summary(id: string, relPath: string): StandaloneNoteSummary {
|
||||
return { id, relPath, filename: relPath, title: null, tags: null, mtime: 0 };
|
||||
}
|
||||
|
||||
describe('notes 列表不该被 TTL 缓存(r193)', () => {
|
||||
let listNotes: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
listNotes = vi.fn().mockResolvedValue({ ok: true, data: [] });
|
||||
(globalThis as unknown as { window: { api: unknown } }).window = {
|
||||
api: {
|
||||
listNotes,
|
||||
renameNoteFile: vi.fn(),
|
||||
onNotesChanged: vi.fn().mockReturnValue(() => {}),
|
||||
},
|
||||
};
|
||||
useNotesStore.setState({
|
||||
notes: [],
|
||||
selectedIds: new Set<string>(),
|
||||
selectedId: null,
|
||||
activeNoteFull: null,
|
||||
anchorId: null,
|
||||
clipboard: null,
|
||||
lastError: null,
|
||||
displayOrderById: {},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('1 秒内连续两次 load(),两次都真发 IPC', async () => {
|
||||
listNotes.mockResolvedValue({ ok: true, data: [summary('a', 'a.md')] });
|
||||
await useNotesStore.getState().load();
|
||||
expect(listNotes).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 第二次:列表内容已经变了(NOTES_CHANGED 之后的典型情形)
|
||||
listNotes.mockResolvedValue({ ok: true, data: [summary('a', 'a.md'), summary('b', 'b.md')] });
|
||||
await useNotesStore.getState().load();
|
||||
|
||||
expect(listNotes, '第二次 load 命中了缓存 ⇒ 没发 IPC').toHaveBeenCalledTimes(2);
|
||||
expect(useNotesStore.getState().notes.map((n) => n.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
168
tests/unit/renderer/store/notes-load-seq-guard.test.ts
Normal file
168
tests/unit/renderer/store/notes-load-seq-guard.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
// tests/unit/renderer/store/notes-load-seq-guard.test.ts
|
||||
//
|
||||
// 守 r218「notes store load() 序号守卫 + 不做 in-flight join」契约。
|
||||
//
|
||||
// ── 被守卫的缺陷(用户可见)──────────────────────────────────────────
|
||||
// 旧实现 load() 走 `loadOnce` 单飞 join:第二次 load 若撞上第一次在途,
|
||||
// **不发新 IPC**,直接并到那个在途 promise 上。于是一条现实的时序链:
|
||||
// t0 load#1 发出(慢 IPC —— 主进程正忙着 reload / DB 写入);
|
||||
// t1 一篇笔记被删掉(本窗口 / 另一个窗口 / 同步盘),主进程广播
|
||||
// NOTES_CHANGED ⇒ renderer 推送监听器调 load();
|
||||
// t2 这次 load **并到 load#1 上**(listNotes 的请求在 t0 就已到达
|
||||
// 主进程、先于删除被处理 ⇒ 返回的旧快照里**还有刚删的笔记**);
|
||||
// t3 旧快照落地 —— 之后**没有任何后续 fetch**。
|
||||
// ⇒ 列表永远停在旧状态:刚删的笔记还在列表里,要等下一次无关的外部
|
||||
// 事件才自愈。join 是缓存设计的残留,与 r193「不缓存、每次都真发 IPC」
|
||||
// 的意图直接矛盾。
|
||||
//
|
||||
// ── 修法(r218)────────────────────────────────────────────────────────
|
||||
// 镜像 literature store 的 r73 序号守卫,且 notes 侧**不做 join**:
|
||||
// 每次 load 都发新 IPC(listNotes 在主进程是内存 map 直读,并发几发
|
||||
// 没有成本);`_loadIssued` 每请求 +1,`_loadApplied` 记录已落地的最新
|
||||
// 序号,迟到的旧快照 / 旧失败 `seq < _loadApplied` 直接丢弃。
|
||||
//
|
||||
// ── 本测锁的三条 ─────────────────────────────────────────────────────
|
||||
// A. 在途旧 load 期间再 load ⇒ 必须发**第二次** IPC,且最终落地**新**
|
||||
// 快照(缺陷本体;HEAD 上 join ⇒ 只发 1 次且拿到旧快照 ⇒ 红);
|
||||
// B. 乱序到达:新快照先落地、旧响应后到达 ⇒ 旧响应被序号闸丢弃;
|
||||
// C. 迟到的**旧失败**不得把已落地的新快照标成 lastError(catch 也过闸)。
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../../src/renderer/src/store/workspace', () => ({
|
||||
useWorkspaceStore: {
|
||||
getState: () => ({
|
||||
pendingFocusNote: { hasPending: false, payload: null },
|
||||
consumeFocusNoteRequest: () => {},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../../src/renderer/src/lib/toast', () => ({
|
||||
useToast: {
|
||||
getState: () => ({ show: () => {} }),
|
||||
},
|
||||
}));
|
||||
|
||||
import { useNotesStore } from '../../../../src/renderer/src/store/notes';
|
||||
import type { StandaloneNoteSummary } from '../../../../src/shared/types';
|
||||
|
||||
function summary(id: string, relPath: string): StandaloneNoteSummary {
|
||||
return { id, relPath, filename: relPath, title: null, tags: null, mtime: 0 };
|
||||
}
|
||||
|
||||
interface Deferred {
|
||||
promise: Promise<{ ok: true; data: StandaloneNoteSummary[] }>;
|
||||
resolve: (v: { ok: true; data: StandaloneNoteSummary[] }) => void;
|
||||
reject: (e: unknown) => void;
|
||||
}
|
||||
|
||||
function deferred(): Deferred {
|
||||
let resolve!: Deferred['resolve'];
|
||||
let reject!: Deferred['reject'];
|
||||
const promise = new Promise<{ ok: true; data: StandaloneNoteSummary[] }>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('notes store load() 序号守卫(r218)', () => {
|
||||
let listNotes: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
listNotes = vi.fn();
|
||||
(globalThis as unknown as { window: { api: unknown } }).window = {
|
||||
api: {
|
||||
listNotes,
|
||||
onNotesChanged: vi.fn().mockReturnValue(() => {}),
|
||||
},
|
||||
};
|
||||
useNotesStore.setState({
|
||||
notes: [],
|
||||
selectedIds: new Set<string>(),
|
||||
selectedId: null,
|
||||
activeNoteFull: null,
|
||||
anchorId: null,
|
||||
clipboard: null,
|
||||
lastError: null,
|
||||
displayOrderById: {},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('A 在途旧 load 期间再 load ⇒ 发第二次 IPC,最终落地新快照', async () => {
|
||||
const d1 = deferred();
|
||||
const d2 = deferred();
|
||||
listNotes.mockReturnValueOnce(d1.promise).mockReturnValueOnce(d2.promise);
|
||||
|
||||
const l1 = useNotesStore.getState().load(); // 变更前发出的慢请求
|
||||
const l2 = useNotesStore.getState().load(); // 变更后(NOTES_CHANGED)的刷新
|
||||
|
||||
expect(
|
||||
listNotes,
|
||||
'第二次 load 并到了在途 promise 上 ⇒ 变更后的状态永远拉不到',
|
||||
).toHaveBeenCalledTimes(2);
|
||||
|
||||
d1.resolve({ ok: true, data: [summary('stale', 'stale.md')] });
|
||||
await l1;
|
||||
d2.resolve({ ok: true, data: [] }); // 删除后的真实列表
|
||||
await l2;
|
||||
|
||||
expect(
|
||||
useNotesStore.getState().notes.map((n) => n.id),
|
||||
'旧快照盖过了新快照(刚删的笔记还在列表里)',
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('B 乱序到达:新快照先落地,迟到的旧响应被丢弃', async () => {
|
||||
const d1 = deferred();
|
||||
const d2 = deferred();
|
||||
listNotes.mockReturnValueOnce(d1.promise).mockReturnValueOnce(d2.promise);
|
||||
|
||||
const l1 = useNotesStore.getState().load();
|
||||
const l2 = useNotesStore.getState().load();
|
||||
expect(listNotes).toHaveBeenCalledTimes(2);
|
||||
|
||||
// 新快照先回来并落地
|
||||
d2.resolve({ ok: true, data: [summary('b', 'b.md')] });
|
||||
await l2;
|
||||
// 旧快照**后**回来 —— 必须被序号闸丢掉
|
||||
d1.resolve({ ok: true, data: [summary('a', 'a.md')] });
|
||||
await l1;
|
||||
|
||||
expect(useNotesStore.getState().notes.map((n) => n.id)).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('C 迟到的旧失败不得把已落地的新快照标成 lastError', async () => {
|
||||
const d1 = deferred();
|
||||
const d2 = deferred();
|
||||
listNotes.mockReturnValueOnce(d1.promise).mockReturnValueOnce(d2.promise);
|
||||
|
||||
const l1 = useNotesStore.getState().load();
|
||||
const l2 = useNotesStore.getState().load();
|
||||
expect(listNotes).toHaveBeenCalledTimes(2);
|
||||
|
||||
d2.resolve({ ok: true, data: [summary('b', 'b.md')] });
|
||||
await l2;
|
||||
expect(useNotesStore.getState().lastError).toBeNull();
|
||||
|
||||
// 旧请求**之后**失败 —— 不得覆盖新快照,也不得置 lastError
|
||||
d1.reject(new Error('boom'));
|
||||
await l1;
|
||||
|
||||
expect(useNotesStore.getState().notes.map((n) => n.id)).toEqual(['b']);
|
||||
expect(
|
||||
useNotesStore.getState().lastError,
|
||||
'迟到的旧失败把新列表标成了「load 失败」',
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('D 单次 load 正常落地(防回归)', async () => {
|
||||
listNotes.mockResolvedValue({ ok: true, data: [summary('a', 'a.md')] });
|
||||
await useNotesStore.getState().load();
|
||||
expect(useNotesStore.getState().notes.map((n) => n.id)).toEqual(['a']);
|
||||
expect(useNotesStore.getState().lastError).toBeNull();
|
||||
});
|
||||
});
|
||||
158
tests/unit/renderer/store/notes-save-id-drift.test.ts
Normal file
158
tests/unit/renderer/store/notes-save-id-drift.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
// tests/unit/renderer/store/notes-save-id-drift.test.ts
|
||||
//
|
||||
// 守 r192「保存时 id 漂移」契约 —— 这是「造成数据丢失」的一条真实链路。
|
||||
//
|
||||
// 背景:
|
||||
// 笔记 id = computeFileId(relPath)(相对 notes 根的路径哈希)。但读的时候
|
||||
// extractNoteId(note-id.ts)**优先取磁盘上的 frontmatter id**(兼容旧文件),
|
||||
// 写的时候 writeNote(standalone.ts)又把 fm.id **重钉**为 computeFileId(relPath)
|
||||
// (r124 规则)⇒ 当磁盘上的 fm.id 与路径哈希不一致时,保存后 id 会变。
|
||||
//
|
||||
// 现实来源(不是理论):
|
||||
// - 用户在资源管理器里改了文件名 / 移动了文件(fm.id 还是旧路径的哈希);
|
||||
// - 从别的机器 / 别的工具拷进来的 .md(自带别的路径的 fm.id);
|
||||
// - 手改过 frontmatter。
|
||||
//
|
||||
// 旧行为(丢字):
|
||||
// 主进程已把旧 byId 键 + 旧 DB 行删掉(新 id 建了新的),renderer 却还拿着
|
||||
// 旧 id ⇒ **下一次自动保存必撞 NOT_FOUND** ⇒ 正文只进 failedDrafts
|
||||
// (纯内存)⇒ **重启即丢**;用户看到的只是「保存失败」红 badge。
|
||||
//
|
||||
// 本测锁:
|
||||
// 1. save() 返回的 id 与请求 id 不同时,store 里**所有以 id 为键的状态**
|
||||
// (selectedId / selectedIds / anchorId / activeNoteFull / notes[])
|
||||
// 全部换成新 id;
|
||||
// 2. 换完之后的**下一次保存用的是新 id**(不再打旧 id —— 旧 id 已死);
|
||||
// 3. 请求参数本身不变(expectedMtime 仍按旧 id 的快照走);
|
||||
// 4. 没有漂移时,状态**不被动**(不回归)。
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../../src/renderer/src/store/workspace', () => ({
|
||||
useWorkspaceStore: {
|
||||
getState: () => ({
|
||||
pendingFocusNote: { hasPending: false, payload: null },
|
||||
consumeFocusNoteRequest: () => {},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../../src/renderer/src/lib/toast', () => ({
|
||||
useToast: {
|
||||
getState: () => ({ show: () => {} }),
|
||||
},
|
||||
}));
|
||||
|
||||
import { useNotesStore } from '../../../../src/renderer/src/store/notes';
|
||||
import type { StandaloneNote, StandaloneNoteSummary } from '../../../../src/shared/types';
|
||||
|
||||
const OLD_ID = 'aaaaaaaaaaaaaaaa';
|
||||
const NEW_ID = 'bbbbbbbbbbbbbbbb';
|
||||
|
||||
function summary(id: string, mtime: number): StandaloneNoteSummary {
|
||||
return {
|
||||
id,
|
||||
relPath: 'n.md',
|
||||
filename: 'n.md',
|
||||
title: 'T',
|
||||
tags: null,
|
||||
mtime,
|
||||
};
|
||||
}
|
||||
|
||||
function full(id: string, mtime: number, body: string): StandaloneNote {
|
||||
return {
|
||||
id,
|
||||
relPath: 'n.md',
|
||||
filename: 'n.md',
|
||||
title: 'T',
|
||||
tags: null,
|
||||
mtime,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
interface MockApi {
|
||||
writeNote: ReturnType<typeof vi.fn>;
|
||||
listNotes: ReturnType<typeof vi.fn>;
|
||||
onNotesChanged: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function setupMockApi(): MockApi {
|
||||
const api: MockApi = {
|
||||
writeNote: vi.fn(),
|
||||
listNotes: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||
onNotesChanged: vi.fn().mockReturnValue(() => {}),
|
||||
};
|
||||
(globalThis as unknown as { window: { api: MockApi } }).window = { api };
|
||||
useNotesStore.setState({
|
||||
notes: [summary(OLD_ID, 1)],
|
||||
selectedIds: new Set<string>([OLD_ID]),
|
||||
selectedId: OLD_ID,
|
||||
anchorId: OLD_ID,
|
||||
activeNoteFull: full(OLD_ID, 1, 'v1'),
|
||||
clipboard: null,
|
||||
lastError: null,
|
||||
displayOrderById: {},
|
||||
});
|
||||
return api;
|
||||
}
|
||||
|
||||
describe('notes store:保存时 id 漂移(r192)', () => {
|
||||
let api: MockApi;
|
||||
|
||||
beforeEach(() => {
|
||||
api = setupMockApi();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('主进程返回新 id → selectedId / selectedIds / anchorId / activeNoteFull / 列表行全部换新 id', async () => {
|
||||
api.writeNote.mockResolvedValue({ ok: true, data: full(NEW_ID, 2, 'v2') });
|
||||
|
||||
await useNotesStore.getState().save(OLD_ID, 'v2', 'T2');
|
||||
|
||||
const st = useNotesStore.getState();
|
||||
expect(st.selectedId).toBe(NEW_ID);
|
||||
expect(st.selectedIds.has(NEW_ID)).toBe(true);
|
||||
expect(st.selectedIds.has(OLD_ID)).toBe(false);
|
||||
expect(st.anchorId).toBe(NEW_ID);
|
||||
expect(st.activeNoteFull?.id).toBe(NEW_ID);
|
||||
// 列表里那一行也要换成新 id —— 否则侧栏点开拿死 id ⇒ NOT_FOUND
|
||||
expect(st.notes.map((n) => n.id)).toEqual([NEW_ID]);
|
||||
});
|
||||
|
||||
it('漂移之后下一次保存用**新 id**(旧 id 已死,再打过去必 NOT_FOUND)', async () => {
|
||||
api.writeNote.mockResolvedValueOnce({ ok: true, data: full(NEW_ID, 2, 'v2') });
|
||||
await useNotesStore.getState().save(OLD_ID, 'v2', 'T2');
|
||||
expect(api.writeNote).toHaveBeenCalledTimes(1);
|
||||
|
||||
api.writeNote.mockResolvedValueOnce({ ok: true, data: full(NEW_ID, 3, 'v3') });
|
||||
await useNotesStore.getState().save(NEW_ID, 'v3', 'T3');
|
||||
|
||||
// 第二次保存打的是新 id
|
||||
expect(api.writeNote).toHaveBeenLastCalledWith(NEW_ID, 'v3', 'T3', 2);
|
||||
expect(
|
||||
api.writeNote.mock.calls.some((call) => call[0] === OLD_ID && call[1] === 'v3'),
|
||||
'漂移后的保存不得再打旧 id',
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('请求参数不变:expectedMtime 仍按「请求发出时的 activeNoteFull 快照」走', async () => {
|
||||
api.writeNote.mockResolvedValue({ ok: true, data: full(NEW_ID, 2, 'v2') });
|
||||
await useNotesStore.getState().save(OLD_ID, 'v2', 'T2');
|
||||
expect(api.writeNote).toHaveBeenCalledWith(OLD_ID, 'v2', 'T2', 1);
|
||||
});
|
||||
|
||||
it('没有漂移(返回 id === 请求 id)→ 状态原样,不做任何重映射', async () => {
|
||||
api.writeNote.mockResolvedValue({ ok: true, data: full(OLD_ID, 2, 'v2') });
|
||||
await useNotesStore.getState().save(OLD_ID, 'v2', 'T2');
|
||||
|
||||
const st = useNotesStore.getState();
|
||||
expect(st.selectedId).toBe(OLD_ID);
|
||||
expect(st.activeNoteFull?.id).toBe(OLD_ID);
|
||||
expect(st.notes.map((n) => n.id)).toEqual([OLD_ID]);
|
||||
// 正文更新仍要落到列表行上
|
||||
expect(st.notes[0]!.mtime).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
// tests/unit/renderer/store/notes-selection-survives-reload.test.ts
|
||||
//
|
||||
// 守 r193「列表刷新不该吃掉多选」契约。
|
||||
//
|
||||
// 现象(用户可复现):
|
||||
// 1. 侧栏里 Ctrl 多选 3 篇笔记;
|
||||
// 2. 侧栏发生一次**与这 3 篇无关**的刷新 —— 新建一篇 / 触发一次
|
||||
// NOTES_CHANGED 广播(另一个窗口写盘、chokidar 事件、网盘同步落盘……);
|
||||
// 3. 按 Delete(或 Ctrl+X 剪切)→ **静默无反应**:菜单项在、点了没动静。
|
||||
//
|
||||
// 根因:notes store 的 load() 无条件 `selectedIds: new Set()` +
|
||||
// `anchorId: null`,而 load() 的调用点有一大把(create / trash / rename /
|
||||
// paste(copy) / NOTES_CHANGED 广播)。「防 stale id」的意图没错,但实现
|
||||
// 过头了 —— 活着的 id 也被清掉。
|
||||
//
|
||||
// 两侧不对称(literature 的 loadImpl 从不清多选)⇒ 同一个「多选 → 删」的
|
||||
// 动作在文献侧好用、在笔记侧不好用 —— 与用户诉求「两侧操作要对齐」冲突。
|
||||
//
|
||||
// 本测锁:
|
||||
// 1. reload 后**活着的**多选 id 保留(且 Set 引用不变 —— 不引无谓重渲);
|
||||
// 2. reload 后**已经不存在的** id 被剔掉(这才是 stale id 该防的);
|
||||
// 3. 锚点(id)在列表里消失时置 null(否则 Shift 范围选择会算错);
|
||||
// 4. 改名的笔记**跟着换 id**留在多选里(旧 id 已死,不该静默掉出选择)。
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../../src/renderer/src/store/workspace', () => ({
|
||||
useWorkspaceStore: {
|
||||
getState: () => ({
|
||||
pendingFocusNote: { hasPending: false, payload: null },
|
||||
consumeFocusNoteRequest: () => {},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../../src/renderer/src/lib/toast', () => ({
|
||||
useToast: {
|
||||
getState: () => ({ show: () => {} }),
|
||||
},
|
||||
}));
|
||||
|
||||
import { useNotesStore } from '../../../../src/renderer/src/store/notes';
|
||||
import type { StandaloneNoteSummary } from '../../../../src/shared/types';
|
||||
|
||||
function summary(id: string, relPath: string): StandaloneNoteSummary {
|
||||
return { id, relPath, filename: relPath, title: null, tags: null, mtime: 0 };
|
||||
}
|
||||
|
||||
interface MockApi {
|
||||
listNotes: ReturnType<typeof vi.fn>;
|
||||
renameNoteFile: ReturnType<typeof vi.fn>;
|
||||
onNotesChanged: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function setupMockApi(): MockApi {
|
||||
const api: MockApi = {
|
||||
listNotes: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||
renameNoteFile: vi.fn(),
|
||||
onNotesChanged: vi.fn().mockReturnValue(() => {}),
|
||||
};
|
||||
(globalThis as unknown as { window: { api: MockApi } }).window = { api };
|
||||
useNotesStore.setState({
|
||||
notes: [],
|
||||
selectedIds: new Set<string>(),
|
||||
selectedId: null,
|
||||
activeNoteFull: null,
|
||||
anchorId: null,
|
||||
clipboard: null,
|
||||
lastError: null,
|
||||
displayOrderById: {},
|
||||
});
|
||||
return api;
|
||||
}
|
||||
|
||||
describe('notes store:刷新不吃掉多选(r193)', () => {
|
||||
let api: MockApi;
|
||||
|
||||
beforeEach(() => {
|
||||
api = setupMockApi();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reload 后活着的多选 id 保留,Set 引用不变(不引无谓重渲)', async () => {
|
||||
useNotesStore.setState({
|
||||
notes: [summary('a', 'a.md'), summary('b', 'b.md')],
|
||||
selectedIds: new Set(['a', 'b']),
|
||||
anchorId: 'a',
|
||||
});
|
||||
const before = useNotesStore.getState().selectedIds;
|
||||
|
||||
api.listNotes.mockResolvedValue({
|
||||
ok: true,
|
||||
data: [summary('a', 'a.md'), summary('b', 'b.md'), summary('c', 'c.md')],
|
||||
});
|
||||
await useNotesStore.getState().load();
|
||||
|
||||
const st = useNotesStore.getState();
|
||||
expect([...st.selectedIds].sort()).toEqual(['a', 'b']);
|
||||
expect(st.selectedIds).toBe(before);
|
||||
expect(st.anchorId).toBe('a');
|
||||
});
|
||||
|
||||
it('reload 后已经不存在的 id 被剔掉(这才是 stale id 该防的)', async () => {
|
||||
useNotesStore.setState({
|
||||
notes: [summary('a', 'a.md'), summary('b', 'b.md')],
|
||||
selectedIds: new Set(['a', 'b']),
|
||||
anchorId: 'b',
|
||||
});
|
||||
|
||||
api.listNotes.mockResolvedValue({ ok: true, data: [summary('a', 'a.md')] });
|
||||
await useNotesStore.getState().load();
|
||||
|
||||
const st = useNotesStore.getState();
|
||||
expect([...st.selectedIds]).toEqual(['a']);
|
||||
// 锚点没了 → 置 null(留着会让 Shift 范围选择算错区间)
|
||||
expect(st.anchorId).toBeNull();
|
||||
});
|
||||
|
||||
it('改名的笔记跟着换 id 留在多选里(旧 id 已死,不该静默掉出选择)', async () => {
|
||||
useNotesStore.setState({
|
||||
notes: [summary('a', 'a.md'), summary('b', 'b.md')],
|
||||
selectedIds: new Set(['a', 'b']),
|
||||
anchorId: 'a',
|
||||
});
|
||||
|
||||
api.renameNoteFile.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { id: 'a2', relPath: 'a2.md', filename: 'a2.md', title: null, tags: null, mtime: 1 },
|
||||
});
|
||||
api.listNotes.mockResolvedValue({
|
||||
ok: true,
|
||||
data: [summary('a2', 'a2.md'), summary('b', 'b.md')],
|
||||
});
|
||||
|
||||
await useNotesStore.getState().renameFile('a', 'a2.md');
|
||||
|
||||
const st = useNotesStore.getState();
|
||||
expect([...st.selectedIds].sort()).toEqual(['a2', 'b']);
|
||||
expect(st.anchorId).toBe('a2');
|
||||
});
|
||||
});
|
||||
159
tests/unit/renderer/store/select-range-visible-order.test.ts
Normal file
159
tests/unit/renderer/store/select-range-visible-order.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
// tests/unit/renderer/store/select-range-visible-order.test.ts
|
||||
//
|
||||
// 守 audit-r243「Shift 范围选择的区间口径必须是**屏幕上可见的行序**」契约。
|
||||
//
|
||||
// 现象(用户可复现,文献域):
|
||||
// 1. 侧栏切到「仅收藏」视图(只显示被收藏的 PDF);
|
||||
// 2. 在第 1 行按住 Shift 点第 3 行 → 高亮 3 行,看起来没问题;
|
||||
// 3. 但底层 store 的 items 里还有**没被收藏、被过滤掉**的文件。旧实现
|
||||
// `selectRange` 切的是 items 序(未过滤),区间两端之间的**每一个**
|
||||
// items 元素都被塞进 selectedIds —— 包括用户**根本没看见**的行;
|
||||
// 4. 按 Delete → 确认框写「删除 N 个文件?」,随后把那些用户没看见、
|
||||
// 也没打算选的文件**丢进回收站**。
|
||||
//
|
||||
// 根因:store 数组序 ≠ 屏幕行序。列表是**树状 + 过滤 + 自定义排序**的:
|
||||
// - 文献域 visibleItems 走 favFiltered / 文件夹过滤 / displayOrderById;
|
||||
// - 笔记域 visibleItems 走同样的过滤链。
|
||||
// store 的 items/notes 只是「磁盘事实」的全集。
|
||||
//
|
||||
// 修法:selectRange 多收一个 visibleIds(渲染序),用它的下标切区间。
|
||||
// 不传 visibleIds 时退回 store 序(向后兼容旧调用点)。
|
||||
//
|
||||
// 本测锁三件事:
|
||||
// 1. 传 visibleIds 时,区间**只含可见行**(被过滤掉的行不在 selectedIds);
|
||||
// 2. 不传 visibleIds 时退回旧行为(store 序全量)—— 保证没接线的调用点
|
||||
// 不会静默变成「只选一行」;
|
||||
// 3. 锚点不在可见序里 / 目标不在可见序里 ⇒ 退化成单选(不越界选)。
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../../src/renderer/src/store/workspace', () => ({
|
||||
useWorkspaceStore: {
|
||||
getState: () => ({
|
||||
pendingFocusNote: { hasPending: false, payload: null },
|
||||
consumeFocusNoteRequest: () => {},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../../src/renderer/src/lib/toast', () => ({
|
||||
useToast: {
|
||||
getState: () => ({ show: () => {} }),
|
||||
},
|
||||
}));
|
||||
|
||||
import { useNotesStore } from '../../../../src/renderer/src/store/notes';
|
||||
import { useLiteratureStore } from '../../../../src/renderer/src/store/literature';
|
||||
import type {
|
||||
LiteratureResource,
|
||||
ResourceId,
|
||||
StandaloneNoteSummary,
|
||||
} from '../../../../src/shared/types';
|
||||
|
||||
function note(id: string): StandaloneNoteSummary {
|
||||
return { id, relPath: `${id}.md`, filename: `${id}.md`, title: null, tags: null, mtime: 0 };
|
||||
}
|
||||
|
||||
function lit(id: string): LiteratureResource {
|
||||
return {
|
||||
id: id as ResourceId,
|
||||
kind: 'literature',
|
||||
title: id,
|
||||
mtime: 0,
|
||||
size: 0,
|
||||
indexedAt: 0,
|
||||
absPath: `C:/root/literature/${id}.pdf`,
|
||||
relPath: `${id}.pdf`,
|
||||
rootId: 'r1',
|
||||
extractStatus: 'ok',
|
||||
};
|
||||
}
|
||||
|
||||
describe('audit-r243:Shift 范围选择按可见行序切区间', () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as unknown as { window: { api: unknown } }).window = { api: {} };
|
||||
useNotesStore.setState({
|
||||
notes: [],
|
||||
selectedIds: new Set<string>(),
|
||||
selectedId: null,
|
||||
activeNoteFull: null,
|
||||
anchorId: null,
|
||||
clipboard: null,
|
||||
lastError: null,
|
||||
displayOrderById: {},
|
||||
});
|
||||
useLiteratureStore.setState({
|
||||
items: [],
|
||||
selectedIds: new Set<string>(),
|
||||
selectedId: null,
|
||||
anchorId: null,
|
||||
clipboard: null,
|
||||
loading: false,
|
||||
lastError: null,
|
||||
displayOrderById: {},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('笔记域:传 visibleIds 时区间不含被过滤掉的行', () => {
|
||||
// store 全量序:a b c d e;屏幕(搜索过滤后)只显示 a c e
|
||||
useNotesStore.setState({
|
||||
notes: [note('a'), note('b'), note('c'), note('d'), note('e')],
|
||||
selectedIds: new Set(['a']),
|
||||
anchorId: 'a',
|
||||
});
|
||||
|
||||
useNotesStore.getState().selectRange('c', ['a', 'c', 'e']);
|
||||
|
||||
// 旧实现会选到 a b c(含看不见的 b);正确结果只含 a c
|
||||
expect([...useNotesStore.getState().selectedIds].sort()).toEqual(['a', 'c']);
|
||||
});
|
||||
|
||||
it('文献域:传 visibleIds 时区间不含被过滤掉的行(仅收藏视图)', () => {
|
||||
// store 全量序:fav1 x1 fav2 x2 fav3;屏幕(仅收藏)只显示 fav1 fav2 fav3
|
||||
useLiteratureStore.setState({
|
||||
items: [lit('fav1'), lit('x1'), lit('fav2'), lit('x2'), lit('fav3')],
|
||||
selectedIds: new Set(['fav1']),
|
||||
anchorId: 'fav1',
|
||||
});
|
||||
|
||||
useLiteratureStore.getState().selectRange('fav3', ['fav1', 'fav2', 'fav3']);
|
||||
|
||||
// 旧实现会选到 fav1 x1 fav2 x2 fav3(4 个看不见/不存在的行被删)
|
||||
expect([...useLiteratureStore.getState().selectedIds].sort()).toEqual([
|
||||
'fav1',
|
||||
'fav2',
|
||||
'fav3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('不传 visibleIds 时退回 store 序(旧调用点行为不变)', () => {
|
||||
useNotesStore.setState({
|
||||
notes: [note('a'), note('b'), note('c')],
|
||||
selectedIds: new Set(['a']),
|
||||
anchorId: 'a',
|
||||
});
|
||||
|
||||
useNotesStore.getState().selectRange('c');
|
||||
|
||||
expect([...useNotesStore.getState().selectedIds].sort()).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('锚点 / 目标不在可见序里 ⇒ 退化成单选(不越界选)', () => {
|
||||
useLiteratureStore.setState({
|
||||
items: [lit('fav1'), lit('x1'), lit('fav2')],
|
||||
selectedIds: new Set(['x1']),
|
||||
anchorId: 'x1',
|
||||
});
|
||||
|
||||
// 锚点 x1 被过滤掉了 ⇒ 区间无意义 ⇒ 只选目标行
|
||||
useLiteratureStore.getState().selectRange('fav2', ['fav1', 'fav2']);
|
||||
expect([...useLiteratureStore.getState().selectedIds]).toEqual(['fav2']);
|
||||
|
||||
// 目标不在可见序里 ⇒ 同样退化
|
||||
useLiteratureStore.setState({ selectedIds: new Set(['fav1']), anchorId: 'fav1' });
|
||||
useLiteratureStore.getState().selectRange('x1', ['fav1', 'fav2']);
|
||||
expect([...useLiteratureStore.getState().selectedIds]).toEqual(['x1']);
|
||||
});
|
||||
});
|
||||
100
tests/unit/renderer/store/self-write-window-parity.test.ts
Normal file
100
tests/unit/renderer/store/self-write-window-parity.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
// tests/unit/renderer/store/self-write-window-parity.test.ts
|
||||
//
|
||||
// 守 r221「自写抑制窗口两域数值刻意不同」这条文档-代码一致性契约。
|
||||
//
|
||||
// ── 背景(peer r217 登记的未修项 ③)────────────────────────────────
|
||||
// 自写抑制窗口(写操作后跳过自己引发的 CHANGED 推送)在两域取值不同:
|
||||
// src/renderer/src/store/notes.ts SELF_WRITE_SUPPRESS_WINDOW_MS = 500
|
||||
// src/renderer/src/store/literature.ts SELF_WRITE_SUPPRESS_WINDOW_MS = 200
|
||||
// 而注释互相矛盾:notes 注释说「500ms 比 IPC roundtrip 略长」(真话是
|
||||
// 要盖 watcher echo);ARCHITECTURE §8② 说两域都是 ~200ms;main/index.ts
|
||||
// 的 literature 广播注释还停在 r136 M3 收紧前的「500ms self-write」。
|
||||
//
|
||||
// ── 为什么不是「统一成一个值」(r221 对账结论)─────────────────────
|
||||
// notes 500ms:save() 走乐观 patch、**不自己 load**,窗口必须盖过
|
||||
// watcher echo(chokidar awaitWriteFinish 50ms + reload 去抖 100ms +
|
||||
// scan/广播 ≈ 写后 150~400ms);收 200ms 会让 echo 漏出窗口 ⇒ 每秒
|
||||
// autosave 多一次全量拉 + 全列表重渲(perf 2026-09-04 的优化失效)。
|
||||
// literature 200ms:writer 自己 force load,窗口只需盖主进程同步 echo
|
||||
// (≈130ms);它的 watcher echo(awf 200ms + 去抖 100ms ≥ 300ms)
|
||||
// 本来就盖不住,漏出的那次 force load 当 no-op 兜底接受(r136 注释
|
||||
// 明示)。窗口越宽,窗口内的**外部**变更被当自写吞掉越久 ⇒
|
||||
// literature 坚持 200ms 是 r136 的刻意收紧,notes 保留 500ms 是
|
||||
// 上述时序的必然。两值不同是设计,不是漂移。
|
||||
//
|
||||
// ── 本测锁的契约 ───────────────────────────────────────────────────
|
||||
// A. 两个常量保持 500 / 200(回归锚 —— 数值本身是设计决策);
|
||||
// B. main/index.ts 的 literature 广播注释不再谎称 500ms(r136 旧值);
|
||||
// D. notes 的常量注释块必须说明 500ms 的真实原因(watcher echo);
|
||||
// E. 两侧注释块互相交叉引用对方数值(防未来「好心统一」时不读理由)。
|
||||
//
|
||||
// (原契约 C 锁 ARCHITECTURE.md §8② 同时写出两个值;2026-10-20 文档
|
||||
// 三合一并入 README 后该文件删除,设计理由的单一真相源收编进两侧
|
||||
// 代码注释 —— 即契约 D/E,ARCH 守卫随之退役。)
|
||||
//
|
||||
// 纯静态扫源码文本,不起进程。
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const read = (rel: string): string => readFileSync(join(ROOT, rel), 'utf-8');
|
||||
|
||||
const NOTES_STORE = 'src/renderer/src/store/notes.ts';
|
||||
const LIT_STORE = 'src/renderer/src/store/literature.ts';
|
||||
const MAIN_INDEX = 'src/main/index.ts';
|
||||
|
||||
/** 取常量定义行之前的注释块(理由就写在那里)。 */
|
||||
function commentBlockBefore(src: string, anchor: string, span = 1800): string {
|
||||
const i = src.indexOf(anchor);
|
||||
expect(i, `锚点缺失:${anchor}`).toBeGreaterThanOrEqual(0);
|
||||
return src.slice(Math.max(0, i - span), i);
|
||||
}
|
||||
|
||||
describe('自写抑制窗口两域刻意不同(r221)', () => {
|
||||
it('A1 notes 窗口 = 500ms(回归锚:盖 watcher echo 的必然值)', () => {
|
||||
expect(read(NOTES_STORE)).toContain('SELF_WRITE_SUPPRESS_WINDOW_MS = 500');
|
||||
});
|
||||
|
||||
it('A2 literature 窗口 = 200ms(回归锚:r136 M3 刻意收紧值)', () => {
|
||||
expect(read(LIT_STORE)).toContain('SELF_WRITE_SUPPRESS_WINDOW_MS = 200');
|
||||
});
|
||||
|
||||
it('B main/index.ts 的 literature 广播注释不再谎称窗口是 500ms', () => {
|
||||
const src = read(MAIN_INDEX);
|
||||
const block = commentBlockBefore(src, "literatureService.on('changed'");
|
||||
// 谎的具体形态 = 「订阅 + 500ms self-write 抑制窗口」(r136 M3 收紧前的
|
||||
// 旧值);注释里交叉引用 notes 的 500ms 是允许的。
|
||||
expect(block, 'literature 广播注释还停在 r136 M3 收紧前的 500ms 旧值').not.toMatch(
|
||||
/订阅 \+ 500ms/,
|
||||
);
|
||||
expect(block).toMatch(/订阅 \+ 200ms/);
|
||||
});
|
||||
|
||||
it('D notes 的 500ms 注释块说明真实原因(watcher echo),不是「比 IPC roundtrip 略长」', () => {
|
||||
const block = commentBlockBefore(
|
||||
read(NOTES_STORE),
|
||||
'SELF_WRITE_SUPPRESS_WINDOW_MS = 500',
|
||||
);
|
||||
expect(block, '500ms 的真实原因是盖 watcher echo,注释必须写明').toContain(
|
||||
'watcher echo',
|
||||
);
|
||||
});
|
||||
|
||||
it('E 两侧注释块交叉引用对方数值(防未来「好心统一」时不读理由)', () => {
|
||||
const notesBlock = commentBlockBefore(
|
||||
read(NOTES_STORE),
|
||||
'SELF_WRITE_SUPPRESS_WINDOW_MS = 500',
|
||||
);
|
||||
const litBlock = commentBlockBefore(
|
||||
read(LIT_STORE),
|
||||
'SELF_WRITE_SUPPRESS_WINDOW_MS = 200',
|
||||
);
|
||||
expect(notesBlock, 'notes 注释必须点名 literature 的 200ms 及差异原因').toContain(
|
||||
'literature 的 200ms',
|
||||
);
|
||||
expect(litBlock, 'literature 注释必须点名 notes 的 500ms 及差异原因').toContain(
|
||||
'notes 域是 500ms',
|
||||
);
|
||||
});
|
||||
});
|
||||
237
tests/unit/renderer/store/struct-share.test.tsx
Normal file
237
tests/unit/renderer/store/struct-share.test.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
// tests/unit/renderer/store/struct-share.test.tsx
|
||||
//
|
||||
// 守 r258「列表刷新不许把整表换成新对象」契约 ——
|
||||
// IPC 快照的**结构共享**:内容没变的条目沿用上一轮的对象引用。
|
||||
//
|
||||
// ── 被守卫的缺陷(假绿优化)─────────────────────────────────────────
|
||||
// 两个列表 store(notes / literature)的 load() 都把 IPC 回来的快照**整
|
||||
// 表替换**进 state。IPC 走 structured clone ⇒ 每次回来的都是**全新对
|
||||
// 象**(内容一模一样也是新引用)。于是:
|
||||
//
|
||||
// ① store.notes / store.items 每轮换引用;
|
||||
// ② 组件用 `useShallow(s => s.notes)` 订阅 —— useShallow 的浅比是
|
||||
// **逐元素 Object.is** ⇒ 元素全是新引用 ⇒ 浅比恒为「不等」⇒
|
||||
// **useShallow 从来没生效过**(两处组件注释里写的「浅比较元素相同
|
||||
// 时不重渲」是**假绿**:元素引用从不相同);
|
||||
// ③ 每次 NOTES_CHANGED / LITERATURE_CHANGED(自动保存、外部改盘、网
|
||||
// 盘同步落盘、另一个窗口写盘)⇒ 整个可见列表 + 每一行都重渲。
|
||||
//
|
||||
// ── 本测锁五件事 ───────────────────────────────────────────────────
|
||||
// A. store 层:内容完全相同的二次 load ⇒ `notes` **数组引用都不变**
|
||||
// (不只是元素,连数组都是旧的)—— 这是 useShallow 能 bail out 的前提;
|
||||
// B. store 层:一条笔记变了 ⇒ 变的那条是**新对象**,其余条目**沿用旧
|
||||
// 引用**(行级 React.memo 只重渲那一条);
|
||||
// C. store 层:顺序变了 ⇒ 数组是新数组,但**元素全部沿用旧引用**;
|
||||
// D. **组件层(本轮的性能契约本体)**:订阅 `useShallow(s => s.notes)`
|
||||
// 的组件,在一次「内容完全相同」的 load() 之后**渲染次数不变**
|
||||
// —— 修前每次广播都重渲;
|
||||
// E. 文献域同款(items 引用不变)。
|
||||
//
|
||||
// 文献域与笔记域的 useShallow 订阅点:
|
||||
// NotesList.tsx:94 / LiteratureList.tsx:104。
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, act } from '@testing-library/react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
vi.mock('../../../../src/renderer/src/store/workspace', () => ({
|
||||
useWorkspaceStore: {
|
||||
getState: () => ({
|
||||
pendingFocusNote: { hasPending: false, payload: null },
|
||||
consumeFocusNoteRequest: () => {},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../../src/renderer/src/lib/toast', () => ({
|
||||
useToast: {
|
||||
getState: () => ({ show: () => {} }),
|
||||
},
|
||||
}));
|
||||
|
||||
import { useNotesStore } from '../../../../src/renderer/src/store/notes';
|
||||
import { useLiteratureStore } from '../../../../src/renderer/src/store/literature';
|
||||
import type { LiteratureResource, StandaloneNoteSummary } from '../../../../src/shared/types';
|
||||
|
||||
function summary(id: string, over: Partial<StandaloneNoteSummary> = {}): StandaloneNoteSummary {
|
||||
return {
|
||||
id,
|
||||
relPath: `${id}.md`,
|
||||
filename: `${id}.md`,
|
||||
title: null,
|
||||
tags: null,
|
||||
mtime: 1000,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function litItem(id: string, over: Partial<LiteratureResource> = {}): LiteratureResource {
|
||||
return {
|
||||
id: id as LiteratureResource['id'],
|
||||
kind: 'literature',
|
||||
title: id,
|
||||
mtime: 1000,
|
||||
size: 10,
|
||||
indexedAt: 0,
|
||||
absPath: `C:/lit/${id}.pdf`,
|
||||
relPath: `${id}.pdf`,
|
||||
rootId: null,
|
||||
extractStatus: 'filename-only',
|
||||
...over,
|
||||
} as LiteratureResource;
|
||||
}
|
||||
|
||||
interface MockApi {
|
||||
listNotes: ReturnType<typeof vi.fn>;
|
||||
listLiterature: ReturnType<typeof vi.fn>;
|
||||
onNotesChanged: ReturnType<typeof vi.fn>;
|
||||
onLiteratureChanged: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function setupMockApi(): MockApi {
|
||||
const api: MockApi = {
|
||||
listNotes: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||
listLiterature: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||
onNotesChanged: vi.fn().mockReturnValue(() => {}),
|
||||
onLiteratureChanged: vi.fn().mockReturnValue(() => {}),
|
||||
};
|
||||
// 注意:只**挂** api,不要整体替换 window —— jsdom 环境下 window 就是
|
||||
// document 的宿主,整体替换会把 react-dom 依赖的 window/document 一起
|
||||
// 抹掉(实测报 "Right-hand side of 'instanceof' is not an object")。
|
||||
const w = globalThis as unknown as { window: { api?: MockApi } };
|
||||
w.window.api = api;
|
||||
useNotesStore.setState({
|
||||
notes: [],
|
||||
selectedIds: new Set<string>(),
|
||||
selectedId: null,
|
||||
activeNoteFull: null,
|
||||
anchorId: null,
|
||||
clipboard: null,
|
||||
lastError: null,
|
||||
displayOrderById: {},
|
||||
});
|
||||
useLiteratureStore.setState({
|
||||
items: [],
|
||||
selectedId: null,
|
||||
loading: false,
|
||||
lastError: null,
|
||||
selectedIds: new Set<string>(),
|
||||
anchorId: null,
|
||||
clipboard: null,
|
||||
displayOrderById: {},
|
||||
});
|
||||
return api;
|
||||
}
|
||||
|
||||
describe('列表快照的结构共享(r258)', () => {
|
||||
let api: MockApi;
|
||||
|
||||
beforeEach(() => {
|
||||
api = setupMockApi();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('A 内容完全相同的二次 load ⇒ notes 数组引用都不变', async () => {
|
||||
api.listNotes.mockResolvedValue({ ok: true, data: [summary('a'), summary('b')] });
|
||||
await useNotesStore.getState().load();
|
||||
const first = useNotesStore.getState().notes;
|
||||
expect(first).toHaveLength(2);
|
||||
|
||||
// IPC 再发一次「一模一样」的内容(structured clone ⇒ 全是新对象)
|
||||
api.listNotes.mockResolvedValue({ ok: true, data: [summary('a'), summary('b')] });
|
||||
await useNotesStore.getState().load();
|
||||
const second = useNotesStore.getState().notes;
|
||||
|
||||
expect(second, '内容没变却换数组引用 ⇒ useShallow 的浅比永远判「不等」').toBe(first);
|
||||
expect(second[0]).toBe(first[0]);
|
||||
expect(second[1]).toBe(first[1]);
|
||||
});
|
||||
|
||||
it('B 一条笔记变了 ⇒ 变的那条换新对象,其余沿用旧引用', async () => {
|
||||
api.listNotes.mockResolvedValue({ ok: true, data: [summary('a'), summary('b')] });
|
||||
await useNotesStore.getState().load();
|
||||
const first = useNotesStore.getState().notes;
|
||||
|
||||
api.listNotes.mockResolvedValue({
|
||||
ok: true,
|
||||
data: [summary('a', { mtime: 2000, title: 'A' }), summary('b')],
|
||||
});
|
||||
await useNotesStore.getState().load();
|
||||
const second = useNotesStore.getState().notes;
|
||||
|
||||
expect(second, '有一条变了 ⇒ 数组必须换新引用').not.toBe(first);
|
||||
expect(second[0], '变的那条必须是新对象').not.toBe(first[0]);
|
||||
expect(second[1], '没变的那条必须沿用旧引用(行级 memo 才不会白失效)').toBe(first[1]);
|
||||
});
|
||||
|
||||
it('C 顺序变了 ⇒ 新数组,但元素全部沿用旧引用', async () => {
|
||||
api.listNotes.mockResolvedValue({ ok: true, data: [summary('a'), summary('b')] });
|
||||
await useNotesStore.getState().load();
|
||||
const first = useNotesStore.getState().notes;
|
||||
|
||||
api.listNotes.mockResolvedValue({ ok: true, data: [summary('b'), summary('a')] });
|
||||
await useNotesStore.getState().load();
|
||||
const second = useNotesStore.getState().notes;
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(second[0]).toBe(first[1]);
|
||||
expect(second[1]).toBe(first[0]);
|
||||
});
|
||||
|
||||
it('D 组件层:内容相同的 load ⇒ useShallow 订阅者不重渲(本轮的性能契约)', async () => {
|
||||
api.listNotes.mockResolvedValue({ ok: true, data: [summary('a'), summary('b')] });
|
||||
|
||||
let renders = 0;
|
||||
function Probe(): JSX.Element {
|
||||
const notes = useNotesStore(useShallow((s) => s.notes));
|
||||
renders += 1;
|
||||
return <div data-testid="count">{notes.length}</div>;
|
||||
}
|
||||
|
||||
render(<Probe />);
|
||||
await act(async () => {
|
||||
await useNotesStore.getState().load();
|
||||
});
|
||||
const afterFirst = renders;
|
||||
expect(afterFirst).toBeGreaterThan(0);
|
||||
|
||||
// 第二次:IPC 内容一模一样(新对象)—— 组件**不该**重渲
|
||||
api.listNotes.mockResolvedValue({ ok: true, data: [summary('a'), summary('b')] });
|
||||
await act(async () => {
|
||||
await useNotesStore.getState().load();
|
||||
});
|
||||
|
||||
expect(
|
||||
renders,
|
||||
'内容一个字没变却整表重渲 —— 这就是 useShallow 一直没生效的代价',
|
||||
).toBe(afterFirst);
|
||||
});
|
||||
|
||||
it('F 带 tags 的笔记:tags 内容相同也算「没变」(数组要逐元素比)', async () => {
|
||||
api.listNotes.mockResolvedValue({ ok: true, data: [summary('a', { tags: ['x', 'y'] })] });
|
||||
await useNotesStore.getState().load();
|
||||
const first = useNotesStore.getState().notes;
|
||||
|
||||
// tags 经 IPC clone 后是**新数组**,但内容一样 —— 必须判为「没变」
|
||||
api.listNotes.mockResolvedValue({ ok: true, data: [summary('a', { tags: ['x', 'y'] })] });
|
||||
await useNotesStore.getState().load();
|
||||
const second = useNotesStore.getState().notes;
|
||||
|
||||
expect(second, 'tags 数组是新对象但内容一样 ⇒ 必须判为「没变」').toBe(first);
|
||||
});
|
||||
|
||||
it('E 文献域同款:内容相同的 load ⇒ items 数组引用不变', async () => {
|
||||
api.listLiterature.mockResolvedValue({ ok: true, data: [litItem('x'), litItem('y')] });
|
||||
await useLiteratureStore.getState().load({ force: true });
|
||||
const first = useLiteratureStore.getState().items;
|
||||
expect(first).toHaveLength(2);
|
||||
|
||||
api.listLiterature.mockResolvedValue({ ok: true, data: [litItem('x'), litItem('y')] });
|
||||
await useLiteratureStore.getState().load({ force: true });
|
||||
const second = useLiteratureStore.getState().items;
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(second[0]).toBe(first[0]);
|
||||
});
|
||||
});
|
||||
43
tests/unit/renderer/store/workspace-default-tab.test.ts
Normal file
43
tests/unit/renderer/store/workspace-default-tab.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// tests/unit/renderer/store/workspace-default-tab.test.ts
|
||||
//
|
||||
// 行为锁(audit-r236,2026-10-20):启动首屏左栏默认 tab = 'library'(文献)。
|
||||
//
|
||||
// 用户要求「软件启动的时候,左侧栏默认在文献 tab 中」。此前默认是 'notes'
|
||||
// (audit-r69 2026-10-16,当时 library 模块已下线、首屏会白屏)。前提在
|
||||
// audit-r71 恢复 LiteratureList + 2026-10-18 主区双列解耦后已失效,默认值
|
||||
// 纯属产品选择,极易被无意改回 —— 用初始状态锁住。
|
||||
//
|
||||
// 形态:import 真 store(不 mock),只读模块初始化后的 active 字段。
|
||||
// zustand 的初始值就是 create() 传入的对象,store 实例在本 test 文件独享,
|
||||
// 不受其他文件 mock 影响。
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { useWorkspaceStore } from '../../../../src/renderer/src/store/workspace';
|
||||
|
||||
describe('workspace 启动默认 tab(r236 行为锁)', () => {
|
||||
it('初始 active === "library"(首屏左栏落在文献 tab)', () => {
|
||||
expect(useWorkspaceStore.getState().active).toBe('library');
|
||||
});
|
||||
|
||||
it("默认值在 WorkbenchListPanel 的 VALID_TAB_IDS 白名单里", () => {
|
||||
// 默认 active 必须落在列表面板白名单里,否则首屏 Tabs 高亮与 tabpanel id
|
||||
// 配不上对(tab-library / panel-library 悬空),且 handleTabChange 会拒绝
|
||||
// 切回默认 tab。白名单从组件源码字面量解析,防止 store 默认值与 UI
|
||||
// 白名单悄悄分叉(同构块分叉家族)。
|
||||
const panelSrc = readFileSync(
|
||||
join(process.cwd(), 'src/renderer/src/components/layout/WorkbenchListPanel.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
const m = panelSrc.match(/const VALID_TAB_IDS = \[([^\]]*)\]/);
|
||||
expect(m, 'WorkbenchListPanel 里找不到 VALID_TAB_IDS 字面量(结构变了要同步本测试)').toBeTruthy();
|
||||
const ids = (m as RegExpMatchArray)[1]
|
||||
.split(',')
|
||||
.map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
|
||||
.filter(Boolean);
|
||||
expect(ids).toContain(useWorkspaceStore.getState().active);
|
||||
});
|
||||
});
|
||||
75
tests/unit/renderer/styles/no-html-scrollbar-gutter.test.ts
Normal file
75
tests/unit/renderer/styles/no-html-scrollbar-gutter.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// tests/unit/renderer/styles/no-html-scrollbar-gutter.test.ts
|
||||
//
|
||||
// 行为锁(audit-r237,2026-10-20):globals.css 里**任何命中 html 元素的规则**
|
||||
// (html / html.xxx / :root / :root[...] 等)都不得带 scrollbar-gutter 声明。
|
||||
//
|
||||
// audit-r145c 给 html 加了 scrollbar-gutter: stable,想治「列表出现/收起
|
||||
// 滚动条时内容跳 8px」。但本 app 文档流根本不滚动(App 根节点
|
||||
// `h-screen … overflow-hidden`,所有滚动都在内部面板),stable 在 html 上
|
||||
// = 永久预留一条右侧死白条(实测 1440px 窗口只排版到 1429px,用户报
|
||||
// 「窗口关闭按钮右边一条窄空白」)。r145c 的跳变属于内部滚动容器,要治
|
||||
// 也得加在真正滚动的元素上。
|
||||
//
|
||||
// 形态:源码字面解析(css-tree 不在 devDeps,用注释剥离 + 最内层块正则)。
|
||||
// 注释先剥掉 ⇒ 本文件/globals.css 注释里出现 "scrollbar-gutter" 字样不会误判。
|
||||
// 读文件走 process.cwd()(jsdom 环境下 fileURLToPath(import.meta.url) 不可靠,
|
||||
// 见 r236 同族测试)。
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/** 剥注释后的 globals.css 全文 */
|
||||
function cssWithoutComments(): string {
|
||||
return readFileSync(join(process.cwd(), 'src/renderer/src/styles/globals.css'), 'utf8').replace(
|
||||
/\/\*[\s\S]*?\*\//g,
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
interface CssRule {
|
||||
selector: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取所有**最内层**规则块。@layer / @media 等嵌套 at-rule 的头部
|
||||
* (`... {` 后紧跟的内容含 `{`)正则匹配不上会被自然跳过,只留下叶子规则。
|
||||
*/
|
||||
function leafRules(css: string): CssRule[] {
|
||||
const out: CssRule[] = [];
|
||||
const re = /([^{}]+)\{([^{}]*)\}/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(css)) !== null) {
|
||||
out.push({ selector: m[1].trim(), body: m[2] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 选择器逗号拆段后,任一段命中 html 元素本体:html / html.light / :root / :root[...] */
|
||||
function hitsHtmlElement(selector: string): boolean {
|
||||
return selector
|
||||
.split(',')
|
||||
.some((part) => /^(?:html|:root)(?:[.#[:]|$)/.test(part.trim()));
|
||||
}
|
||||
|
||||
describe('html 不得带 scrollbar-gutter(r237 行为锁)', () => {
|
||||
const rules = leafRules(cssWithoutComments());
|
||||
|
||||
it('能解析出 html 的 scrollbar 着色规则(解析器没瞎、r143c 着色未被顺手删)', () => {
|
||||
const htmlRules = rules.filter((r) => hitsHtmlElement(r.selector));
|
||||
expect(htmlRules.length).toBeGreaterThan(0);
|
||||
expect(htmlRules.some((r) => /scrollbar-color\s*:/.test(r.body))).toBe(true);
|
||||
});
|
||||
|
||||
it('没有任何命中 html 元素的规则声明 scrollbar-gutter(右侧死白条不复发)', () => {
|
||||
const offenders = rules.filter(
|
||||
(r) => hitsHtmlElement(r.selector) && /scrollbar-gutter\s*:/.test(r.body),
|
||||
);
|
||||
expect(
|
||||
offenders.map((o) => o.selector),
|
||||
'html/:root 上的 scrollbar-gutter 会在不滚动的文档流上永久预留死白条;r145c 的跳变要治请加到真正滚动的内部元素',
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
// tests/unit/renderer/styles/scrollbar-standard-props-firefox-only.test.ts
|
||||
//
|
||||
// 行为锁(audit-r250,2026-10-21):globals.css 里 `scrollbar-color` /
|
||||
// `scrollbar-width: thin` 这类**标准滚动条属性**只允许出现在
|
||||
// `@supports (-moz-appearance: none)`(≈ Firefox-only)块里。
|
||||
//
|
||||
// 为什么:这两个属性一旦在祖先(html)上生效,就会**继承**到每个内部滚动
|
||||
// 容器(侧栏列表 / 笔记编辑器 / Reader / 设置抽屉 / 搜索面板)。
|
||||
// Chromium 的规则是:scrollbar-color 不是 auto 时,UA 改用「标准滚动条」
|
||||
// 渲染并**忽略整套 ::-webkit-scrollbar 规则** —— 于是 r143 校准到 3:1 的
|
||||
// --scrollbar-thumb-rgb 整套主题从来没生效,用户看到的是 Windows 原生
|
||||
// 17px 滚动条(▲▼ 箭头 + 浅灰 thumb),暗色主题下尤其刺眼,还白占 9px 宽度。
|
||||
//
|
||||
// 实测(Chromium 124 / Electron 30,真机 CDP 探针):
|
||||
// scrollbar-color 继承中 → 滚动条 17px
|
||||
// scrollbar-color: auto → 滚动条 8px(::-webkit-scrollbar 生效)
|
||||
//
|
||||
// 形态:源码字面解析(css-tree 不在 devDeps ⇒ 注释剥离 + 括号配平)。
|
||||
// 注释先剥掉 ⇒ 本文件/globals.css 注释里出现 "scrollbar-color" 字样不会误判。
|
||||
// 读文件走 process.cwd()(jsdom 环境下 fileURLToPath(import.meta.url) 不可靠,
|
||||
// 见 r236 同族测试)。
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const FIREFOX_ONLY_HEADER = '@supports (-moz-appearance: none)';
|
||||
|
||||
/** 剥注释后的 globals.css 全文 */
|
||||
function cssWithoutComments(): string {
|
||||
return readFileSync(join(process.cwd(), 'src/renderer/src/styles/globals.css'), 'utf8').replace(
|
||||
/\/\*[\s\S]*?\*\//g,
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
interface BlockRange {
|
||||
/** 块内容起止(含头不含尾),用于「某次出现是否落在块内」的区间判定 */
|
||||
start: number;
|
||||
end: number;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** 按括号配平取出 `header` 之后那个块的正文;找不到返回 null */
|
||||
function extractBlock(css: string, header: string): BlockRange | null {
|
||||
const headerIdx = css.indexOf(header);
|
||||
if (headerIdx < 0) return null;
|
||||
const open = css.indexOf('{', headerIdx + header.length);
|
||||
if (open < 0) return null;
|
||||
let depth = 0;
|
||||
for (let i = open; i < css.length; i += 1) {
|
||||
if (css[i] === '{') depth += 1;
|
||||
else if (css[i] === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return { start: open + 1, end: i, body: css.slice(open + 1, i) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 全文里某个 token 的所有出现位置 */
|
||||
function occurrences(css: string, token: string): number[] {
|
||||
const out: number[] = [];
|
||||
let idx = css.indexOf(token);
|
||||
while (idx >= 0) {
|
||||
out.push(idx);
|
||||
idx = css.indexOf(token, idx + token.length);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('标准滚动条属性必须收在 Firefox-only 块里(r250 行为锁)', () => {
|
||||
const css = cssWithoutComments();
|
||||
|
||||
it('Firefox-only 块存在,且带 scrollbar-color + scrollbar-width: thin', () => {
|
||||
const block = extractBlock(css, FIREFOX_ONLY_HEADER);
|
||||
expect(block, `globals.css 必须有 ${FIREFOX_ONLY_HEADER} 块承载标准滚动条属性`).not.toBeNull();
|
||||
expect(block?.body).toMatch(/scrollbar-color\s*:/);
|
||||
expect(block?.body).toMatch(/scrollbar-width\s*:\s*thin/);
|
||||
});
|
||||
|
||||
it('任何 scrollbar-color 声明都落在 Firefox-only 块内(Chromium 不再被劫持)', () => {
|
||||
const block = extractBlock(css, FIREFOX_ONLY_HEADER);
|
||||
expect(block).not.toBeNull();
|
||||
const outside = occurrences(css, 'scrollbar-color').filter(
|
||||
(i) => i < block!.start || i >= block!.end,
|
||||
);
|
||||
expect(
|
||||
outside.length,
|
||||
'scrollbar-color 是可继承属性,漏在块外会继承到所有内部滚动容器,' +
|
||||
'Chromium 随即忽略 ::-webkit-scrollbar ⇒ 用户看到 Windows 原生滚动条',
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('任何 scrollbar-width: thin 声明都落在 Firefox-only 块内', () => {
|
||||
const block = extractBlock(css, FIREFOX_ONLY_HEADER);
|
||||
expect(block).not.toBeNull();
|
||||
const thinPositions = occurrences(css, 'scrollbar-width').filter((i) =>
|
||||
/scrollbar-width\s*:\s*thin/.test(css.slice(i, i + 40)),
|
||||
);
|
||||
const outside = thinPositions.filter((i) => i < block!.start || i >= block!.end);
|
||||
expect(outside.length, 'scrollbar-width: thin 落在块外同样会让 Chromium 走标准滚动条').toBe(0);
|
||||
});
|
||||
|
||||
it('::-webkit-scrollbar 主题仍在(8px 细滚动条 + token 着色没被顺手删)', () => {
|
||||
// Chromium 下真正生效的就是这一套 —— 它被忽略等于主题全废
|
||||
expect(css).toMatch(/::-webkit-scrollbar\s*\{[^}]*width\s*:\s*8px/);
|
||||
expect(css).toMatch(
|
||||
/::-webkit-scrollbar-thumb\s*\{[^}]*background\s*:\s*rgb\(var\(--scrollbar-thumb-rgb\)\)/,
|
||||
);
|
||||
expect(css).toMatch(/--scrollbar-thumb-rgb\s*:/);
|
||||
});
|
||||
});
|
||||
112
tests/unit/renderer/styles/theme-css.ts
Normal file
112
tests/unit/renderer/styles/theme-css.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
// tests/unit/renderer/styles/theme-css.ts
|
||||
//
|
||||
// 主题 CSS 的最小解析工具(只给 theme-presets-*.test.ts 用,不是测试文件)。
|
||||
//
|
||||
// 为什么自己写解析器而不用 postcss:
|
||||
// 依赖树里没有 postcss,为一个断言脚本引入一个解析器不划算;而本文件要读的
|
||||
// 只是「顶层 CSS 块 + 块内的 `--token: R G B` 声明」,60 行足够且没有歧义。
|
||||
//
|
||||
// 解析结果:
|
||||
export interface ThemeBlock {
|
||||
/** 块选择器原文(可能多行,已折叠空白) */
|
||||
selector: string;
|
||||
/** `--token 名 → [r, g, b]` */
|
||||
tokens: Map<string, [number, number, number]>;
|
||||
/** 块内的非颜色声明(如 color-scheme) */
|
||||
decls: Map<string, string>;
|
||||
}
|
||||
|
||||
/** 从 globals.css 解析出所有顶层块 */
|
||||
export function parseTopLevelBlocks(css: string): ThemeBlock[] {
|
||||
// 注释整体剥掉(含跨行注释 —— 里面的 `选择器写法 :root[data-theme-preset='x']`
|
||||
// 这种示例文字会被误当成真选择器)。
|
||||
const lines = css.replace(/\/\*[\s\S]*?\*\//g, '').split('\n');
|
||||
const out: ThemeBlock[] = [];
|
||||
let depth = 0;
|
||||
let selectorBuf: string[] = [];
|
||||
let current: ThemeBlock | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (depth === 0) {
|
||||
// 块外:攒选择器文本(选择器可能跨多行,`:root,` 单独一行那种)
|
||||
const braceAt = line.indexOf('{');
|
||||
if (braceAt >= 0) {
|
||||
selectorBuf.push(line.slice(0, braceAt));
|
||||
const selector = selectorBuf.join(' ').replace(/\s+/g, ' ').trim();
|
||||
selectorBuf = [];
|
||||
current = { selector, tokens: new Map(), decls: new Map() };
|
||||
} else if (line.trim()) {
|
||||
selectorBuf.push(line);
|
||||
}
|
||||
} else {
|
||||
const decl = line.match(/^\s*([-a-z0-9]+)\s*:\s*([^;]+);/);
|
||||
if (decl && current) {
|
||||
const name = decl[1];
|
||||
const value = decl[2].trim();
|
||||
const rgb = value.match(/^(\d+)\s+(\d+)\s+(\d+)$/);
|
||||
if (name.startsWith('--')) {
|
||||
if (rgb) {
|
||||
current.tokens.set(name.slice(2), [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])]);
|
||||
} else {
|
||||
current.decls.set(name, value);
|
||||
}
|
||||
} else {
|
||||
current.decls.set(name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const ch of line) {
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
if (current) out.push(current);
|
||||
current = null;
|
||||
selectorBuf = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 找「选择器里带 [data-theme-preset='x']」的块 */
|
||||
export function findPresetBlock(blocks: ThemeBlock[], preset: string): ThemeBlock | undefined {
|
||||
return blocks.find((b) => b.selector.includes(`[data-theme-preset='${preset}']`));
|
||||
}
|
||||
|
||||
/** 找基础配方块([data-theme-preview='dark' | 'light']) */
|
||||
export function findPreviewBlock(blocks: ThemeBlock[], polarity: 'dark' | 'light'): ThemeBlock | undefined {
|
||||
return blocks.find((b) => b.selector.includes(`[data-theme-preview='${polarity}']`));
|
||||
}
|
||||
|
||||
// ── WCAG 对比度 ──────────────────────────────────────────────────────────
|
||||
|
||||
export type Rgb = [number, number, number];
|
||||
|
||||
function srgbToLin(c: number): number {
|
||||
const x = c / 255;
|
||||
return x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
export function relativeLuminance([r, g, b]: Rgb): number {
|
||||
return 0.2126 * srgbToLin(r) + 0.7152 * srgbToLin(g) + 0.0722 * srgbToLin(b);
|
||||
}
|
||||
|
||||
/** WCAG 2.x 对比度(1..21) */
|
||||
export function contrastRatio(a: Rgb, b: Rgb): number {
|
||||
const l1 = relativeLuminance(a);
|
||||
const l2 = relativeLuminance(b);
|
||||
const hi = Math.max(l1, l2);
|
||||
const lo = Math.min(l1, l2);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
}
|
||||
|
||||
export const WHITE: Rgb = [255, 255, 255];
|
||||
|
||||
/** 从块里取 token(缺了就 throw,调用方保证覆盖) */
|
||||
export function token(block: ThemeBlock, name: string): Rgb {
|
||||
const v = block.tokens.get(name);
|
||||
if (!v) throw new Error(`块「${block.selector}」缺 token ${name}`);
|
||||
return v;
|
||||
}
|
||||
165
tests/unit/renderer/styles/theme-presets-contrast.test.ts
Normal file
165
tests/unit/renderer/styles/theme-presets-contrast.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
// tests/unit/renderer/styles/theme-presets-contrast.test.ts
|
||||
//
|
||||
// 守 r247「15 档预设都按 WCAG 校准过」的**对比度**契约。
|
||||
//
|
||||
// 为什么要有这个守卫:
|
||||
// 配色方案的「好不好看」很难自动判,但「看不看得清」可以。本守卫把每一档
|
||||
// 配方的关键色对量成 WCAG 2.x 对比度,低于阈值就红 —— 于是
|
||||
// 「新加一档配色,顺手把某个灰阶调暗了两档」这种事在 CI 就被拦住,
|
||||
// 不用等用户来吐槽「Nord 下正文发灰看不清」。
|
||||
//
|
||||
// 阈值出处(WCAG 2.1):
|
||||
// - 正文级文字 4.5:1 (SC 1.4.3 AA)
|
||||
// - 大号文字 / 图形控件 / 焦点环 3:1 (SC 1.4.11)
|
||||
// - 状态色的「实心底 + 白字」= 正文级 ⇒ 4.5:1
|
||||
//
|
||||
// 语义分工(见 globals.css「状态色两槽」注释):
|
||||
// --success-rgb 等是「色」(图标 / 文字 / 描边);
|
||||
// --success-strong-rgb 等是「底」(实心按钮 / toast 底 + 白字)——
|
||||
// Dracula 的原色 #50FA7B 压白字只有 1.37:1,所以底必须走 strong 档。
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { THEME_PRESETS } from '../../../../src/shared/types';
|
||||
import {
|
||||
parseTopLevelBlocks,
|
||||
findPresetBlock,
|
||||
findPreviewBlock,
|
||||
contrastRatio,
|
||||
token,
|
||||
WHITE,
|
||||
type ThemeBlock,
|
||||
} from './theme-css';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const CSS = readFileSync(join(ROOT, 'src/renderer/src/styles/globals.css'), 'utf8');
|
||||
const blocks = parseTopLevelBlocks(CSS);
|
||||
|
||||
/** 每一档都要查的「块」:两块基础配方 + 14 档具体预设 */
|
||||
function allThemeBlocks(): Array<{ name: string; block: ThemeBlock }> {
|
||||
const out: Array<{ name: string; block: ThemeBlock }> = [];
|
||||
for (const polarity of ['dark', 'light'] as const) {
|
||||
const b = findPreviewBlock(blocks, polarity);
|
||||
if (b) out.push({ name: `基础${polarity}`, block: b });
|
||||
}
|
||||
for (const preset of THEME_PRESETS) {
|
||||
if (preset === 'default') continue;
|
||||
const b = findPresetBlock(blocks, preset);
|
||||
if (b) out.push({ name: preset, block: b });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const THEMES = allThemeBlocks();
|
||||
|
||||
/** 收集不达标的项,统一报错(比逐条 expect 好读) */
|
||||
function collect(
|
||||
themes: Array<{ name: string; block: ThemeBlock }>,
|
||||
check: (block: ThemeBlock) => { label: string; ratio: number; min: number },
|
||||
): string[] {
|
||||
const bad: string[] = [];
|
||||
for (const { name, block } of themes) {
|
||||
const { label, ratio, min } = check(block);
|
||||
if (ratio < min) bad.push(`${name}: ${label} = ${ratio.toFixed(2)}:1 (< ${min}:1)`);
|
||||
}
|
||||
return bad;
|
||||
}
|
||||
|
||||
describe('主题预设的 WCAG 对比度(r247)', () => {
|
||||
it('量到了全部 16 个配方块(2 基础 + 14 预设)', () => {
|
||||
expect(THEMES.length).toBe(16);
|
||||
});
|
||||
|
||||
it('正文级文字 ≥ 4.5:1(fg / fg-secondary 压在主区 bg 上)', () => {
|
||||
const bad = collect(THEMES, (b) => ({
|
||||
label: 'fg vs bg',
|
||||
ratio: contrastRatio(token(b, 'fg-rgb'), token(b, 'bg-rgb')),
|
||||
min: 4.5,
|
||||
}));
|
||||
expect(bad, `正文对比度不足:\n${bad.join('\n')}`).toEqual([]);
|
||||
|
||||
const bad2 = collect(THEMES, (b) => ({
|
||||
label: 'fg-secondary vs bg',
|
||||
ratio: contrastRatio(token(b, 'fg-secondary-rgb'), token(b, 'bg-rgb')),
|
||||
min: 4.5,
|
||||
}));
|
||||
expect(bad2, `次级文字对比度不足:\n${bad2.join('\n')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('三级文字 ≥ 4.5:1(fg-tertiary 压在 header / tabs 的 bg-raised 上)', () => {
|
||||
const bad = collect(THEMES, (b) => ({
|
||||
label: 'fg-tertiary vs bg-raised',
|
||||
ratio: contrastRatio(token(b, 'fg-tertiary-rgb'), token(b, 'bg-raised-rgb')),
|
||||
min: 4.5,
|
||||
}));
|
||||
expect(bad, `三级文字对比度不足:\n${bad.join('\n')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('弱化文字 ≥ 3:1(fg-muted 压在面板 bg-surface 上)', () => {
|
||||
const bad = collect(THEMES, (b) => ({
|
||||
label: 'fg-muted vs bg-surface',
|
||||
ratio: contrastRatio(token(b, 'fg-muted-rgb'), token(b, 'bg-surface-rgb')),
|
||||
min: 3,
|
||||
}));
|
||||
expect(bad, `弱化文字对比度不足:\n${bad.join('\n')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('强调色 ≥ 3:1(accent 作为图标 / 焦点环 / 链接底)', () => {
|
||||
const bad = collect(THEMES, (b) => ({
|
||||
label: 'accent vs bg',
|
||||
ratio: contrastRatio(token(b, 'accent-rgb'), token(b, 'bg-rgb')),
|
||||
min: 3,
|
||||
}));
|
||||
expect(bad, `强调色对比度不足:\n${bad.join('\n')}`).toEqual([]);
|
||||
|
||||
const bad2 = collect(THEMES, (b) => ({
|
||||
label: 'accent vs bg-raised',
|
||||
ratio: contrastRatio(token(b, 'accent-rgb'), token(b, 'bg-raised-rgb')),
|
||||
min: 3,
|
||||
}));
|
||||
expect(bad2, `强调色在 header 上对比度不足:\n${bad2.join('\n')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('状态色 ≥ 3:1(作为文字 / 图标色时)', () => {
|
||||
for (const name of ['success', 'warning', 'danger']) {
|
||||
const bad = collect(THEMES, (b) => ({
|
||||
label: `${name} vs bg`,
|
||||
ratio: contrastRatio(token(b, `${name}-rgb`), token(b, 'bg-rgb')),
|
||||
min: 3,
|
||||
}));
|
||||
expect(bad, `${name} 对比度不足:\n${bad.join('\n')}`).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('模块语义色 ≥ 3:1(侧栏模块图标 / 徽标)', () => {
|
||||
for (const name of ['module-library', 'module-notes']) {
|
||||
const bad = collect(THEMES, (b) => ({
|
||||
label: `${name} vs bg`,
|
||||
ratio: contrastRatio(token(b, `${name}-rgb`), token(b, 'bg-rgb')),
|
||||
min: 3,
|
||||
}));
|
||||
expect(bad, `${name} 对比度不足:\n${bad.join('\n')}`).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('实心底 + 白字 ≥ 4.5:1(success-strong / warning-strong / danger-strong)', () => {
|
||||
for (const name of ['success-strong', 'warning-strong', 'danger-strong']) {
|
||||
const bad = collect(THEMES, (b) => ({
|
||||
label: `white vs ${name}`,
|
||||
ratio: contrastRatio(WHITE, token(b, `${name}-rgb`)),
|
||||
min: 4.5,
|
||||
}));
|
||||
expect(bad, `${name} 压白字对比度不足:\n${bad.join('\n')}`).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('滚动条 thumb ≥ 3:1(压在面板 bg-surface 上)', () => {
|
||||
const bad = collect(THEMES, (b) => ({
|
||||
label: 'scrollbar-thumb vs bg-surface',
|
||||
ratio: contrastRatio(token(b, 'scrollbar-thumb-rgb'), token(b, 'bg-surface-rgb')),
|
||||
min: 3,
|
||||
}));
|
||||
expect(bad, `滚动条对比度不足:\n${bad.join('\n')}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
140
tests/unit/renderer/styles/theme-presets-token-coverage.test.ts
Normal file
140
tests/unit/renderer/styles/theme-presets-token-coverage.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
// tests/unit/renderer/styles/theme-presets-token-coverage.test.ts
|
||||
//
|
||||
// 守 r247「主题预设扩到 15 档」的**配方完整性**契约:
|
||||
//
|
||||
// 每档预设(除 default 外)在 globals.css 里都必须有一块自己的配方,
|
||||
// 且该块必须把 **全部 28 个颜色 token** 都定义齐 —— 少一个,那个 token
|
||||
// 就会**继承**自 :root(基础暗色 / 浅色配方),于是「选 Nord 却看到一截
|
||||
// 基础暗色的灰」这种半吊子配色,而且极难被发现(只有切到那一档才看得见)。
|
||||
//
|
||||
// 为什么这是行为面而不是实现细节:
|
||||
// 预设清单的真相源是 shared/types.ts 的 THEME_PRESETS;CSS 配方是它的
|
||||
// **另一半**。加了一档预设却忘了写 CSS ⇒ 用户在设置页点得到那一档,
|
||||
// 主题「切了但没变」(或只变了一半)—— 这正是 r125 / r229 在字体族、
|
||||
// 主题枚举上修过的同款漂移。
|
||||
//
|
||||
// 守卫点:
|
||||
// 1. THEME_PRESETS 里每个非 default 预设都有对应的 CSS 块;
|
||||
// 2. 每块(以及两块基础配方)都定义了全部必需 token;
|
||||
// 3. 每块都声明了 color-scheme(浏览器原生控件 / 滚动条 / 表单跟随);
|
||||
// 4. 没有「孤儿块」:CSS 里有、THEME_PRESETS 里没有的预设(删预设漏删 CSS)。
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { THEME_PRESETS } from '../../../../src/shared/types';
|
||||
import {
|
||||
parseTopLevelBlocks,
|
||||
findPresetBlock,
|
||||
findPreviewBlock,
|
||||
} from './theme-css';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const CSS = readFileSync(join(ROOT, 'src/renderer/src/styles/globals.css'), 'utf8');
|
||||
|
||||
/**
|
||||
* 每一档配方必须定义齐的颜色 token。
|
||||
*
|
||||
* 分组与 globals.css 的设计 token 注释一一对应:
|
||||
* 背景 6 档 + 边框 4 档 + 文字 4 档 + 强调 3 档
|
||||
* + 状态 6 个(色 / 实心底 各 3)+ 模块语义 2 + 遮罩 1 + 滚动条 1
|
||||
*/
|
||||
const REQUIRED_TOKENS = [
|
||||
// 背景层级
|
||||
'bg-rgb',
|
||||
'bg-canvas-rgb',
|
||||
'bg-surface-rgb',
|
||||
'bg-raised-rgb',
|
||||
'bg-hover-rgb',
|
||||
'bg-active-rgb',
|
||||
'bg-selected-rgb',
|
||||
// 边框
|
||||
'border-rgb',
|
||||
'border-subtle-rgb',
|
||||
'border-strong-rgb',
|
||||
'border-focus-rgb',
|
||||
// 文字四级灰阶
|
||||
'fg-rgb',
|
||||
'fg-secondary-rgb',
|
||||
'fg-tertiary-rgb',
|
||||
'fg-muted-rgb',
|
||||
// 强调
|
||||
'accent-rgb',
|
||||
'accent-hover-rgb',
|
||||
'accent-dim-rgb',
|
||||
// 状态色(色 / 实心底 两槽)
|
||||
'success-rgb',
|
||||
'success-strong-rgb',
|
||||
'warning-rgb',
|
||||
'warning-strong-rgb',
|
||||
'danger-rgb',
|
||||
'danger-strong-rgb',
|
||||
// 模块语义色 / 滚动条
|
||||
'module-library-rgb',
|
||||
'module-notes-rgb',
|
||||
'scrollbar-thumb-rgb',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 刻意**不**要求每档预设都定义的 token:
|
||||
* overlay-scrim-rgb —— 遮罩层永远是纯黑(浅色主题上也是黑遮罩,这是
|
||||
* 桌面端通用做法)。它随 :root 继承即可,逐档重复写没有意义。
|
||||
*/
|
||||
|
||||
const blocks = parseTopLevelBlocks(CSS);
|
||||
|
||||
describe('主题配方 token 完整性(r247)', () => {
|
||||
it('两块基础配方(暗 / 浅)定义了全部必需 token', () => {
|
||||
for (const polarity of ['dark', 'light'] as const) {
|
||||
const block = findPreviewBlock(blocks, polarity);
|
||||
expect(block, `globals.css 里找不到 [data-theme-preview='${polarity}'] 基础配方块`).toBeDefined();
|
||||
const missing = REQUIRED_TOKENS.filter((t) => !block!.tokens.has(t));
|
||||
expect(missing, `基础${polarity}配方缺 token: ${missing.join(', ')}`).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('每档预设都有对应的 CSS 配方块', () => {
|
||||
const missing = THEME_PRESETS.filter((p) => p !== 'default' && !findPresetBlock(blocks, p));
|
||||
expect(missing, `这些预设没有 CSS 配方块(切过去会继承基础配方): ${missing.join(', ')}`).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('每档预设都定义了全部必需 token', () => {
|
||||
const problems: string[] = [];
|
||||
for (const preset of THEME_PRESETS) {
|
||||
if (preset === 'default') continue;
|
||||
const block = findPresetBlock(blocks, preset);
|
||||
if (!block) continue; // 上一条已报
|
||||
const missing = REQUIRED_TOKENS.filter((t) => !block.tokens.has(t));
|
||||
if (missing.length) problems.push(`${preset} 缺 ${missing.join(', ')}`);
|
||||
}
|
||||
expect(problems, `预设配方不完整:\n${problems.join('\n')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('每档预设都声明了 color-scheme(原生控件 / 滚动条跟随)', () => {
|
||||
const problems: string[] = [];
|
||||
for (const preset of THEME_PRESETS) {
|
||||
if (preset === 'default') continue;
|
||||
const block = findPresetBlock(blocks, preset);
|
||||
if (!block) continue;
|
||||
const scheme = block.decls.get('color-scheme');
|
||||
if (scheme !== 'light' && scheme !== 'dark') {
|
||||
problems.push(`${preset} → color-scheme=${String(scheme)}`);
|
||||
}
|
||||
}
|
||||
expect(problems, `这些预设没声明 color-scheme:\n${problems.join('\n')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('没有孤儿预设块(CSS 里有、THEME_PRESETS 里没有的预设)', () => {
|
||||
const orphans: string[] = [];
|
||||
for (const block of blocks) {
|
||||
const m = block.selector.match(/\[data-theme-preset='([^']+)'\]/);
|
||||
if (!m) continue;
|
||||
if (!(THEME_PRESETS as readonly string[]).includes(m[1])) orphans.push(m[1]);
|
||||
}
|
||||
expect(orphans, `这些 CSS 预设块已不在 THEME_PRESETS 里(删预设漏删 CSS): ${orphans.join(', ')}`).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user