Files
arboretum/packages/web/test/dock-model.test.ts
T
johanleroy 8aea0ae32d fix(server, web, vscode): les terminaux ne restent plus noirs, panes côte à côte, git scopé au terminal
Un terminal pouvait rester tout noir alors que sa session tournait. Le PTY était
vivant et avait bien écrit sa sortie : la panne était dans le transport. Le replay
d'attache est une frame BINAIRE, mais un client n'apprend son numéro de canal
qu'avec le message `attached` ; le serveur envoyait le replay AVANT, donc tout
client le jetait sur un canal inconnu. Rien n'était peint, et un TUI au repos
(Claude à son prompt) ne réémet jamais rien de lui-même. `attach()` renvoie
désormais le replay et la gateway l'émet APRÈS `attached` : un seul correctif
serveur répare le web, l'app de bureau et l'extension VS Code, qui portaient le
même défaut client. Le resize de l'attache masquait le bug en provoquant un
SIGWINCH, d'où son apparence intermittente.

Seconde moitié du symptôme (« je tape et rien ne se passe ») : le dock montait
avant la liste des sessions, en déduisait « non attachable » et s'attachait en
observateur, à vie et en silence. Un pane n'attache plus avant de connaître sa
session (`sessions.loaded`).

Attaches sans écran : le message `attach` accepte un `screen` optionnel (défaut
true). Un client qui n'affiche rien et veut seulement répondre à un dialogue ne
prend plus le contrôle de la session, ne lui impose plus ses dimensions (ce qui
figeait la géométrie du vrai terminal) et ne reçoit plus le flux pour le jeter.

Rendre les pannes visibles : la raison d'un exit est écrite dans le terminal
(`[arboretum] bash exited with code 3`) avant le détachement ; un repaint est
forcé si rien n'arrive 1,2 s après l'attache, puis annoncé avec « Refresh
screen » ; les refus de canal remontent à l'écran au lieu d'un console.warn ; le
chemin du CLI claude est revalidé (périmé après une bascule nvm/asdf, le PTY
mourait sans un octet).

Colonnes de terminaux : le dock devient une rangée de colonnes redimensionnables
(3 max), chacune avec ses onglets. Algèbre pure dans lib/dock-model.ts, cinq
invariants documentés, ratios plutôt que pixels. `dockSessionIds` et
`activeDockSessionId` deviennent des computed dérivés : aucun consommateur ni
test existant ne change. Alt+clic ouvre à côté depuis les quatre panneaux. Le
plafond de hauteur du dock suit le viewport au lieu d'un 640 px figé. Correctif
préexistant au passage : PanelSplitter passait ses bornes par valeur, figées au
premier rendu, alors que le clavier les relisait.

Portée git : la vue Changements suit le worktree du terminal focalisé, ou tous
les dépôts de son groupe pour une session de groupe, avec « tout voir » à un
clic. L'index Git de la sidebar reste global (c'est la sortie d'une portée
étroite) et le badge d'activité aussi (il sert à signaler le travail qu'on ne
regarde pas). Seul le TERMINAL impose le contexte : le repli sur l'onglet
éditeur, essayé d'abord, rétrécissait la vue multi-projet dès qu'on ouvrait un
fichier.

Vérifications : acceptance-p17.mjs prouve l'ordre des trames sur un vrai
WebSocket (avec l'ancien ordre : 0 octet rejoué, échec), verify-terminals.mjs
prouve par interaction réelle que le terminal peint, que deux colonnes
coexistent, que la frappe atteint le bon PTY (fichier témoin par cwd) et que la
vue suit le terminal.
2026-08-05 10:56:42 +02:00

316 lines
11 KiB
TypeScript

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<string>();
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();
});
});