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,294 @@
// tests/unit/main/literature-stat-refresh.test.ts
//
// 守 r211「文献行的 mtime / size 永远停在导入那一刻」契约。
//
// 现象(文献域与笔记域的口径差):
// 笔记侧 syncDbWithDisk(r180)每次 reload 都重读文件 ⇒ 行的 mtime 跟着磁盘走。
// 文献侧 reload() 只对 **disk-only**(新出现)的文件 stat,已经在库里的行
// **一次都不 stat** ⇒ 行上的 mtime/size 永远停在导入那一刻。
//
// 连带三处用户可见的错:
// 1. 列表里「2.3 MB · 2h ago」是假的 —— 文件早就被重下 / 被同步盘覆盖;
// 2. 列表按 mtime 倒序 ⇒ 顺序也停在导入那一刻(新下载的文献排不上去);
// 3. emit 指纹是 `${id}|${mtime}` ⇒ 指纹恒定 ⇒ **连 'changed' 事件都
// 不发** ⇒ 渲染层永远收不到刷新通知(手动点「刷新」拉回来的还是旧值)。
//
// 本测锁四件事:
// A. 文件在磁盘上被替换 ⇒ reload 后行的 size/mtime 是**磁盘真值**;
// B. svc.list()(渲染层拿的就是它)也必须给新值;
// C. 变化 ⇒ 必须 emit('changed');没变化 ⇒ **不许** emit(否则每次 reload
// 都把渲染层拖一遍全量拉);
// D. stat 失败(EPERM / EBUSY)⇒ 行**保留**旧值,不崩、不当成「文件没了」
// (那是 db-only 分支的职责)。
//
// 另加一条「内容变但 mtime 被保留」(备份还原 / cp -p)⇒ 指纹必须**也**看
// size,否则这种情况仍然是「静默无变化」。
//
// 真库(electron app.getPath)在测试环境不可用 → resources-repo 整体 mock
// 成内存 Map;被测行为(什么时候 stat、什么时候写库、什么时候 emit)全在
// service 内。chokidar 也 mock 掉 —— 真 watcher 会在测试写文件时异步发
// 'add'/'change' ⇒ 额外排一次 reload ⇒ emit 计数变成竞态。
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>();
const calls = { upsert: 0, del: 0, stat: 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.calls.del++;
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 });
}),
// r211 新增:只更 size/mtime 两列(**不碰 FTS**)。
updateResourceStat: vi.fn((id: string, size: number, mtime: number) => {
const r = db.rows.get(id);
if (r) {
r.size = size;
r.mtime = mtime;
}
}),
}));
// chokidar mock:真 watcher 会在测试写文件时异步发 'add'/'change',
// 排一次 reload ⇒ emit 计数变竞态。mock 掉让 reload 只由测试显式驱动。
vi.mock('chokidar', () => {
const watch = vi.fn(() => {
const w = {
on: () => w,
close: vi.fn(async () => {}),
removeAllListeners: vi.fn(),
};
return w;
});
return { default: { watch }, watch };
});
// 「某个文件 stat 不出来」(Defender 锁 / 同步盘占位 / 权限翻转):
// fsFail.statLocked 打开后,路径含 'locked' 的 stat / lstat 直接 reject(EPERM)。
// 用开关而不是「路径即开关」—— 首轮 reload 需要把这个文件**正常插进来**
// (它得先在 DB 里才有「刷不动」这回事)。
//
// perf r254:stat **与** lstat 一起挡 —— 旧实现里「刷 stat」走的是独立的
// fs.stat 一轮,所以只挡 stat 就能造出「拿不到 stat」的场景;r254 把
// 刷 stat 改成**复用扫描期那次 lstat**(见 literature-service.ts 的
// LitDiskStat 头注),「拿不到 stat 信息」的第一个落点变成**扫描期**:
// lstat 失败 ⇒ 该文件不进 diskPaths ⇒ 落进 db-only 分支 ⇒
// isReallyGone 只在 ENOENT 时才判「没了」,EPERM 一律 fail-safe 当
// 「还在」⇒ 行保留旧值。**可观察行为不变**(行保留旧值 / 不删行 /
// 列表里还在),只是「拿不到」发生在哪一 syscall 上换了。
const fsFail = vi.hoisted(() => ({ statLocked: false }));
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
const realStat = actual.promises.stat;
const realLstat = actual.promises.lstat;
const blocked = (path: unknown, op: string): Error | null => {
if (fsFail.statLocked && typeof path === 'string' && path.includes('locked')) {
return Object.assign(new Error(`EPERM: operation not permitted, ${op} '${path}'`), {
code: 'EPERM',
});
}
return null;
};
return {
...actual,
default: actual,
promises: {
...actual.promises,
stat: (path: unknown, opts: unknown) => {
const err = blocked(path, 'stat');
if (err) return Promise.reject(err);
return realStat(path as never, opts as never);
},
lstat: (path: unknown, opts: unknown) => {
const err = blocked(path, 'lstat');
if (err) return Promise.reject(err);
return realLstat(path as never, opts as never);
},
},
};
});
import { setRootPath } from '../../../src/main/utils/safe-write';
import { LiteratureService } from '../../../src/main/literature/literature-service';
describe('文献行的 stat 必须跟着磁盘刷新(r211)', () => {
let root: string;
let litDir: string;
let svc: LiteratureService;
/** 当前 DB 里唯一的文献行(测试里始终只有一篇)。 */
function onlyRow(): FakeRow {
const rows = [...db.rows.values()];
expect(rows).toHaveLength(1);
return rows[0]!;
}
/** 把文件内容换掉并把 mtime 推到未来(模拟「重下 / 同步盘覆盖」)。 */
async function replaceFile(p: string, content: string): Promise<void> {
await fs.writeFile(p, content, 'utf-8');
const t = new Date(Date.now() + 60_000);
await fs.utimes(p, t, t);
}
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-litstat-'));
setRootPath(root);
litDir = join(root, 'literature');
await fs.mkdir(litDir, { recursive: true });
db.rows.clear();
db.calls.upsert = 0;
db.calls.del = 0;
fsFail.statLocked = false;
svc = new LiteratureService();
await svc.start({ skipInitialLoad: true });
});
afterEach(async () => {
await svc.stop();
await fs.rm(root, { recursive: true, force: true });
});
it('磁盘上的文件被替换 ⇒ reload 后行的 size/mtime 是磁盘真值', async () => {
const p = join(litDir, 'paper.pdf');
await fs.writeFile(p, 'AAAA', 'utf-8');
await svc.load();
const before = onlyRow();
expect(before.size).toBe(4);
// 取快照(memory mock 的 updateResourceStat 是**原地改**行对象,
// 直接留引用会让「改之前」的值也跟着变 ⇒ 断言失真)
const beforeMtime = before.mtime;
const beforeSize = before.size;
await replaceFile(p, 'AAAAAAAAAA'); // 10 字节 + 未来 mtime
const disk = await fs.stat(p);
await svc.load();
const after = onlyRow();
expect(after.size).toBe(10);
expect(after.mtime).toBe(disk.mtimeMs);
expect(after.size).not.toBe(beforeSize);
expect(after.mtime).toBeGreaterThan(beforeMtime);
});
it('svc.list()(渲染层拿的就是它)必须给新值', async () => {
const p = join(litDir, 'paper.pdf');
await fs.writeFile(p, 'AAAA', 'utf-8');
await svc.load();
await replaceFile(p, 'AAAAAAAAAA');
await svc.load();
const row = svc.list()[0];
expect(row?.size).toBe(10);
expect(row?.mtime).toBe((await fs.stat(p)).mtimeMs);
});
it('文件被替换 ⇒ 必须 emit changed(否则渲染层永远看不到变化)', async () => {
const p = join(litDir, 'paper.pdf');
await fs.writeFile(p, 'AAAA', 'utf-8');
await svc.load(); // 第一次:指纹 null → 有值,这次 emit 不计入
let changedCount = 0;
svc.on('changed', () => {
changedCount++;
});
await replaceFile(p, 'BBBBBBBB');
await svc.load();
expect(changedCount).toBe(1);
});
it('文件没变 ⇒ 不许 emit changed(否则每次 reload 都把渲染层拖一遍)', async () => {
const p = join(litDir, 'paper.pdf');
await fs.writeFile(p, 'AAAA', 'utf-8');
await svc.load();
let changedCount = 0;
svc.on('changed', () => {
changedCount++;
});
await svc.load();
await svc.load();
expect(changedCount).toBe(0);
});
it('内容变了但 mtime 被保留(备份还原 / cp -p)⇒ 也要刷(指纹必须看 size)', async () => {
const p = join(litDir, 'paper.pdf');
await fs.writeFile(p, 'AAAA', 'utf-8');
await svc.load();
const orig = await fs.stat(p);
let changedCount = 0;
svc.on('changed', () => {
changedCount++;
});
await fs.writeFile(p, 'AAAAAAAAAA', 'utf-8');
await fs.utimes(p, orig.atime, orig.mtime); // mtime 还原成原值
await svc.load();
expect(onlyRow().size).toBe(10);
expect(changedCount).toBe(1);
});
it('stat 失败(EPERM)⇒ 行保留旧值,不崩、不当成「文件没了」', async () => {
const p = join(litDir, 'locked.pdf');
await fs.writeFile(p, 'AAAA', 'utf-8');
await svc.load();
const before = onlyRow();
const beforeSize = before.size;
const beforeMtime = before.mtime;
// 从这里起 stat 拿不到(文件还在,只是读不出 stat)
fsFail.statLocked = true;
// 换成更大内容 + 未来 mtime
await fs.writeFile(p, 'AAAAAAAAAA', 'utf-8');
const t = new Date(Date.now() + 60_000);
await fs.utimes(p, t, t);
await svc.load();
fsFail.statLocked = false;
const after = onlyRow();
expect(after.id).toBe(before.id);
expect(after.size).toBe(beforeSize); // 刷不动就保持旧值
expect(after.mtime).toBe(beforeMtime);
expect(db.calls.del).toBe(0); // 绝不能把「读不到」当成「没了」
expect(svc.list()).toHaveLength(1);
});
});