This commit is contained in:
2026-10-21 14:42:59 +08:00
commit 954236d3f9
399 changed files with 85885 additions and 0 deletions

View File

@@ -0,0 +1,311 @@
// tests/unit/main/folder-dir-rename.test.ts
//
// 守 r175「应用内重命名文件夹」契约 —— 重命名后子树里每个资源的 id
// 必须跟着变,否则用户会丢数据。
//
// 旧实现(src/main/ipc/folders.ts 的 rename-dir handler)只有
// 「同级重名检测 + await fs.rename(oldAbs, newAbs)」:
// - **不重钉** 子树里每个笔记的 frontmatter id;
// - **不更新** DB 的 rel_path / absPath;
// - **不换** byId 的键;
// - **不返回** idRemap(旧契约只返 { relPath })。
//
// 笔记 id 与路径强绑定(fm.id = SHA-256(relPath) 前 16 位)⇒ 重命名目录
// 后 id 全变。旧实现下:文件里的 fm.id 还是旧路径的哈希,下一次 writeNote
// 无条件重烧 `id: computeFileId(relPath)` ⇒ id 突变 ⇒ renderer 手里的
// id 变**死 id** ⇒ 之后每次保存都 NOT_FOUND,用户新敲的字困在死 id
// 草稿里(重启即丢)—— 真实数据丢失路径。
//
// 本测锁四件事:
// 1. 物理搬移真的发生(子树整体挪位);
// 2. 子树里每个资源的 id 都重算(notes id 随 relPath、lit id 随
// absPath),文件里的 frontmatter id 也被重钉;
// 3. byId 换键 + DB 行跟着改(old id 查不到 / new id 查得到);
// 4. 返回 idRemap(oldId → newId + 新路径),renderer 靠它把选中态跟过去;
// 且 IPC 通道真的把 idRemap 交出去(旧契约只返 { relPath })。
//
// 真库(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,
}));
// electron 需整体 mock:ipcMain.handle 用来捕获 IPC handler(验「通道真的
// 把 idRemap 交出去」),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 { NotesService } from '../../../src/main/notes/service';
import { LiteratureService } from '../../../src/main/literature/literature-service';
import { registerFoldersIPC } from '../../../src/main/ipc/folders';
import { computeFileId } from '../../../src/main/notes/path-mapping';
import { IPC } from '../../../src/shared/types';
describe('应用内重命名文件夹(r175)', () => {
let root: string;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-dirrename-'));
setRootPath(root);
await fs.mkdir(join(root, 'notes'), { recursive: true });
await fs.mkdir(join(root, 'literature'), { recursive: true });
electronMock.handlers.clear();
repo.renameResource.mockClear();
repo.upsertResource.mockClear();
repo.deleteResource.mockClear();
});
afterEach(async () => {
await fs.rm(root, { recursive: true, force: true });
});
// === notes 域 ===
describe('NotesService.renameDir', () => {
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');
const inSub = await svc.moveFile(n.id, 'sub');
expect(inSub.relPath).toBe('sub/a.md');
const oldId = inSub.id;
const renamed = await svc.renameDir('sub', 'renamed');
// 1) 物理搬移
expect(renamed.relPath).toBe('renamed');
await expect(fs.stat(join(root, 'notes', 'renamed', 'a.md'))).resolves.toBeTruthy();
await expect(fs.stat(join(root, 'notes', 'sub'))).rejects.toThrow();
// 2) id 重钉:笔记 id = SHA-256(relPath) 前 16 位 → 路径变 id 必变
expect(renamed.idRemap.length).toBe(1);
const remap = renamed.idRemap[0]!;
expect(remap.oldId).toBe(oldId);
expect(remap.newId).not.toBe(oldId);
expect(remap.newId).toBe(computeFileId('renamed/a.md'));
expect(remap.relPath).toBe('renamed/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('renamed/a.md');
// 4) 文件里的 frontmatter id 也被重钉 —— 否则下次写盘 id 突变,
// renderer 手里的 id 变死 id,之后每次保存都 NOT_FOUND
const raw = await fs.readFile(join(root, 'notes', 'renamed', 'a.md'), 'utf-8');
expect(raw).toContain(remap.newId);
expect(raw).not.toContain(oldId);
// 5) DB 的 rel_path 跟着改(最后一次 renameResource = 改名后的行)
expect(repo.renameResource).toHaveBeenCalled();
const lastCall = repo.renameResource.mock.calls.at(-1)!;
expect(lastCall[1].relPath).toBe('renamed/a.md');
expect(lastCall[3]).toContain('renamed');
});
it('嵌套目录改名保留目录内层级(a/b/n.md → c/n.md)', async () => {
const n = await svc.create('n.md');
const deep = await svc.moveFile(n.id, 'a/b');
expect(deep.relPath).toBe('a/b/n.md');
const renamed = await svc.renameDir('a/b', 'c');
expect(renamed.relPath).toBe('c');
await expect(fs.stat(join(root, 'notes', 'c', 'n.md'))).resolves.toBeTruthy();
expect(renamed.idRemap[0]?.relPath).toBe('c/n.md');
expect(renamed.idRemap[0]?.newId).toBe(computeFileId('c/n.md'));
});
it('目标已存在 → 拒绝,源目录内容完好(不丢数据)', async () => {
const a = await svc.create('a.md');
await svc.moveFile(a.id, 'x');
const b = await svc.create('b.md');
await svc.moveFile(b.id, 'y');
await expect(svc.renameDir('x', 'y')).rejects.toThrow();
// 源目录与它的笔记都还在(没发生半截搬移)
await expect(fs.stat(join(root, 'notes', 'x', 'a.md'))).resolves.toBeTruthy();
await expect(fs.stat(join(root, 'notes', 'y', 'b.md'))).resolves.toBeTruthy();
});
it('同名 → no-op,idRemap 为空', async () => {
const n = await svc.create('a.md');
await svc.moveFile(n.id, 'sub');
const renamed = await svc.renameDir('sub', 'sub');
expect(renamed.relPath).toBe('sub');
expect(renamed.idRemap).toEqual([]);
await expect(fs.stat(join(root, 'notes', 'sub', 'a.md'))).resolves.toBeTruthy();
});
});
// === literature 域 ===
describe('LiteratureService.renameDir', () => {
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 renamed = await svc.renameDir('sub', 'renamed');
expect(renamed.relPath).toBe('renamed');
await expect(
fs.stat(join(root, 'literature', 'renamed', 'paper.pdf')),
).resolves.toBeTruthy();
await expect(fs.stat(join(root, 'literature', 'sub'))).rejects.toThrow();
expect(renamed.idRemap.length).toBe(1);
const remap = renamed.idRemap[0]!;
expect(remap.oldId).toBe(oldId);
expect(remap.newId).not.toBe(oldId);
expect(remap.absPath).toContain('renamed/paper.pdf');
expect(remap.relPath).toBe('renamed/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, 'x');
await fs.mkdir(join(root, 'literature', 'y'), { recursive: true });
await expect(svc.renameDir('x', 'y')).rejects.toThrow();
await expect(
fs.stat(join(root, 'literature', 'x', 'paper.pdf')),
).resolves.toBeTruthy();
});
});
// === IPC 通道契约 ===
// 旧契约只返 { relPath } —— id 变化对 renderer 完全不可见 ⇒ 选中态
// 永远钉在死 id 上。这里锁「通道必须把 idRemap 交出去」。
describe('rename-dir IPC', () => {
it('NOTES_FOLDERS_RENAME_DIR 返回 idRemap', async () => {
const notes = new NotesService();
const lit = new LiteratureService();
await notes.start({ skipInitialLoad: true });
await lit.start({ skipInitialLoad: true });
try {
registerFoldersIPC(notes, lit);
const n = await notes.create('a.md');
const inSub = await notes.moveFile(n.id, 'sub');
const oldId = inSub.id;
const handler = electronMock.handlers.get(IPC.NOTES_FOLDERS_RENAME_DIR);
expect(handler).toBeTruthy();
const res = (await handler!({}, 'sub', 'renamed')) as {
ok: boolean;
data?: { relPath: string; idRemap: Array<{ oldId: string; newId: string }> };
};
expect(res.ok).toBe(true);
expect(res.data?.relPath).toBe('renamed');
expect(res.data?.idRemap.length).toBe(1);
expect(res.data?.idRemap[0]?.oldId).toBe(oldId);
expect(res.data?.idRemap[0]?.newId).toBe(computeFileId('renamed/a.md'));
} finally {
await notes.stop();
await lit.stop();
}
});
it('LITERATURE_FOLDERS_RENAME_DIR 返回 idRemap', async () => {
const notes = new NotesService();
const lit = new LiteratureService();
await notes.start({ skipInitialLoad: true });
await lit.start({ skipInitialLoad: true });
try {
registerFoldersIPC(notes, lit);
const src = join(root, 'paper.pdf');
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
const imported = await lit.importFile(src);
const inSub = await lit.moveFile(imported.id, 'sub');
const oldId = inSub.id;
const handler = electronMock.handlers.get(IPC.LITERATURE_FOLDERS_RENAME_DIR);
expect(handler).toBeTruthy();
const res = (await handler!({}, 'sub', 'renamed')) as {
ok: boolean;
data?: { relPath: string; idRemap: Array<{ oldId: string; newId: string }> };
};
expect(res.ok).toBe(true);
expect(res.data?.relPath).toBe('renamed');
expect(res.data?.idRemap.length).toBe(1);
expect(res.data?.idRemap[0]?.oldId).toBe(oldId);
} finally {
await notes.stop();
await lit.stop();
}
});
});
});