update
This commit is contained in:
192
tests/unit/main/notes-id-drift.test.ts
Normal file
192
tests/unit/main/notes-id-drift.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
// tests/unit/main/notes-id-drift.test.ts
|
||||
//
|
||||
// 守 r192「笔记 id 漂移」在**主进程**这一侧的契约。
|
||||
//
|
||||
// id 的两套真相(这是漂移的根源):
|
||||
// - 读:extractNoteId(note-id.ts)优先取**磁盘上的 frontmatter id**
|
||||
// (兼容旧文件 / 手写 / 从别的机器拷来的 .md);
|
||||
// - 写:writeNote(standalone.ts)把 fm.id **重钉**为 computeFileId(relPath)
|
||||
// (r124「副本重映射」规则)⇒ 只要磁盘上的 fm.id 与路径哈希不一致,
|
||||
// 保存之后 id 就变了。
|
||||
//
|
||||
// 现实触发源(不是理论):
|
||||
// - 用户在资源管理器里改了文件名 / 移动了文件(fm.id 还是旧路径的哈希);
|
||||
// - 从别的机器 / 别的工具拷进来的 .md(自带别的路径的 fm.id);
|
||||
// - 手改过 frontmatter。
|
||||
//
|
||||
// 本测锁(主进程侧的三条事实):
|
||||
// 1. 漂移**确实会发生**:磁盘 fm.id ≠ 路径哈希时,write 返回的 id 是**新 id**;
|
||||
// 2. 旧 id **真的死了**(byId 里查不到)⇒ renderer 若还拿着旧 id,下一次
|
||||
// 自动保存必撞 NOT_FOUND(正文只进内存草稿 ⇒ 重启即丢 = 数据丢失);
|
||||
// 3. 收藏(★)跟着改:旧 fileId 变孤儿 ⇒ ★ 静默消失(r189 同款,触发点是
|
||||
// 「保存」)。
|
||||
//
|
||||
// renderer 侧的重映射(选中态 / 多选 / 正在编辑的那篇)由
|
||||
// tests/unit/renderer/store/notes-save-id-drift.test.ts 守。
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
const repo = vi.hoisted(() => ({
|
||||
upsertResource: vi.fn(),
|
||||
deleteResource: vi.fn(),
|
||||
listResources: vi.fn(() => [] as unknown[]),
|
||||
listResourceRelPaths: vi.fn(() => [] as string[]),
|
||||
listStaleIndexIds: vi.fn(() => [] as string[]),
|
||||
renameResource: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/main/db/resources-repo', () => ({
|
||||
upsertResource: repo.upsertResource,
|
||||
deleteResource: repo.deleteResource,
|
||||
listResources: repo.listResources,
|
||||
listResourceRelPaths: repo.listResourceRelPaths,
|
||||
listStaleIndexIds: repo.listStaleIndexIds,
|
||||
renameResource: repo.renameResource,
|
||||
}));
|
||||
|
||||
const electronMock = vi.hoisted(() => {
|
||||
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
|
||||
return {
|
||||
handlers,
|
||||
ipcMain: {
|
||||
handle: (channel: string, fn: (event: unknown, ...args: unknown[]) => unknown) => {
|
||||
handlers.set(channel, fn);
|
||||
},
|
||||
},
|
||||
shell: {
|
||||
trashItem: vi.fn(),
|
||||
showItemInFolder: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: electronMock.ipcMain,
|
||||
shell: electronMock.shell,
|
||||
}));
|
||||
|
||||
import { setRootPath } from '../../../src/main/utils/safe-write';
|
||||
import { NotesService } from '../../../src/main/notes/service';
|
||||
import { registerNotesIPC } from '../../../src/main/ipc/notes';
|
||||
import { getFavoritesService } from '../../../src/main/workspace/favorites';
|
||||
import { computeFileId } from '../../../src/main/notes/path-mapping';
|
||||
import { IPC } from '../../../src/shared/types';
|
||||
|
||||
interface IpcOk<T> {
|
||||
ok: boolean;
|
||||
data?: T;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
const STALE_FM_ID = 'aaaaaaaaaaaaaaaa';
|
||||
|
||||
describe('笔记 id 漂移(保存时 fm.id 被重钉)(r192)', () => {
|
||||
let root: string;
|
||||
let notes: NotesService;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await fs.mkdtemp(join(tmpdir(), 'rkms-iddrift-'));
|
||||
setRootPath(root);
|
||||
await fs.mkdir(join(root, 'notes'), { recursive: true });
|
||||
electronMock.handlers.clear();
|
||||
await getFavoritesService().start();
|
||||
notes = new NotesService();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await notes.stop();
|
||||
await getFavoritesService().stop();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* 落一个 frontmatter id 与路径哈希**不一致**的笔记(模拟「从别的机器
|
||||
* 拷来的 .md」/「用户在资源管理器里改过文件名」)。
|
||||
*/
|
||||
async function writeStaleNote(filename: string, body: string): Promise<void> {
|
||||
await fs.writeFile(
|
||||
join(root, 'notes', filename),
|
||||
`---\nid: ${STALE_FM_ID}\ntitle: T\n---\n\n${body}\n`,
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
it('磁盘 fm.id ≠ 路径哈希 ⇒ 扫描时按 fm.id 载入,保存后返回**新 id**,且旧 id 真的死了', async () => {
|
||||
await writeStaleNote('n.md', 'v1');
|
||||
await notes.start(); // 全量扫描
|
||||
|
||||
// 扫描后:按磁盘上的 fm.id 载入
|
||||
expect(notes.findById(STALE_FM_ID)).toBeTruthy();
|
||||
const pathId = computeFileId('n.md');
|
||||
expect(pathId).not.toBe(STALE_FM_ID);
|
||||
|
||||
// 保存 ⇒ 主进程把 fm.id 重钉为路径哈希 ⇒ 返回新 id
|
||||
const written = await notes.write(STALE_FM_ID, 'v2');
|
||||
expect(written.id).toBe(pathId);
|
||||
expect(written.id).not.toBe(STALE_FM_ID);
|
||||
|
||||
// 旧 id **真的死了** —— renderer 若还拿着它,下一次自动保存必 NOT_FOUND
|
||||
expect(notes.findById(STALE_FM_ID)).toBeNull();
|
||||
expect(notes.findById(pathId)).toBeTruthy();
|
||||
|
||||
// 磁盘上的 fm.id 也已经是新 id(下次读不会又漂一次)
|
||||
const raw = await fs.readFile(join(root, 'notes', 'n.md'), 'utf-8');
|
||||
expect(raw).toContain(`id: ${pathId}`);
|
||||
});
|
||||
|
||||
it('走 IPC 保存(真实链路)⇒ 收藏(★)跟着改,不会在保存成功的瞬间静默消失', async () => {
|
||||
await writeStaleNote('n.md', 'v1');
|
||||
await notes.start();
|
||||
|
||||
const fav = getFavoritesService();
|
||||
// 用户给这篇点了 ★(renderer 存的是当前 id = 磁盘 fm.id)
|
||||
await fav.toggle(STALE_FM_ID, { title: 'n.md', relPath: 'n.md' });
|
||||
|
||||
registerNotesIPC(notes);
|
||||
const handler = electronMock.handlers.get(IPC.NOTES_WRITE);
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
const res = (await handler!(
|
||||
{},
|
||||
STALE_FM_ID,
|
||||
'v2',
|
||||
undefined,
|
||||
undefined,
|
||||
)) as IpcOk<{ id: string; relPath: string }>;
|
||||
expect(res.ok).toBe(true);
|
||||
const newId = res.data!.id;
|
||||
expect(newId).not.toBe(STALE_FM_ID);
|
||||
|
||||
// 收藏条目:fileId 已是新 id,路径形态仍是 relPath(notes 域)
|
||||
const entries = fav
|
||||
.listEntries()
|
||||
.filter((e) => e.kind === 'file')
|
||||
.map((e) => ({ fileId: (e as { fileId: string }).fileId, relPath: e.relPath }));
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]!.fileId).toBe(newId);
|
||||
expect(entries[0]!.relPath).toBe('n.md');
|
||||
});
|
||||
|
||||
it('没有漂移(fm.id 本来就是路径哈希)⇒ id 不变,收藏也不该被无谓改写', async () => {
|
||||
await notes.start();
|
||||
const created = await notes.create('fresh.md');
|
||||
const fav = getFavoritesService();
|
||||
await fav.toggle(created.id, { title: 'fresh.md', relPath: 'fresh.md' });
|
||||
|
||||
registerNotesIPC(notes);
|
||||
const handler = electronMock.handlers.get(IPC.NOTES_WRITE);
|
||||
const res = (await handler!({}, created.id, 'v2', undefined, undefined)) as IpcOk<{
|
||||
id: string;
|
||||
}>;
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.data!.id).toBe(created.id);
|
||||
|
||||
const entries = fav
|
||||
.listEntries()
|
||||
.filter((e) => e.kind === 'file')
|
||||
.map((e) => (e as { fileId: string }).fileId);
|
||||
expect(entries).toEqual([created.id]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user