update
This commit is contained in:
212
tests/unit/main/literature-rename.test.ts
Normal file
212
tests/unit/main/literature-rename.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
// tests/unit/main/literature-rename.test.ts
|
||||
//
|
||||
// 守 r187「文献单文件重命名」契约。
|
||||
//
|
||||
// 背景(用户诉求:「相关的文件操作也是对应的」):
|
||||
// 左侧两个列表的操作集此前**不对应** —— 笔记能右键改名(notes:rename-file),
|
||||
// 文献不能(literature 域根本没有重命名通道)。用户只能去资源管理器改,
|
||||
// 改完 app 里还是旧名(直到手动刷新),两侧行为不一致。
|
||||
//
|
||||
// 本测锁的契约:
|
||||
// 1. 改名落盘:文件出现在新路径、旧路径消失、id 随路径变、内存索引换键;
|
||||
// 2. **撞名不覆盖**(数据丢失红线):Windows 的 fs.rename 在目标已存在时
|
||||
// 静默替换目标文件(MoveFileExW + MOVEFILE_REPLACE_EXISTING)—— 直接
|
||||
// rename 等于把用户另一份同名 PDF 抹掉;
|
||||
// 3. 不写扩展名 → 沿用原扩展名(pdf/epub/... 多种格式,不能一律 .md);
|
||||
// 4. 非法文件名(带路径分隔符)→ 拒收,文件原地不动;
|
||||
// 5. 仅大小写重命名 → 允许(Win32 大小写不敏感,资源管理器能做);
|
||||
// 6. 改名后收藏(★)跟着走(lit id = 'lit:'+absPath,改名即换 id)。
|
||||
//
|
||||
// 真库(electron app.getPath)在测试环境不可用 → resources-repo 整体 mock;
|
||||
// electron 也 mock(ipcMain.handle 捕获 handler 以验 IPC 接线,shell 兜底)。
|
||||
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: 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 { registerLiteratureIPC } from '../../../src/main/ipc/literature';
|
||||
import { getFavoritesService } from '../../../src/main/workspace/favorites';
|
||||
import { IPC } from '../../../src/shared/types';
|
||||
|
||||
describe('文献单文件重命名(r187)', () => {
|
||||
let root: string;
|
||||
let lit: LiteratureService;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await fs.mkdtemp(join(tmpdir(), 'rkms-litrename-'));
|
||||
setRootPath(root);
|
||||
await fs.mkdir(join(root, 'notes'), { recursive: true });
|
||||
await fs.mkdir(join(root, 'literature'), { recursive: true });
|
||||
electronMock.handlers.clear();
|
||||
repo.upsertResource.mockClear();
|
||||
repo.deleteResource.mockClear();
|
||||
lit = new LiteratureService();
|
||||
await lit.start({ skipInitialLoad: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await lit.stop();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function importPaper(name: string, bytes = 'PDF-BYTES'): Promise<string> {
|
||||
const src = join(root, name);
|
||||
await fs.writeFile(src, bytes, 'utf-8');
|
||||
const imported = await lit.importFile(src);
|
||||
return imported.id;
|
||||
}
|
||||
|
||||
/** absPath 在库里可能是 OS 原生形态(导入)也可能是 POSIX 形态(搬移)——
|
||||
* 断言时统一成正斜杠再比。 */
|
||||
const norm = (p: string): string => p.replace(/\\/g, '/');
|
||||
|
||||
it('改名落盘:新路径有文件、旧路径消失、id 变、内存索引换键', async () => {
|
||||
const id = await importPaper('paper.pdf');
|
||||
// 导入那一步本身会调 upsertResource(写新行)—— 先清掉调用计数,
|
||||
// 下面只断言「改名这一步」的 DB 调用。
|
||||
repo.upsertResource.mockClear();
|
||||
repo.deleteResource.mockClear();
|
||||
repo.renameResource.mockClear();
|
||||
const renamed = await lit.renameFile(id as never, 'thesis');
|
||||
|
||||
const litDir = join(root, 'literature');
|
||||
expect(await fs.readFile(join(litDir, 'thesis.pdf'), 'utf-8')).toBe('PDF-BYTES');
|
||||
await expect(fs.lstat(join(litDir, 'paper.pdf'))).rejects.toThrow();
|
||||
// id 随路径变(lit id = 'lit:'+posix absPath)
|
||||
expect(renamed.id).not.toBe(id);
|
||||
expect(norm(renamed.absPath)).toBe(norm(join(litDir, 'thesis.pdf')));
|
||||
// 内存索引换键
|
||||
expect(lit.findById(id)).toBeNull();
|
||||
expect(lit.findById(renamed.id)).not.toBeNull();
|
||||
// DB:r196 契约 —— 「upsert 新行 + 删旧行」必须**同事务**
|
||||
// (renameResource),不再是 deleteResource + upsertResource 两个独立
|
||||
// 事务。理由:两个事务中间失败 ⇒ DB 里旧行没了、新行没进 ⇒ 这条文献
|
||||
// 从列表/搜索里凭空消失,而磁盘没动 ⇒ 无 watcher 事件可自愈。
|
||||
expect(repo.renameResource).toHaveBeenCalledWith(
|
||||
id,
|
||||
expect.objectContaining({ id: renamed.id }),
|
||||
'',
|
||||
expect.stringContaining('thesis.pdf'),
|
||||
);
|
||||
// 旧的两个独立事务调用**不许**再出现
|
||||
expect(repo.deleteResource).not.toHaveBeenCalled();
|
||||
expect(repo.upsertResource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('撞名不覆盖:目标已存在 → VALIDATION,目标文件内容原封不动', async () => {
|
||||
const id = await importPaper('paper.pdf', 'MINE');
|
||||
// 同目录再放一个同名目标(直接写盘,绕开 import 的撞名自动改名)
|
||||
const litDir = join(root, 'literature');
|
||||
await fs.writeFile(join(litDir, 'other.pdf'), 'THEIRS', 'utf-8');
|
||||
|
||||
await expect(lit.renameFile(id as never, 'other')).rejects.toMatchObject({
|
||||
code: 'VALIDATION',
|
||||
});
|
||||
// 目标文件没被覆盖(数据丢失红线)
|
||||
expect(await fs.readFile(join(litDir, 'other.pdf'), 'utf-8')).toBe('THEIRS');
|
||||
// 源文件也还在原地
|
||||
expect(await fs.readFile(join(litDir, 'paper.pdf'), 'utf-8')).toBe('MINE');
|
||||
});
|
||||
|
||||
it('不写扩展名 → 沿用原扩展名(literature 有 pdf/epub/... 多种格式)', async () => {
|
||||
const id = await importPaper('paper.pdf');
|
||||
const renamed = await lit.renameFile(id as never, 'thesis');
|
||||
expect(norm(renamed.absPath).endsWith('/thesis.pdf')).toBe(true);
|
||||
});
|
||||
|
||||
it('文件名带路径分隔符 → 拒收,文件原地不动(重命名不改位置)', async () => {
|
||||
const id = await importPaper('paper.pdf');
|
||||
await expect(lit.renameFile(id as never, 'sub/paper.pdf')).rejects.toMatchObject({
|
||||
code: 'VALIDATION',
|
||||
});
|
||||
const litDir = join(root, 'literature');
|
||||
expect(await fs.readFile(join(litDir, 'paper.pdf'), 'utf-8')).toBe('PDF-BYTES');
|
||||
});
|
||||
|
||||
it('仅大小写重命名 → 允许(Win32 大小写不敏感,资源管理器能做)', async () => {
|
||||
const id = await importPaper('paper.pdf');
|
||||
const renamed = await lit.renameFile(id as never, 'Paper');
|
||||
expect(norm(renamed.absPath).toLowerCase().endsWith('/paper.pdf')).toBe(true);
|
||||
expect(lit.findById(renamed.id)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('不存在的 id → NOT_FOUND(契约不因本轮改动而松动)', async () => {
|
||||
const ghost = 'lit:no-such' as never;
|
||||
await expect(lit.renameFile(ghost, 'x')).rejects.toMatchObject({
|
||||
code: 'NOT_FOUND',
|
||||
});
|
||||
});
|
||||
|
||||
it('IPC 通道 literature:rename-file 存在,且改名后收藏(★)跟着走', async () => {
|
||||
const fav = getFavoritesService();
|
||||
await fav.start();
|
||||
try {
|
||||
const id = await importPaper('paper.pdf');
|
||||
await fav.toggle(id, { title: 'paper.pdf', relPath: join(root, 'literature', 'paper.pdf') });
|
||||
|
||||
registerLiteratureIPC(lit);
|
||||
const handler = electronMock.handlers.get(IPC.LITERATURE_RENAME_FILE);
|
||||
expect(handler).toBeTruthy();
|
||||
const res = (await handler!({}, id, 'thesis')) as {
|
||||
ok: boolean;
|
||||
data?: { id: string; absPath: string; oldAbsPath: string };
|
||||
};
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.data!.id).not.toBe(id);
|
||||
expect(res.data!.oldAbsPath).toContain('paper.pdf');
|
||||
|
||||
const entries = fav.listEntries().filter((e) => e.kind === 'file');
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]?.fileId).toBe(res.data!.id);
|
||||
expect(entries[0]?.relPath).toContain('thesis.pdf');
|
||||
} finally {
|
||||
await getFavoritesService().stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user