// Store IDE : onglets (ouverture/dedup/fermeture), dock terminaux, activité, réconciliation. import { describe, it, expect, beforeEach } from 'vitest'; import { createPinia, setActivePinia } from 'pinia'; import { MOBILE_PANELS, parseWtKey, useIdeStore, tabId, wtKey } from '../src/stores/ide'; 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(), }; } describe('store IDE', () => { beforeEach(() => { (globalThis as unknown as { localStorage: unknown }).localStorage = fakeStorage(); setActivePinia(createPinia()); }); it('openFile ouvre un onglet, fixe actif + contexte', () => { const ide = useIdeStore(); ide.openFile('r1', '/wt/a', 'src/x.ts'); expect(ide.editorTabs).toHaveLength(1); expect(ide.activeTabId).toBe(tabId('r1', '/wt/a', 'src/x.ts')); expect(ide.activeContext).toEqual({ repoId: 'r1', wtPath: '/wt/a' }); expect(ide.activeTab?.file).toBe('src/x.ts'); }); it('openFile est idempotent (pas de doublon), refocalise l onglet existant', () => { const ide = useIdeStore(); ide.openFile('r1', '/wt/a', 'x.ts'); ide.openFile('r1', '/wt/b', 'y.ts'); ide.openFile('r1', '/wt/a', 'x.ts'); // ré-ouverture expect(ide.editorTabs).toHaveLength(2); expect(ide.activeTabId).toBe(tabId('r1', '/wt/a', 'x.ts')); }); it('closeTab recalcule l onglet actif sur le voisin', () => { const ide = useIdeStore(); ide.openFile('r', '/w', 'a'); ide.openFile('r', '/w', 'b'); ide.openFile('r', '/w', 'c'); // actif = c ide.setActiveTab(tabId('r', '/w', 'b')); // actif = b (index 1) ide.closeTab(tabId('r', '/w', 'b')); expect(ide.editorTabs).toHaveLength(2); // le voisin qui a glissé à l index 1 est c expect(ide.activeTabId).toBe(tabId('r', '/w', 'c')); }); it('closeTab sur le dernier onglet vide l actif', () => { const ide = useIdeStore(); ide.openFile('r', '/w', 'a'); ide.closeTab(tabId('r', '/w', 'a')); expect(ide.editorTabs).toHaveLength(0); expect(ide.activeTabId).toBeNull(); }); it('closeTabsForWorktree retire tous les onglets d un worktree', () => { const ide = useIdeStore(); ide.openFile('r', '/w1', 'a'); ide.openFile('r', '/w1', 'b'); ide.openFile('r', '/w2', 'c'); ide.closeTabsForWorktree('r', '/w1'); expect(ide.editorTabs.map((t) => t.file)).toEqual(['c']); }); it('moveTab réordonne', () => { const ide = useIdeStore(); ide.openFile('r', '/w', 'a'); ide.openFile('r', '/w', 'b'); ide.openFile('r', '/w', 'c'); ide.moveTab(0, 2); // a -> fin expect(ide.editorTabs.map((t) => t.file)).toEqual(['b', 'c', 'a']); }); it('setTabDirty alimente hasUnsaved / isDirty', () => { const ide = useIdeStore(); ide.openFile('r', '/w', 'a'); const id = tabId('r', '/w', 'a'); expect(ide.hasUnsaved).toBe(false); ide.setTabDirty(id, true); expect(ide.isDirty(id)).toBe(true); expect(ide.hasUnsaved).toBe(true); ide.setTabDirty(id, false); expect(ide.hasUnsaved).toBe(false); }); it('dock : openTerminal ajoute + focus + ouvre le dock ; sans doublon', () => { const ide = useIdeStore(); ide.openTerminal('s1'); ide.openTerminal('s2'); ide.openTerminal('s1'); // déjà présent expect(ide.dockSessionIds).toEqual(['s1', 's2']); expect(ide.activeDockSessionId).toBe('s1'); expect(ide.bottomVisible).toBe(true); }); it('dock : closeTerminal recalcule l actif et referme si vide', () => { const ide = useIdeStore(); ide.openTerminal('s1'); ide.openTerminal('s2'); ide.focusTerminal('s2'); ide.closeTerminal('s2'); expect(ide.dockSessionIds).toEqual(['s1']); expect(ide.activeDockSessionId).toBe('s1'); ide.closeTerminal('s1'); expect(ide.dockSessionIds).toEqual([]); expect(ide.activeDockSessionId).toBeNull(); expect(ide.bottomVisible).toBe(false); }); // --- dock en colonnes (plusieurs terminaux visibles côte à côte) --- it('dock : openTerminal { split } crée une seconde colonne, les deux panes restent visibles', () => { const ide = useIdeStore(); ide.openTerminal('s1'); ide.openTerminal('s2', { split: true }); expect(ide.dockColumns).toHaveLength(2); expect(ide.dockSessionIds).toEqual(['s1', 's2']); expect(ide.visibleDockSessionIds).toEqual(['s1', 's2']); expect(ide.activeDockSessionId).toBe('s2'); expect(ide.canSplitTerminal).toBe(true); }); it('dock : splitTerminal détache le pane focalisé, mergeDockColumns ramène tout en une colonne', () => { const ide = useIdeStore(); ide.openTerminal('s1'); ide.openTerminal('s2'); ide.splitTerminal(); // s2 est focalisé expect(ide.dockColumns.map((c) => c.sessionIds)).toEqual([['s1'], ['s2']]); ide.mergeDockColumns(); expect(ide.dockColumns.map((c) => c.sessionIds)).toEqual([['s1', 's2']]); expect(ide.activeDockSessionId).toBe('s2'); }); it('dock : focusDockColumn change la colonne active, donc le terminal focalisé', () => { const ide = useIdeStore(); ide.openTerminal('s1'); ide.openTerminal('s2', { split: true }); ide.focusDockColumn(ide.dockColumns[0]!.id); expect(ide.activeDockSessionId).toBe('s1'); }); it('dock : une session n’est jamais dans deux colonnes (2e attache = PTY figé)', () => { const ide = useIdeStore(); ide.openTerminal('s1'); ide.openTerminal('s2'); ide.openTerminal('s2', { split: true }); // déjà ouvert : déplacé, pas dupliqué expect(ide.dockSessionIds.filter((s) => s === 's2')).toHaveLength(1); expect(ide.dockColumns.map((c) => c.sessionIds)).toEqual([['s1'], ['s2']]); }); it('dock : fermer le dernier pane d’une colonne supprime la colonne, pas le dock', () => { const ide = useIdeStore(); ide.openTerminal('s1'); ide.openTerminal('s2', { split: true }); ide.closeTerminal('s2'); expect(ide.dockColumns).toHaveLength(1); expect(ide.bottomVisible).toBe(true); ide.closeTerminal('s1'); expect(ide.dockColumns).toEqual([]); expect(ide.bottomVisible).toBe(false); }); it('dock : setDockColumnSplit redistribue la paire saisie', () => { const ide = useIdeStore(); ide.openTerminal('s1'); ide.openTerminal('s2', { split: true }); ide.setDockColumnSplit(0, 0.7); expect(ide.dockColumns[0]!.ratio).toBeCloseTo(0.7); expect(ide.dockColumns[1]!.ratio).toBeCloseTo(0.3); }); it('dock : migration silencieuse de l’ancien état mono-colonne, et oubli des clés legacy', () => { localStorage.setItem('arb.ide.dock', JSON.stringify(['a', 'b'])); localStorage.setItem('arb.ide.activeDock', JSON.stringify('b')); setActivePinia(createPinia()); const ide = useIdeStore(); expect(ide.dockColumns).toHaveLength(1); expect(ide.dockSessionIds).toEqual(['a', 'b']); expect(ide.activeDockSessionId).toBe('b'); expect(localStorage.getItem('arb.ide.dock')).toBeNull(); expect(localStorage.getItem('arb.ide.activeDock')).toBeNull(); }); it('toggleActivity : re-clic sur la vue active replie le panneau', () => { const ide = useIdeStore(); expect(ide.activeActivity).toBe('explorer'); expect(ide.leftVisible).toBe(true); ide.toggleActivity('explorer'); // même vue, ouverte -> repli expect(ide.leftVisible).toBe(false); ide.toggleActivity('git'); // autre vue -> ouvre expect(ide.activeActivity).toBe('git'); expect(ide.leftVisible).toBe(true); }); it('pruneDeadResources retire onglets/terminaux morts ; dirty gardé pour les conservés, nettoyé pour les retirés', () => { const ide = useIdeStore(); ide.openFile('r', '/w1', 'a'); // worktree connu -> gardé ide.openFile('r', '/w2', 'b'); // worktree inconnu -> retiré ide.setTabDirty(tabId('r', '/w1', 'a'), true); ide.setTabDirty(tabId('r', '/w2', 'b'), true); ide.openTerminal('sLive'); ide.openTerminal('sDead'); ide.openTerminal('sDeadAlone', { split: true }); // colonne entière à supprimer ide.pruneDeadResources(new Set(['sLive']), new Set([wtKey('r', '/w1')])); expect(ide.dockColumns).toHaveLength(1); expect(ide.dockColumns[0]!.ratio).toBeCloseTo(1); expect(ide.editorTabs.map((t) => t.wtPath)).toEqual(['/w1']); expect(ide.dockSessionIds).toEqual(['sLive']); // sDead retiré expect(ide.isDirty(tabId('r', '/w1', 'a'))).toBe(true); // onglet gardé : dirty conservé expect(ide.isDirty(tabId('r', '/w2', 'b'))).toBe(false); // onglet retiré : dirty nettoyé expect(ide.hasUnsaved).toBe(true); // il reste l'onglet /w1 modifié }); // --- zone centrale : mode fichiers / changements, et cohérence du layout mobile --- it('setCenterMode amène le bon panneau au premier plan sur mobile', () => { const ide = useIdeStore(); ide.setCenterMode('changes'); expect(ide.centerMode).toBe('changes'); expect(ide.mobilePanel).toBe('changes'); ide.setCenterMode('files'); expect(ide.mobilePanel).toBe('editor'); }); it('ouvrir un fichier ou un onglet ramène la zone centrale aux fichiers', () => { const ide = useIdeStore(); ide.setCenterMode('changes'); ide.openFile('r1', '/wt/a', 'src/x.ts'); expect(ide.centerMode).toBe('files'); expect(ide.mobilePanel).toBe('editor'); ide.setCenterMode('changes'); ide.setActiveTab(tabId('r1', '/wt/a', 'src/x.ts')); expect(ide.centerMode).toBe('files'); }); it('openChanges rend le worktree actif, déplie son bloc et bascule la vue', () => { const ide = useIdeStore(); ide.openChanges('r1', '/wt/a'); expect(ide.activeContext).toEqual({ repoId: 'r1', wtPath: '/wt/a' }); expect(ide.changesExpanded).toEqual([wtKey('r1', '/wt/a')]); expect(ide.centerMode).toBe('changes'); ide.openChanges('r1', '/wt/a'); // idempotent expect(ide.changesExpanded).toHaveLength(1); }); it('toggleChangesWt bascule et réassigne le tableau (persistedRef non profond)', () => { const ide = useIdeStore(); const before = ide.changesExpanded; ide.toggleChangesWt('k1'); expect(ide.changesExpanded).toEqual(['k1']); expect(ide.changesExpanded).not.toBe(before); // nouvelle référence, donc persistée ide.toggleChangesWt('k1'); expect(ide.changesExpanded).toEqual([]); }); it('setActivity pose un panneau mobile que le layout sait rendre', () => { const ide = useIdeStore(); for (const view of ['explorer', 'sessions', 'groups'] as const) { ide.setActivity(view); expect(ide.mobilePanel).toBe(view); expect(MOBILE_PANELS).toContain(ide.mobilePanel); } // 'git' est l'index de la sidebar desktop : sur mobile, c'est la vue Changements qui le remplace. ide.setActivity('git'); expect(ide.mobilePanel).toBe('changes'); expect(ide.centerMode).toBe('changes'); expect(MOBILE_PANELS).toContain(ide.mobilePanel); }); it('un mobilePanel persisté obsolète est ramené à une valeur gérée', () => { localStorage.setItem('arb.ide.mobilePanel', JSON.stringify('sidebar')); // persistedRef stocke du JSON setActivePinia(createPinia()); expect(useIdeStore().mobilePanel).toBe('explorer'); localStorage.setItem('arb.ide.mobilePanel', JSON.stringify('git')); setActivePinia(createPinia()); expect(useIdeStore().mobilePanel).toBe('changes'); }); it('parseWtKey est l’inverse de wtKey', () => { expect(parseWtKey(wtKey('r1', '/wt/mon projet'))).toEqual({ repoId: 'r1', wtPath: '/wt/mon projet' }); expect(parseWtKey('sans-separateur')).toBeNull(); expect(parseWtKey('')).toBeNull(); }); });