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

269 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// tests/unit/main/literature-reload-emit-fingerprint.test.ts
//
// 守 r257「文献 reload 的 emit 幂等指纹」契约 —— 并锁住「指纹不许再拼
// 成一根大字符串」的性能契约。
//
// ── 为什么这一轮是「补守卫」而不是「补功能」 ─────────────────────────
// 文献域的 emit 指纹自 r136 M1 起就有(byId 内容没变 ⇒ 不 emit),
// notes 域自 r223 起也补上了(r223 的守卫:notes-reload-emit-fingerprint
// .test.ts)。但**文献域这半边一直没有守卫** —— tests/unit/main/ 下的
// literature-* 守卫覆盖的是导入 / 改名 / 移动 / stat 刷新 / 目录信号,
// **没有一个**盯 emit 指纹本身。r136 M1 之后这块代码被动过至少四次
// (r172 relPath / r197 O(N×M) / r211 双因子 / r254 单遍 stat),每一次
// 都可能把「指纹维度」悄悄改掉 —— 而没有守卫会发现。
// notes 侧守的是 r223 契约;文献侧现在补齐同一份契约(孪生实现口径
// 应当一致 —— 这正是 r223 那条注释里说的「第八次应验」)。
//
// ── 指纹的三个维度(缺一个就有一类用户可见的错) ──────────────────────
// ① 文件集 + 每行的 (mtime, size) —— 内容变了要 emit;
// ② 目录集(r215)—— 空文件夹增删不改任何文件行;
// ③ 首轮必 emit(r136 M1:初始 null)—— 否则首屏拿不到数据。
//
// ── 本测锁七件事 ───────────────────────────────────────────────────
// A. 磁盘零变化 → 第二次 load **不再 emit**(缺陷本体;文献侧此前无
// 守卫 ⇒ 这条是本轮新增的回归网);
// B. 外部改内容(mtime + size 都变)→ load 仍 emit(真变化不许被吞);
// C. 外部建**空文件夹** → load 仍 emit(r215 维度);
// D. 内容变长 + **mtime 保留**(cp -p / 备份还原 / 同步盘按内容去重的
// 真实形态)→ load 仍 emit(r211 的 size 维度必须参与指纹);
// F. 内容长度不变但 mtime 变(外部 touch / no-op 写)→ load 仍 emit
// (mtime 维度必须参与指纹);
// G. 首轮 load 必须 emit(r136 M1:初始基线为空也要把初始状态推出去);
// E. 指纹表示:**每行状态落在 Map 里**(不是拼成一根大字符串)——
// 这是本轮的性能契约(旧实现每轮拼 N 个 `${id}|${mtime}|${size}`
// 字符串 + 排序 + join:lit id 是绝对路径(平均 108 字符)⇒ 每轮
// 额外分配 ≈ 250KB 字符串、实测 ≈ 0.8ms/2000 行纯主线程 CPU;新
// 实现只做 O(n) 逐项比对 ≈ 0.13ms)。
//
// chokidar mock 掉(本测要的是「reload 之后」的判定,不是「事件有没有
// 被订阅」—— 后者由 literature-dir-structure-signal.test.ts 守);
// resources-repo mock 成内存 Map(真库要 electron app.getPath)。
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;
mtime: number;
size: number;
indexedAt: number;
absPath: string;
rootId: string | null;
extractStatus: string;
body: 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),
),
renameResource: vi.fn((prevId: string, r: FakeRow, body: string, absPath: string) => {
db.rows.delete(prevId);
db.rows.set(r.id, { ...r, body, absPath });
}),
updateResourceStat: vi.fn((id: string, size: number, mtime: number) => {
const r = db.rows.get(id);
if (r) {
r.size = size;
r.mtime = mtime;
}
}),
}));
vi.mock('chokidar', () => {
const watch = vi.fn(() => {
const w = {
on: () => w,
close: vi.fn(async () => {}),
removeAllListeners: vi.fn(),
};
return w;
});
return { default: { watch }, watch };
});
import { setRootPath } from '../../../src/main/utils/safe-write';
import { LiteratureService } from '../../../src/main/literature/literature-service';
describe('文献 reload 的 emit 幂等指纹(r257)', () => {
let root: string;
let litDir: string;
let svc: LiteratureService;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-litfp-'));
setRootPath(root);
litDir = join(root, 'literature');
await fs.mkdir(litDir, { recursive: true });
db.rows.clear();
svc = new LiteratureService();
await svc.start({ skipInitialLoad: true });
});
afterEach(async () => {
await svc.stop();
await fs.rm(root, { recursive: true, force: true });
});
async function seed(name: string, content = 'AAAA'): Promise<string> {
const p = join(litDir, name);
await fs.writeFile(p, content, 'utf-8');
return p;
}
it('A 磁盘零变化 → 第二次 load 不再 emit(缺陷本体)', async () => {
await seed('a.pdf');
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 外部改内容(mtime + size 都变)→ load 仍 emit(真变化不许被吞)', async () => {
const p = await seed('b.pdf');
await svc.load();
const changed = vi.fn();
svc.on('changed', changed);
await fs.writeFile(p, 'AAAAAAAAAAAA', 'utf-8');
const t = new Date(Date.now() + 60_000);
await fs.utimes(p, t, t);
await svc.load();
expect(changed).toHaveBeenCalledTimes(1);
expect(svc.list().find((r) => r.absPath === p)?.size).toBe(12);
});
it('C 外部建空文件夹 → load 仍 emit(r215 维度:目录集参与指纹)', async () => {
await seed('c.pdf');
await svc.load();
const changed = vi.fn();
svc.on('changed', changed);
await fs.mkdir(join(litDir, 'empty-folder'));
await svc.load();
expect(
changed,
'目录结构变化必须 emit —— 只看文件行会把空文件夹的增删判成「没变」',
).toHaveBeenCalledTimes(1);
});
it('D 内容变长 + mtime 保留 → load 仍 emit(r211 的 size 维度)', async () => {
// mtime 从**第一次落盘起**就钉死(不是「改完再写回」—— 那条路会把
// 亚毫秒精度截掉,于是「mtime 保留」其实没保住,size 维度就被
// mtime 维度的假差掩盖了)。这里两轮都是同一个整秒 mtime。
const p = join(litDir, 'd.pdf');
const fixed = new Date(1_700_000_000_000);
await fs.writeFile(p, 'AAAA', 'utf-8');
await fs.utimes(p, fixed, fixed);
await svc.load();
const changed = vi.fn();
svc.on('changed', changed);
// 内容变长但 mtime 钉死(备份还原 / cp -p / 同步盘按内容去重)
await fs.writeFile(p, 'AAAAAAAAAAAA', 'utf-8');
await fs.utimes(p, fixed, fixed);
await svc.load();
expect(
changed,
'mtime 被保留但 size 变了 ⇒ 指纹必须变(只看 mtime 会静默不刷)',
).toHaveBeenCalledTimes(1);
});
it('F 内容长度不变但 mtime 变(外部 touch / no-op 写)→ load 仍 emit(mtime 维度)', async () => {
const p = await seed('f.pdf', 'AAAA');
await svc.load();
const changed = vi.fn();
svc.on('changed', changed);
const t = new Date(Date.now() + 60_000);
await fs.utimes(p, t, t);
await svc.load();
expect(
changed,
'mtime 变了就必须 emit —— 指纹的 mtime 维度不许被删(否则外部改动静默不刷)',
).toHaveBeenCalledTimes(1);
});
it('G 首轮 load 必须 emit(初始基线为空 ⇒ 必须把初始状态推给 renderer)', async () => {
await seed('g.pdf');
const changed = vi.fn();
svc.on('changed', changed);
await svc.load();
expect(
changed,
'首轮不 emit ⇒ renderer 永远拿不到初始列表(r136 M1 的「初始 null ⇒ 必 emit」)',
).toHaveBeenCalledTimes(1);
});
it('E 指纹表示:每行状态落在 Map 里(不是拼成一根大字符串)', async () => {
await seed('e1.pdf');
await seed('e2.pdf');
await seed('e3.pdf');
// 观测点:Map.prototype.set 的调用记录。reload 期间出现的 Map 里,
// 只有 per-row 状态表的**值**是纯 {mtime, size}:
// - db.rows / rowsById / refreshed / byId 的值是资源行(带 id);
// - diskStats 的值是扫描期 stat(带 mtimeMs,不是 mtime);
// 所以「值只含 mtime+size」这一条就能把状态表单独摘出来。
const seen: Array<Map<unknown, unknown>> = [];
const origSet = Map.prototype.set;
const spy = vi
.spyOn(Map.prototype, 'set')
.mockImplementation(function (this: Map<unknown, unknown>, key: unknown, value: unknown) {
seen.push(this);
return origSet.call(this, key, value);
});
try {
await svc.load();
} finally {
spy.mockRestore();
}
const tables = new Set(
seen.filter((m) => {
const vals = [...m.values()];
return (
vals.length > 0 &&
vals.every((v) => {
const o = v as Record<string, unknown>;
return typeof o.mtime === 'number' && typeof o.size === 'number' && !('id' in o);
})
);
}),
);
expect(
tables.size,
'reload 必须有一张纯 {mtime,size} 的 per-row 状态表(旧实现只有一根字符串)',
).toBe(1);
expect([...tables][0]!.size, '状态表的条数必须等于文件行数').toBe(3);
});
});