import { describe, expect, it } from 'vitest'; import { addPane, activeColumnOf, closePane, DOCK_MIN_HEIGHT, dockMaxHeight, dockOpenHeight, EMPTY_DOCK, findPane, focusColumn, focusPane, MAX_DOCK_COLUMNS, MIN_DOCK_COLUMN_RATIO, mergeColumns, migrateDock, movePane, prunePanes, setSplit, type DockState, } from '../src/lib/dock-model'; /** Ids déterministes : les assertions portent sur la structure, pas sur une horloge. */ function ids(): () => string { let n = 0; return () => `col${++n}`; } /** * Les 5 invariants du modèle, vérifiés APRÈS chaque opération : sur une machine à états, c'est le * filet qui rattrape les cas qu'on n'a pas pensé à écrire. */ function expectDockInvariants(s: DockState): void { const seen = new Set(); for (const c of s.columns) { expect(c.sessionIds.length).toBeGreaterThan(0); expect(c.sessionIds).toContain(c.activeSessionId); for (const sid of c.sessionIds) { expect(seen.has(sid)).toBe(false); seen.add(sid); } expect(c.ratio).toBeGreaterThanOrEqual(Math.min(MIN_DOCK_COLUMN_RATIO, 1 / s.columns.length) - 1e-9); } if (s.columns.length === 0) { expect(s.activeColumnId).toBeNull(); } else { expect(s.columns.some((c) => c.id === s.activeColumnId)).toBe(true); expect(s.columns.reduce((sum, c) => sum + c.ratio, 0)).toBeCloseTo(1, 9); } } /** Dock à N colonnes d'un pane chacune, parts égales. */ function dock(...columns: string[][]): DockState { const cols = columns.map((sessionIds, i) => ({ id: `col${i + 1}`, sessionIds, activeSessionId: sessionIds[0]!, ratio: 1 / columns.length, })); return { columns: cols, activeColumnId: cols[0]?.id ?? null }; } describe('addPane', () => { it('premier pane : une colonne pleine largeur, active', () => { const s = addPane(EMPTY_DOCK, 's1', 'tab', ids()); expect(s.columns).toHaveLength(1); expect(s.columns[0]!.ratio).toBe(1); expect(s.activeColumnId).toBe('col1'); expectDockInvariants(s); }); it("'tab' ajoute un onglet à la colonne active et le rend visible", () => { const make = ids(); let s = addPane(EMPTY_DOCK, 's1', 'tab', make); s = addPane(s, 's2', 'tab', make); expect(s.columns).toHaveLength(1); expect(s.columns[0]!.sessionIds).toEqual(['s1', 's2']); expect(s.columns[0]!.activeSessionId).toBe('s2'); expectDockInvariants(s); }); it("'split' insère une colonne APRÈS l'active, qui lui cède la moitié de sa part", () => { const make = ids(); let s = addPane(EMPTY_DOCK, 's1', 'tab', make); s = addPane(s, 's2', 'split', make); expect(s.columns.map((c) => c.sessionIds)).toEqual([['s1'], ['s2']]); expect(s.columns[0]!.ratio).toBeCloseTo(0.5); expect(s.columns[1]!.ratio).toBeCloseTo(0.5); expect(s.activeColumnId).toBe('col2'); expectDockInvariants(s); }); it("'split' ne touche pas les colonnes voisines de l'active", () => { const make = ids(); let s = dock(['a'], ['b']); s = focusColumn(s, 'col2'); s = addPane(s, 'c', 'split', () => 'new'); expect(s.columns.map((c) => c.sessionIds)).toEqual([['a'], ['b'], ['c']]); expect(s.columns[0]!.ratio).toBeCloseTo(0.5); expect(s.columns[1]!.ratio).toBeCloseTo(0.25); expect(s.columns[2]!.ratio).toBeCloseTo(0.25); expectDockInvariants(s); expect(make).toBeTruthy(); }); it('une session déjà ouverte est déplacée, jamais dupliquée', () => { let s = dock(['a', 'b'], ['c']); s = addPane(s, 'b', 'split', () => 'new'); expect(findPane(s, 'b')!.column.sessionIds).toEqual(['b']); expect(s.columns.flatMap((c) => c.sessionIds).filter((id) => id === 'b')).toHaveLength(1); expectDockInvariants(s); }); it('au plafond de colonnes, un split retombe en onglet (et focalise)', () => { const make = ids(); let s = dock(['a'], ['b'], ['c']); expect(s.columns).toHaveLength(MAX_DOCK_COLUMNS); s = addPane(s, 'd', 'split', make); expect(s.columns).toHaveLength(MAX_DOCK_COLUMNS); expect(s.columns[0]!.sessionIds).toEqual(['a', 'd']); expect(s.columns[0]!.activeSessionId).toBe('d'); expectDockInvariants(s); }); }); describe('focusPane / focusColumn', () => { it('focaliser un onglet active AUSSI sa colonne', () => { const s = focusPane(dock(['a'], ['b', 'c']), 'c'); expect(s.activeColumnId).toBe('col2'); expect(s.columns[1]!.activeSessionId).toBe('c'); expectDockInvariants(s); }); it('session inconnue ou état déjà correct : identité', () => { const s = dock(['a'], ['b']); expect(focusPane(s, 'zzz')).toBe(s); expect(focusPane(s, 'a')).toBe(s); expect(focusColumn(s, 'col1')).toBe(s); expect(focusColumn(s, 'inconnue')).toBe(s); }); }); describe('closePane', () => { it('fermer un onglet parmi plusieurs : le voisin devient visible', () => { const s = closePane({ ...dock(['a', 'b', 'c']), columns: [{ id: 'col1', sessionIds: ['a', 'b', 'c'], activeSessionId: 'b', ratio: 1 }] }, 'b'); expect(s.columns[0]!.sessionIds).toEqual(['a', 'c']); expect(s.columns[0]!.activeSessionId).toBe('c'); expectDockInvariants(s); }); it('colonne vidée : supprimée, sa part léguée au voisin de gauche', () => { const s = closePane(dock(['a'], ['b']), 'b'); expect(s.columns).toHaveLength(1); expect(s.columns[0]!.sessionIds).toEqual(['a']); expect(s.columns[0]!.ratio).toBeCloseTo(1); expect(s.activeColumnId).toBe('col1'); expectDockInvariants(s); }); it('première colonne vidée : la part va au voisin de droite', () => { const s = closePane(dock(['a'], ['b']), 'a'); expect(s.columns).toHaveLength(1); expect(s.columns[0]!.sessionIds).toEqual(['b']); expect(s.columns[0]!.ratio).toBeCloseTo(1); expectDockInvariants(s); }); it('dernier pane du dock : dock vide', () => { const s = closePane(dock(['a']), 'a'); expect(s).toEqual(EMPTY_DOCK); expectDockInvariants(s); }); it('session inconnue : identité', () => { const s = dock(['a']); expect(closePane(s, 'zzz')).toBe(s); }); }); describe('movePane', () => { it('vers une autre colonne : la source vidée disparaît', () => { const s = movePane(dock(['a'], ['b']), 'a', { columnId: 'col2' }); expect(s.columns).toHaveLength(1); expect(s.columns[0]!.sessionIds).toEqual(['b', 'a']); expect(s.columns[0]!.activeSessionId).toBe('a'); expectDockInvariants(s); }); it('détacher un onglet crée une colonne à droite de la sienne', () => { const s = movePane(dock(['a', 'b']), 'b', { split: true }, () => 'new'); expect(s.columns.map((c) => c.sessionIds)).toEqual([['a'], ['b']]); expect(s.activeColumnId).toBe('new'); expectDockInvariants(s); }); it('détacher un pane déjà seul en colonne : identité de contenu, simple focus', () => { const s = dock(['a'], ['b']); const next = movePane(s, 'b', { split: true }, () => 'new'); expect(next.columns.map((c) => c.sessionIds)).toEqual([['a'], ['b']]); expect(next.activeColumnId).toBe('col2'); }); it('cible inconnue ou déjà la bonne colonne : identité', () => { const s = dock(['a', 'b']); expect(movePane(s, 'a', { columnId: 'col1' })).toBe(s); expect(movePane(s, 'a', { columnId: 'zzz' })).toBe(s); expect(movePane(s, 'zzz', { split: true })).toBe(s); }); }); describe('setSplit', () => { it('la somme de la paire est préservée, la 3e colonne intacte', () => { const s = setSplit(dock(['a'], ['b'], ['c']), 0, 0.5); expect(s.columns[0]!.ratio + s.columns[1]!.ratio).toBeCloseTo(2 / 3); expect(s.columns[0]!.ratio).toBeCloseTo(0.5); expect(s.columns[2]!.ratio).toBeCloseTo(1 / 3); expectDockInvariants(s); }); it('clampé par MIN_DOCK_COLUMN_RATIO des deux côtés', () => { const wide = setSplit(dock(['a'], ['b']), 0, 0.99); expect(wide.columns[1]!.ratio).toBeCloseTo(MIN_DOCK_COLUMN_RATIO); const narrow = setSplit(dock(['a'], ['b']), 0, 0.01); expect(narrow.columns[0]!.ratio).toBeCloseTo(MIN_DOCK_COLUMN_RATIO); expectDockInvariants(wide); expectDockInvariants(narrow); }); it('frontière inexistante : identité', () => { const s = dock(['a'], ['b']); expect(setSplit(s, 1, 0.5)).toBe(s); expect(setSplit(s, -1, 0.5)).toBe(s); }); }); describe('prunePanes', () => { it('retire les sessions mortes, supprime les colonnes vidées et renormalise', () => { const s = prunePanes(dock(['a', 'b'], ['c']), new Set(['a'])); expect(s.columns).toHaveLength(1); expect(s.columns[0]!.sessionIds).toEqual(['a']); expect(s.columns[0]!.ratio).toBeCloseTo(1); expectDockInvariants(s); }); it("répare l'onglet visible quand c'est lui qui est mort", () => { const base: DockState = { columns: [{ id: 'col1', sessionIds: ['a', 'b'], activeSessionId: 'b', ratio: 1 }], activeColumnId: 'col1' }; const s = prunePanes(base, new Set(['a'])); expect(s.columns[0]!.activeSessionId).toBe('a'); expectDockInvariants(s); }); it('tout est vivant : identité (aucune écriture inutile dans le localStorage)', () => { const s = dock(['a'], ['b']); expect(prunePanes(s, new Set(['a', 'b']))).toBe(s); }); it('plus rien de vivant : dock vide', () => { expect(prunePanes(dock(['a'], ['b']), new Set())).toEqual(EMPTY_DOCK); }); }); describe('mergeColumns', () => { it("conserve l'ordre des onglets et l'onglet focalisé", () => { const s = mergeColumns(focusPane(dock(['a', 'b'], ['c']), 'c')); expect(s.columns).toHaveLength(1); expect(s.columns[0]!.sessionIds).toEqual(['a', 'b', 'c']); expect(s.columns[0]!.activeSessionId).toBe('c'); expectDockInvariants(s); }); it('une seule colonne (ou aucune) : identité', () => { const one = dock(['a']); expect(mergeColumns(one)).toBe(one); expect(mergeColumns(EMPTY_DOCK)).toBe(EMPTY_DOCK); }); }); describe('migrateDock', () => { it('ancien dock à plat : une colonne, ordre et onglet actif conservés', () => { const s = migrateDock(['a', 'b'], 'b', () => 'col1'); expect(s).toEqual({ columns: [{ id: 'col1', sessionIds: ['a', 'b'], activeSessionId: 'b', ratio: 1 }], activeColumnId: 'col1' }); expectDockInvariants(s); }); it('actif périmé ou absent : premier onglet ; liste vide : dock vide', () => { expect(migrateDock(['a'], 'zzz', () => 'col1').columns[0]!.activeSessionId).toBe('a'); expect(migrateDock([], null)).toEqual(EMPTY_DOCK); }); }); describe('géométrie du dock', () => { it('plafond à 80 % du viewport, plancher garanti', () => { expect(dockMaxHeight(1080)).toBe(864); expect(dockMaxHeight(1000)).toBe(800); expect(dockMaxHeight(600)).toBe(400); // 600-200 borne avant les 80 % expect(dockMaxHeight(150)).toBe(DOCK_MIN_HEIGHT); }); it("hauteur d'ouverture : ~45 %, jamais au-delà du plafond ni sous le plancher", () => { expect(dockOpenHeight(1080)).toBe(486); expect(dockOpenHeight(600)).toBe(270); expect(dockOpenHeight(200)).toBe(DOCK_MIN_HEIGHT); for (const h of [200, 400, 768, 1080, 1440, 2160]) { expect(dockOpenHeight(h)).toBeLessThanOrEqual(dockMaxHeight(h)); expect(dockOpenHeight(h)).toBeGreaterThanOrEqual(DOCK_MIN_HEIGHT); } }); }); describe('activeColumnOf', () => { it('rend la colonne active, ou null', () => { expect(activeColumnOf(dock(['a']))!.sessionIds).toEqual(['a']); expect(activeColumnOf(EMPTY_DOCK)).toBeNull(); }); });