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,201 @@
// tests/unit/main/notes-reload-db-sync.test.ts
//
// 守 r180「笔记域 reload 必须把 DB 拉齐到磁盘」契约。
//
// 旧实现:src/main/notes/service.ts 的 reload() 只重建内存 byId ——
// const notes = await scanNotes(...); this.byId = next; this.emit('changed');
// —— **从不碰 DB**。磁盘差集逻辑只存在于 resyncIfNeeded(),而它只在
// 启动期跑一次(r156 起的调用点)。
//
// 于是会话运行期间出现三个「列表与文件夹对不上」的洞:
// 1. 用户从 OS 拖进 <rootPath>/notes/ 的 .md:侧栏能看见(byId 重建了),
// 但 resources 表里**没有这一行** ⇒ 全局搜索 / FTS 永远搜不到它
// (literature 域 r136 起每次 reload 都做差集,两个域行为相反);
// 2. 用户用外部编辑器改过的 .md:侧栏标题是新的,DB 里的 body 还是旧的
// ⇒ 搜索命中旧正文,正文与文件内容不一致;
// 3. 用户在 OS 删掉的 .md:DB 留下 ghost 行 ⇒ 搜索还能搜到一篇磁盘上
// 已经不存在的东西(点进去 NOT_FOUND)。
//
// 本测锁四件事:
// 1. OS 拖入的 .md → reload 后进 DB(带 body ⇒ FTS 可搜);
// 2. 外部改写过的 .md → reload 后 DB 正文跟着更新(mtime 变 ⇒ 重写);
// 3. OS 删掉的 .md → reload 后 ghost 行被清理;
// 4. 磁盘没变 → reload **不重复写 DB**(否则每次防抖都全量重写,
// indexed_at 被刷掉,「indexed_at=0 = body 未填」这个标记就失真了)。
//
// 真库(electron app.getPath)在测试环境不可用 → resources-repo 整体 mock
// 成一个内存 Map;被测行为(差集判定 + upsert/删除时机)全在 service 内。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
interface FakeRow {
id: string;
kind: string;
title: string;
relPath: string;
mtime: number;
indexedAt: number;
body: string;
absPath: string;
}
const db = vi.hoisted(() => {
const rows = new Map<string, FakeRow>();
const calls = { upsert: 0, del: 0 };
return { rows, calls };
});
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: vi.fn((r: FakeRow, body: string, absPath: string) => {
db.calls.upsert++;
db.rows.set(r.id, { ...r, body, absPath });
}),
deleteResource: vi.fn((id: string) => {
db.calls.del++;
db.rows.delete(id);
}),
listResources: vi.fn((kind?: string) =>
[...db.rows.values()].filter((r) => !kind || r.kind === kind),
),
listResourceRelPaths: vi.fn((kind: string) =>
[...db.rows.values()].filter((r) => r.kind === kind).map((r) => r.relPath),
),
listStaleIndexIds: vi.fn((kind: string) =>
[...db.rows.values()].filter((r) => r.kind === kind && r.indexedAt === 0).map((r) => r.id),
),
renameResource: vi.fn((prevId: string, r: FakeRow, body: string, absPath: string) => {
db.rows.delete(prevId);
db.rows.set(r.id, { ...r, body, absPath });
}),
}));
// electron 需整体 mock:safe-write 用 shell.trashItem(节点环境没有真回收站)
vi.mock('electron', () => ({
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)));
},
},
}));
import { setRootPath } from '../../../src/main/utils/safe-write';
import { NotesService } from '../../../src/main/notes/service';
import { noteResourceId } from '../../../src/shared/resource-id';
describe('笔记 reload ↔ DB 差集(r180)', () => {
let root: string;
let notesDir: string;
let svc: NotesService;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-notereload-'));
setRootPath(root);
notesDir = join(root, 'notes');
await fs.mkdir(notesDir, { recursive: true });
db.rows.clear();
db.calls.upsert = 0;
db.calls.del = 0;
svc = new NotesService();
await svc.start({ skipInitialLoad: true });
});
afterEach(async () => {
await svc.stop();
await fs.rm(root, { recursive: true, force: true });
});
it('OS 拖入的 .md → reload 后进 DB(带正文 ⇒ FTS 能搜到)', async () => {
// 模拟用户在资源管理器里把 a.md 丢进 <root>/notes/
await fs.writeFile(join(notesDir, 'external.md'), '# 外部笔记\n\n正文内容\n', 'utf-8');
await svc.load();
expect(svc.list().map((n) => n.relPath)).toEqual(['external.md']);
const rows = [...db.rows.values()];
expect(rows).toHaveLength(1);
expect(rows[0]?.relPath).toBe('external.md');
expect(rows[0]?.kind).toBe('note');
// 正文进了 DB(否则 FTS 索引里没有它,全局搜索搜不到)
expect(rows[0]?.body).toContain('外部笔记');
});
it('外部编辑器改过的 .md → reload 后 DB 正文跟着更新', async () => {
const created = await svc.create('edited.md');
const file = join(notesDir, 'edited.md');
const rowId = noteResourceId(created.id);
const before = db.rows.get(rowId);
expect(before?.body ?? '').not.toContain('外部新增的一行');
// 外部改写正文(保留 frontmatter,只动 body)+ 显式推 mtime ——
// 不推的话同毫秒内两次写会拿到相同 mtimeMs,断言会随机红。
await fs.appendFile(file, '\n外部新增的一行\n', 'utf-8');
const future = new Date(Date.now() + 5000);
await fs.utimes(file, future, future);
await svc.load();
const after = db.rows.get(rowId);
expect(after?.body).toContain('外部新增的一行');
});
it('OS 删掉的 .md → reload 后 ghost 行被清理(文件没了,索引也不能留)', async () => {
const created = await svc.create('gone.md');
const rowId = noteResourceId(created.id);
expect(db.rows.has(rowId)).toBe(true);
await fs.rm(join(notesDir, 'gone.md'), { force: true });
await svc.load();
expect(db.rows.has(rowId)).toBe(false);
expect(db.rows.size).toBe(0);
});
it('内容变但 mtime 被保留(备份还原 / cp -p / 同步盘)⇒ DB 正文仍跟着更新(r220 size 双因子)', async () => {
const created = await svc.create('restored.md');
const file = join(notesDir, 'restored.md');
const rowId = noteResourceId(created.id);
await svc.load(); // 先收敛(建文件那刻与落盘后的 mtime 差补写一次)
const upsertsBefore = db.calls.upsert;
// 外部改写正文(字节数变),然后把 DB 行的 mtime 拨成**当前磁盘
// mtime** —— 等价于「备份还原 / cp -p 保留了 mtime ⇒ DB 行的 mtime
// 与磁盘一致,但内容早就不一样了」。直接拨行而不是 fs.utimes:
// utimes 的 sub-ms 精度在 float64 秒往返里会丢,断言会随机红;
// 这里要钉的语义是「row.mtime === disk mtime 且内容不同」,
// 与 mtime 的绝对值无关。
await fs.appendFile(file, '\n还原回来的新内容\n', 'utf-8');
const diskMtime = (await fs.stat(file)).mtimeMs;
const row = db.rows.get(rowId);
expect(row).toBeDefined();
row!.mtime = diskMtime;
await svc.load();
const after = db.rows.get(rowId);
expect(
after?.body,
'只看 mtime 的旧判定把这次变更当成「没变」跳过 ⇒ FTS 永久停在旧正文',
).toContain('还原回来的新内容');
expect(db.calls.upsert).toBeGreaterThan(upsertsBefore);
});
it('磁盘没变 → reload 不重复写 DB(不无脑刷新 indexed_at)', async () => {
await svc.create('stable.md');
// 第一次 load 可能因「建文件那一刻的 mtime 与落盘后的 mtime 不同」而补写
// 一次 —— 先让它收敛,再量「稳态下不写」
await svc.load();
const before = db.calls.upsert;
await svc.load();
await svc.load();
expect(db.calls.upsert).toBe(before);
});
});