This commit is contained in:
2026-10-16 16:34:15 +08:00
parent 7a3769670b
commit ff366a4daf
80 changed files with 9961 additions and 888 deletions

View File

@@ -0,0 +1,172 @@
// main/file-list-cache.js 单测(audit R7a P1-20)
//
// 覆盖:
// - 首次 getOrBuild → 调 scanFn,结果缓存
// - 同 dir 二次 getOrBuild → 命中缓存,不调 scanFn
// - invalidate → 下次 getOrBuild 重扫
// - dir 变化 → 重扫
// - scanFn 返回 {ok:false} → 返回 {error} 不缓存
// - files 字段只含非文件夹条目(兼容 state.files)
// - peek / _setForTest 测试钩子
import { describe, it, expect, beforeEach } from 'vitest';
const cache = require('../../main/file-list-cache.js');
const ENTRIES_500 = Array.from({ length: 500 }, (_, i) => ({
name: `f-${i}.md`,
path: `/data/f-${i}.md`,
entryType: 'editable',
isFolder: false,
size: 100,
mtimeMs: 1000 + i,
}));
function scanFnMock(entries = ENTRIES_500) {
return async (dir) => ({ ok: true, dir, entries });
}
beforeEach(() => {
cache.invalidate();
cache._setForTest(null);
});
describe('file-list-cache: 缓存命中 / 失效', () => {
it('首次 getOrBuild 调 scanFn,结果缓存', async () => {
let calls = 0;
const scan = async (dir) => { calls += 1; return { ok: true, dir, entries: ENTRIES_500 }; };
const r = await cache.getOrBuild('/data', scan);
expect(calls).toBe(1);
expect(r.entries).toHaveLength(500);
expect(r.total).toBe(500);
expect(r.files).toHaveLength(500);
expect(r.dir).toBe('/data');
});
it('同 dir 二次 getOrBuild 命中缓存,不调 scanFn', async () => {
let calls = 0;
const scan = async (dir) => { calls += 1; return { ok: true, dir, entries: ENTRIES_500 }; };
await cache.getOrBuild('/data', scan);
await cache.getOrBuild('/data', scan);
expect(calls).toBe(1);
});
it('invalidate → 下次 getOrBuild 重扫', async () => {
let calls = 0;
const scan = async (dir) => { calls += 1; return { ok: true, dir, entries: ENTRIES_500 }; };
await cache.getOrBuild('/data', scan);
expect(calls).toBe(1);
cache.invalidate();
await cache.getOrBuild('/data', scan);
expect(calls).toBe(2);
});
it('dir 变化 → 重扫', async () => {
let calls = 0;
const scan = async (dir) => { calls += 1; return { ok: true, dir, entries: ENTRIES_500 }; };
await cache.getOrBuild('/data', scan);
await cache.getOrBuild('/other', scan);
expect(calls).toBe(2);
});
it('scanFn 返回 {ok:false} → 返回 {error},不缓存', async () => {
let calls = 0;
const scan = async () => {
calls += 1;
return { ok: false, error: 'DATA_DIR_NOT_FOUND', code: 'ENOENT' };
};
const r = await cache.getOrBuild('/missing', scan);
expect(r.error).toBeTruthy();
expect(r.error.ok).toBe(false);
expect(r.error.code).toBe('ENOENT');
expect(cache.peek()).toBeNull();
// 下次再 getOrBuild 同 dir 仍会重扫(没缓存可命中)
await cache.getOrBuild('/missing', scan);
expect(calls).toBe(2);
});
});
describe('file-list-cache: files 字段派生', () => {
it('files 只含非文件夹条目(state.files 兼容)', async () => {
const mixed = [
{ name: 'a.md', path: '/a', entryType: 'editable', isFolder: false, size: 1, mtimeMs: 1 },
{ name: 'sub', path: '/sub', entryType: 'folder', isFolder: true },
{ name: 'b.png', path: '/b', entryType: 'binary', isFolder: false, size: 2, mtimeMs: 2 },
];
const scan = async (dir) => ({ ok: true, dir, entries: mixed });
const r = await cache.getOrBuild('/data', scan);
expect(r.entries).toHaveLength(3);
expect(r.files).toHaveLength(2); // a.md + b.png
expect(r.files.every((f) => !f.isFolder)).toBe(true);
});
it('files 字段投影 shape 与 renderer state.files 一致', async () => {
const scan = scanFnMock();
const r = await cache.getOrBuild('/data', scan);
const sample = r.files[0];
expect(sample).toHaveProperty('name');
expect(sample).toHaveProperty('path');
expect(sample).toHaveProperty('size');
expect(sample).toHaveProperty('mtimeMs');
expect(sample).toHaveProperty('entryType');
// isFolder 故意不进 files(filter 已经移除)
expect(sample).not.toHaveProperty('isFolder');
});
});
describe('file-list-cache: peek / _setForTest', () => {
it('peek 拿当前缓存(不改)', async () => {
const scan = scanFnMock();
await cache.getOrBuild('/data', scan);
const snap = cache.peek();
expect(snap.dir).toBe('/data');
expect(snap.total).toBe(500);
// 二次 peek 同一引用
expect(cache.peek()).toBe(snap);
});
it('_setForTest 直接设缓存,getOrBuild 同 dir 命中', async () => {
cache._setForTest({ dir: '/preset', entries: [], files: [], total: 0, at: 0 });
let called = false;
const scan = async () => { called = true; return { ok: true, dir: '/preset', entries: [] }; };
const r = await cache.getOrBuild('/preset', scan);
expect(called).toBe(false);
expect(r.dir).toBe('/preset');
expect(r.total).toBe(0);
});
});
describe('file-list-cache: 分页一致性', () => {
// 模拟 file:list handler 的使用方式:
// 1) 首次 getOrBuild → 拿 entries
// 2) slice(offset, offset+limit) → 返回第一页
// 3) loadMore → 再次 slice(off+limit) → 不重扫(命中缓存)
it('分页多次 slice 共享一次 scan(10000 文件 50 页)', async () => {
const big = Array.from({ length: 10000 }, (_, i) => ({
name: `n-${String(i).padStart(5, '0')}.md`,
path: `/data/n-${i}.md`,
entryType: 'editable',
isFolder: false,
size: 100,
mtimeMs: 1000 + i,
}));
let calls = 0;
const scan = async (dir) => { calls += 1; return { ok: true, dir, entries: big }; };
// 首屏
const r = await cache.getOrBuild('/data', scan);
expect(r.total).toBe(10000);
const limit = 200;
const seen = [];
for (let offset = 0; offset < r.total; offset += limit) {
// 每次分页只 slice 缓存,不重扫
const page = r.entries.slice(offset, offset + limit);
seen.push(...page);
}
expect(calls).toBe(1); // 一次 scan,50 次 slice
expect(seen).toHaveLength(10000);
// 顺序保持(slice 不会重排)
expect(seen[0].name).toBe('n-00000.md');
expect(seen[9999].name).toBe('n-09999.md');
});
});