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