Files
Notes/tests/unit/file-list-cache.test.js
2026-10-16 16:34:15 +08:00

173 lines
6.1 KiB
JavaScript
Raw Permalink 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.
// 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');
});
});