This commit is contained in:
2026-10-21 14:42:59 +08:00
commit 954236d3f9
399 changed files with 85885 additions and 0 deletions

View File

@@ -0,0 +1,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([]);
});
});

View 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); // ⇒ 不在本守卫的作用集里
});
});

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

View 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);
});
});

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

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

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

View 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();
});
});

View 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);
});
});

View File

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

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

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

View 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]);
});
});

View 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);
});
});