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

203 lines
8.6 KiB
TypeScript

// tests/unit/main/favorites-delete-cleanup.test.ts
//
// 守 r198「文件被删除后,收藏(★)必须跟着走」契约 —— remapFavoriteIds 的
// **另一半**。
//
// 背景(用户诉求「文件操作也是对应的」的元数据一半):
// r182 补上了「id 变了 → ★ 跟着改」(改名 / 搬移 / 目录改名)。
// 但**删除**路径一直只动文件 + DB 行,没人管 favorites.md ⇒ 收藏条目
// 变成永远命中不了的孤儿,三个后果:
//
// 1. favorites.md 无限增长(每个删掉的 ★ 文献留一条死条目);
// 2. **★ 状态错位(用户可见)**:文献 id = 'lit:'+toPosix(absPath) ——
// 把同一个 PDF 从原路径再导入一次 ⇒ id 完全相同 ⇒ 新行**继承**旧 ★
// (用户从没点过,却显示已收藏);再点一下是「取消收藏」(toggle 语义),
// 用户更困惑;
// 3. notes 域同样会留孤儿(现在还没笔记 ★ 的 UI,但 favorites.md 设计上
// 可手工编辑,老版本 / 手改都可能留下笔记条目)。
//
// 本测锁(行为级,对着 favorites.md 的真实内容说话):
// 1. 文献删除 → 该 id 的收藏条目消失;
// 2. 笔记移入回收站 → 该 id 的收藏条目消失;
// 3. 删除**没收藏**的文件 → favorites.md 条目数不变(不空写盘),
// 别的收藏也不受影响;
// 4. 同路径再导入 → **不继承**旧 ★(toggle 返回 true = 加入收藏,
// 而不是 false = 取消收藏)。
//
// 真库(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 unknown[]),
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(验「通道真的把清理
// 串到 favorites 上」),shell.trashItem 让 safeTrash 在 node 环境可调
// (把文件搬进 .trash 目录 —— 证明「文件还在、只是换了位置」= 可恢复)
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('文件被删除后收藏(★)跟着清理(r198)', () => {
let root: string;
let lit: LiteratureService;
let notes: NotesService;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-favdel-'));
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();
// maxRetries/retryDelay:Windows 上 rm 递归删目录偶发 ENOTEMPTY
// (删除期间目录内容还在变 —— 本测的 shell.trashItem mock 会往 .trash
// 里 rename 文件)。Node 的 rm 对 EBUSY/EMFILE/ENFILE/ENOTEMPTY/EPERM
// 会线性退避重试 ⇒ 加重试把「全量套件并行时偶发红」变成稳定绿。
await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
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);
await fav.toggle(imported.id, { title: 'paper.pdf', relPath: imported.absPath });
expect(fav.listEntries().filter((e) => e.kind === 'file')).toHaveLength(1);
registerLiteratureIPC(lit);
const handler = electronMock.handlers.get(IPC.LITERATURE_DELETE);
expect(handler).toBeTruthy();
const res = (await handler!({}, imported.id)) as { ok: boolean };
expect(res.ok).toBe(true);
expect(fav.listEntries().filter((e) => e.kind === 'file')).toHaveLength(0);
});
it('笔记移入回收站 → 该 id 的收藏条目消失', async () => {
const fav = getFavoritesService();
const created = await notes.create('n.md');
await fav.toggle(created.id, { title: 'n.md', relPath: created.relPath });
expect(fav.listEntries().filter((e) => e.kind === 'file')).toHaveLength(1);
registerNotesIPC(notes);
const handler = electronMock.handlers.get(IPC.NOTES_TRASH);
expect(handler).toBeTruthy();
const res = (await handler!({}, created.id)) as { ok: boolean };
expect(res.ok).toBe(true);
expect(fav.listEntries().filter((e) => e.kind === 'file')).toHaveLength(0);
});
it('删除没收藏的文件 → favorites.md 不动(别的收藏也不受影响)', async () => {
const fav = getFavoritesService();
// 另一条**别的**收藏
const other = join(root, 'other.pdf');
await fs.writeFile(other, 'PDF-BYTES', 'utf-8');
const otherImp = await lit.importFile(other);
await fav.toggle(otherImp.id, { title: 'other.pdf', relPath: otherImp.absPath });
const src = join(root, 'paper.pdf');
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
const imported = await lit.importFile(src);
// imported 没有被收藏
const before = fav.listEntries().length;
registerLiteratureIPC(lit);
const handler = electronMock.handlers.get(IPC.LITERATURE_DELETE);
const res = (await handler!({}, imported.id)) as { ok: boolean };
expect(res.ok).toBe(true);
expect(fav.listEntries().length).toBe(before);
const files = fav.listEntries().filter((e) => e.kind === 'file');
expect(files).toHaveLength(1);
expect(files[0]?.fileId).toBe(otherImp.id);
});
it('同路径再导入 → 不继承旧 ★(toggle 是「加入收藏」而不是「取消收藏」)', async () => {
const fav = getFavoritesService();
const src = join(root, 'paper.pdf');
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
const first = await lit.importFile(src);
await fav.toggle(first.id, { title: 'paper.pdf', relPath: first.absPath });
registerLiteratureIPC(lit);
const del = electronMock.handlers.get(IPC.LITERATURE_DELETE);
expect((await del!({}, first.id)) as { ok: boolean }).toMatchObject({ ok: true });
// 同一路径再导入 ⇒ id 相同(lit id = 'lit:'+posix absPath)
const src2 = join(root, 'src2');
await fs.mkdir(src2, { recursive: true });
const src2File = join(src2, 'paper.pdf');
await fs.writeFile(src2File, 'PDF-BYTES-2', 'utf-8');
const second = await lit.importFile(src2File);
expect(second.id).toBe(first.id);
// 收藏条目已被清掉 ⇒ 再点 ★ 是「加入收藏」(true),不是「取消收藏」(false)
const nowFavorited = await fav.toggle(second.id, {
title: 'paper.pdf',
relPath: second.absPath,
});
expect(nowFavorited).toBe(true);
});
});