Files
Codeman/test/tile-grid-dividers.test.ts
T
Codeman maintainer a07c663ca9 test(tiles): the grid harness finds a tile's element, and serves the pure helpers
Five tile-grid tests defined the same `tileEl(id)` lookup and three more
inlined it; the harness (test/mocks/tile-grid-vm.ts) now exports it and
they import it. tile-grid-open-set built a second vm context just to read
constants.js, although the harness it already imports has loaded the same
file: it reads windowStub.CodemanTileGrid instead.

No assertion changed. Mutation-checked: tiles without their
data-session-id fail 34 tests across the seven files, and a broken
tileGridOpenSet fails the open-set cases.

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

215 lines
8.2 KiB
TypeScript

/**
* @fileoverview Draggable column and row dividers.
*
* Every tile is placed explicitly (`grid-column` / `grid-row`, reading order),
* with a divider track between columns and between rows. Dragging a divider
* trades size between the two tracks either side, each kept at the minimum tile
* size. The affected tiles reflow LOCALLY at most once per animation frame
* (`localFit`, no PTY resize), and each hears exactly ONE `fit()` (one PTY
* resize) at pointer-up; tiles in other tracks hear nothing. A drag in progress
* is torn down when the grid closes or a tile is removed (the split's mid-drag
* lesson). Fractions reset when the column or row count changes.
*
* 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 {
FakeEl,
FakeTile,
body,
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 divider = (app: GridApp, key: string) => app._tileGrid.dividers.get(key) as FakeEl;
const tile = (id: string) => FakeTile.all.find((t) => t.sessionId === id) as FakeTile;
function grid(ids: string[]): GridApp {
const app = makeGridApp(ids);
app.openTileGrid(ids);
for (const t of FakeTile.all) {
t.fit.mockClear();
t.localFit.mockClear();
}
return app;
}
const runFrames = () => {
for (const cb of rafCallbacks.splice(0)) cb();
};
beforeEach(() => {
resetGridHarness();
});
describe('placement', () => {
it('2x2: one column divider, one row divider, tiles placed in reading order', () => {
const app = grid(['s-a', 's-b', 's-c', 's-d']);
expect([...app._tileGrid.dividers.keys()].sort()).toEqual(['col-0', 'row-0']);
expect(section.style.gridTemplateColumns).toBe('minmax(0, 1fr) 6px minmax(0, 1fr)');
expect(section.style.gridTemplateRows).toBe('minmax(0, 1fr) 6px minmax(0, 1fr)');
expect([tileEl('s-d').style.gridColumn, tileEl('s-d').style.gridRow]).toEqual(['3', '3']);
expect([tileEl('s-b').style.gridColumn, tileEl('s-b').style.gridRow]).toEqual(['3', '1']);
expect([divider(app, 'col-0').style.gridColumn, divider(app, 'col-0').style.gridRow]).toEqual(['2', '1 / -1']);
expect([divider(app, 'row-0').style.gridColumn, divider(app, 'row-0').style.gridRow]).toEqual(['1 / -1', '2']);
});
it('3x2 has two column dividers; a single tile has none', () => {
expect([...grid(SIX)._tileGrid.dividers.keys()].sort()).toEqual(['col-0', 'col-1', 'row-0']);
resetGridHarness();
expect(grid(['s-a'])._tileGrid.dividers.size).toBe(0);
});
it('dividers are separators a screen reader can name', () => {
const app = grid(['s-a', 's-b']);
const d = divider(app, 'col-0');
expect(d.getAttribute('role')).toBe('separator');
expect(d.getAttribute('aria-orientation')).toBe('vertical');
});
});
describe('dragging', () => {
function drag(app: GridApp, key: string, from: number, to: number[]) {
const d = divider(app, key);
const axisCol = key.startsWith('col');
const at = (v: number) => (axisCol ? { clientX: v, clientY: 0 } : { clientX: 0, clientY: v });
d.dispatch('pointerdown', {
button: 0,
pointerId: 7,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
...at(from),
});
for (const v of to) d.dispatch('pointermove', at(v));
return d;
}
it('reflows locally once per frame and resizes each affected PTY exactly once, at pointer-up', () => {
const app = grid(SIX);
const d = drag(app, 'col-1', 1600, [1620, 1640, 1660, 1680]);
// Four moves, one frame.
expect(rafCallbacks).toHaveLength(1);
runFrames();
// Column 1 and 2 tiles: b, c (row 0) and e, f (row 1).
for (const id of ['s-b', 's-c', 's-e', 's-f']) expect(tile(id).localFit).toHaveBeenCalledTimes(1);
expect(tile('s-a').localFit).not.toHaveBeenCalled();
expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true);
d.dispatch('pointerup', {});
for (const id of ['s-b', 's-c', 's-e', 's-f']) expect(tile(id).fit).toHaveBeenCalledTimes(1);
expect(tile('s-a').fit).not.toHaveBeenCalled();
expect(tile('s-d').fit).not.toHaveBeenCalled();
});
it('the frame still queued at pointer-up is applied, so the tracks end where the pointer did', () => {
const app = grid(['s-a', 's-b']);
const d = drag(app, 'col-0', 1200, [1300]);
d.dispatch('pointerup', {});
// 2400 wide, 8px padding, one 6px divider: 2386px shared; the left track gained 100px.
const [a, b] = app._tileGrid.colFr;
expect(Math.round((a / (a + b)) * 2386)).toBe(1293);
expect(section.style.gridTemplateColumns).toMatch(/^minmax\(0, [\d.]+fr\) 6px minmax\(0, [\d.]+fr\)$/);
});
it('keeps both neighbours at the minimum tile size', () => {
const app = grid(['s-a', 's-b']);
const d = drag(app, 'col-0', 1200, [100]);
d.dispatch('pointerup', {});
const [a, b] = app._tileGrid.colFr;
expect(Math.round((a / (a + b)) * 2386)).toBe(480);
});
it('a row divider moves rows and resizes the tiles in them', () => {
const app = grid(['s-a', 's-b', 's-c', 's-d']);
const d = drag(app, 'row-0', 600, [650]);
d.dispatch('pointerup', {});
for (const id of ['s-a', 's-b', 's-c', 's-d']) expect(tile(id).fit).toHaveBeenCalledTimes(1);
expect(app._tileGrid.rowFr[0]).toBeGreaterThan(app._tileGrid.rowFr[1]);
});
it('locks the cursor and text selection for the drag, released at the end', () => {
const app = grid(['s-a', 's-b']);
const d = drag(app, 'col-0', 1200, []);
expect(body.classList.contains('tile-grid-resizing')).toBe(true);
expect(d.classList.contains('dragging')).toBe(true);
d.dispatch('pointerup', {});
expect(body.classList.contains('tile-grid-resizing')).toBe(false);
expect(d.setPointerCapture).toHaveBeenCalledWith(7);
expect(d.releasePointerCapture).toHaveBeenCalledWith(7);
});
});
describe('a drag torn down mid-way', () => {
function startDrag(app: GridApp) {
const d = divider(app, 'col-0');
d.dispatch('pointerdown', {
button: 0,
pointerId: 1,
clientX: 800,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
});
d.dispatch('pointermove', { clientX: 900 });
return d;
}
it('closing the grid ends it: no body lock left behind, no late resize', () => {
const app = grid(['s-a', 's-b']);
app.selectSession = vi.fn();
const d = startDrag(app);
app.closeTileGrid({ reselect: false });
expect(body.classList.contains('tile-grid-resizing')).toBe(false);
expect(d.listeners.pointermove ?? []).toHaveLength(0);
runFrames();
d.dispatch('pointerup', {});
expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true);
});
it('removing a tile ends it', () => {
const app = grid(['s-a', 's-b', 's-c']);
startDrag(app);
app.removeTile('s-c');
expect(body.classList.contains('tile-grid-resizing')).toBe(false);
expect(app._tileDividerDragTeardown ?? null).toBeNull();
});
});
describe('fractions', () => {
it('reset to equal when the column count changes', () => {
const app = grid(['s-a', 's-b', 's-c', 's-d']);
app._tileGrid.colFr = [3, 1];
app._applyTileLayout();
expect(app._tileGrid.colFr).toEqual([3, 1]);
app.sessions.set('s-e', { id: 's-e', name: 's-e', mode: 'claude', pid: 1 });
app.addTile('s-e');
expect(app._tileGrid.colFr).toEqual([1, 1, 1]);
});
});
describe('fractions on fewer columns', () => {
it('reset to equal when the column count drops (no stale track left over)', () => {
const app = grid(['s-a', 's-b', 's-c', 's-d', 's-e']);
app._tileGrid.colFr = [3, 1, 1];
app.removeTile('s-e');
expect(app._tileGrid.colFr).toEqual([1, 1]);
expect(section.style.gridTemplateColumns).toBe('minmax(0, 1fr) 6px minmax(0, 1fr)');
});
});
describe('zoomed', () => {
it('has no dividers while a tile fills the grid, and gets them back on restore', () => {
const app = grid(['s-a', 's-b', 's-c', 's-d']);
const dividerEls = () => section.children.filter((el) => el.className.startsWith('tile-divider'));
app.zoomTile('s-a');
expect(app._tileGrid.dividers.size).toBe(0);
expect(dividerEls()).toHaveLength(0);
app.zoomTile('s-a');
expect(dividerEls()).toHaveLength(2);
});
});