sotashimozono_obsidian-remo.../plugin/tests/FakeFileExplorer.test.ts

241 lines
8.9 KiB
TypeScript
Raw Permalink Normal View History

test(plugin): Phase C.M7 — FakeFileExplorer test double for UI-reflect (0.4.45) Pure-helper module; production code: untouched. Lays the foundation for M8 (`assertSyncReflect`) and M9 (E2E matrix) — the slice of Phase C that brings UI-reflect (the T5 wedge of the sync-latency sequence) under automated assertion. What ships: - `plugin/tests/helpers/FakeFileExplorer.ts` (new) A test double that listens to the same `vault.trigger(...)` event stream Obsidian's File Explorer + MetadataCache + plugins subscribe to, and maintains a small synthetic "what does this vault look like after the events that have fired so far" model. M9 will use it as the **T5 observation point**: once `awaitReflect(path, event)` resolves, an Obsidian-faithful downstream consumer would also have observed the change. Surface: - `attach(vault: VaultLike) → dispose`: subscribes to all four vault events (create/modify/delete/rename) via `vault.on(...)`, returns a disposer that calls `vault.offref(...)` for each registration. `VaultLike` is a structural type (`Pick<Vault,'on'|'offref'>`) so per-test stubs only have to expose those two methods — no real `obsidian.Vault` runtime required. - `observe(event, ...args)`: direct event injection for tests that don't even want a Vault stub. Mirrors the `vault.trigger(event, ...args)` arg shape so a FakeVault's `trigger` can forward straight here. - `snapshot() → { paths[], mtimes }`: deterministic sorted-paths view of the current model. Folders are present in `paths` but absent from `mtimes` (mirroring the real vault, where TFolder has no `stat`). - `awaitReflect(path, event, timeoutMs?)`: resolves with `{ atMs }` (the `performance.now()` from when the underlying event fired — that's what M9 will feed into PerfAggregator's S.paint bucket; late-arriving awaits don't inflate latency). History-walks first so an event observed BEFORE `awaitReflect` was called still resolves it (eliminates a class of listener-race flakes); times out with a descriptive error including the recent history tail. - `reset()`: drop state and reject pending awaits — for `afterEach`-style hygiene. Folder semantics intentionally mirror VaultModelBuilder: - delete on a folder also drops descendants from the snapshot (vault.trigger fires once for the folder itself; descendants are silently orphaned in the production fileMap and we don't want the FE to lie about what File Explorer would show). - rename on a folder rewrites every descendant path under the new prefix. history is bounded (cap 1024 entries) so a long-running suite doesn't grow it unbounded; the cap matters in M9 where each test may observe dozens of events. - `plugin/tests/FakeFileExplorer.test.ts` (new, 20 tests): observe / snapshot: - empty start - create adds path + records mtime - create on a folder records path with no mtime - modify updates mtime - delete removes path + mtime - folder delete removes descendants (depth ≥ 2) - rename moves path, preserves mtime when no new stat - rename uses the new file's stat when present - folder rename rewrites descendants awaitReflect: - resolves from history when event already fired - second await for same event doesn't double-consume - resolves a pending waiter when event arrives later - waiter matches its event even when others fire first - times out with descriptive error - timeout leaves no leaked waiter (later observe doesn't crash) attach(): - subscribes to all four events (verified via FakeVault's listenerCount) - disposer detaches all four - post-dispose, vault events no longer reflected reset(): - drops state - rejects pending waiters Inline `FakeVault` helper (~30 lines) covers `on`/`offref`/ `trigger`/`listenerCount`. Not extracted — it's the only consumer in the unit suite; M9 will likely pull a similar shape into its own integration helper. Verification: - `npx vitest run` — 35 files / 474 tests green (was 454, +20 new) - `npx tsc --noEmit -p tsconfig.json` — clean manifest/package/versions bumped 0.4.44 → 0.4.45. Roadmap context: M7 is the prerequisite for M8 (assertSyncReflect) and M9 (E2E matrix). After M8 lands, the assertion vocabulary is "writer mutation → reader notification → FakeFileExplorer reflects path/event"; M9 fills out the case matrix (create / modify / delete / rename / large / binary / nested / conflict / disconnect-reconnect). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:44:26 +00:00
import { describe, it, expect } from 'vitest';
import type { EventRef, TAbstractFile, TFile } from 'obsidian';
import { FakeFileExplorer, type VaultLike } from './helpers/FakeFileExplorer';
// ── tiny FakeVault: enough surface for FakeFileExplorer.attach ──────────
interface Ref { name: string; cb: (...args: unknown[]) => void }
class FakeVault implements VaultLike {
private readonly listeners = new Map<string, Set<(...args: unknown[]) => void>>();
private readonly refs = new Map<symbol, Ref>();
on(name: string, cb: (...args: unknown[]) => unknown): EventRef {
const set = this.listeners.get(name) ?? new Set();
set.add(cb as (...args: unknown[]) => void);
this.listeners.set(name, set);
const sym = Symbol(name);
this.refs.set(sym, { name, cb: cb as (...args: unknown[]) => void });
return sym as unknown as EventRef;
}
offref(ref: EventRef): void {
const sym = ref as unknown as symbol;
const r = this.refs.get(sym);
if (!r) return;
this.listeners.get(r.name)?.delete(r.cb);
this.refs.delete(sym);
}
trigger(name: string, ...args: unknown[]): void {
const set = this.listeners.get(name);
if (!set) return;
for (const cb of [...set]) cb(...args);
}
listenerCount(name: string): number {
return this.listeners.get(name)?.size ?? 0;
}
}
function fakeFile(path: string, mtime = 0): TFile {
// Cast through unknown — we only need the structural fields the
// FakeFileExplorer reads (`path`, `stat?.mtime`).
return {
path,
name: basename(path),
stat: { ctime: 0, mtime, size: 0 },
} as unknown as TFile;
}
function fakeFolder(path: string): TAbstractFile {
return {
path,
name: basename(path),
} as unknown as TAbstractFile;
}
function basename(p: string): string {
const i = p.lastIndexOf('/');
return i < 0 ? p : p.slice(i + 1);
}
// ── observe(): direct event injection ───────────────────────────────────
describe('FakeFileExplorer — observe / snapshot', () => {
it('starts empty', () => {
const fe = new FakeFileExplorer();
expect(fe.snapshot()).toEqual({ paths: [], mtimes: {} });
});
it('create adds path and records mtime when present', () => {
const fe = new FakeFileExplorer();
fe.observe('create', fakeFile('a.md', 100));
expect(fe.snapshot()).toEqual({ paths: ['a.md'], mtimes: { 'a.md': 100 } });
});
it('create on a folder records the path with no mtime', () => {
const fe = new FakeFileExplorer();
fe.observe('create', fakeFolder('docs'));
expect(fe.snapshot()).toEqual({ paths: ['docs'], mtimes: {} });
});
it('modify updates the mtime', () => {
const fe = new FakeFileExplorer();
fe.observe('create', fakeFile('a.md', 100));
fe.observe('modify', fakeFile('a.md', 200));
expect(fe.snapshot().mtimes['a.md']).toBe(200);
});
it('delete removes the path and any mtime', () => {
const fe = new FakeFileExplorer();
fe.observe('create', fakeFile('a.md', 100));
fe.observe('delete', fakeFile('a.md'));
expect(fe.snapshot()).toEqual({ paths: [], mtimes: {} });
});
it('folder delete also removes descendants', () => {
const fe = new FakeFileExplorer();
fe.observe('create', fakeFolder('docs'));
fe.observe('create', fakeFile('docs/a.md', 100));
fe.observe('create', fakeFile('docs/sub/b.md', 200));
fe.observe('create', fakeFile('outside.md', 300));
fe.observe('delete', fakeFolder('docs'));
expect(fe.snapshot()).toEqual({ paths: ['outside.md'], mtimes: { 'outside.md': 300 } });
});
it('rename moves the path and preserves mtime when no new stat is available', () => {
const fe = new FakeFileExplorer();
fe.observe('create', fakeFile('old.md', 100));
fe.observe('rename', fakeFolder('new.md'), 'old.md'); // no stat on the rename arg
expect(fe.snapshot()).toEqual({ paths: ['new.md'], mtimes: { 'new.md': 100 } });
});
it('rename uses the new file\'s stat when present', () => {
const fe = new FakeFileExplorer();
fe.observe('create', fakeFile('old.md', 100));
fe.observe('rename', fakeFile('new.md', 999), 'old.md');
expect(fe.snapshot()).toEqual({ paths: ['new.md'], mtimes: { 'new.md': 999 } });
});
it('folder rename rewrites descendant paths', () => {
const fe = new FakeFileExplorer();
fe.observe('create', fakeFolder('docs'));
fe.observe('create', fakeFile('docs/a.md', 100));
fe.observe('create', fakeFile('docs/sub/b.md', 200));
fe.observe('rename', fakeFolder('archive'), 'docs');
expect(fe.snapshot()).toEqual({
paths: ['archive', 'archive/a.md', 'archive/sub/b.md'],
mtimes: { 'archive/a.md': 100, 'archive/sub/b.md': 200 },
});
});
});
// ── awaitReflect: history walk + late waiter + timeout ──────────────────
describe('FakeFileExplorer — awaitReflect', () => {
it('resolves from history when the event already fired', async () => {
const fe = new FakeFileExplorer();
fe.observe('create', fakeFile('a.md', 100));
const info = await fe.awaitReflect('a.md', 'create');
expect(typeof info.atMs).toBe('number');
expect(info.atMs).toBeGreaterThan(0);
});
it('a later awaitReflect for the same event does not double-consume', async () => {
const fe = new FakeFileExplorer();
fe.observe('create', fakeFile('a.md', 100));
await fe.awaitReflect('a.md', 'create'); // consumes the history entry
await expect(fe.awaitReflect('a.md', 'create', 50)).rejects.toThrow(/no create.*within 50ms/);
});
it('resolves a pending waiter when the event arrives later', async () => {
const fe = new FakeFileExplorer();
const p = fe.awaitReflect('a.md', 'create');
queueMicrotask(() => fe.observe('create', fakeFile('a.md', 100)));
const info = await p;
expect(info.atMs).toBeGreaterThan(0);
});
it('the right event matches its pending waiter even when others fire', async () => {
const fe = new FakeFileExplorer();
const p = fe.awaitReflect('a.md', 'modify');
fe.observe('create', fakeFile('a.md', 100)); // wrong event for waiter
fe.observe('create', fakeFile('b.md', 100)); // different path
fe.observe('modify', fakeFile('a.md', 200)); // ← match
const info = await p;
expect(info.atMs).toBeGreaterThan(0);
});
it('times out with a descriptive error when no event arrives', async () => {
const fe = new FakeFileExplorer();
await expect(fe.awaitReflect('a.md', 'create', 30))
.rejects.toThrow(/no create.*"a\.md".*within 30ms/);
});
it('timeout leaves no leaked waiter (a later observe does not crash)', async () => {
const fe = new FakeFileExplorer();
await fe.awaitReflect('a.md', 'create', 20).catch(() => {});
expect(() => fe.observe('create', fakeFile('a.md', 100))).not.toThrow();
});
});
// ── attach(vault) round-trip ────────────────────────────────────────────
describe('FakeFileExplorer — attach()', () => {
it('subscribes to all four events and tracks them', () => {
const fe = new FakeFileExplorer();
const v = new FakeVault();
fe.attach(v);
expect(v.listenerCount('create')).toBe(1);
expect(v.listenerCount('modify')).toBe(1);
expect(v.listenerCount('delete')).toBe(1);
expect(v.listenerCount('rename')).toBe(1);
v.trigger('create', fakeFile('a.md', 100));
v.trigger('modify', fakeFile('a.md', 200));
expect(fe.snapshot().mtimes['a.md']).toBe(200);
});
it('disposer detaches all four listeners', () => {
const fe = new FakeFileExplorer();
const v = new FakeVault();
const dispose = fe.attach(v);
dispose();
expect(v.listenerCount('create')).toBe(0);
expect(v.listenerCount('modify')).toBe(0);
expect(v.listenerCount('delete')).toBe(0);
expect(v.listenerCount('rename')).toBe(0);
});
it('after dispose, vault events are no longer reflected', () => {
const fe = new FakeFileExplorer();
const v = new FakeVault();
const dispose = fe.attach(v);
v.trigger('create', fakeFile('a.md', 100));
dispose();
v.trigger('create', fakeFile('b.md', 200));
expect(fe.snapshot()).toEqual({ paths: ['a.md'], mtimes: { 'a.md': 100 } });
});
});
// ── reset() ──────────────────────────────────────────────────────────────
describe('FakeFileExplorer — reset', () => {
it('drops all state', () => {
const fe = new FakeFileExplorer();
fe.observe('create', fakeFile('a.md', 100));
fe.reset();
expect(fe.snapshot()).toEqual({ paths: [], mtimes: {} });
});
it('rejects any pending waiter', async () => {
const fe = new FakeFileExplorer();
const p = fe.awaitReflect('a.md', 'create');
fe.reset();
await expect(p).rejects.toThrow(/reset\(\) while awaiting/);
});
});