Files
Codeman/test/tile-grid-restore.test.ts
T
Codeman maintainer 7debebb7b3 feat(tiles): right-click Tiles is a 2 / 4 / 6 count menu
Owner decision 10 ("give me then the option to choose only HOW many
tiles, 2,4,6 default is 6 so the menu is easier"; asked where it lives:
"Click opens 6"). The click still opens the grid at once; right-click,
Shift+F10 or the Menu key on the button opens a small menu of three
counts, each drawn as the grid's own layout (2x1, 2x2, 3x2), the
remembered one checked. It replaces the session picker, which is gone
(method, markup hook, CSS, zh-CN entries).

- A pick is remembered per device in codeman:tile-count (default 6;
  codeman:tile-grid stays ids only) and opens that many tiles. The click
  and Ctrl+Shift+G then open with it, at most what the window fits.
- Which sessions: the rule the click already had (the grid this tab
  last had, else an open split's two, else tab order, the active one
  included and focused), trimmed from the end (the focused one kept) or
  filled from tab order to the count. A remembered grid comes back with
  its tiles first, in their cells, holes filled first, then tab order
  (owner answer, superseding decision 8's "exactly the stored set"). A
  page-load restore still brings back exactly what was stored.
- With the grid open a pick re-forms it: a count change is a shape
  change under the cell model's rule (reformTileCells), the focused tile
  always kept, every joining tile mounted and laid out before any of
  them connects, so each fits once and sends one PTY resize.
- Ctrl/Cmd+click on a tab with the grid closed opens the count in total,
  that session among them and focused (owner answer: N, not N+1).
- A count the window cannot fit is greyed out with the reason; a
  remembered one stays checked, the keyboard starts on the largest that
  fits. Arrows, Home/End, Enter or Space; Escape closes the menu alone
  (the global handler gives it the key first, like the tab-group menu)
  and puts the keyboard back on the Tiles button; Tab and a click
  elsewhere close it.
- zh-CN for every new string (N 个窗格, 窗格数量, the titles, the Help
  modal row); the i18n test harvests the menu now, with a session named
  "6 tiles" as the user-text trap.

Tests: tile-grid-picker.test.ts becomes tile-grid-count-menu.test.ts
(the button checks kept, every picker check carried over to the menu,
plus keyboard, remembered count, re-form, Ctrl/Cmd+click and the batch
connect); the open-set, cap, restore, shortcuts, split-coexistence and
i18n expectations follow the count; the Help modal test escapes its
label (the new one has parentheses). The vm harness tracks
document.activeElement and makes SVG elements.

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

271 lines
10 KiB
TypeScript

/**
* @fileoverview The grid survives a page reload, per device (`codeman:tile-grid`).
*
* - What is stored: ids, focus, a zoom the user chose and the divider
* fractions, never content. Closing the grid (Tiles, a pick outside it)
* keeps it remembered as `open: false` for one-click return; the last tile
* leaving forgets it. Never written or read in a solo window.
* - The restore runs INSIDE handleInit, in place of its single-view
* `selectSession(restoreId, { auto: true })`: with a stored open grid the
* main terminal never loads on that page load (no select, no socket, no
* capture), deleted / detached / duplicate ids are dropped, and the stored
* fractions and zoom come back. A narrow window keeps the single view.
* - A `#session=` link on load wins, and leaves the stored grid remembered
* but closed.
* - Leaving the grid invalidates the main terminal's cached content for every
* tiled id (snapshot, its localStorage copy, buffer cache).
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FakeTile, localStore, makeGridApp, resetGridHarness, windowStub, type GridApp } from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c', 's-d'];
const KEY = 'codeman:tile-grid';
const stored = () => JSON.parse(localStore.get(KEY) ?? 'null');
/** A fresh page: the app as handleInit leaves it on its FIRST run (gen 1). */
function pageLoad(liveIds: string[], setup: (app: GridApp) => void = () => {}) {
const app = makeGridApp(IDS);
app._initGeneration = 0;
app.activeSessionId = null;
app.selectSession = vi.fn();
app._fetchTerminalCapture = vi.fn();
app._resetAllAppState = vi.fn(() => app.sessions.clear());
for (const name of [
'_clearTimer',
'_updateCjkInputState',
'syncSessionOrder',
'_loadTabLayout',
'cleanupAllFloatingWindows',
'startSystemStatsPolling',
'stopSystemStatsPolling',
'updateCost',
]) {
app[name] = vi.fn();
}
app.$ = () => null;
setup(app);
app.handleInit({
sessions: liveIds.map((id) => ({ id, name: id, mode: 'claude', pid: 1 })),
scheduledRuns: [],
});
return app;
}
beforeEach(() => {
resetGridHarness();
});
describe('what is stored', () => {
it('opening the grid stores ids, focus and fractions, nothing else', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS, { focusedId: 's-b' });
expect(stored()).toEqual({
v: 1,
open: true,
ids: IDS,
focused: 's-b',
zoomed: null,
colFr: [1, 1],
rowFr: [1, 1],
});
});
it('focus, a zoom by hand and a divider drag are written as they happen', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
app.selectSession('s-c');
expect(stored().focused).toBe('s-c');
app.zoomTile('s-c');
expect(stored().zoomed).toBe('s-c');
app.zoomTile('s-c');
app._tileGrid.colFr = [2, 1];
app._persistTileGrid();
expect(stored().colFr).toEqual([2, 1]);
});
it('an automatic zoom (window too small) is not stored: it is worked out again', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
app._tileGrid.zoomedId = 's-a';
app._tileGrid.autoZoom = true;
app._persistTileGrid();
expect(stored().zoomed).toBeNull();
});
it('closing keeps it remembered (open: false); the last tile leaving forgets it', () => {
const app = makeGridApp(IDS);
app.selectSession = vi.fn();
app.openTileGrid(IDS);
app.closeTileGrid({ reselect: false });
expect(stored()).toMatchObject({ open: false, ids: IDS });
app.openTileGrid(['s-a']);
app.removeTile('s-a');
expect(localStore.has(KEY)).toBe(false);
});
it('a solo window never writes', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
localStore.delete(KEY);
app.isSoloWindow = true;
app._persistTileGrid();
expect(localStore.has(KEY)).toBe(false);
});
});
describe('page load with a stored open grid', () => {
const storeGrid = (state: Record<string, unknown>) =>
localStore.set(KEY, JSON.stringify({ v: 1, open: true, zoomed: null, ...state }));
it('restores the grid IN PLACE of the single view: the main terminal never loads', () => {
storeGrid({ ids: IDS, focused: 's-c' });
const app = pageLoad([...IDS, 's-other']);
expect(app._tilesOwnTerminal()).toBe(true);
expect(app._tileGrid.ids).toEqual(IDS);
expect(app.activeSessionId).toBe('s-c');
expect(app.selectSession).not.toHaveBeenCalled();
expect(app._connectWs).not.toHaveBeenCalled();
expect(app._fetchTerminalCapture).not.toHaveBeenCalled();
// Restoring is the app's choice of focus: no idle alert spent.
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
});
it('drops a session that no longer exists, and stores the cleaned list', () => {
storeGrid({ ids: ['s-a', 'gone', 's-b'], focused: 'gone' });
const app = pageLoad(IDS);
expect(app._tileGrid.ids).toEqual(['s-a', 's-b']);
expect(app.activeSessionId).toBe('s-a');
expect(stored().ids).toEqual(['s-a', 's-b']);
});
it('brings back the fractions (same layout only) and a zoom the user chose', () => {
storeGrid({ ids: IDS, focused: 's-a', zoomed: 's-b', colFr: [2, 1], rowFr: [1, 1, 1] });
const app = pageLoad(IDS);
expect(app._tileGrid.colFr).toEqual([2, 1]);
// Three row fractions do not fit a 2x2: equal rows.
expect(app._tileGrid.rowFr).toEqual([1, 1]);
expect(app._tileGrid.zoomedId).toBe('s-b');
expect(app.activeSessionId).toBe('s-b');
});
it('a stored 3x3 (before the cap of 6) comes back as its first six, focus kept, a dropped zoom cleared', () => {
const nine = Array.from({ length: 9 }, (_, i) => `n-${i + 1}`);
storeGrid({ ids: nine, focused: 'n-5', zoomed: 'n-8', colFr: [2, 1, 1], rowFr: [1, 1, 1] });
const app = pageLoad(nine);
expect(app._tileGrid.ids).toEqual(nine.slice(0, 6));
expect(FakeTile.all.filter((t) => !t._destroyed)).toHaveLength(6);
expect(app.activeSessionId).toBe('n-5');
expect(app._tileGrid.zoomedId).toBeNull();
// 3x2 now: the columns still match, the three stored rows do not.
expect(app._tileGrid.colFr).toEqual([2, 1, 1]);
expect(app._tileGrid.rowFr).toEqual([1, 1]);
expect(stored().ids).toEqual(nine.slice(0, 6));
});
it('a stored closed grid leaves the single view, and the Tiles toggle brings it back', () => {
localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: ['s-b', 's-c'], focused: 's-c' }));
localStore.set('codeman:tile-count', '2');
const app = pageLoad(IDS, (a) => localStore.set('codeman-active-session', 's-a'));
expect(app._tilesOwnTerminal()).toBe(false);
expect(app.selectSession).toHaveBeenCalledWith('s-a', { auto: true });
app.activeSessionId = 's-a';
app.toggleTileGrid();
expect(app._tileGrid.ids).toEqual(['s-b', 's-c']);
expect(app.activeSessionId).toBe('s-c');
});
it('the toggle fills a stored grid to the remembered count: its tiles first, in their cells, then tab order', () => {
// Default 6 (owner answer 1, superseding decision 8's "exactly the stored set").
localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: ['s-b', 's-c'], focused: 's-c' }));
const app = pageLoad(IDS, (a) => localStore.set('codeman-active-session', 's-a'));
app.activeSessionId = 's-a';
app.toggleTileGrid();
// The two stay in the first row (2x1 to 2x2 keeps them), the rest join
// in tab order: every live session, fewer than the count.
expect(app._tileGrid.cells).toEqual(['s-b', 's-c', 's-a', 's-d']);
expect(app.activeSessionId).toBe('s-c');
});
it('a stored hole is filled first when the count needs more tiles', () => {
// A 2x2 of three with the hole first; the count asks for four.
localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: [null, 's-b', 's-c', 's-d'], focused: 's-b' }));
localStore.set('codeman:tile-count', '4');
const app = pageLoad(IDS, (a) => localStore.set('codeman-active-session', 's-a'));
app.toggleTileGrid();
// s-a joins in the hole (packing would have put it last).
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d']);
});
it('a reload brings back exactly the stored grid, whatever the count', () => {
localStore.set('codeman:tile-count', '6');
storeGrid({ ids: ['s-b', 's-c'], focused: 's-c' });
const app = pageLoad(IDS);
expect(app._tileGrid.ids).toEqual(['s-b', 's-c']);
});
it('a window too narrow for the grid keeps the single view (the stored grid waits)', () => {
storeGrid({ ids: IDS, focused: 's-a' });
windowStub.innerWidth = 1100;
const app = pageLoad(IDS);
expect(app._tileGrid?.open ?? false).toBe(false);
expect(app.selectSession).toHaveBeenCalled();
expect(stored().open).toBe(true);
});
it('a solo window never restores it', () => {
storeGrid({ ids: IDS, focused: 's-a' });
const app = pageLoad(IDS, (a) => {
a.isSoloWindow = true;
a._applySoloMode = vi.fn();
});
expect(app._tileGrid?.open ?? false).toBe(false);
expect(FakeTile.all).toHaveLength(0);
});
it.each([
['another version', JSON.stringify({ v: 2, open: true, ids: IDS })],
['not JSON', '{oops'],
['not an object', '[1,2]'],
])('ignores a stored value that is %s', (_label, raw) => {
localStore.set(KEY, raw);
const app = pageLoad(IDS);
expect(app._tileGrid?.open ?? false).toBe(false);
expect(app.selectSession).toHaveBeenCalled();
});
it('a #session= link on load wins, and the grid stays remembered, closed', () => {
storeGrid({ ids: IDS, focused: 's-a' });
const app = pageLoad(IDS, (a) => {
a._urlSessionId = 's-d';
});
expect(app._tileGrid?.open ?? false).toBe(false);
expect(app.selectSession).toHaveBeenCalledWith('s-d', { auto: true, leaveTiles: true });
expect(stored()).toMatchObject({ open: false, ids: IDS });
});
});
describe('leaving the grid', () => {
it("invalidates the main terminal's cached content for every tiled id", () => {
const app = makeGridApp(IDS);
app.selectSession = vi.fn();
for (const id of [...IDS, 's-other']) {
app._xtermSnapshots.set(id, 'old');
app.terminalBufferCache.set(id, 'old');
localStore.set(`codeman-xs-${id}`, 'old');
}
app.openTileGrid(['s-a', 's-b']);
app.closeTileGrid();
for (const id of ['s-a', 's-b']) {
expect(app._xtermSnapshots.has(id)).toBe(false);
expect(app.terminalBufferCache.has(id)).toBe(false);
expect(localStore.has(`codeman-xs-${id}`)).toBe(false);
}
expect(app._xtermSnapshots.get('s-c')).toBe('old');
});
});