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,268 @@
// 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);
});
});