Files
Literature-Management/tests/unit/main/favorites-id-remap.test.ts
2026-10-21 14:42:59 +08:00

171 lines
6.7 KiB
TypeScript

// tests/unit/main/favorites-id-remap.test.ts
//
// 守 r182「资源 id 变更后,收藏(★)必须跟着走」契约。
//
// 用户的收藏条目存在 favorites.md 里,主键是**资源 id**:
// - literature id = 'lit:' + toPosix(absPath) → 路径一变 id 就变;
// - note id = SHA-256(relPath) 前 16 位 → 路径一变 id 就变。
// 而 renderer 侧 ★ 的显示/过滤只看 id(favoritedFileIds Set)。
//
// 旧实现:改名 / 搬移 / 文件夹改名改完 id,**没人管 favorites.md** ——
// 收藏条目里留着旧 id,旧 id 再也不会命中任何一行 ⇒ 用户的 ★ 在操作成功的
// 瞬间静默消失(favorites.md 里那条记录变成永远点不亮的孤儿)。
//
// 本测锁:
// 1. 文献搬进文件夹(LITERATURE_MOVE)→ 收藏条目 fileId 跟着变;
// 2. 笔记改名(NOTES_RENAME_FILE)→ 收藏条目跟着变(notes 域 id 是裸 id);
// 3. 没有收藏指向该 id → 不动 favorites.md(不写盘、不空转);
//
// 真库(electron app.getPath)在测试环境不可用 → resources-repo 整体 mock;
// favorites 走真 AtomicFile(临时 root 下的 workspace/favorites/favorites.md)。
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,
}));
// electron 整体 mock:ipcMain.handle 捕获 handler(验「通道真的把 remap
// 串到 favorites 上」),shell.trashItem 让 safeTrash 在 node 环境可调
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: async (target: string) => {
const { rename } = await import('fs/promises');
const { basename, join: j } = await import('path');
const trashDir = j(target, '..', '..', '.trash');
await fs.mkdir(trashDir, { recursive: true });
await rename(target, j(trashDir, basename(target)));
},
},
};
});
vi.mock('electron', () => ({
ipcMain: electronMock.ipcMain,
shell: electronMock.shell,
}));
import { setRootPath } from '../../../src/main/utils/safe-write';
import { LiteratureService } from '../../../src/main/literature/literature-service';
import { NotesService } from '../../../src/main/notes/service';
import { registerLiteratureIPC } from '../../../src/main/ipc/literature';
import { registerNotesIPC } from '../../../src/main/ipc/notes';
import { getFavoritesService } from '../../../src/main/workspace/favorites';
import { IPC } from '../../../src/shared/types';
describe('id 变更后收藏(★)跟着走(r182)', () => {
let root: string;
let lit: LiteratureService;
let notes: NotesService;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-favremap-'));
setRootPath(root);
await fs.mkdir(join(root, 'notes'), { recursive: true });
await fs.mkdir(join(root, 'literature'), { recursive: true });
electronMock.handlers.clear();
const fav = getFavoritesService();
await fav.start();
lit = new LiteratureService();
notes = new NotesService();
await lit.start({ skipInitialLoad: true });
await notes.start({ skipInitialLoad: true });
});
afterEach(async () => {
await notes.stop();
await lit.stop();
await getFavoritesService().stop();
await fs.rm(root, { recursive: true, force: true });
});
it('文献搬进文件夹 → 收藏条目的 fileId 跟着变(★ 不丢)', async () => {
const fav = getFavoritesService();
const src = join(root, 'paper.pdf');
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
const imported = await lit.importFile(src);
// renderer 的 ★ toggle:fileId = 文献 id,relPath 存的是绝对路径
await fav.toggle(imported.id, { title: 'paper.pdf', relPath: imported.absPath });
registerLiteratureIPC(lit);
const handler = electronMock.handlers.get(IPC.LITERATURE_MOVE);
expect(handler).toBeTruthy();
const res = (await handler!({}, imported.id, 'sub')) as {
ok: boolean;
data?: { absPath: string; id: string };
};
expect(res.ok).toBe(true);
const newId = res.data!.id;
expect(newId).not.toBe(imported.id);
// 收藏条目:fileId 已经是新 id,展示路径也是新路径
const entries = fav.listEntries().filter((e) => e.kind === 'file');
expect(entries).toHaveLength(1);
expect(entries[0]?.fileId).toBe(newId);
expect(entries[0]?.relPath).toContain('sub');
});
it('笔记改名 → 收藏条目跟着变(notes 域 id 是裸 id)', async () => {
const fav = getFavoritesService();
const created = await notes.create('n.md');
await fav.toggle(created.id, { title: 'n.md', relPath: created.relPath });
registerNotesIPC(notes);
const handler = electronMock.handlers.get(IPC.NOTES_RENAME_FILE);
expect(handler).toBeTruthy();
const res = (await handler!({}, created.id, 'renamed.md')) as {
ok: boolean;
data?: { id: string; relPath: string };
};
expect(res.ok).toBe(true);
const newId = res.data!.id;
expect(newId).not.toBe(created.id);
const entries = fav.listEntries().filter((e) => e.kind === 'file');
expect(entries).toHaveLength(1);
expect(entries[0]?.fileId).toBe(newId);
expect(entries[0]?.relPath).toBe('renamed.md');
});
it('没有收藏指向该 id → 不动 favorites.md(不空写盘)', async () => {
const fav = getFavoritesService();
const src = join(root, 'paper.pdf');
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
const imported = await lit.importFile(src);
// 不 toggle —— 没有任何收藏指向这个 id
const before = fav.listEntries().length;
registerLiteratureIPC(lit);
const handler = electronMock.handlers.get(IPC.LITERATURE_MOVE);
const res = (await handler!({}, imported.id, 'sub')) as { ok: boolean };
expect(res.ok).toBe(true);
expect(fav.listEntries().length).toBe(before);
expect(fav.listEntries().filter((e) => e.kind === 'file')).toHaveLength(0);
});
});