diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 03d352bd..b170c453 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -1848,6 +1848,45 @@ function tileGridOpenSet({ stored = null, split = null, sessions, sessionOrder, return { source: 'tabs', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] }; } +/** + * The tile counts the Tiles button's right-click menu offers, and the count a + * click opens until one is picked (owner decision 10 in docs/tile-grid-plan.md). + */ +const TILE_GRID_COUNTS = [2, 4, 6]; +const TILE_GRID_COUNT_DEFAULT = 6; + +/** A remembered tile count made safe: one of TILE_GRID_COUNTS, else the default. */ +function sanitizeTileCount(raw) { + const n = Number(raw); + return TILE_GRID_COUNTS.includes(n) ? n : TILE_GRID_COUNT_DEFAULT; +} + +/** + * `base` (what the grid would open, or what an open grid shows, in its order) + * trimmed or filled to `n` tiles: trimmed from the end, the session to focus + * (`keepId`) always kept (it takes the last place when it sat past `n`, as in + * tileGridOpenSet's case c); filled from `all` (the open sessions in tab order) + * with the ones not in it yet. Fewer sessions than `n` give fewer tiles. + * + * @param {string[]} base + * @param {string[]} all + * @param {number} n - at most TILE_GRID_MAX + * @param {string|null} [keepId] + * @returns {string[]} + */ +function tileGridSetForCount(base, all, n, keepId = null) { + const max = Math.max(1, Math.min(Math.floor(Number(n) || 0), TILE_GRID_MAX)); + const ids = []; + for (const id of [...(base || []), ...(all || [])]) { + if (typeof id === 'string' && id && !ids.includes(id)) ids.push(id); + } + // Every `base` id comes before every filler, so a trim never drops a base id + // in favour of one. + let out = ids.slice(0, max); + if (keepId && ids.includes(keepId) && !out.includes(keepId)) out = [...out.slice(0, max - 1), keepId]; + return out; +} + /** * Which tile takes focus when `id` leaves the grid: the next one in grid * order, else the previous one, else null. @@ -1962,6 +2001,54 @@ function fitTileCells(cells, oldCols, cols, rows) { return out; } +/** + * How many columns a grid of `length` cells was laid out with: stored cells + * carry no shape of their own, and the layout table gives each cell count one + * shape (computeTileLayout: 1x1, 2x1, 3x1, 2x2, 3x2, 3x3). 0 for any other + * length (fitTileCells then packs). + * + * @param {number} length + * @returns {number} + */ +function tileCellCols(length) { + // Every cell count is some count's shape on a wide grid area (2x2, the + // narrow 3-tile shape, is also the 4-tile one). + for (let n = 1; n <= TILE_LAYOUT_MAX; n++) { + const { cols, rows } = computeTileLayout({ count: n }); + if (cols * rows === length) return cols; + } + return 0; +} + +/** + * The cells of a grid re-formed to another set of tiles (a count picked in the + * Tiles menu, or the Tiles button bringing back a remembered grid with more or + * fewer tiles): the tiles in `keep` stay in their cells and every other cell + * empties, then the cell model's shape rule (fitTileCells: each tile keeps its + * row and column when all fit, else they pack in reading order), then the + * tiles in `add` fill the empty cells in reading order, holes first. + * + * @param {(string|null)[]} cells - the cells now, laid out `oldCols` wide + * @param {number} oldCols + * @param {string[]} keep - tiles that stay + * @param {string[]} add - tiles that join, in the order they fill + * @param {number} cols - the new shape + * @param {number} rows + * @returns {(string|null)[]} + */ +function reformTileCells(cells, oldCols, keep, add, cols, rows) { + const kept = (cells || []).map((id) => (id && keep.includes(id) ? id : null)); + const out = fitTileCells(kept, oldCols, cols, rows); + for (const id of add) { + if (!id || out.includes(id)) continue; + const k = out.indexOf(null); + // Not for a shape made for the count; a full grid takes no more. + if (k === -1) break; + out[k] = id; + } + return out; +} + /** * The tile Ctrl+Tab / Alt+] (delta 1) or Alt+[ (delta -1) moves to while the * grid is open: tiles cycle in reading order and wrap. @@ -2288,6 +2375,12 @@ if (typeof window !== 'undefined') { fitTileCells, cycleTile, tileGridOpenSet, + sanitizeTileCount, + tileGridSetForCount, + tileCellCols, + reformTileCells, + TILE_GRID_COUNTS, + TILE_GRID_COUNT_DEFAULT, TILE_GRID_MAX, TILE_LAYOUT_MAX, TILE_MIN_W, diff --git a/test/tile-grid-count-helpers.test.ts b/test/tile-grid-count-helpers.test.ts new file mode 100644 index 00000000..99b0fcb0 --- /dev/null +++ b/test/tile-grid-count-helpers.test.ts @@ -0,0 +1,157 @@ +/** + * @fileoverview The tile count (owner decision 10): the pure helpers behind the + * Tiles button's 2 / 4 / 6 right-click menu. + * + * - `sanitizeTileCount`: a remembered count is one of TILE_GRID_COUNTS, else + * the default (6). + * - `tileGridSetForCount`: what the grid opens (or an open grid shows) trimmed + * or filled to N: trimmed from the end with the session to focus always + * kept, filled from the open sessions in tab order; fewer sessions than N + * give fewer tiles; never past the cap. + * - `tileCellCols`: the column count a stored cell list was laid out with. + * - `reformTileCells`: a count change is a shape change: kept tiles stay in + * their cells, the cell model's rule (fitTileCells) reshapes, joining tiles + * fill the empty cells in reading order, holes first. + * + * The helpers from constants.js as the shared harness loads them. + * Port: N/A. + */ +import { describe, expect, it } from 'vitest'; +import { windowStub } from './mocks/tile-grid-vm.js'; + +type Cells = (string | null)[]; +type Helpers = { + sanitizeTileCount(raw: unknown): number; + tileCellCols(length: number): number; + reformTileCells(cells: Cells, oldCols: number, keep: string[], add: string[], cols: number, rows: number): Cells; + computeTileLayout(p: { count: number; width?: number }): { cols: number; rows: number }; + tileGridSetForCount(base: string[], all: string[], n: number, keepId?: string | null): string[]; + TILE_GRID_COUNTS: number[]; + TILE_GRID_COUNT_DEFAULT: number; + TILE_GRID_MAX: number; +}; +const T = windowStub.CodemanTileGrid as Helpers; +const all = ['t1', 't2', 't3', 't4', 't5', 't6', 't7', 't8']; + +describe('sanitizeTileCount', () => { + it('offers 2, 4 and 6, default 6', () => { + expect(T.TILE_GRID_COUNTS).toEqual([2, 4, 6]); + expect(T.TILE_GRID_COUNT_DEFAULT).toBe(6); + }); + + it('keeps a valid count, as a number or as stored text', () => { + expect(T.sanitizeTileCount(2)).toBe(2); + expect(T.sanitizeTileCount('4')).toBe(4); + expect(T.sanitizeTileCount('6')).toBe(6); + }); + + it('anything else is the default', () => { + for (const raw of [null, undefined, '', '3', 5, 9, 0, -2, 'six', '{}', NaN]) { + expect(T.sanitizeTileCount(raw)).toBe(6); + } + }); +}); + +describe('tileGridSetForCount', () => { + it('fills from tab order after the base, skipping ones already in it', () => { + expect(T.tileGridSetForCount(['t5', 't2'], all, 6)).toEqual(['t5', 't2', 't1', 't3', 't4', 't6']); + }); + + it('trims from the end', () => { + expect(T.tileGridSetForCount(['t1', 't2', 't3', 't4', 't5', 't6'], all, 4)).toEqual(['t1', 't2', 't3', 't4']); + }); + + it('keeps the session to focus when it sat past N, in the last place', () => { + expect(T.tileGridSetForCount(['t1', 't2', 't3', 't4', 't5', 't6'], all, 2, 't5')).toEqual(['t1', 't5']); + expect(T.tileGridSetForCount(['t1', 't2', 't3', 't4'], all, 2, 't2')).toEqual(['t1', 't2']); + }); + + it('a base id is never trimmed in favour of a filler', () => { + expect(T.tileGridSetForCount(['t7', 't8'], all, 2)).toEqual(['t7', 't8']); + }); + + it('fewer open sessions than N: fewer tiles', () => { + expect(T.tileGridSetForCount(['t1'], ['t1', 't2', 't3'], 6)).toEqual(['t1', 't2', 't3']); + expect(T.tileGridSetForCount([], [], 4)).toEqual([]); + }); + + it('never past the cap, and at least one', () => { + expect(T.tileGridSetForCount([], all, 9)).toHaveLength(T.TILE_GRID_MAX); + expect(T.tileGridSetForCount([], all, 0)).toEqual(['t1']); + }); + + it('drops duplicates and non-ids', () => { + expect(T.tileGridSetForCount(['t2', 't2', '', null as unknown as string], all, 3)).toEqual(['t2', 't1', 't3']); + }); +}); + +describe('tileCellCols', () => { + it('reads the shape back from the cell count, as the layout table lays it out', () => { + expect([1, 2, 3, 4, 6, 9].map((n) => T.tileCellCols(n))).toEqual([1, 2, 3, 2, 3, 3]); + }); + + it('agrees with computeTileLayout for every count, wide and narrow', () => { + for (let n = 1; n <= 9; n++) { + for (const width of [Infinity, 0]) { + const { cols, rows } = T.computeTileLayout({ count: n, width }); + expect(T.tileCellCols(cols * rows)).toBe(cols); + } + } + }); + + it('an unknown length is 0 (the tiles then pack)', () => { + for (const n of [0, 5, 7, 8, 10, -1, NaN]) expect(T.tileCellCols(n)).toBe(0); + }); +}); + +describe('reformTileCells', () => { + it('growing 2 (2x1) to 6 (3x2): both stay in their cells, the four new ones fill in reading order', () => { + expect(T.reformTileCells(['a', 'b'], 2, ['a', 'b'], ['c', 'd', 'e', 'f'], 3, 2)).toEqual([ + 'a', 'b', 'c', + 'd', 'e', 'f', + ]); + }); + + it('growing fills the holes first, in reading order', () => { + // 5 of a 3x2 with the hole in the middle of the first row, then 6. + expect(T.reformTileCells(['a', null, 'b', 'c', 'd', 'e'], 3, ['a', 'b', 'c', 'd', 'e'], ['f'], 3, 2)).toEqual([ + 'a', 'f', 'b', + 'c', 'd', 'e', + ]); + // 3 of a 2x2 (hole first) growing to 4 keeps the shape: the hole fills. + expect(T.reformTileCells([null, 'a', 'b', 'c'], 2, ['a', 'b', 'c'], ['d'], 2, 2)).toEqual(['d', 'a', 'b', 'c']); + }); + + it('a 2x2 growing to 3x2 keeps every tile at its row and column', () => { + expect(T.reformTileCells(['a', 'b', 'c', 'd'], 2, ['a', 'b', 'c', 'd'], ['e', 'f'], 3, 2)).toEqual([ + 'a', 'b', 'e', + 'c', 'd', 'f', + ]); + }); + + it('shrinking 6 (3x2) to 4 (2x2): a tile in the third column does not fit, so the kept ones pack', () => { + expect(T.reformTileCells(['a', 'b', 'c', 'd', 'e', 'f'], 3, ['a', 'b', 'c', 'd'], [], 2, 2)).toEqual([ + 'a', 'b', 'c', 'd', + ]); + }); + + it('shrinking keeps rows and columns when every kept tile still fits', () => { + // 3x2 to 2x2 keeping two tiles inside the first two columns: they stay at + // their row and column, holes and all (packing would move b up). + expect(T.reformTileCells([null, 'a', 'x', 'b', null, 'y'], 3, ['a', 'b'], [], 2, 2)).toEqual([ + null, 'a', + 'b', null, + ]); + // 2x2 to 2x1 keeping the first row. + expect(T.reformTileCells(['a', 'b', 'c', 'd'], 2, ['a', 'b'], [], 2, 1)).toEqual(['a', 'b']); + }); + + it('a stored grid of unknown shape (oldCols 0) packs', () => { + expect(T.reformTileCells([null, 'a', 'b'], 0, ['a', 'b'], ['c'], 2, 2)).toEqual(['a', 'b', 'c', null]); + }); + + it('a full grid takes no more, and a tile already there is not added twice', () => { + expect(T.reformTileCells(['a', 'b'], 2, ['a', 'b'], ['c', 'a'], 2, 1)).toEqual(['a', 'b']); + expect(T.reformTileCells(['a', null], 2, ['a'], ['a', 'c'], 2, 1)).toEqual(['a', 'c']); + }); +});