Files
Literature-Management/tests/unit/main/notes-reload-emit-fingerprint.test.ts
2026-10-21 14:42:59 +08:00

175 lines
6.6 KiB
TypeScript

// tests/unit/main/notes-reload-emit-fingerprint.test.ts
//
// 守 r223「notes reload 的 emit 幂等指纹」契约(镜像 literature r136 M1)。
//
// ── 被守卫的缺陷 ───────────────────────────────────────────────────
// literature reload 自 r136 M1 起有 emit 幂等指纹(byId 内容没变 ⇒
// 不 emit),notes reload 却**无条件** `this.emit('changed')` ——
// 孪生实现口径不一致第八次应验。后果:chokidar 对 no-op 触碰(同步盘
// 周期 touch / 元数据抖动)也触发 scheduleReload → emit ⇒ renderer
// 每次都全量 listNotes IPC + setState(100+ 行重渲),内容一个字没变;
// 高频时正是 perf(2026-09-04) 系列一直在压的卡顿源。
//
// 修法(r223):指纹 = `${id}|${mtime}|${size}` 排序 join(r211/r220
// 双因子口径)+ **目录集**(r215 的教训:空文件夹增/删/改名不改变
// 任何文件行,只看文件指纹会把目录结构变化判成「没变」;r184 起
// notes watcher 就监听 addDir/unlinkDir,指纹必须接住这一类)。
// 指纹只管 emit;syncDbWithDisk **无条件跑**(DB 自愈不依赖 renderer
// 是否需要刷新)。
//
// ── 本测锁四件事 ───────────────────────────────────────────────────
// A. 磁盘零变化 → 第二次 load **不再 emit**(缺陷本体,修前红);
// B. 外部改内容 → load 仍 emit(回归锚:真变化不许被指纹吞掉);
// C. 外部建**空文件夹** → load 仍 emit(回归锚,r215 类:文件行没变,
// 目录集变 —— 没有目录维度的「修复」会把这类判成没变);
// D. DB stale 行(indexed_at=0)→ load 仍回填(syncDbWithDisk 无条件
// 跑)但**不 emit**(修前红:HEAD unconditional emit 多打一次)。
//
// 真库(electron app.getPath)在测试环境不可用 → resources-repo 整体 mock
// 成内存 Map(与 notes-reload-db-sync.test.ts 同款 harness)。
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;
size: number;
indexedAt: number;
body: string;
absPath: string;
}
const db = vi.hoisted(() => {
const rows = new Map<string, FakeRow>();
return { rows };
});
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: vi.fn((r: FakeRow, body: string, absPath: string) => {
db.rows.set(r.id, { ...r, body, absPath });
}),
deleteResource: vi.fn((id: string) => {
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('notes reload 的 emit 幂等指纹(r223)', () => {
let root: string;
let notesDir: string;
let svc: NotesService;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-notefp-'));
setRootPath(root);
notesDir = join(root, 'notes');
await fs.mkdir(notesDir, { recursive: true });
db.rows.clear();
svc = new NotesService();
await svc.start({ skipInitialLoad: true });
});
afterEach(async () => {
await svc.stop();
await fs.rm(root, { recursive: true, force: true });
});
it('A 磁盘零变化 → 第二次 load 不再 emit(修前红:HEAD 无条件 emit)', async () => {
await svc.create('a.md');
await svc.load(); // 指纹落基线
const changed = vi.fn();
svc.on('changed', changed);
await svc.load();
await svc.load();
expect(
changed,
'byId 一个字没变也 emit ⇒ 同步盘 no-op 触碰每次都拖 renderer 全量重拉',
).not.toHaveBeenCalled();
});
it('B 外部改内容 → load 仍 emit(真变化不许被指纹吞掉)', async () => {
const created = await svc.create('b.md');
await svc.load();
const changed = vi.fn();
svc.on('changed', changed);
await fs.appendFile(join(notesDir, 'b.md'), '\n外部新增内容\n', 'utf-8');
// mtime 与 size 都变 ⇒ 指纹必变
await svc.load();
expect(changed).toHaveBeenCalledTimes(1);
expect(svc.findById(created.id)?.body).toContain('外部新增内容');
});
it('C 外部建空文件夹 → load 仍 emit(r215 类:文件行没变,目录集变)', async () => {
await svc.create('c.md');
await svc.load();
const changed = vi.fn();
svc.on('changed', changed);
await fs.mkdir(join(notesDir, 'empty-folder'));
await svc.load();
expect(
changed,
'目录结构变化必须 emit —— 只看文件指纹会把空文件夹的增删判成「没变」',
).toHaveBeenCalledTimes(1);
});
it('D DB stale 行(indexed_at=0)→ load 仍回填但不 emit(修前红)', async () => {
const created = await svc.create('d.md');
await svc.load();
const rowId = noteResourceId(created.id);
const row = db.rows.get(rowId);
expect(row).toBeDefined();
// 模拟 0005 迁移标记:FTS 只有 title 种子,等回填
row!.indexedAt = 0;
const changed = vi.fn();
svc.on('changed', changed);
await svc.load();
expect(
db.rows.get(rowId)?.indexedAt,
'stale 回填被指纹跳过 ⇒ indexed_at=0 行永久残废(syncDbWithDisk 必须无条件跑)',
).not.toBe(0);
expect(changed, 'byId 没变却为 DB 回填多打一次 emit').not.toHaveBeenCalled();
});
});