213 lines
8.9 KiB
TypeScript
213 lines
8.9 KiB
TypeScript
// 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();
|
|
}
|
|
});
|
|
});
|