Files
Codeman/test/tile-grid-layout.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

344 lines
16 KiB
TypeScript

/**
* @fileoverview The tile grid's pure helpers (constants.js, `window.CodemanTileGrid`).
*
* - `computeTileLayout`: columns x rows by tile count (the spec's table), with
* the 3-tile special case (3x1 only on a wide grid area) and whether every
* cell clears the minimum tile size.
* - `tileGridCapacity`: how many tiles a grid area can hold.
* - `sanitizeTileGridState`: the stored `codeman:tile-grid` value made safe to
* apply (unknown, deleted, detached and duplicate ids dropped); the stored
* `ids` are the grid's cells, `null` for a hole, and come back as `cells`
* (holes kept, a dropped id a hole) beside the packed `ids`. The old packed
* format reads as cells with no hole.
* - `fitTileCells`: the cells after a shape change (owner: an empty cell can be
* any cell). Same shape: unchanged; a new one: each tile keeps its row and
* column when all fit, else the tiles pack in reading order.
* - `tileNeighbor`, `tileInDirection`, `cycleTile`: which tile takes focus when
* one leaves, on a directional chord (over cells: never onto a hole), and on
* Ctrl+Tab / Alt+[ ]. `tileCellInDirection`: the adjacent cell a Move Tile
* chord moves into or swaps with.
*
* Loaded via `vm` like split-pane-helpers.test.ts. Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
type Layout = { cols: number; rows: number; fits: boolean };
type TileGrid = {
computeTileLayout(p: Record<string, number>): Layout;
tileGridCapacity(p: Record<string, number>): number;
sanitizeTileGridState(raw: unknown, live: unknown, detached?: Set<string>): Record<string, unknown> | null;
tileNeighbor(ids: string[], id: string): string | null;
tileInDirection(cells: Array<string | null>, focused: string, dir: string, cols: number): string | null;
tileCellInDirection(index: number, dir: string, cols: number, cellCount: number): number;
fitTileCells(cells: Array<string | null>, oldCols: number, cols: number, rows: number): Array<string | null>;
cycleTile(ids: string[], focused: string, delta: number): string | null;
TILE_GRID_MAX: number;
TILE_LAYOUT_MAX: number;
TILE_MIN_W: number;
TILE_MIN_H: number;
TILE_SCROLLBACK: number;
};
function loadTileGrid(): TileGrid {
const context = vm.createContext({ window: {}, globalThis: {} });
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
vm.runInContext(source, context, { filename: 'constants.js' });
return (context.window as { CodemanTileGrid: TileGrid }).CodemanTileGrid;
}
const T = loadTileGrid();
// Plenty of room: every layout fits.
const BIG = { width: 3000, height: 2000 };
describe('computeTileLayout', () => {
it.each([
[1, 1, 1],
[2, 2, 1],
[4, 2, 2],
[5, 3, 2],
[6, 3, 2],
[7, 3, 3],
[8, 3, 3],
[9, 3, 3],
])('%i tiles lay out as %ix%i', (count, cols, rows) => {
expect(T.computeTileLayout({ count, ...BIG })).toMatchObject({ cols, rows, fits: true });
});
it('puts 3 tiles side by side only on a grid area at least 1800px wide', () => {
expect(T.computeTileLayout({ count: 3, width: 1800, height: 900 })).toMatchObject({ cols: 3, rows: 1 });
expect(T.computeTileLayout({ count: 3, width: 1799, height: 900 })).toMatchObject({ cols: 2, rows: 2 });
});
it('lays out up to 9 (past the cap, unreachable but kept) and treats nothing as an empty grid', () => {
expect(T.TILE_LAYOUT_MAX).toBe(9);
expect(T.computeTileLayout({ count: 12, ...BIG })).toMatchObject({ cols: 3, rows: 3 });
expect(T.computeTileLayout({ count: 0, ...BIG })).toMatchObject({ cols: 0, rows: 0 });
});
it('reports whether every cell clears the minimum tile size', () => {
// 3x2 needs 3 * 480 = 1440 wide and 2 * 240 = 480 high.
expect(T.computeTileLayout({ count: 6, width: 1440, height: 480 }).fits).toBe(true);
expect(T.computeTileLayout({ count: 6, width: 1439, height: 480 }).fits).toBe(false);
expect(T.computeTileLayout({ count: 6, width: 1440, height: 479 }).fits).toBe(false);
});
});
describe('tileGridCapacity', () => {
it('never holds more than the cap of 6 (owner decision 7), even where nine would fit', () => {
expect(T.TILE_GRID_MAX).toBe(6);
expect(T.computeTileLayout({ count: 9, ...BIG }).fits).toBe(true);
expect(T.tileGridCapacity(BIG)).toBe(6);
});
it('stops at the first count whose layout does not fit', () => {
// 1440 x 600: 3x2 fits (480 x 300) but 3x3 (480 x 200) does not.
expect(T.tileGridCapacity({ width: 1440, height: 600 })).toBe(6);
// 1200 x 900: 2x2 fits (600 x 450), 3x2 does not (400 wide).
expect(T.tileGridCapacity({ width: 1200, height: 900 })).toBe(4);
// 1000 x 400: 2x1 fits (500 x 400); three tiles take a 2x2 (200 high), which does not.
expect(T.tileGridCapacity({ width: 1000, height: 400 })).toBe(2);
});
it('is 0 when not even one tile fits', () => {
expect(T.tileGridCapacity({ width: 400, height: 900 })).toBe(0);
});
});
describe('sanitizeTileGridState', () => {
const live = new Map([
['a', {}],
['b', {}],
['c', {}],
['d', {}],
]);
it('keeps a valid stored grid as it is', () => {
const raw = { v: 1, open: true, ids: ['a', 'b'], focused: 'b', zoomed: 'a', colFr: [1, 2], rowFr: [1] };
expect(T.sanitizeTileGridState(raw, live, new Set())).toEqual({
v: 1,
open: true,
ids: ['a', 'b'],
cells: ['a', 'b'],
// Nothing freed; no stored count: the sessions it names.
freed: [],
count: 2,
focused: 'b',
zoomed: 'a',
colFr: [1, 2],
rowFr: [1],
});
});
it('accepts the stored JSON string', () => {
const raw = JSON.stringify({ v: 1, open: true, ids: ['c'], focused: 'c' });
expect(T.sanitizeTileGridState(raw, live)?.ids).toEqual(['c']);
});
it('drops unknown (deleted), detached and duplicate ids', () => {
const raw = { v: 1, open: true, ids: ['a', 'gone', 'b', 'a', 'c', 7, ''], focused: 'a' };
const out = T.sanitizeTileGridState(raw, live, new Set(['b']));
expect(out?.ids).toEqual(['a', 'c']);
});
it('moves focus to the first kept tile when the focused one was dropped, and drops a dropped zoom', () => {
const raw = { v: 1, open: true, ids: ['gone', 'b', 'c'], focused: 'gone', zoomed: 'gone' };
const out = T.sanitizeTileGridState(raw, live);
expect(out?.focused).toBe('b');
expect(out?.zoomed).toBeNull();
});
it('is closed when no tile survives', () => {
const out = T.sanitizeTileGridState({ v: 1, open: true, ids: ['gone'] }, live);
expect(out).toMatchObject({ open: false, ids: [], focused: null });
});
it('caps the list at the cap (6): the extras are dropped', () => {
const many = Array.from({ length: 12 }, (_, i) => `s${i}`);
const out = T.sanitizeTileGridState({ v: 1, open: true, ids: many }, many);
expect(out?.ids).toEqual(many.slice(0, 6));
});
it('a stored 3x3 keeps its focus if it survives the cap, and loses a zoom that did not', () => {
const nine = Array.from({ length: 9 }, (_, i) => `s${i}`);
const kept = T.sanitizeTileGridState({ v: 1, open: true, ids: nine, focused: 's4', zoomed: 's7' }, nine);
expect(kept).toMatchObject({ ids: nine.slice(0, 6), focused: 's4', zoomed: null });
const lost = T.sanitizeTileGridState({ v: 1, open: true, ids: nine, focused: 's8', zoomed: 's2' }, nine);
expect(lost).toMatchObject({ focused: 's0', zoomed: 's2' });
});
it('drops malformed track fractions', () => {
const out = T.sanitizeTileGridState({ v: 1, open: true, ids: ['a'], colFr: [1, -1], rowFr: [1, 1, 1, 1] }, live);
expect(out?.colFr).toBeNull();
expect(out?.rowFr).toBeNull();
});
it('stored cells keep their holes; the list beside them is packed', () => {
const raw = { v: 1, open: true, ids: ['a', null, 'b', 'c', 'd', null], focused: 'c' };
const out = T.sanitizeTileGridState(raw, live);
expect(out?.cells).toEqual(['a', null, 'b', 'c', 'd', null]);
expect(out?.ids).toEqual(['a', 'b', 'c', 'd']);
expect(out?.focused).toBe('c');
});
it('a dropped id (gone, detached, a duplicate, malformed) leaves a hole where it was, never a shift', () => {
const raw = { v: 1, open: true, ids: ['a', 'gone', 'b', 'a', 7, 'c'] };
const out = T.sanitizeTileGridState(raw, live, new Set(['b']));
expect(out?.cells).toEqual(['a', null, null, null, null, 'c']);
expect(out?.ids).toEqual(['a', 'c']);
});
it('the old packed format reads unchanged: cells with no hole', () => {
const out = T.sanitizeTileGridState({ v: 1, open: true, ids: ['a', 'b', 'c'] }, live);
expect(out?.cells).toEqual(['a', 'b', 'c']);
expect(out?.ids).toEqual(['a', 'b', 'c']);
});
it('past the cap a tile becomes a hole; no more cells than the largest layout', () => {
const many = Array.from({ length: 12 }, (_, i) => `s${i}`);
const out = T.sanitizeTileGridState({ v: 1, open: true, ids: many }, many);
expect(out?.cells).toEqual([...many.slice(0, 6), null, null, null]);
});
it.each([null, 'not json', '[]', 42, { v: 2, ids: ['a'] }, { ids: ['a'] }])('rejects %j', (raw) => {
expect(T.sanitizeTileGridState(raw, live)).toBeNull();
});
});
describe('focus helpers', () => {
it('tileNeighbor prefers the next tile, then the previous one', () => {
expect(T.tileNeighbor(['a', 'b', 'c'], 'b')).toBe('c');
expect(T.tileNeighbor(['a', 'b', 'c'], 'c')).toBe('b');
expect(T.tileNeighbor(['a'], 'a')).toBeNull();
});
it('tileInDirection moves within a row-major grid', () => {
// 3x2: a b c
// d e
const ids = ['a', 'b', 'c', 'd', 'e'];
expect(T.tileInDirection(ids, 'b', 'left', 3)).toBe('a');
expect(T.tileInDirection(ids, 'a', 'left', 3)).toBeNull();
expect(T.tileInDirection(ids, 'b', 'right', 3)).toBe('c');
expect(T.tileInDirection(ids, 'c', 'right', 3)).toBeNull();
expect(T.tileInDirection(ids, 'e', 'right', 3)).toBeNull();
expect(T.tileInDirection(ids, 'd', 'up', 3)).toBe('a');
expect(T.tileInDirection(ids, 'a', 'up', 3)).toBeNull();
expect(T.tileInDirection(ids, 'b', 'down', 3)).toBe('e');
// Nothing below c in that column: the short last row's last tile.
expect(T.tileInDirection(ids, 'c', 'down', 3)).toBe('e');
expect(T.tileInDirection(ids, 'e', 'down', 3)).toBeNull();
});
it('tileInDirection never lands on a hole: along a row it skips one, never leaving the row', () => {
// 3x2: a _ c
// d e f
const cells = ['a', null, 'c', 'd', 'e', 'f'];
expect(T.tileInDirection(cells, 'a', 'right', 3)).toBe('c');
expect(T.tileInDirection(cells, 'c', 'left', 3)).toBe('a');
expect(T.tileInDirection(cells, 'c', 'right', 3)).toBeNull();
expect(T.tileInDirection(cells, 'd', 'left', 3)).toBeNull();
// Up from e (a hole above): the nearest tile in that row, the lower column on a tie.
expect(T.tileInDirection(cells, 'e', 'up', 3)).toBe('a');
expect(T.tileInDirection(cells, 'f', 'up', 3)).toBe('c');
expect(T.tileInDirection(cells, 'a', 'down', 3)).toBe('d');
});
it('tileInDirection: up and down go to the nearest row that has a tile, even across a column', () => {
// 3x2: a b c
// _ _ f
const cells = ['a', 'b', 'c', null, null, 'f'];
expect(T.tileInDirection(cells, 'a', 'down', 3)).toBe('f');
expect(T.tileInDirection(cells, 'c', 'down', 3)).toBe('f');
expect(T.tileInDirection(cells, 'f', 'up', 3)).toBe('c');
expect(T.tileInDirection(cells, 'f', 'left', 3)).toBeNull();
// A row with no tile at all is passed over (a 3x3, which the layout table keeps).
const tall = ['a', null, null, null, null, null, null, 'h', null];
expect(T.tileInDirection(tall, 'a', 'down', 3)).toBe('h');
expect(T.tileInDirection(tall, 'h', 'up', 3)).toBe('a');
// A hole is never the focused cell either.
expect(T.tileInDirection(cells, null as unknown as string, 'up', 3)).toBeNull();
});
it('tileCellInDirection: the adjacent cell, or -1 at the edge', () => {
// 3x2 cells 0 1 2 / 3 4 5
expect([0, 1, 2, 3, 4, 5].map((i) => T.tileCellInDirection(i, 'left', 3, 6))).toEqual([-1, 0, 1, -1, 3, 4]);
expect([0, 1, 2, 3, 4, 5].map((i) => T.tileCellInDirection(i, 'right', 3, 6))).toEqual([1, 2, -1, 4, 5, -1]);
expect([0, 1, 2, 3, 4, 5].map((i) => T.tileCellInDirection(i, 'up', 3, 6))).toEqual([-1, -1, -1, 0, 1, 2]);
expect([0, 1, 2, 3, 4, 5].map((i) => T.tileCellInDirection(i, 'down', 3, 6))).toEqual([3, 4, 5, -1, -1, -1]);
// 2x2
expect([0, 1, 2, 3].map((i) => T.tileCellInDirection(i, 'down', 2, 4))).toEqual([2, 3, -1, -1]);
expect(T.tileCellInDirection(7, 'left', 3, 6)).toBe(-1);
});
it('cycleTile wraps in reading order', () => {
expect(T.cycleTile(['a', 'b', 'c'], 'c', 1)).toBe('a');
expect(T.cycleTile(['a', 'b', 'c'], 'a', -1)).toBe('c');
expect(T.cycleTile([], 'a', 1)).toBeNull();
});
});
describe('fitTileCells (the cells after a shape change)', () => {
it('the same shape keeps every cell, holes included', () => {
expect(T.fitTileCells(['a', null, 'c', 'd', 'e', 'f'], 3, 3, 2)).toEqual(['a', null, 'c', 'd', 'e', 'f']);
});
it('a first layout (no columns yet) packs and pads', () => {
expect(T.fitTileCells(['a', 'b', 'c', 'd', 'e'], 0, 3, 2)).toEqual(['a', 'b', 'c', 'd', 'e', null]);
expect(T.fitTileCells(['a', 'b', 'c'], 0, 2, 2)).toEqual(['a', 'b', 'c', null]);
});
it('growing 2x2 to 3x2: every tile keeps its row and column', () => {
expect(T.fitTileCells(['a', 'b', 'c', 'd'], 2, 3, 2)).toEqual(['a', 'b', null, 'c', 'd', null]);
// 2x1 to 2x2 likewise (the same as packing there).
expect(T.fitTileCells(['a', 'b'], 2, 2, 2)).toEqual(['a', 'b', null, null]);
});
it('shrinking: kept where every tile fits, else packed in reading order', () => {
// 3x2 -> 2x2 with the third column empty: the tiles stay put.
expect(T.fitTileCells(['a', 'b', null, 'd', 'e', null], 3, 2, 2)).toEqual(['a', 'b', 'd', 'e']);
// A tile in the third column: packed, holes collapsed.
expect(T.fitTileCells(['a', null, 'c', 'd', 'e', null], 3, 2, 2)).toEqual(['a', 'c', 'd', 'e']);
// 2x2 -> 3x1 (a wider window, three tiles): row 1 does not fit, packed.
expect(T.fitTileCells(['a', null, 'c', 'd'], 2, 3, 1)).toEqual(['a', 'c', 'd']);
});
});
describe('dragTrackFractions (divider drags)', () => {
const drag = (fr: number[], i: number, d: number, total = 1200, min = 300) =>
(T as unknown as { dragTrackFractions: (...a: unknown[]) => number[] }).dragTrackFractions(fr, i, d, total, min);
const px = (fr: number[], total = 1200) => fr.map((f) => Math.round((f / fr.reduce((a, b) => a + b, 0)) * total));
it('moves size from one neighbour to the other, the rest untouched', () => {
// 3 equal tracks of 400px; divider 0 moves 100px right.
expect(px(drag([1, 1, 1], 0, 100))).toEqual([500, 300, 400]);
expect(px(drag([1, 1, 1], 1, -50))).toEqual([400, 350, 450]);
});
it('clamps both neighbours to the minimum size', () => {
expect(px(drag([1, 1, 1], 0, 300))).toEqual([500, 300, 400]);
expect(px(drag([1, 1, 1], 0, -300))).toEqual([300, 500, 400]);
});
it('a pair too small for two minimums splits evenly instead of inverting', () => {
expect(px(drag([1, 1], 0, 200, 500, 300), 500)).toEqual([250, 250]);
});
it('works from the fractions the drag started with (no drift)', () => {
const start = [2, 1];
expect(px(drag(start, 0, 0))).toEqual([800, 400]);
expect(start).toEqual([2, 1]);
});
it('ignores a divider that is not between two tracks', () => {
expect(drag([1, 1], 1, 100)).toEqual([1, 1]);
});
});
describe('tile constants', () => {
it('a tile keeps 10,000 lines of scrollback, not the primary pane 50,000', () => {
expect(T.TILE_SCROLLBACK).toBe(10000);
});
});