mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
Owner: an empty cell need not be the last one ("the empty tab can also
be tab nr 4 or 3"). The helpers that let the grid hold cells instead of
a packed list, with no behaviour change on their own:
- fitTileCells: the cells after a shape change. The same shape keeps
every cell, holes included; a new shape keeps each tile at its row
and column when all fit (2x2 growing to 3x2: the four tiles stay
put), else the tiles pack in reading order.
- tileInDirection takes cells: focus never lands on an empty cell.
Left and right go along the row past a hole and never leave it; up
and down take the nearest row with a tile, the same column else the
nearest (lower on a tie). For a packed list this is exactly the old
rule, short last row included.
- tileCellInDirection: the adjacent cell a Move Tile chord moves into
or swaps with.
- sanitizeTileGridState reads the stored ids as cells (null for an
empty one) and returns them as `cells` beside the packed `ids`; a
dropped id becomes a hole, never a shift. The old packed format reads
as cells with no hole.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
341 lines
16 KiB
TypeScript
341 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'],
|
|
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);
|
|
});
|
|
});
|