Files
Literature-Management/tests/unit/renderer/store/notes-save-id-drift.test.ts
2026-10-21 14:42:59 +08:00

159 lines
5.6 KiB
TypeScript

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