// Règles d'affichage de la vue Changements et de l'index Git. Fonctions pures, testées sans DOM. import { describe, expect, it } from 'vitest'; import type { RepoSummary, WorktreeGitStatus, WorktreeSummary } from '@arboretum/shared'; import { dirtyFileCount, groupWorktreesByRepo, hasPendingWork, pendingWorktreeCount, sortForIndex, } from '../src/lib/changes-model'; const git = (over: Partial = {}): WorktreeGitStatus => ({ ahead: 0, behind: 0, dirtyCount: 0, upstream: 'origin/main', ...over, }); const wt = (path: string, over: Partial = {}): WorktreeSummary => ({ repoId: 'r1', path, branch: path.split('/').pop() ?? null, head: 'abc1234', detached: false, locked: false, prunable: false, isMain: false, git: git(), sessions: [], ...over, }); const repo = (id: string, label = id): RepoSummary => ({ id, path: `/repos/${id}`, label, hidden: false, valid: true }) as unknown as RepoSummary; describe('hasPendingWork', () => { it('un worktree propre et à jour n a rien à traiter', () => { expect(hasPendingWork(git())).toBe(false); }); it('détecte chaque forme de travail en attente', () => { expect(hasPendingWork(git({ dirtyCount: 2 }))).toBe(true); expect(hasPendingWork(git({ stagedCount: 1 }))).toBe(true); expect(hasPendingWork(git({ unstagedCount: 1 }))).toBe(true); expect(hasPendingWork(git({ conflictCount: 1 }))).toBe(true); expect(hasPendingWork(git({ ahead: 3 }))).toBe(true); // rien à committer mais à pousser }); it('être en retard ne demande rien de notre côté', () => { expect(hasPendingWork(git({ behind: 5 }))).toBe(false); }); it('les compteurs fins étant optionnels dans le protocole, dirtyCount reste le filet', () => { // Cas d'un daemon plus ancien : ni stagedCount ni unstagedCount, seulement dirtyCount. expect(hasPendingWork({ ahead: 0, behind: 0, dirtyCount: 4, upstream: null })).toBe(true); }); }); describe('dirtyFileCount', () => { it('prend le maximum entre dirtyCount et la somme des compteurs fins', () => { expect(dirtyFileCount(git({ dirtyCount: 3 }))).toBe(3); expect(dirtyFileCount(git({ dirtyCount: 3, stagedCount: 2, unstagedCount: 2 }))).toBe(4); }); }); describe('groupWorktreesByRepo', () => { const repos = [repo('r1', 'alpha'), repo('r2', 'beta')]; const byRepo: Record = { r1: [ wt('/wt/main', { isMain: true, git: git({ dirtyCount: 2, unstagedCount: 2 }) }), wt('/wt/feature'), ], r2: [wt('/wt/clean', { repoId: 'r2' })], }; const forRepo = (id: string): WorktreeSummary[] => byRepo[id] ?? []; it('ne garde que ce qui a du travail, et masque un dépôt sans worktree retenu', () => { const groups = groupWorktreesByRepo(repos, forRepo, { showClean: false, active: null }); expect(groups).toHaveLength(1); expect(groups[0]?.repo.id).toBe('r1'); expect(groups[0]?.worktrees.map((w) => w.path)).toEqual(['/wt/main']); expect(groups[0]?.dirtyFiles).toBe(2); }); it('showClean inclut tout', () => { const groups = groupWorktreesByRepo(repos, forRepo, { showClean: true, active: null }); expect(groups.map((g) => g.repo.id)).toEqual(['r1', 'r2']); expect(groups[0]?.worktrees).toHaveLength(2); }); it('le worktree actif reste affiché même propre (la vue ne se vide pas après un commit)', () => { const groups = groupWorktreesByRepo(repos, forRepo, { showClean: false, active: { repoId: 'r2', wtPath: '/wt/clean' }, }); expect(groups.map((g) => g.repo.id)).toEqual(['r1', 'r2']); expect(groups[1]?.worktrees.map((w) => w.path)).toEqual(['/wt/clean']); }); // Portée : le terminal focalisé (ou son groupe) restreint la vue. Le filtre est un ET, sinon un // worktree étranger fuirait dans une portée de groupe. it('inScope masque un worktree sale hors portée', () => { const groups = groupWorktreesByRepo(repos, forRepo, { showClean: false, active: null, inScope: (w) => w.repoId === 'r2', }); expect(groups).toHaveLength(0); }); it('inScope PRIME sur active et sur showClean', () => { const onlyR1 = (w: { repoId: string }): boolean => w.repoId === 'r1'; const withActive = groupWorktreesByRepo(repos, forRepo, { showClean: false, active: { repoId: 'r2', wtPath: '/wt/clean' }, inScope: onlyR1, }); expect(withActive.map((g) => g.repo.id)).toEqual(['r1']); const withClean = groupWorktreesByRepo(repos, forRepo, { showClean: true, active: null, inScope: onlyR1 }); expect(withClean.map((g) => g.repo.id)).toEqual(['r1']); }); it('inScope absent : comportement inchangé', () => { const sansPortee = groupWorktreesByRepo(repos, forRepo, { showClean: true, active: null }); const portéeTotale = groupWorktreesByRepo(repos, forRepo, { showClean: true, active: null, inScope: () => true }); expect(portéeTotale).toEqual(sansPortee); }); }); describe('pendingWorktreeCount · portée', () => { it('compte tout par défaut, et se restreint quand inScope est fourni', () => { const repos = [repo('r1', 'alpha'), repo('r2', 'beta')]; const byRepo: Record = { r1: [wt('/wt/main', { git: git({ dirtyCount: 1 }) })], r2: [wt('/wt/other', { repoId: 'r2', git: git({ ahead: 2 }) })], }; const forRepo = (id: string): WorktreeSummary[] => byRepo[id] ?? []; expect(pendingWorktreeCount(repos, forRepo)).toBe(2); expect(pendingWorktreeCount(repos, forRepo, (w) => w.repoId === 'r1')).toBe(1); }); }); describe('sortForIndex', () => { it('ce qui demande une action d abord, puis le principal, puis le chemin', () => { const list = [ wt('/b/clean'), wt('/a/main', { isMain: true }), wt('/c/dirty', { git: git({ dirtyCount: 1 }) }), ]; expect(sortForIndex(list).map((w) => w.path)).toEqual(['/c/dirty', '/a/main', '/b/clean']); }); it('ne mute pas la liste reçue', () => { const list = [wt('/b'), wt('/a')]; sortForIndex(list); expect(list.map((w) => w.path)).toEqual(['/b', '/a']); }); }); describe('pendingWorktreeCount', () => { it('compte les worktrees à traiter, tous dépôts visibles confondus', () => { const forRepo = (id: string): WorktreeSummary[] => id === 'r1' ? [wt('/a', { git: git({ dirtyCount: 1 }) }), wt('/b')] : [wt('/c', { repoId: 'r2', git: git({ ahead: 2 }) })]; expect(pendingWorktreeCount([repo('r1'), repo('r2')], forRepo)).toBe(2); }); });