// Cache des listes de fichiers modifiés : invalidation par jeton, plafond de chargements simultanés, // et erreur qui reste visible au lieu d'être avalée (le défaut de l'ancien panneau Git). import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createPinia, setActivePinia } from 'pinia'; import type { FileChange, WorktreeChangesResponse } from '@arboretum/shared'; interface Deferred { resolve: (res: WorktreeChangesResponse) => void; reject: (err: Error) => void; } let calls: string[] = []; let inFlight = 0; let maxInFlight = 0; let deferreds: Deferred[] = []; /** Quand true, chaque appel reste en vol jusqu'à résolution explicite (test du plafond). */ let manual = false; let failNext = false; vi.mock('../src/lib/git-api', () => ({ gitApi: { changes: (repoId: string, wt: string): Promise => { calls.push(`${repoId}\0${wt}`); inFlight++; maxInFlight = Math.max(maxInFlight, inFlight); const settle = (p: Promise): Promise => p.finally(() => { inFlight--; }); if (manual) { return settle( new Promise((resolve, reject) => { deferreds.push({ resolve, reject }); }), ); } if (failNext) { failNext = false; return settle(Promise.reject(new Error('boom'))); } return settle(Promise.resolve({ repoId, path: wt, changes: [], truncated: false })); }, }, })); const { useChangesStore, MAX_CONCURRENT_LOADS } = await import('../src/stores/changes'); const { useWorktreesStore } = await import('../src/stores/worktrees'); function fakeStorage() { const m = new Map(); return { getItem: (k: string) => (m.has(k) ? (m.get(k) as string) : null), setItem: (k: string, v: string) => void m.set(k, v), removeItem: (k: string) => void m.delete(k), clear: () => m.clear(), }; } /** Laisse la file se dépiler (les chargements sont enchaînés par des microtâches). */ const settle = async (): Promise => { for (let i = 0; i < 20; i++) await Promise.resolve(); }; describe('store changes', () => { beforeEach(() => { calls = []; deferreds = []; inFlight = 0; maxInFlight = 0; manual = false; failNext = false; (globalThis as unknown as { localStorage: unknown }).localStorage = fakeStorage(); setActivePinia(createPinia()); }); it('ne recharge pas une entrée fraîche, recharge dès que le jeton bouge', async () => { const changes = useChangesStore(); const worktrees = useWorktreesStore(); changes.request('r1', '/wt/a'); await settle(); expect(calls).toHaveLength(1); changes.request('r1', '/wt/a'); // rien n'a bougé await settle(); expect(calls).toHaveLength(1); worktrees.noteChange('r1', '/wt/a'); // le disque a bougé changes.request('r1', '/wt/a'); await settle(); expect(calls).toHaveLength(2); }); it('invalidate force le rechargement sans jeton', async () => { const changes = useChangesStore(); changes.request('r1', '/wt/a'); await settle(); changes.invalidate('r1', '/wt/a'); await settle(); expect(calls).toHaveLength(2); }); it('une erreur reste visible et la liste précédente est conservée', async () => { const changes = useChangesStore(); const seeded: FileChange[] = [ { path: 'src/x.ts', indexStatus: '.', worktreeStatus: 'M', staged: false, unstaged: true, untracked: false, conflicted: false, insertions: 1, deletions: 0, binary: false, }, ]; // premier chargement réussi, puis échec changes.request('r1', '/wt/a'); await settle(); changes.entries['r1\0/wt/a'] = { ...changes.entries['r1\0/wt/a']!, changes: seeded }; failNext = true; changes.invalidate('r1', '/wt/a'); await settle(); const entry = changes.entry('r1', '/wt/a'); expect(entry?.error).toBe('boom'); expect(entry?.changes).toEqual(seeded); // pas d'écran qui se vide expect(entry?.loading).toBe(false); }); it('ne dépasse jamais le plafond de chargements simultanés', async () => { manual = true; const changes = useChangesStore(); for (let i = 0; i < 10; i++) changes.request('r1', `/wt/${i}`); await settle(); expect(maxInFlight).toBe(MAX_CONCURRENT_LOADS); expect(calls).toHaveLength(MAX_CONCURRENT_LOADS); // on libère tout : la file se dépile jusqu'au bout while (deferreds.length > 0) { const d = deferreds.shift(); d?.resolve({ repoId: 'r1', path: '/wt/x', changes: [], truncated: false }); await settle(); } expect(calls).toHaveLength(10); expect(maxInFlight).toBe(MAX_CONCURRENT_LOADS); }); it('forget libère, invalidateAll repérime, reset vide', async () => { const changes = useChangesStore(); changes.request('r1', '/wt/a'); changes.request('r1', '/wt/b'); await settle(); expect(Object.keys(changes.entries)).toHaveLength(2); changes.forget('r1', '/wt/a'); expect(changes.entry('r1', '/wt/a')).toBeNull(); expect(changes.isStale('r1', '/wt/b')).toBe(false); changes.invalidateAll(); expect(changes.isStale('r1', '/wt/b')).toBe(true); changes.reset(); expect(Object.keys(changes.entries)).toHaveLength(0); }); });