159 lines
5.6 KiB
TypeScript
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);
|
|
});
|
|
});
|