Files
Literature-Management/tests/unit/main/folder-dir-move.test.ts
2026-10-21 14:42:59 +08:00

175 lines
6.7 KiB
TypeScript

// tests/unit/main/folder-dir-move.test.ts
//
// 守 r173「目录(子树)跨父目录搬移」契约 —— 侧栏「剪切文件夹 → 粘贴到
// 别的目录」「把文件夹拖到别的目录」「移到根目录」三条入口的落点。
//
// 用户报的「粘贴功能有问题」第 4 轮:文件夹维度全是死的 ——
// - renderer 的 pasteFolderInto / moveFolderNow 是 throw「not yet
// supported」的占位(UI 菜单项可见,点了必报错);
// - 文件夹拖拽的 dataTransfer 载荷写 ids: [],而 readMovePayload 要求
// ids 非空 → 载荷在读端被整条拒收,drop 端永远收不到。
//
// 本测在 service 层锁三件事:
// 1. 物理搬移真的发生(子树整体挪位,非 .md 文件也带走);
// 2. 子树里每个资源的 id 都重算(notes id 随 relPath、lit id 随
// absPath)且 byId 换键 —— 否则「刚搬过去的笔记」打不开;
// 3. 返回 idRemap(oldId → newId + 新路径),renderer 靠它把选中态跟过去。
//
// 真库(electron app.getPath)在测试环境不可用 → resources-repo 整体 mock;
// 被测行为(fs.rename + frontmatter 重钉 + byId 维护)全在 service 内。
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,
}));
import { setRootPath } from '../../../src/main/utils/safe-write';
import { NotesService } from '../../../src/main/notes/service';
import { LiteratureService } from '../../../src/main/literature/literature-service';
import { computeFileId } from '../../../src/main/notes/path-mapping';
describe('目录(子树)跨父目录搬移(r173)', () => {
let root: string;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-dirmove-'));
setRootPath(root);
await fs.mkdir(join(root, 'notes'), { recursive: true });
await fs.mkdir(join(root, 'literature'), { recursive: true });
});
afterEach(async () => {
await fs.rm(root, { recursive: true, force: true });
});
// === notes 域 ===
describe('NotesService.moveDir', () => {
let svc: NotesService;
beforeEach(async () => {
svc = new NotesService();
await svc.start({ skipInitialLoad: true });
});
afterEach(async () => {
await svc.stop();
});
it('子树整体挪位 + 每个笔记 id 重钉 + byId 换键 + 返回 idRemap', async () => {
const n = await svc.create('a.md');
// 先把笔记放进子目录 'sub'
const inSub = await svc.moveFile(n.id, 'sub');
expect(inSub.relPath).toBe('sub/a.md');
const oldId = inSub.id;
const moved = await svc.moveDir('sub', 'target');
// 1) 物理搬移:整棵子树挪到 target/sub,原位置不留残影
expect(moved.relPath).toBe('target/sub');
await expect(fs.stat(join(root, 'notes', 'target', 'sub', 'a.md'))).resolves.toBeTruthy();
await expect(fs.stat(join(root, 'notes', 'sub'))).rejects.toThrow();
// 2) id 重钉:笔记 id = SHA-256(relPath) 前 16 位 → 路径变 id 必变
expect(moved.idRemap.length).toBe(1);
const remap = moved.idRemap[0]!;
expect(remap.oldId).toBe(oldId);
expect(remap.newId).not.toBe(oldId);
expect(remap.newId).toBe(computeFileId('target/sub/a.md'));
expect(remap.relPath).toBe('target/sub/a.md');
// 3) byId 换键:旧 id 查不到,新 id 查得到
expect(svc.findById(oldId)).toBeNull();
expect(svc.findById(remap.newId)).not.toBeNull();
expect(svc.findById(remap.newId)?.relPath).toBe('target/sub/a.md');
// 4) 文件里的 frontmatter id 也跟着重钉(否则重开应用索引对不上)
const raw = await fs.readFile(join(root, 'notes', 'target', 'sub', 'a.md'), 'utf-8');
expect(raw).toContain(remap.newId);
});
it('搬到根(目标父目录 = 空串)', async () => {
const n = await svc.create('a.md');
await svc.moveFile(n.id, 'sub');
const moved = await svc.moveDir('sub', '');
expect(moved.relPath).toBe('sub');
await expect(fs.stat(join(root, 'notes', 'sub', 'a.md'))).resolves.toBeTruthy();
});
it('搬进自己的子树被拒(自我嵌套)', async () => {
const n = await svc.create('a.md');
await svc.moveFile(n.id, 'sub');
await expect(svc.moveDir('sub', 'sub')).rejects.toThrow();
// 目录还在原处(未发生半截搬移)
await expect(fs.stat(join(root, 'notes', 'sub', 'a.md'))).resolves.toBeTruthy();
});
});
// === literature 域 ===
describe('LiteratureService.moveDir', () => {
let svc: LiteratureService;
beforeEach(async () => {
svc = new LiteratureService();
await svc.start({ skipInitialLoad: true });
});
afterEach(async () => {
await svc.stop();
});
it('子树整体挪位 + 文献 id 随 absPath 变 + byId 换键 + 返回 idRemap', async () => {
const src = join(root, 'paper.pdf');
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
const imported = await svc.importFile(src);
const inSub = await svc.moveFile(imported.id, 'sub');
const oldId = inSub.id;
const moved = await svc.moveDir('sub', 'target');
expect(moved.relPath).toBe('target/sub');
await expect(fs.stat(join(root, 'literature', 'target', 'sub', 'paper.pdf'))).resolves.toBeTruthy();
await expect(fs.stat(join(root, 'literature', 'sub'))).rejects.toThrow();
expect(moved.idRemap.length).toBe(1);
const remap = moved.idRemap[0]!;
expect(remap.oldId).toBe(oldId);
expect(remap.newId).not.toBe(oldId);
expect(remap.absPath).toContain('target/sub/paper.pdf');
expect(remap.relPath).toBe('target/sub/paper.pdf');
expect(svc.findById(oldId)).toBeNull();
expect(svc.findById(remap.newId)).not.toBeNull();
});
it('搬进自己的子树被拒', async () => {
const src = join(root, 'paper.pdf');
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
const imported = await svc.importFile(src);
await svc.moveFile(imported.id, 'sub');
await expect(svc.moveDir('sub', 'sub')).rejects.toThrow();
await expect(fs.stat(join(root, 'literature', 'sub', 'paper.pdf'))).resolves.toBeTruthy();
});
});
});