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

237 lines
9.0 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/notes-reload-body-bytes.test.ts
//
// 守 perf r255「笔记正文的 UTF-8 字节数不许每轮 reload 重算」的契约。
//
// ── 被守卫的优化 ───────────────────────────────────────────────────
// notes reload 的两处判定都要「这篇笔记的 body 有多少字节」:
// ① emit 幂等指纹(r223):`${id}|${mtime}|${byteLength(body)}`
// ② DB 同步(r220):`row.size === byteLength(body)` 判「变了没」
// 旧实现两处都现场 `Buffer.byteLength(body,'utf8')` —— 而 byteLength
// 是**一次全量 UTF-8 扫描**:实测 2000 篇 × 8KB = 12.7ms/遍,两处
// = 25.4ms 的**主线程纯 CPU**(不是 I/O 等待 ⇒ 直接推迟同期的其它
// IPC 响应 ⇒ 用户侧「卡一下」)。稳态(文件没变)下这 25ms 是纯白烧。
//
// 修法:字节数跟着解析结果一起缓存(stat 门控解析缓存,r239),
// 命中即复用 ⇒ 稳态零计算。
//
// ── 本测锁四件事 ───────────────────────────────────────────────────
// A. **字节数维度必须被看见**:正文变长 + **mtime 保留**(备份还原 /
// cp -p / 同步盘按内容去重的真实形态)⇒ 必须 emit('changed') 且
// DB 行的 size 跟着更新。
// —— 这是唯一能把「字节数」与「mtime」两个维度分开的构造:若实现
// 复用了**陈旧**的字节数,指纹不变 ⇒ 不 emit;DB 判定相等 ⇒ 不
// upsert ⇒ 两处都红。
// B. **稳态不白写 DB**:文件没变 ⇒ 二次 reload **零 upsert**
// (字节数口径与写库口径若不一致 ⇒ 每轮都重写 ⇒ 红)。
// C. **字节数是 UTF-8 字节数,不是字符数**:中文正文的字节数必须
// > 字符串长度,且 DB 行的 size == Buffer.byteLength(body)。
// D. **缓存不得陈旧**:文件改了 ⇒ 下一轮拿到的是**新**字节数
// (解析缓存的 stat 门控命中判据变了 ⇒ 必须重算)。
//
// 真库(electron app.getPath)在测试环境不可用 → resources-repo 整体
// mock 成内存 Map(与 notes-reload-emit-fingerprint.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>();
const calls = { upsert: 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.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 { scanNotes, clearNoteParseCache } from '../../../src/main/notes/standalone';
import { noteResourceId } from '../../../src/shared/resource-id';
/** 固定 mtime(整秒 ⇒ mtimeMs 无小数,断言可以严格相等)。 */
const FIXED_MTIME = new Date(1_700_000_000_000);
describe('笔记正文字节数的复用(r255)', () => {
let root: string;
let notesDir: string;
let svc: NotesService;
/**
* 落盘后的 body 期望字节数。
* 文件形态 = `---
title: "T"
---
<text>
` ⇒ gray-matter 的
* content = `
<text>
` ⇒ cleanBody 只收尾的换行 ⇒ body = `
<text>`
* (前导那个空行是 frontmatter 之后的换行,属于正文)。
*/
function expectedBytes(text: string): number {
return Buffer.byteLength(`
${text}`, 'utf8');
}
/** 落一个 .md 并把 mtime 钉到 FIXED_MTIME。 */
async function writeNote(name: string, title: string, body: string): Promise<string> {
const p = join(notesDir, name);
await fs.writeFile(p, `---\ntitle: "${title}"\n---\n\n${body}\n`, 'utf-8');
await fs.utimes(p, FIXED_MTIME, FIXED_MTIME);
return p;
}
beforeEach(async () => {
clearNoteParseCache();
root = await fs.mkdtemp(join(tmpdir(), 'rkms-notebytes-'));
setRootPath(root);
notesDir = join(root, 'notes');
await fs.mkdir(notesDir, { recursive: true });
db.rows.clear();
db.calls.upsert = 0;
svc = new NotesService();
await svc.start({ skipInitialLoad: true });
});
afterEach(async () => {
await svc.stop();
clearNoteParseCache();
await fs.rm(root, { recursive: true, force: true });
});
it('A 正文变长 + mtime 保留 ⇒ 必须 emit 且 DB 行的 size 更新(字节数维度要能看见)', async () => {
await writeNote('a.md', 'A', '短正文');
await svc.load();
const rowId = noteResourceId(svc.findById(svc.list()[0]!.id)!.id);
const before = db.rows.get(rowId)!;
expect(before.size).toBe(expectedBytes('短正文'));
// 外部改写:正文变长,mtime **保留**(cp -p / 备份还原的形态)
const p = join(notesDir, 'a.md');
await fs.writeFile(p, `---\ntitle: "A"\n---\n\n短正文而且更长了\n`, 'utf-8');
await fs.utimes(p, FIXED_MTIME, FIXED_MTIME);
const changed = vi.fn();
svc.on('changed', changed);
await svc.load();
expect(changed, 'mtime 保留但正文变长 ⇒ 指纹必须变(否则 renderer 看不到更新)').toHaveBeenCalledTimes(1);
const after = db.rows.get(rowId)!;
expect(after.size, 'DB 行的 size 必须跟着正文长度更新').toBe(
expectedBytes('短正文而且更长了'),
);
expect(after.size).not.toBe(before.size);
});
it('B 文件没变 ⇒ 二次 reload 零 upsert(不白写 DB)', async () => {
await writeNote('b.md', 'B', '正文 B');
await svc.load();
const upsertsAfterFirst = db.calls.upsert;
expect(upsertsAfterFirst).toBeGreaterThan(0);
const changed = vi.fn();
svc.on('changed', changed);
await svc.load();
await svc.load();
expect(db.calls.upsert, '内容没变却每轮重写 DB ⇒ FTS 被反复重建').toBe(upsertsAfterFirst);
expect(changed).not.toHaveBeenCalled();
});
it('C 中文正文的 size 是 UTF-8 字节数(不是字符数)', async () => {
await writeNote('c.md', 'C', '注意力机制');
await svc.load();
const note = svc.list()[0]!;
const row = db.rows.get(noteResourceId(note.id))!;
const body = svc.findById(note.id)!.body;
expect(Buffer.byteLength(body, 'utf8'), '本用例必须真的含多字节字符(否则测不出维度)').toBeGreaterThan(
body.length,
);
expect(row.size).toBe(Buffer.byteLength(body, 'utf8'));
});
it('D 文件改了 ⇒ 下一轮拿到的是新字节数(缓存不许陈旧)', async () => {
await writeNote('d.md', 'D', '第一版');
const first = new Map<string, number>();
await scanNotes(root, { bodyBytesOut: first });
expect(first.get('d.md')).toBe(expectedBytes('第一版'));
const p = join(notesDir, 'd.md');
await fs.writeFile(p, `---\ntitle: "D"\n---\n\n第二版更长了\n`, 'utf-8');
const second = new Map<string, number>();
await scanNotes(root, { bodyBytesOut: second });
expect(second.get('d.md'), '文件变了 ⇒ 字节数必须重算,不能拿缓存里的旧值').toBe(
expectedBytes('第二版更长了'),
);
});
it('E 稳态 reload 不再算字节数(零 byteLength 调用 —— 这就是本轮的性能契约)', async () => {
await writeNote('e.md', 'E', '正文 E');
// 首轮:解析期算一次(缓存回填)
await svc.load();
const spy = vi.spyOn(Buffer, 'byteLength');
try {
await svc.load();
expect(
spy.mock.calls.length,
'文件没变 ⇒ 字节数应直接取解析缓存,不该再全量扫一遍正文',
).toBe(0);
} finally {
spy.mockRestore();
}
});
});