update
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user