Files
Codeman/test/tile-grid-cells.test.ts
T
Codeman maintainer 16980d644d feat(tiles): keep the arranged layout per browser across toggles and reloads
The Tiles toggle used to fill a remembered grid to the count picked in the
menu (refilling a hole the user made, or trimming tiles), removing the last
tile or "Open group as tiles" forgot it, and Ctrl/Cmd+click repacked it.
The owner asked for the opposite: "when I turn tiles off and on, always
keep what the last setting was".

Now the layout the user arranged (which session sits in which cell, holes
included, the tile count, the divider sizes, the focused tile and a zoom
they chose) is written to localStorage on every change and comes back
exactly from the toggle, Ctrl+Shift+G and a reload, however the grid was
closed (toggle, a non-tiled tab, leaveTiles or a #session= link, Home, the
width gate, the last tile, a group, closing or killing sessions). Nothing
of it reaches the server.

- The stored value stays v: 1 and gains `count`: how many tiles the user's
  own last change left. A session that goes away by itself (deleted,
  popped out, socket refused) does not lower it, so the next activation
  fills that cell from the ranking; a hole the user made stays a hole.
- sanitizeTileGridState reports `freed` cells (sessions gone since) and
  derives `count` for values written before it; restoreTileGridCells (pure)
  puts a stored grid back, freed cells first, never trimmed to the window
  (a too-small window shows the focused tile until it fits).
- Only when none of the stored sessions survive does activation rank from
  scratch. A count picked in the menu still re-forms the grid to it.
- Nothing is persisted while a stored grid is being put back, and a
  #session= link only flips `open`, keeping gone ids for the fill.
- The hover card no longer promises "a click opens N" when a stored grid
  will open instead.

This supersedes decision 10's answer that the count wins over a
remembered grid's size; the affected tests say so.

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

357 lines
15 KiB
TypeScript

/**
* @fileoverview The grid as cells (owner: "the empty tab doesnt always have to
* be the last one ... it can also be tab nr 4 or 3"). `grid.cells` holds a
* session id or `null` per cell and is the one source of truth; `grid.ids` is
* the tiles in reading order, derived. The shape still comes from the tile
* count (the layout table), and the cap counts tiles, never empty cells.
*
* - Removing a tile without a shape change leaves its cell empty where it was.
* - Adding one without a shape change takes the first empty cell (or the cell a
* tab was dropped on: tile-grid-move.test.ts).
* - A shape change keeps each tile's row and column when all fit (2x2 growing
* to 3x2), else the tiles pack in reading order (fitTileCells).
* - Focus never lands on an empty cell: the Alt+Shift+Arrow chords skip it,
* and Ctrl+Tab / Alt+[ ] cycle through the tiles only.
* - `codeman:tile-grid` stores the cells (ids, `null` for a hole); a reload
* brings the holes back when the shape is the same, a session gone by then
* frees its cell for the ranking to fill (empty only when no other session
* is left), a different shape packs, and the old packed format reads
* unchanged. A followed `#session=` link keeps the holes.
* - A divider drag refits only the tiles in its two tracks, holes skipped.
*
* Real code 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,
documentAddEventListener,
localStore,
makeGridApp,
rafCallbacks,
resetGridHarness,
section,
type GridApp,
tileEl,
} from './mocks/tile-grid-vm.js';
const SIX = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f'];
const FIVE = SIX.slice(0, 5);
const KEY = 'codeman:tile-grid';
const slots = () => section.children.filter((el) => el.className.split(' ').includes('tile-slot'));
const stored = () => JSON.parse(localStore.get(KEY) ?? 'null');
const tile = (id: string) => FakeTile.all.find((t) => t.sessionId === id && !t._destroyed) as FakeTile;
const place = (id: string) => [tileEl(id).style.gridColumn, tileEl(id).style.gridRow];
// Under 1800px wide three tiles take a 2x2 (over it, 3x1).
const narrow = () => {
section.getBoundingClientRect = () => ({ width: 1700, height: 1000, top: 0, left: 0, right: 1700, bottom: 1000 });
};
function openGrid(ids: string[], focusedId = ids[0], all: string[] = SIX): GridApp {
const app = makeGridApp(all);
app.openTileGrid(ids, { focusedId });
return app;
}
function setCells(app: GridApp, cells: Array<string | null>) {
app._tileGrid.cells = cells.slice();
app._applyTileLayout();
}
beforeEach(() => {
resetGridHarness();
narrow();
});
afterEach(() => {
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
});
describe('the cells are the source of truth', () => {
it('ids are the tiles in reading order, derived from the cells (holes skipped)', () => {
const app = openGrid(FIVE);
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
expect(app._tileGrid.ids).toEqual(FIVE);
expect(app._tileGrid.cells).toEqual(['s-a', null, 's-b', 's-c', 's-d', 's-e']);
// A tile sits in its cell, the slot in the empty one.
expect(place('s-b')).toEqual(['5', '1']);
expect([slots()[0].style.gridColumn, slots()[0].style.gridRow]).toEqual(['3', '1']);
});
it('opening packs from the first cell and pads the shape with empty cells', () => {
const app = openGrid(FIVE);
expect(app._tileGrid.cells).toEqual([...FIVE, null]);
});
});
describe('removing a tile', () => {
it('without a shape change (6 to 5 stays 3x2): its cell is left empty where it was', () => {
const app = openGrid(SIX);
app.removeTile('s-b');
expect(app._tileGrid.cells).toEqual(['s-a', null, 's-c', 's-d', 's-e', 's-f']);
expect(place('s-c')).toEqual(['5', '1']);
expect(slots()).toHaveLength(1);
expect(slots()[0].dataset.cell).toBe('1');
// Nothing else was remounted.
expect(FakeTile.all.filter((t) => t._destroyed).map((t) => t.sessionId)).toEqual(['s-b']);
expect(stored().ids).toEqual(['s-a', null, 's-c', 's-d', 's-e', 's-f']);
});
it('a shape change (5 to 4 is 3x2 to 2x2) keeps the tiles when all fit, else packs them', () => {
let app = openGrid(FIVE);
setCells(app, ['s-a', 's-b', null, 's-d', 's-e', 's-c']);
// Removing the only tile in the third column: the rest keep their places.
app.removeTile('s-c');
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-d', 's-e']);
resetGridHarness();
narrow();
app = openGrid(FIVE);
setCells(app, ['s-a', null, 's-c', 's-d', 's-e', 's-b']);
// A tile is left in the third column: packed in reading order.
app.removeTile('s-e');
expect(app._tileGrid.cells).toEqual(['s-a', 's-c', 's-d', 's-b']);
});
it('the focused tile leaving hands focus to the next tile in reading order, past the hole', () => {
const app = openGrid(SIX, 's-a');
setCells(app, ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f']);
app.removeTile('s-b');
app.removeTile('s-a');
expect(app.activeSessionId).toBe('s-c');
});
});
describe('adding a tile', () => {
it('without a shape change: the first empty cell in reading order', () => {
const app = openGrid(FIVE);
setCells(app, ['s-a', 's-b', null, 's-c', 's-d', 's-e']);
app.addTile('s-f');
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-f', 's-c', 's-d', 's-e']);
expect(slots()).toHaveLength(0);
});
it('Ctrl/Cmd+click on a tab and a session Run from this tab fill the first hole too', () => {
const app = openGrid(['s-a', 's-b', 's-c', 's-d'], 's-a', [...SIX, 's-g']);
// 2x2 [a b / c d]; the 5th makes it 3x2 with the four kept in place.
app.addSessionToTiles('s-e');
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-e', 's-c', 's-d', null]);
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
app._joinTileGridFromRun('s-f');
expect(app._tileGrid.cells).toEqual(['s-a', 's-f', 's-b', 's-c', 's-d', 's-e']);
});
it('a shape change (4 to 5 is 2x2 to 3x2): every tile keeps its row and column', () => {
const app = openGrid(['s-a', 's-b', 's-c', 's-d']);
app.addTile('s-e');
// [a b / c d] -> [a b e / c d _]
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-e', 's-c', 's-d', null]);
expect(place('s-c')).toEqual(['1', '3']);
expect(place('s-e')).toEqual(['5', '1']);
});
it('into the cell named when it is empty, not just the first one (two holes, a state the cap never reaches)', () => {
const app = openGrid(['s-a', 's-b']);
// A 2x2 with two holes, set up by hand: the third tile still keeps the 2x2.
app._tileGrid.cells = ['s-a', null, 's-b', null];
app._tileGrid.cols = 2;
app._tileGrid.rows = 2;
app.addTile('s-c', { cell: 3 });
expect(app._tileGrid.cells).toEqual(['s-a', null, 's-b', 's-c']);
// A cell that is taken is not: the first empty one instead.
resetGridHarness();
narrow();
const other = openGrid(['s-a', 's-b']);
other._tileGrid.cells = ['s-a', null, 's-b', null];
other._tileGrid.cols = 2;
other._tileGrid.rows = 2;
other.addTile('s-c', { cell: 2 });
expect(other._tileGrid.cells).toEqual(['s-a', 's-c', 's-b', null]);
});
it('the cap counts tiles, never empty cells', () => {
const app = openGrid(FIVE);
setCells(app, [null, 's-a', 's-b', 's-c', 's-d', 's-e']);
expect(app.addTile('s-f')).toBe(true);
expect(app._tileGrid.cells).toEqual(['s-f', 's-a', 's-b', 's-c', 's-d', 's-e']);
expect(app.addTile('s-other')).toBe(false);
});
});
describe('a shape change from the window (three tiles: 2x2 narrow, 3x1 wide)', () => {
it('positions do not map between shapes: the tiles keep their reading order, packed', () => {
const app = openGrid(['s-a', 's-b', 's-c']);
setCells(app, ['s-a', null, 's-b', 's-c']);
section.getBoundingClientRect = () => ({ width: 2400, height: 1000, top: 0, left: 0, right: 2400, bottom: 1000 });
app._applyTileLayout();
expect([app._tileGrid.cols, app._tileGrid.rows]).toEqual([3, 1]);
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c']);
narrow();
app._applyTileLayout();
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', null]);
});
});
describe('focus never lands on an empty cell', () => {
function handlerFor(app: GridApp) {
app.$ = () => null;
app.setupColorPicker = vi.fn();
const before = (documentAddEventListener.mock.calls as unknown[]).length;
app.setupEventListeners();
const added = (documentAddEventListener.mock.calls as Array<[string, (e: unknown) => void, boolean]>).slice(before);
return added.find(([type, , capture]) => type === 'keydown' && capture === true)![1];
}
const chord = (key: string, mods: Record<string, boolean>) => ({
type: 'keydown',
key,
code: key,
ctrlKey: false,
metaKey: false,
shiftKey: false,
altKey: false,
preventDefault: vi.fn(),
target: { closest: () => null },
...mods,
});
const focusKey = (dir: string) => chord(`Arrow${dir}`, { altKey: true, shiftKey: true });
it('Alt+Shift+Arrows skip a hole to the next tile, and do nothing when there is none', () => {
const app = openGrid(FIVE, 's-a');
// [a _ b / c d e]
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
delete app.selectSession;
const onKeydown = handlerFor(app);
onKeydown(focusKey('Right'));
expect(app.activeSessionId).toBe('s-b');
onKeydown(focusKey('Right'));
expect(app.activeSessionId).toBe('s-b');
app._selectTiledSession('s-d', { auto: true });
// Up from d (the hole above): the nearest tile in that row, the lower column on a tie.
onKeydown(focusKey('Up'));
expect(app.activeSessionId).toBe('s-a');
expect(app._tileGrid.focusedId).toBe('s-a');
});
it('Ctrl+Tab and Alt+[ / ] cycle through the tiles in reading order, never a hole', () => {
const app = openGrid(FIVE, 's-a');
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
delete app.selectSession;
const seen: string[] = [];
for (let i = 0; i < 5; i++) {
app._cycleTileFocus(1);
seen.push(app.activeSessionId);
}
expect(seen).toEqual(['s-b', 's-c', 's-d', 's-e', 's-a']);
app._cycleTileFocus(-1);
expect(app.activeSessionId).toBe('s-e');
});
});
describe('persistence: the cells, holes included', () => {
function reload(ids: string[] = SIX, { others = true } = {}): GridApp {
// The page goes away; a fresh one on the same device restores the grid.
section.children = [];
FakeTile.all = [];
const app = makeGridApp(ids);
// `others: false`: no session but `ids` is open, so a freed cell has nothing to take.
if (!others) {
app.sessions.delete('s-other');
app.sessionOrder = app.sessionOrder.filter((id: string) => id !== 's-other');
}
expect(app._restoreTileGrid()).toBe(true);
return app;
}
it('a hole comes back where it was when the shape is the same', () => {
const app = openGrid(FIVE, 's-c');
setCells(app, ['s-a', 's-b', null, 's-c', 's-d', 's-e']);
expect(stored().ids).toEqual(['s-a', 's-b', null, 's-c', 's-d', 's-e']);
app._tileGrid.open = false;
const again = reload();
expect(again._tileGrid.cells).toEqual(['s-a', 's-b', null, 's-c', 's-d', 's-e']);
expect(again.activeSessionId).toBe('s-c');
expect(slots()[0].dataset.cell).toBe('2');
});
it('a session gone by then frees its cell, which the ranking fills, the hole the user left kept', () => {
const app = openGrid(FIVE);
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
app._tileGrid.open = false;
const again = reload(['s-a', 's-b', 's-d', 's-e']);
expect(again._tileGrid.cells).toEqual(['s-a', null, 's-b', 's-other', 's-d', 's-e']);
});
it('a freed cell stays empty only when no other session is left, if the shape still fits', () => {
const app = openGrid(SIX);
app._tileGrid.open = false;
const again = reload(['s-a', 's-b', 's-d', 's-e', 's-f'], { others: false });
expect(again._tileGrid.cells).toEqual(['s-a', 's-b', null, 's-d', 's-e', 's-f']);
});
it('otherwise (another shape) the grid packs', () => {
const app = openGrid(FIVE);
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
app._tileGrid.open = false;
// s-d gone and nothing to take its cell: four tiles take a 2x2.
const again = reload(['s-a', 's-b', 's-c', 's-e'], { others: false });
expect(again._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-e']);
});
it('another shape packs even when the stored holes would fit the new one', () => {
// Stored as 3x2 [a _ b / c d e]; d and e gone: three tiles take a 2x2.
localStore.set(KEY, JSON.stringify({ v: 1, open: true, ids: ['s-a', null, 's-b', 's-c', 's-d', 's-e'] }));
const again = reload(['s-a', 's-b', 's-c'], { others: false });
expect(again._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', null]);
});
it('the old packed format reads unchanged', () => {
localStore.set(KEY, JSON.stringify({ v: 1, open: true, ids: FIVE, focused: 's-b' }));
const again = reload();
expect(again._tileGrid.cells).toEqual([...FIVE, null]);
expect(again.activeSessionId).toBe('s-b');
});
it('a followed #session= link closes the stored grid with its holes kept', () => {
const app = openGrid(FIVE);
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
const fresh = makeGridApp(SIX);
fresh._closeStoredTileGrid();
expect(stored()).toMatchObject({ open: false, ids: ['s-a', null, 's-b', 's-c', 's-d', 's-e'] });
expect(stored()).not.toHaveProperty('cells');
void app;
});
});
describe('a move keeps every tile', () => {
it('cells that lose or duplicate a tile are refused, and nothing changes', () => {
const app = openGrid(FIVE);
const before = app._tileGrid.cells.slice();
expect(app._reorderTiles(['s-a', 's-b', 's-c', 's-d', null, null])).toBe(false);
expect(app._reorderTiles(['s-a', 's-a', 's-c', 's-d', 's-e', null])).toBe(false);
expect(app._reorderTiles(['s-a', 's-b', 's-c', 's-d', 's-e'])).toBe(false);
expect(app._tileGrid.cells).toEqual(before);
});
});
describe('a divider drag with a hole', () => {
it('refits only the tiles in its two columns, the hole skipped', () => {
const app = openGrid(FIVE);
// [a _ b / c d e]: column 0 holds a, c; column 1 holds d.
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
for (const t of FakeTile.all) t.fit.mockClear();
const d = app._tileGrid.dividers.get('col-0') as FakeEl;
d.dispatch('pointerdown', {
button: 0,
clientX: 500,
clientY: 100,
pointerId: 1,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
});
d.dispatch('pointermove', { clientX: 560, clientY: 100, pointerId: 1 });
for (const cb of rafCallbacks.splice(0)) cb();
d.dispatch('pointerup', { clientX: 560, clientY: 100, pointerId: 1 });
const fits = Object.fromEntries(FIVE.map((id) => [id, tile(id).fit.mock.calls.length]));
expect(fits).toEqual({ 's-a': 1, 's-b': 0, 's-c': 1, 's-d': 1, 's-e': 0 });
});
});