193 lines
6.9 KiB
TypeScript
193 lines
6.9 KiB
TypeScript
// 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]);
|
|
});
|
|
});
|