Files
Codeman/test/tile-grid-layout-memory.test.ts
T
Codeman maintainer 8b2cb49940 docs(tiles): a reload brings the grid back only when it was open
The wiki, decision 11 in the tile-grid plan, its Persistence section, the
layout-memory test header and two tile-grid.js comments said the Tiles
button and a page reload bring the grid back however it was closed. Only
the Tiles button does: a grid closed before the reload stays stored as
open: false, the page shows the single view, and the toggle puts the grid
back. The behaviour was right; the wording now says that.

A test pins it through the real close path: arrange the grid, close it with
the toggle, start a fresh page, and the restore leaves the single view
while the toggle still brings back the cells, count, divider sizes, focus
and zoom. ('a stored closed grid leaves the single view' in
tile-grid-restore.test.ts covers the same from a raw stored value through
handleInit.)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 06:33:00 +02:00

618 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @fileoverview The grid keeps the layout the user arranged, per browser
* (owner request: "when I moved around and modified it, save it per browser
* the layout, so when I turn tiles off and on, always keep what the last
* setting was").
*
* - Saved on every change (`codeman:tile-grid` in localStorage, never the
* server): which session sits in which cell, holes included, the tile count,
* the divider sizes, the focused tile and a zoom the user chose.
* - The Tiles toggle brings back exactly that, however the grid was closed:
* the toggle, a tab that is not tiled, a followed link (`leaveTiles`, or
* `#session=` on load), Home, the window narrowing past the desktop gate,
* the last tile removed, "Open group as tiles", closing a session, killing
* them all. A page reload brings it back only when the grid was open (a
* closed one stays remembered for the toggle). It is never filled to the
* remembered count and never trimmed to the window (a too-small window
* shows the focused tile until it fits, the arrangement kept).
* - A session that no longer exists frees its cell, and the ranking fills it
* (the hole a user left stays a hole); with none of the stored sessions left
* the grid opens from the ranking, as with nothing stored.
* - The stored format stays `v: 1`: a value written before `count` (or before
* cells) loads, and a malformed one is ignored safely.
*
* Pure helpers (sanitizeTileGridState, restoreTileGridCells) and the app via
* the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
FakeTile,
body,
bySelector,
fetchSpy,
flushFrames,
localStore,
makeGridApp,
resetGridHarness,
section,
windowStub,
type GridApp,
} from './mocks/tile-grid-vm.js';
type Restored = { ids: string[]; cells: Array<string | null>; focusedId: string } | null;
type Sanitized = {
ids: string[];
cells: Array<string | null>;
freed: number[];
count: number;
open: boolean;
focused: string | null;
zoomed: string | null;
colFr: number[] | null;
} | null;
type Helpers = {
sanitizeTileGridState(raw: unknown, live: Iterable<string>, detached?: Set<string>): Sanitized;
restoreTileGridCells(stored: unknown, ranked: string[]): Restored;
};
const T = windowStub.CodemanTileGrid as Helpers;
const KEY = 'codeman:tile-grid';
const SIX = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f'];
const stored = () => JSON.parse(localStore.get(KEY) ?? 'null');
describe('sanitizeTileGridState: freed cells and the count', () => {
const live = ['a', 'b', 'c', 'd'];
it('marks the cells whose session went away (deleted or popped out), never a hole the user left', () => {
const out = T.sanitizeTileGridState(
{ v: 1, open: true, ids: ['a', null, 'gone', 'b', 'c', 'x'] },
live,
new Set(['c'])
);
expect(out?.cells).toEqual(['a', null, null, 'b', null, null]);
expect(out?.freed).toEqual([2, 4, 5]);
});
it('a repeat or a malformed entry is a plain hole, not freed', () => {
const out = T.sanitizeTileGridState({ v: 1, ids: ['a', 'a', 7, '', 'gone', 'gone'] }, live);
expect(out?.cells).toEqual(['a', null, null, null, null, null]);
expect(out?.freed).toEqual([4]);
});
it('reads a stored count; one written before it existed is the number of sessions the cells name', () => {
expect(T.sanitizeTileGridState({ v: 1, ids: ['a', null, 'b'], count: 4 }, live)?.count).toBe(4);
expect(T.sanitizeTileGridState({ v: 1, ids: ['a', null, 'gone', 'b'] }, live)?.count).toBe(3);
// The old packed format of nine (before the cap) reads as the cap.
const nine = Array.from({ length: 9 }, (_, i) => `n${i}`);
expect(T.sanitizeTileGridState({ v: 1, ids: nine }, nine)?.count).toBe(6);
});
it.each([[0], [7], [2.5], ['4'], [null], [-1]])('a malformed count (%j) is ignored', (count) => {
expect(T.sanitizeTileGridState({ v: 1, ids: ['a', 'b'], count }, live)?.count).toBe(2);
});
});
describe('restoreTileGridCells', () => {
const sanitize = (value: Record<string, unknown>, live: string[]) =>
T.sanitizeTileGridState({ v: 1, open: false, ...value }, live);
it('gives back the cells exactly, holes included, focused on the zoomed tile, else the focused one', () => {
const s = sanitize({ ids: ['a', null, 'b', 'c', 'd', 'e'], focused: 'c', zoomed: 'd' }, [
'a',
'b',
'c',
'd',
'e',
'z',
]);
expect(T.restoreTileGridCells(s, ['z'])).toEqual({
ids: ['a', 'b', 'c', 'd', 'e'],
cells: ['a', null, 'b', 'c', 'd', 'e'],
focusedId: 'd',
});
const t = sanitize({ ids: ['a', 'b'], focused: 'b' }, ['a', 'b']);
expect(T.restoreTileGridCells(t, [])?.focusedId).toBe('b');
});
it('fills a freed cell from the ranking, best first, never one already in the grid; the user hole stays', () => {
const s = sanitize({ ids: ['a', null, 'gone', 'b', 'c', 'd'], count: 5 }, ['a', 'b', 'c', 'd', 'x', 'y']);
expect(T.restoreTileGridCells(s, ['b', 'y', 'x'])?.cells).toEqual(['a', null, 'y', 'b', 'c', 'd']);
});
it('fills freed cells first, then other empty cells only up to the count', () => {
// A hand-made value: cell 3 freed (its session gone), cell 1 a plain hole, a count of six.
const s = sanitize({ ids: ['a', null, 'b', 'gone', 'c', 'd'], count: 6 }, ['a', 'b', 'c', 'd', 'x', 'y', 'z']);
expect(T.restoreTileGridCells(s, ['x', 'y', 'z'])?.cells).toEqual(['a', 'y', 'b', 'x', 'c', 'd']);
});
it('a freed cell stays empty only when no session is left to place', () => {
const s = sanitize({ ids: ['a', 'gone', 'b'] }, ['a', 'b']);
expect(T.restoreTileGridCells(s, [])).toEqual({ ids: ['a', 'b'], cells: ['a', null, 'b'], focusedId: 'a' });
});
it('more to place than the stored cells hold: they join after them (the shape grows when laid out)', () => {
// Four stored cells (2x2), the count says six: two sessions closed while the grid was open packed it.
const s = sanitize({ ids: ['a', 'b', 'c', 'd'], count: 6 }, ['a', 'b', 'c', 'd', 'x', 'y', 'z']);
expect(T.restoreTileGridCells(s, ['x', 'y', 'z'])?.ids).toEqual(['a', 'b', 'c', 'd', 'x', 'y']);
});
it('is null when none of its sessions survive', () => {
expect(T.restoreTileGridCells(sanitize({ ids: ['gone', null] }, ['x']), ['x'])).toBeNull();
expect(T.restoreTileGridCells(null, ['x'])).toBeNull();
});
});
// ── In the app ──────────────────────────────────────────────────────────────
/** The real selectSession (tile branch, leaving the grid); the main terminal's select path bails at once. */
function layoutApp(ids: string[] = SIX): GridApp {
const app = makeGridApp(ids);
app._setTerminalLoadState = vi.fn();
app._clearTerminalLoadState = vi.fn();
app._renderHistoryTruncationBanner = vi.fn();
app._isStaleSelect = vi.fn(() => true);
app._retireUrlSession = vi.fn();
return app;
}
const divider = (app: GridApp, key: string) => app._tileGrid.dividers.get(key) as FakeEl;
/** A column divider dragged from `from` to `to` (px), released. */
function dragColumn(app: GridApp, key: string, from: number, to: number) {
const d = divider(app, key);
d.dispatch('pointerdown', {
button: 0,
pointerId: 3,
clientX: from,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
});
d.dispatch('pointermove', { clientX: to });
flushFrames();
d.dispatch('pointerup', {});
}
/**
* Six tiles opened, then arranged by hand: s-b removed (its cell left empty),
* s-a and s-f swapped, the first column divider dragged, s-c focused and
* zoomed. [ f _ c / d e a ].
*/
async function arrange(app: GridApp) {
app.openTileGrid(SIX, { focusedId: 's-a' });
flushFrames();
app.removeTile('s-b');
app._swapTiles('s-a', 's-f');
dragColumn(app, 'col-0', 800, 1000);
await app.selectSession('s-c');
app.zoomTile('s-c');
}
const layoutOf = (app: GridApp) => ({
cells: app._tileGrid.cells.slice(),
count: app._tileGrid.count,
colFr: app._tileGrid.colFr.slice(),
rowFr: app._tileGrid.rowFr.slice(),
focused: app._tileGrid.focusedId,
zoomed: app._tileGrid.zoomedId,
});
const ARRANGED_CELLS = ['s-f', null, 's-c', 's-d', 's-e', 's-a'];
beforeEach(() => {
resetGridHarness();
bySelector.set('.terminal-wrap', new FakeEl());
fetchSpy.mockClear();
});
afterEach(() => {
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
});
describe('saved on every change', () => {
it('a move, a removal, a divider drag, a focus and a zoom are each written as they happen', async () => {
const app = layoutApp();
app.openTileGrid(SIX, { focusedId: 's-a' });
flushFrames();
expect(stored()).toMatchObject({ open: true, ids: SIX, count: 6, focused: 's-a', colFr: [1, 1, 1] });
app.removeTile('s-b');
expect(stored()).toMatchObject({ ids: ['s-a', null, 's-c', 's-d', 's-e', 's-f'], count: 5 });
app._swapTiles('s-a', 's-f');
expect(stored().ids).toEqual(ARRANGED_CELLS);
dragColumn(app, 'col-0', 800, 1000);
expect(stored().colFr[0]).toBeGreaterThan(1);
expect(stored().colFr).toEqual(app._tileGrid.colFr);
await app.selectSession('s-c');
expect(stored().focused).toBe('s-c');
app.zoomTile('s-c');
expect(stored().zoomed).toBe('s-c');
});
it('adding a tile (a tab dropped on the empty cell) and picking a count are written, the count with them', () => {
const app = layoutApp([...SIX, 's-g']);
app.openTileGrid(SIX.slice(0, 5));
flushFrames();
app._tileGrid.cells = ['s-a', null, 's-b', 's-c', 's-d', 's-e'];
app._applyTileLayout();
app.dropSessionOnSlot('s-g', 1);
expect(stored()).toMatchObject({ ids: ['s-a', 's-g', 's-b', 's-c', 's-d', 's-e'], count: 6 });
app._pickTileCount(4);
expect(stored().count).toBe(4);
expect(stored().ids.filter(Boolean)).toHaveLength(4);
});
it('is never sent to the server', async () => {
const app = layoutApp();
await arrange(app);
app.toggleTileGrid();
app.toggleTileGrid();
const calls = JSON.stringify(fetchSpy.mock.calls);
expect(calls).not.toContain('tile');
expect(calls).not.toContain('colFr');
});
});
describe('the Tiles toggle (and a reload while it is open) brings back exactly what was arranged', () => {
it('off and on: the cells and the hole, the count, the divider sizes, the focus and the zoom', async () => {
const app = layoutApp();
await arrange(app);
const before = layoutOf(app);
expect(before.cells).toEqual(ARRANGED_CELLS);
app.toggleTileGrid();
expect(app._tilesOwnTerminal()).toBe(false);
expect(stored()).toMatchObject({ open: false, ids: ARRANGED_CELLS, count: 5, focused: 's-c', zoomed: 's-c' });
app.toggleTileGrid();
expect(layoutOf(app)).toEqual(before);
expect(app.activeSessionId).toBe('s-c');
});
it('never filled to the remembered count, though more sessions are free', async () => {
const app = layoutApp([...SIX, 's-g', 's-h']);
await arrange(app);
app._rememberTileGridCount(6);
app.toggleTileGrid();
app.toggleTileGrid();
expect(app._tileGrid.cells).toEqual(ARRANGED_CELLS);
});
it('a reload with the grid open: the same layout on the next page', async () => {
const app = layoutApp();
await arrange(app);
const before = layoutOf(app);
// The page goes away; a fresh one on the same browser.
section.children = [];
FakeTile.all = [];
const next = layoutApp();
expect(next._restoreTileGrid()).toBe(true);
expect(layoutOf(next)).toEqual(before);
});
it('a reload after the grid was closed: the single view, and the Tiles toggle brings it back exactly', async () => {
const app = layoutApp();
await arrange(app);
const before = layoutOf(app);
app.toggleTileGrid();
section.children = [];
FakeTile.all = [];
const next = layoutApp();
// The reload leaves it closed (handleInit then selects the single view)...
expect(next._restoreTileGrid()).toBe(false);
expect(next._tilesOwnTerminal()).toBe(false);
expect(stored()).toMatchObject({ open: false, ids: ARRANGED_CELLS });
// ...and remembered: the toggle puts it back as it was.
next.toggleTileGrid();
expect(layoutOf(next)).toEqual(before);
});
it('never trimmed to a smaller window: the focused tile shows alone, and the arrangement is kept for a wider one', async () => {
const app = layoutApp();
app.openTileGrid(SIX, { focusedId: 's-c' });
flushFrames();
app.toggleTileGrid();
// A window that fits four (3x2 needs 1440px across).
const small = { width: 1300, height: 900, top: 0, left: 0, right: 1300, bottom: 900 };
section.getBoundingClientRect = () => small;
(bySelector.get('.terminal-wrap') as FakeEl).getBoundingClientRect = () => small;
app.toggleTileGrid();
expect(app._tileGrid.ids).toEqual(SIX);
expect(app._tileGrid.zoomedId).toBe('s-c');
expect(app._tileGrid.autoZoom).toBe(true);
expect(stored().ids).toEqual(SIX);
app.toggleTileGrid();
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
(bySelector.get('.terminal-wrap') as FakeEl).getBoundingClientRect = FakeEl.prototype.getBoundingClientRect;
app.toggleTileGrid();
expect(app._tileGrid.ids).toEqual(SIX);
expect(app._tileGrid.zoomedId).toBeNull();
});
});
describe('every way the grid closes keeps it', () => {
const closers: Array<[string, (app: GridApp) => unknown]> = [
['the Tiles toggle', (app) => app.toggleTileGrid()],
['picking a tab that is not tiled', (app) => app.selectSession('s-b').catch(() => {})],
[
'a followed link (leaveTiles)',
(app) => app.selectSession('s-other', { auto: true, leaveTiles: true }).catch(() => {}),
],
['Home', (app) => app.goHome()],
['"Open group as tiles" (its grid opens, then that one is kept)', () => {}],
];
it.each(closers.slice(0, 4))('%s', async (_label, close) => {
const app = layoutApp();
await arrange(app);
const before = layoutOf(app);
await close(app);
expect(app._tilesOwnTerminal()).toBe(false);
expect(stored()).toMatchObject({ open: false, ids: ARRANGED_CELLS, count: 5 });
app.toggleTileGrid();
expect(layoutOf(app)).toEqual(before);
});
it('the window narrowing past the desktop gate', async () => {
const gates: Array<(e: { matches: boolean }) => void> = [];
windowStub.matchMedia = (q: string) => ({
matches: false,
addEventListener: (_t: string, cb: (e: { matches: boolean }) => void) => {
if (q.includes('min-width')) gates.push(cb);
},
});
const app = layoutApp();
await arrange(app);
const before = layoutOf(app);
for (const gate of gates) gate({ matches: false });
expect(app._tilesOwnTerminal()).toBe(false);
app.toggleTileGrid();
expect(layoutOf(app)).toEqual(before);
});
it('"Open group as tiles" replaces it with the group, and THAT is what comes back', async () => {
const app = layoutApp();
await arrange(app);
app.tabLayout = {
groups: [
{
id: 'g',
refs: [
{ kind: 'session', id: 's-b' },
{ kind: 'session', id: 's-d' },
],
},
],
};
app.openGroupAsTiles('g');
expect(app._tileGrid.ids).toEqual(['s-b', 's-d']);
app.toggleTileGrid();
app.toggleTileGrid();
expect(app._tileGrid.cells).toEqual(['s-b', 's-d']);
});
it('the last tile removed: that tile comes back', () => {
const app = layoutApp();
app.openTileGrid(['s-c', 's-d'], { focusedId: 's-c' });
app.removeTile('s-d');
app.removeTile('s-c');
expect(app._tilesOwnTerminal()).toBe(false);
expect(stored()).toMatchObject({ open: false, ids: ['s-c'], count: 1 });
app.toggleTileGrid();
expect(app._tileGrid.cells).toEqual(['s-c']);
});
it('a #session= link on load closes it, its value kept as stored (a session gone since still frees its cell)', async () => {
const app = layoutApp();
await arrange(app);
const page = layoutApp();
page.sessions.delete('s-d');
page._closeStoredTileGrid();
expect(stored()).toMatchObject({ open: false, ids: ARRANGED_CELLS, count: 5 });
page.toggleTileGrid();
// s-d's cell taken by the ranking's best untiled session (all quiet here: tab order, s-other first).
expect(page._tileGrid.cells).toEqual(['s-f', null, 's-c', 's-other', 's-e', 's-a']);
});
it('closing the last tiled session: kept, and with none of its sessions left the ranking opens from scratch', () => {
const app = layoutApp();
app.openTileGrid(['s-c'], { focusedId: 's-c' });
app._onSessionDeleted({ id: 's-c' });
app.sessions.delete('s-c');
app.sessionOrder = app.sessionOrder.filter((id: string) => id !== 's-c');
expect(app._tilesOwnTerminal()).toBe(false);
expect(stored()).toMatchObject({ open: false, ids: ['s-c'] });
Object.assign(app.sessions.get('s-e'), { status: 'busy', lastSubmitAt: 50 });
app.toggleTileGrid();
expect(app._tileGrid.ids[0]).toBe('s-e');
expect(app._tileGrid.ids).toHaveLength(6);
});
it('killing every session: kept, naming only gone sessions, so the next click ranks from scratch', async () => {
const app = layoutApp();
await arrange(app);
app._apiDelete = vi.fn(async () => ({ ok: true }));
app.terminalBuffers = new Map();
app.terminalLoadStates = new Map();
// killAllSessions asks with a bare confirm(): the harness context's own global.
const contextGlobal = (app.toggleTileGrid.constructor as FunctionConstructor)('return globalThis')();
contextGlobal.confirm = () => true;
try {
await app.killAllSessions();
} finally {
delete contextGlobal.confirm;
}
expect(app._tilesOwnTerminal()).toBe(false);
expect(stored()).toMatchObject({ open: false, ids: ARRANGED_CELLS });
// New sessions later: none of the stored ones survive.
app.sessions = new Map(['n-1', 'n-2'].map((id) => [id, { id, name: id, mode: 'claude', pid: 1 }]));
app.sessionOrder = ['n-1', 'n-2'];
app.toggleTileGrid();
expect(app._tileGrid.cells).toEqual(['n-1', 'n-2']);
});
});
describe('a session that went away by itself while the grid was open', () => {
// With the cap at 6 a grid has at most one empty cell (5 tiles in a 3x2, or
// 3 in a narrow 2x2), so the stored count alone says that cell is to be filled.
function six(extra: string[] = []): GridApp {
const app = layoutApp([...SIX, ...extra]);
app.openTileGrid(SIX, { focusedId: 's-a' });
flushFrames();
return app;
}
it('closed from elsewhere: its cell empties now, and the ranking fills it the next time the grid opens', () => {
const app = six(['s-g']);
app._onSessionDeleted({ id: 's-d' });
app.sessions.delete('s-d');
app.sessionOrder = app.sessionOrder.filter((id: string) => id !== 's-d');
// Nothing joins on its own while the grid is open.
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', null, 's-e', 's-f']);
// The count stays what the user left: six.
expect(stored()).toMatchObject({ ids: ['s-a', 's-b', 's-c', null, 's-e', 's-f'], count: 6 });
Object.assign(app.sessions.get('s-g'), { status: 'busy', lastSubmitAt: 10 });
app.toggleTileGrid();
app.toggleTileGrid();
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-g', 's-e', 's-f']);
});
it('popped out (or its socket refused): the same, the place kept for the ranking', () => {
const app = six();
app.detachedSessions.add('s-e');
app._reconcileTileGrid();
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d', null, 's-f']);
FakeTile.all.find((t) => t.sessionId === 's-b' && !t._destroyed)!.onExit!(4010);
expect(stored().count).toBe(6);
app.toggleTileGrid();
app.toggleTileGrid();
// Two places to fill, the two sessions left that are not popped out (all quiet: tab order).
expect(app._tileGrid.ids).toHaveLength(6);
expect(app._tileGrid.ids).not.toContain('s-e');
});
it('popped out while open (the pop-out button, or another tab announcing it): the count stays, the cell refills after re-dock', () => {
const app = six();
// What the real pop-out paths touch: detachSession and the window channel
// both mark through _markDetached; _redock clears it again.
app._detachOrphanStrikes = new Map();
app._detachWatchTimers = new Map();
app._redockGrace = new Map();
app.detachedWindows = new Map();
app._elemCache = {};
app._onWindowMessage({ type: 'detached', id: 's-e' });
expect(app.detachedSessions.has('s-e')).toBe(true);
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d', null, 's-f']);
// Not the user's hand: the count stays six, the same as a pop-out with the grid closed.
expect(stored()).toMatchObject({ ids: ['s-a', 's-b', 's-c', 's-d', null, 's-f'], count: 6 });
app._redock('s-e');
expect(app.detachedSessions.has('s-e')).toBe(false);
app.toggleTileGrid();
app.toggleTileGrid();
// The freed cell is filled from the ranking (all quiet here: tab order, s-other first).
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d', 's-other', 's-f']);
expect(stored().count).toBe(6);
});
it('removed by hand instead: the hole stays a hole', () => {
const app = six(['s-g']);
app.removeTile('s-d');
expect(stored()).toMatchObject({ ids: ['s-a', 's-b', 's-c', null, 's-e', 's-f'], count: 5 });
app.toggleTileGrid();
app.toggleTileGrid();
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', null, 's-e', 's-f']);
});
it('a tile added by hand meanwhile sets the count to what is shown', () => {
const app = six(['s-g']);
app._onSessionDeleted({ id: 's-d' });
app.sessions.delete('s-d');
app._onSessionDeleted({ id: 's-e' });
app.sessions.delete('s-e');
// Four tiles now (a 2x2), count still six; then a Ctrl/Cmd+click adds one.
app.addSessionToTiles('s-g');
expect(app._tileGrid.ids).toHaveLength(5);
expect(stored().count).toBe(5);
});
});
describe('the stored format', () => {
it('a value written before the count (packed, no holes) loads, its sessions in their cells', () => {
localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: ['s-c', 's-a', 's-e', 's-d'], focused: 's-e' }));
const app = layoutApp();
app.toggleTileGrid();
expect(app._tileGrid.cells).toEqual(['s-c', 's-a', 's-e', 's-d']);
expect(app.activeSessionId).toBe('s-e');
expect(stored().count).toBe(4);
});
it.each([
['another version', JSON.stringify({ v: 2, open: false, ids: SIX })],
['not JSON', '{oops'],
['an array', '[1,2]'],
['ids that are not a list', JSON.stringify({ v: 1, open: false, ids: 's-a' })],
['nothing but junk', JSON.stringify({ v: 1, open: false, ids: [1, {}, null], count: 'x', colFr: 'y' })],
])('a malformed value (%s) is ignored: the grid opens from the ranking', (_label, raw) => {
localStore.set(KEY, raw);
const app = layoutApp();
Object.assign(app.sessions.get('s-f'), { status: 'busy', lastSubmitAt: 99 });
expect(() => app.toggleTileGrid()).not.toThrow();
expect(app._tileGrid.ids[0]).toBe('s-f');
expect(app._tileGrid.ids).toHaveLength(6);
});
it('a malformed divider size is dropped, the rest of the layout kept', () => {
localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: ['s-a', 's-b'], colFr: [1, 'x'], rowFr: [-1] }));
const app = layoutApp();
app.toggleTileGrid();
expect(app._tileGrid.cells).toEqual(['s-a', 's-b']);
expect(app._tileGrid.colFr).toEqual([1, 1]);
});
});
describe('Ctrl/Cmd+click with a stored grid', () => {
it('joins the hole while the grid holds fewer than the count, the rest kept where it was', async () => {
const app = layoutApp();
await arrange(app);
app.toggleTileGrid();
app._rememberTileGridCount(6);
app.addSessionToTiles('s-b');
expect(app._tileGrid.cells).toEqual(['s-f', 's-b', 's-c', 's-d', 's-e', 's-a']);
expect(app._tileGrid.focusedId).toBe('s-b');
expect(app._tileGrid.colFr).toEqual(stored().colFr);
});
it('at the count, it takes the last tile’s place: the count in total, never one more', async () => {
const app = layoutApp();
await arrange(app);
app.toggleTileGrid();
app._rememberTileGridCount(4);
app.addSessionToTiles('s-b');
expect(app._tileGrid.cells).toEqual(['s-f', null, 's-c', 's-d', 's-e', 's-b']);
expect(app._tileGrid.focusedId).toBe('s-b');
expect(stored().count).toBe(5);
});
});
describe('the hover card', () => {
it('with a stored grid larger than the window, says what fits, not "a click opens N"', () => {
const btn = new FakeEl();
btn.className = 'btn-icon-header btn-tile-grid';
bySelector.set('.btn-tile-grid', btn);
const small = { width: 1300, height: 900, top: 0, left: 0, right: 1300, bottom: 900 };
(bySelector.get('.terminal-wrap') as FakeEl).getBoundingClientRect = () => small;
const app = layoutApp();
app._applyTileGridButtonVisibility(true);
const card = body.children.find((c) => c.id === 'tileGridHint')!;
const texts = () => card.children.map((c) => c.children.at(-1)?.textContent ?? c.textContent);
// Nothing stored: the click opens what fits.
expect(texts()).toContain('This window fits 4 tiles: a click opens 4');
localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: SIX, focused: 's-a' }));
app._renderTileHint();
expect(texts()).toContain('This window fits 4 tiles');
expect(texts().join('\n')).not.toContain('a click opens');
});
});