2026-10-08 03:08:14 +02:00
|
|
|
/**
|
|
|
|
|
* @fileoverview The grid as cells (owner: "the empty tab doesnt always have to
|
|
|
|
|
* be the last one ... it can also be tab nr 4 or 3"). `grid.cells` holds a
|
|
|
|
|
* session id or `null` per cell and is the one source of truth; `grid.ids` is
|
|
|
|
|
* the tiles in reading order, derived. The shape still comes from the tile
|
|
|
|
|
* count (the layout table), and the cap counts tiles, never empty cells.
|
|
|
|
|
*
|
|
|
|
|
* - Removing a tile without a shape change leaves its cell empty where it was.
|
|
|
|
|
* - Adding one without a shape change takes the first empty cell (or the cell a
|
|
|
|
|
* tab was dropped on: tile-grid-move.test.ts).
|
|
|
|
|
* - A shape change keeps each tile's row and column when all fit (2x2 growing
|
|
|
|
|
* to 3x2), else the tiles pack in reading order (fitTileCells).
|
|
|
|
|
* - Focus never lands on an empty cell: the Alt+Shift+Arrow chords skip it,
|
|
|
|
|
* and Ctrl+Tab / Alt+[ ] cycle through the tiles only.
|
|
|
|
|
* - `codeman:tile-grid` stores the cells (ids, `null` for a hole); a reload
|
|
|
|
|
* brings the holes back when the shape is the same, a session gone by then
|
2026-10-10 05:58:17 +02:00
|
|
|
* frees its cell for the ranking to fill (empty only when no other session
|
|
|
|
|
* is left), a different shape packs, and the old packed format reads
|
|
|
|
|
* unchanged. A followed `#session=` link keeps the holes.
|
2026-10-08 03:08:14 +02:00
|
|
|
* - A divider drag refits only the tiles in its two tracks, holes skipped.
|
|
|
|
|
*
|
|
|
|
|
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
|
|
|
|
*/
|
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
|
import {
|
|
|
|
|
FakeEl,
|
|
|
|
|
FakeTile,
|
|
|
|
|
documentAddEventListener,
|
|
|
|
|
localStore,
|
|
|
|
|
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 FIVE = SIX.slice(0, 5);
|
|
|
|
|
const KEY = 'codeman:tile-grid';
|
|
|
|
|
const slots = () => section.children.filter((el) => el.className.split(' ').includes('tile-slot'));
|
|
|
|
|
const stored = () => JSON.parse(localStore.get(KEY) ?? 'null');
|
|
|
|
|
const tile = (id: string) => FakeTile.all.find((t) => t.sessionId === id && !t._destroyed) as FakeTile;
|
|
|
|
|
const place = (id: string) => [tileEl(id).style.gridColumn, tileEl(id).style.gridRow];
|
|
|
|
|
|
|
|
|
|
// Under 1800px wide three tiles take a 2x2 (over it, 3x1).
|
|
|
|
|
const narrow = () => {
|
|
|
|
|
section.getBoundingClientRect = () => ({ width: 1700, height: 1000, top: 0, left: 0, right: 1700, bottom: 1000 });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function openGrid(ids: string[], focusedId = ids[0], all: string[] = SIX): GridApp {
|
|
|
|
|
const app = makeGridApp(all);
|
|
|
|
|
app.openTileGrid(ids, { focusedId });
|
|
|
|
|
return app;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setCells(app: GridApp, cells: Array<string | null>) {
|
|
|
|
|
app._tileGrid.cells = cells.slice();
|
|
|
|
|
app._applyTileLayout();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
resetGridHarness();
|
|
|
|
|
narrow();
|
|
|
|
|
});
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('the cells are the source of truth', () => {
|
|
|
|
|
it('ids are the tiles in reading order, derived from the cells (holes skipped)', () => {
|
|
|
|
|
const app = openGrid(FIVE);
|
|
|
|
|
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
|
|
|
|
|
expect(app._tileGrid.ids).toEqual(FIVE);
|
|
|
|
|
expect(app._tileGrid.cells).toEqual(['s-a', null, 's-b', 's-c', 's-d', 's-e']);
|
|
|
|
|
// A tile sits in its cell, the slot in the empty one.
|
|
|
|
|
expect(place('s-b')).toEqual(['5', '1']);
|
|
|
|
|
expect([slots()[0].style.gridColumn, slots()[0].style.gridRow]).toEqual(['3', '1']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('opening packs from the first cell and pads the shape with empty cells', () => {
|
|
|
|
|
const app = openGrid(FIVE);
|
|
|
|
|
expect(app._tileGrid.cells).toEqual([...FIVE, null]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('removing a tile', () => {
|
|
|
|
|
it('without a shape change (6 to 5 stays 3x2): its cell is left empty where it was', () => {
|
|
|
|
|
const app = openGrid(SIX);
|
|
|
|
|
app.removeTile('s-b');
|
|
|
|
|
expect(app._tileGrid.cells).toEqual(['s-a', null, 's-c', 's-d', 's-e', 's-f']);
|
|
|
|
|
expect(place('s-c')).toEqual(['5', '1']);
|
|
|
|
|
expect(slots()).toHaveLength(1);
|
|
|
|
|
expect(slots()[0].dataset.cell).toBe('1');
|
|
|
|
|
// Nothing else was remounted.
|
|
|
|
|
expect(FakeTile.all.filter((t) => t._destroyed).map((t) => t.sessionId)).toEqual(['s-b']);
|
|
|
|
|
expect(stored().ids).toEqual(['s-a', null, 's-c', 's-d', 's-e', 's-f']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('a shape change (5 to 4 is 3x2 to 2x2) keeps the tiles when all fit, else packs them', () => {
|
|
|
|
|
let app = openGrid(FIVE);
|
|
|
|
|
setCells(app, ['s-a', 's-b', null, 's-d', 's-e', 's-c']);
|
|
|
|
|
// Removing the only tile in the third column: the rest keep their places.
|
|
|
|
|
app.removeTile('s-c');
|
|
|
|
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-d', 's-e']);
|
|
|
|
|
resetGridHarness();
|
|
|
|
|
narrow();
|
|
|
|
|
app = openGrid(FIVE);
|
|
|
|
|
setCells(app, ['s-a', null, 's-c', 's-d', 's-e', 's-b']);
|
|
|
|
|
// A tile is left in the third column: packed in reading order.
|
|
|
|
|
app.removeTile('s-e');
|
|
|
|
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-c', 's-d', 's-b']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('the focused tile leaving hands focus to the next tile in reading order, past the hole', () => {
|
|
|
|
|
const app = openGrid(SIX, 's-a');
|
|
|
|
|
setCells(app, ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f']);
|
|
|
|
|
app.removeTile('s-b');
|
|
|
|
|
app.removeTile('s-a');
|
|
|
|
|
expect(app.activeSessionId).toBe('s-c');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('adding a tile', () => {
|
|
|
|
|
it('without a shape change: the first empty cell in reading order', () => {
|
|
|
|
|
const app = openGrid(FIVE);
|
|
|
|
|
setCells(app, ['s-a', 's-b', null, 's-c', 's-d', 's-e']);
|
|
|
|
|
app.addTile('s-f');
|
|
|
|
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-f', 's-c', 's-d', 's-e']);
|
|
|
|
|
expect(slots()).toHaveLength(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('Ctrl/Cmd+click on a tab and a session Run from this tab fill the first hole too', () => {
|
|
|
|
|
const app = openGrid(['s-a', 's-b', 's-c', 's-d'], 's-a', [...SIX, 's-g']);
|
|
|
|
|
// 2x2 [a b / c d]; the 5th makes it 3x2 with the four kept in place.
|
|
|
|
|
app.addSessionToTiles('s-e');
|
|
|
|
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-e', 's-c', 's-d', null]);
|
|
|
|
|
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
|
|
|
|
|
app._joinTileGridFromRun('s-f');
|
|
|
|
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-f', 's-b', 's-c', 's-d', 's-e']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('a shape change (4 to 5 is 2x2 to 3x2): every tile keeps its row and column', () => {
|
|
|
|
|
const app = openGrid(['s-a', 's-b', 's-c', 's-d']);
|
|
|
|
|
app.addTile('s-e');
|
|
|
|
|
// [a b / c d] -> [a b e / c d _]
|
|
|
|
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-e', 's-c', 's-d', null]);
|
|
|
|
|
expect(place('s-c')).toEqual(['1', '3']);
|
|
|
|
|
expect(place('s-e')).toEqual(['5', '1']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('into the cell named when it is empty, not just the first one (two holes, a state the cap never reaches)', () => {
|
|
|
|
|
const app = openGrid(['s-a', 's-b']);
|
|
|
|
|
// A 2x2 with two holes, set up by hand: the third tile still keeps the 2x2.
|
|
|
|
|
app._tileGrid.cells = ['s-a', null, 's-b', null];
|
|
|
|
|
app._tileGrid.cols = 2;
|
|
|
|
|
app._tileGrid.rows = 2;
|
|
|
|
|
app.addTile('s-c', { cell: 3 });
|
|
|
|
|
expect(app._tileGrid.cells).toEqual(['s-a', null, 's-b', 's-c']);
|
|
|
|
|
// A cell that is taken is not: the first empty one instead.
|
|
|
|
|
resetGridHarness();
|
|
|
|
|
narrow();
|
|
|
|
|
const other = openGrid(['s-a', 's-b']);
|
|
|
|
|
other._tileGrid.cells = ['s-a', null, 's-b', null];
|
|
|
|
|
other._tileGrid.cols = 2;
|
|
|
|
|
other._tileGrid.rows = 2;
|
|
|
|
|
other.addTile('s-c', { cell: 2 });
|
|
|
|
|
expect(other._tileGrid.cells).toEqual(['s-a', 's-c', 's-b', null]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('the cap counts tiles, never empty cells', () => {
|
|
|
|
|
const app = openGrid(FIVE);
|
|
|
|
|
setCells(app, [null, 's-a', 's-b', 's-c', 's-d', 's-e']);
|
|
|
|
|
expect(app.addTile('s-f')).toBe(true);
|
|
|
|
|
expect(app._tileGrid.cells).toEqual(['s-f', 's-a', 's-b', 's-c', 's-d', 's-e']);
|
|
|
|
|
expect(app.addTile('s-other')).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('a shape change from the window (three tiles: 2x2 narrow, 3x1 wide)', () => {
|
|
|
|
|
it('positions do not map between shapes: the tiles keep their reading order, packed', () => {
|
|
|
|
|
const app = openGrid(['s-a', 's-b', 's-c']);
|
|
|
|
|
setCells(app, ['s-a', null, 's-b', 's-c']);
|
|
|
|
|
section.getBoundingClientRect = () => ({ width: 2400, height: 1000, top: 0, left: 0, right: 2400, bottom: 1000 });
|
|
|
|
|
app._applyTileLayout();
|
|
|
|
|
expect([app._tileGrid.cols, app._tileGrid.rows]).toEqual([3, 1]);
|
|
|
|
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c']);
|
|
|
|
|
narrow();
|
|
|
|
|
app._applyTileLayout();
|
|
|
|
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', null]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('focus never lands on an empty cell', () => {
|
|
|
|
|
function handlerFor(app: GridApp) {
|
|
|
|
|
app.$ = () => null;
|
|
|
|
|
app.setupColorPicker = vi.fn();
|
|
|
|
|
const before = (documentAddEventListener.mock.calls as unknown[]).length;
|
|
|
|
|
app.setupEventListeners();
|
|
|
|
|
const added = (documentAddEventListener.mock.calls as Array<[string, (e: unknown) => void, boolean]>).slice(before);
|
|
|
|
|
return added.find(([type, , capture]) => type === 'keydown' && capture === true)![1];
|
|
|
|
|
}
|
|
|
|
|
const chord = (key: string, mods: Record<string, boolean>) => ({
|
|
|
|
|
type: 'keydown',
|
|
|
|
|
key,
|
|
|
|
|
code: key,
|
|
|
|
|
ctrlKey: false,
|
|
|
|
|
metaKey: false,
|
|
|
|
|
shiftKey: false,
|
|
|
|
|
altKey: false,
|
|
|
|
|
preventDefault: vi.fn(),
|
|
|
|
|
target: { closest: () => null },
|
|
|
|
|
...mods,
|
|
|
|
|
});
|
|
|
|
|
const focusKey = (dir: string) => chord(`Arrow${dir}`, { altKey: true, shiftKey: true });
|
|
|
|
|
|
|
|
|
|
it('Alt+Shift+Arrows skip a hole to the next tile, and do nothing when there is none', () => {
|
|
|
|
|
const app = openGrid(FIVE, 's-a');
|
|
|
|
|
// [a _ b / c d e]
|
|
|
|
|
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
|
|
|
|
|
delete app.selectSession;
|
|
|
|
|
const onKeydown = handlerFor(app);
|
|
|
|
|
onKeydown(focusKey('Right'));
|
|
|
|
|
expect(app.activeSessionId).toBe('s-b');
|
|
|
|
|
onKeydown(focusKey('Right'));
|
|
|
|
|
expect(app.activeSessionId).toBe('s-b');
|
|
|
|
|
app._selectTiledSession('s-d', { auto: true });
|
|
|
|
|
// Up from d (the hole above): the nearest tile in that row, the lower column on a tie.
|
|
|
|
|
onKeydown(focusKey('Up'));
|
|
|
|
|
expect(app.activeSessionId).toBe('s-a');
|
|
|
|
|
expect(app._tileGrid.focusedId).toBe('s-a');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('Ctrl+Tab and Alt+[ / ] cycle through the tiles in reading order, never a hole', () => {
|
|
|
|
|
const app = openGrid(FIVE, 's-a');
|
|
|
|
|
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
|
|
|
|
|
delete app.selectSession;
|
|
|
|
|
const seen: string[] = [];
|
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
|
|
|
app._cycleTileFocus(1);
|
|
|
|
|
seen.push(app.activeSessionId);
|
|
|
|
|
}
|
|
|
|
|
expect(seen).toEqual(['s-b', 's-c', 's-d', 's-e', 's-a']);
|
|
|
|
|
app._cycleTileFocus(-1);
|
|
|
|
|
expect(app.activeSessionId).toBe('s-e');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('persistence: the cells, holes included', () => {
|
2026-10-10 05:58:17 +02:00
|
|
|
function reload(ids: string[] = SIX, { others = true } = {}): GridApp {
|
2026-10-08 03:08:14 +02:00
|
|
|
// The page goes away; a fresh one on the same device restores the grid.
|
|
|
|
|
section.children = [];
|
|
|
|
|
FakeTile.all = [];
|
|
|
|
|
const app = makeGridApp(ids);
|
2026-10-10 05:58:17 +02:00
|
|
|
// `others: false`: no session but `ids` is open, so a freed cell has nothing to take.
|
|
|
|
|
if (!others) {
|
|
|
|
|
app.sessions.delete('s-other');
|
|
|
|
|
app.sessionOrder = app.sessionOrder.filter((id: string) => id !== 's-other');
|
|
|
|
|
}
|
2026-10-08 03:08:14 +02:00
|
|
|
expect(app._restoreTileGrid()).toBe(true);
|
|
|
|
|
return app;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it('a hole comes back where it was when the shape is the same', () => {
|
|
|
|
|
const app = openGrid(FIVE, 's-c');
|
|
|
|
|
setCells(app, ['s-a', 's-b', null, 's-c', 's-d', 's-e']);
|
|
|
|
|
expect(stored().ids).toEqual(['s-a', 's-b', null, 's-c', 's-d', 's-e']);
|
|
|
|
|
app._tileGrid.open = false;
|
|
|
|
|
const again = reload();
|
|
|
|
|
expect(again._tileGrid.cells).toEqual(['s-a', 's-b', null, 's-c', 's-d', 's-e']);
|
|
|
|
|
expect(again.activeSessionId).toBe('s-c');
|
|
|
|
|
expect(slots()[0].dataset.cell).toBe('2');
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-10 05:58:17 +02:00
|
|
|
it('a session gone by then frees its cell, which the ranking fills, the hole the user left kept', () => {
|
|
|
|
|
const app = openGrid(FIVE);
|
|
|
|
|
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
|
|
|
|
|
app._tileGrid.open = false;
|
|
|
|
|
const again = reload(['s-a', 's-b', 's-d', 's-e']);
|
|
|
|
|
expect(again._tileGrid.cells).toEqual(['s-a', null, 's-b', 's-other', 's-d', 's-e']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('a freed cell stays empty only when no other session is left, if the shape still fits', () => {
|
2026-10-08 03:08:14 +02:00
|
|
|
const app = openGrid(SIX);
|
|
|
|
|
app._tileGrid.open = false;
|
2026-10-10 05:58:17 +02:00
|
|
|
const again = reload(['s-a', 's-b', 's-d', 's-e', 's-f'], { others: false });
|
2026-10-08 03:08:14 +02:00
|
|
|
expect(again._tileGrid.cells).toEqual(['s-a', 's-b', null, 's-d', 's-e', 's-f']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('otherwise (another shape) the grid packs', () => {
|
|
|
|
|
const app = openGrid(FIVE);
|
|
|
|
|
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
|
|
|
|
|
app._tileGrid.open = false;
|
2026-10-10 05:58:17 +02:00
|
|
|
// s-d gone and nothing to take its cell: four tiles take a 2x2.
|
|
|
|
|
const again = reload(['s-a', 's-b', 's-c', 's-e'], { others: false });
|
2026-10-08 03:08:14 +02:00
|
|
|
expect(again._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-e']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('another shape packs even when the stored holes would fit the new one', () => {
|
|
|
|
|
// Stored as 3x2 [a _ b / c d e]; d and e gone: three tiles take a 2x2.
|
|
|
|
|
localStore.set(KEY, JSON.stringify({ v: 1, open: true, ids: ['s-a', null, 's-b', 's-c', 's-d', 's-e'] }));
|
2026-10-10 05:58:17 +02:00
|
|
|
const again = reload(['s-a', 's-b', 's-c'], { others: false });
|
2026-10-08 03:08:14 +02:00
|
|
|
expect(again._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', null]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('the old packed format reads unchanged', () => {
|
|
|
|
|
localStore.set(KEY, JSON.stringify({ v: 1, open: true, ids: FIVE, focused: 's-b' }));
|
|
|
|
|
const again = reload();
|
|
|
|
|
expect(again._tileGrid.cells).toEqual([...FIVE, null]);
|
|
|
|
|
expect(again.activeSessionId).toBe('s-b');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('a followed #session= link closes the stored grid with its holes kept', () => {
|
|
|
|
|
const app = openGrid(FIVE);
|
|
|
|
|
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
|
|
|
|
|
const fresh = makeGridApp(SIX);
|
|
|
|
|
fresh._closeStoredTileGrid();
|
|
|
|
|
expect(stored()).toMatchObject({ open: false, ids: ['s-a', null, 's-b', 's-c', 's-d', 's-e'] });
|
|
|
|
|
expect(stored()).not.toHaveProperty('cells');
|
|
|
|
|
void app;
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('a move keeps every tile', () => {
|
|
|
|
|
it('cells that lose or duplicate a tile are refused, and nothing changes', () => {
|
|
|
|
|
const app = openGrid(FIVE);
|
|
|
|
|
const before = app._tileGrid.cells.slice();
|
|
|
|
|
expect(app._reorderTiles(['s-a', 's-b', 's-c', 's-d', null, null])).toBe(false);
|
|
|
|
|
expect(app._reorderTiles(['s-a', 's-a', 's-c', 's-d', 's-e', null])).toBe(false);
|
|
|
|
|
expect(app._reorderTiles(['s-a', 's-b', 's-c', 's-d', 's-e'])).toBe(false);
|
|
|
|
|
expect(app._tileGrid.cells).toEqual(before);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('a divider drag with a hole', () => {
|
|
|
|
|
it('refits only the tiles in its two columns, the hole skipped', () => {
|
|
|
|
|
const app = openGrid(FIVE);
|
|
|
|
|
// [a _ b / c d e]: column 0 holds a, c; column 1 holds d.
|
|
|
|
|
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
|
|
|
|
|
for (const t of FakeTile.all) t.fit.mockClear();
|
|
|
|
|
const d = app._tileGrid.dividers.get('col-0') as FakeEl;
|
|
|
|
|
d.dispatch('pointerdown', {
|
|
|
|
|
button: 0,
|
|
|
|
|
clientX: 500,
|
|
|
|
|
clientY: 100,
|
|
|
|
|
pointerId: 1,
|
|
|
|
|
preventDefault: vi.fn(),
|
|
|
|
|
stopPropagation: vi.fn(),
|
|
|
|
|
});
|
|
|
|
|
d.dispatch('pointermove', { clientX: 560, clientY: 100, pointerId: 1 });
|
|
|
|
|
for (const cb of rafCallbacks.splice(0)) cb();
|
|
|
|
|
d.dispatch('pointerup', { clientX: 560, clientY: 100, pointerId: 1 });
|
|
|
|
|
const fits = Object.fromEntries(FIVE.map((id) => [id, tile(id).fit.mock.calls.length]));
|
|
|
|
|
expect(fits).toEqual({ 's-a': 1, 's-b': 0, 's-c': 1, 's-d': 1, 's-e': 0 });
|
|
|
|
|
});
|
|
|
|
|
});
|