mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
feat(tiles): the grid is cells, and an empty cell can be any cell
Owner feedback 1: "so the empty tab doesnt always have to be the last
one! so I can move freely around and the empty tab can also be tab nr
4 or 3". This replaces the slot refusal of aecada8c.
grid.cells (a session id or null per cell) is now the one source of
truth; grid.ids is a getter deriving the tiles in reading order, so
everything that only wants the tiled sessions (focus neighbour,
cycling, the load queue's order, the picker, closeSession) is
unchanged. The shape still comes from the tile count and the cap
counts tiles, never empty cells.
- A tile dragged onto an empty cell moves there and leaves its own
cell empty, nothing else moving (_moveTileToCell, through
_reorderTiles: no remount, reconnect or reload; only a tile whose
cell size changed fits). A tiled session's tab does the same; a tab
of a session not tiled yet joins in the cell it is dropped on. Each
slot knows its cell and reads "Drop a tab or a tile here".
- Move Tile goes to the adjacent cell: into it when empty, a swap when
a tile is there (tileCellInDirection).
- Removing a tile leaves its cell empty; adding one takes the first
empty cell. A shape change goes through fitTileCells: each tile keeps
its row and column when all fit (2x2 growing to 3x2), else the tiles
pack in reading order.
- Focus never lands on an empty cell: Alt+Shift+Arrows run over the
cells, Ctrl+Tab and Alt+[ ] over the tiles.
- codeman:tile-grid stays ids only: its ids are the cells with null for
an empty one. A reload brings the holes back when the shape is the
same (a session gone since leaves its cell empty), another shape
packs, the old packed format reads unchanged, and a followed
#session= link keeps the holes.
Docs: the spec's as-built bullet (rewritten in place), the wiki's Tile
Grid page and Keyboard Shortcuts, the invariants and CLAUDE.md.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* @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
|
||||
* leaves its cell empty, a different shape packs, and the old packed format
|
||||
* reads unchanged. A followed `#session=` link keeps the holes.
|
||||
* - 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', () => {
|
||||
function reload(ids: string[] = SIX): GridApp {
|
||||
// The page goes away; a fresh one on the same device restores the grid.
|
||||
section.children = [];
|
||||
FakeTile.all = [];
|
||||
const app = makeGridApp(ids);
|
||||
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');
|
||||
});
|
||||
|
||||
it('a session gone by then leaves its cell empty if the shape still fits', () => {
|
||||
const app = openGrid(SIX);
|
||||
app._tileGrid.open = false;
|
||||
const again = reload(['s-a', 's-b', 's-d', 's-e', 's-f']);
|
||||
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;
|
||||
// s-d gone: four tiles take a 2x2.
|
||||
const again = reload(['s-a', 's-b', 's-c', 's-e']);
|
||||
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'] }));
|
||||
const again = reload(['s-a', 's-b', 's-c']);
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -277,7 +277,7 @@ describe('every tile grid string the code puts on screen translates to zh-CN', (
|
||||
'This window fits 4 tiles',
|
||||
'This window fits 1 tile',
|
||||
'The grid already holds what this window fits (4)',
|
||||
'Drop a tab here',
|
||||
'Drop a tab or a tile here',
|
||||
'Resize tile columns',
|
||||
'Resize tile rows',
|
||||
'Zoom this tile',
|
||||
|
||||
+140
-62
@@ -177,52 +177,87 @@ describe('dragging a tile by its header', () => {
|
||||
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
|
||||
});
|
||||
|
||||
// Owner: "dont move the tile". A slot is always the last cell, so a move
|
||||
// there shifted every tile after it; the slot refuses a tile instead.
|
||||
it('the slot refuses it: held (never reaching anything below), no highlight, nothing moves', () => {
|
||||
const app = openGrid(['s-a', 's-b', 's-c'], 's-b');
|
||||
app.markIdleAlertSeen.mockClear();
|
||||
localStore.delete('codeman:tile-grid');
|
||||
const costsNothing = snapshotCost();
|
||||
clearFits();
|
||||
expect(slots()).toHaveLength(1);
|
||||
startDrag('s-a');
|
||||
const o = over(slots()[0]);
|
||||
expect(o.preventDefault).toHaveBeenCalled();
|
||||
expect(o.stopPropagation).toHaveBeenCalled();
|
||||
expect(o.dataTransfer.dropEffect).toBe('none');
|
||||
// Owner: an empty cell can be any cell, and a tile dragged onto one moves
|
||||
// THERE, leaving its own cell empty; nothing else moves.
|
||||
const LAYOUTS = [
|
||||
{ name: '2x2 with 3 tiles', ids: ['s-a', 's-b', 's-c'], cells: 4 },
|
||||
{ name: '3x2 with 5 tiles', ids: SIX.slice(0, 5), cells: 6 },
|
||||
];
|
||||
for (const layout of LAYOUTS) {
|
||||
it(`${layout.name}: from every cell into the hole wherever it is, by header drag`, () => {
|
||||
const bad: string[] = [];
|
||||
for (let hole = 0; hole < layout.cells; hole++) {
|
||||
for (let from = 0; from < layout.cells; from++) {
|
||||
if (from === hole) continue;
|
||||
resetGridHarness();
|
||||
section.getBoundingClientRect = () => ({
|
||||
width: 1700,
|
||||
height: 1000,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 1700,
|
||||
bottom: 1000,
|
||||
});
|
||||
const app = openGrid(layout.ids, layout.ids[0]);
|
||||
const cells: Array<string | null> = layout.ids.slice();
|
||||
cells.splice(hole, 0, null);
|
||||
app._tileGrid.cells = cells.slice(0, layout.cells);
|
||||
app._applyTileLayout();
|
||||
const moved = app._tileGrid.cells[from];
|
||||
const costsNothing = snapshotCost();
|
||||
expect(slots()).toHaveLength(1);
|
||||
expect(slots()[0].dataset.cell).toBe(String(hole));
|
||||
const { over: o } = dragTileOnto(moved, slots()[0]);
|
||||
const expected = app._tileGrid.cells.slice();
|
||||
const want = cells.slice(0, layout.cells);
|
||||
want[hole] = moved;
|
||||
want[from] = null;
|
||||
const ok =
|
||||
JSON.stringify(expected) === JSON.stringify(want) &&
|
||||
o.dataTransfer.dropEffect === 'move' &&
|
||||
app.activeSessionId === moved &&
|
||||
JSON.stringify(stored().ids) === JSON.stringify(want) &&
|
||||
slots().length === 1 &&
|
||||
slots()[0].dataset.cell === String(from);
|
||||
if (!ok) bad.push(`hole ${hole} from ${from}: got ${JSON.stringify(expected)}`);
|
||||
costsNothing();
|
||||
}
|
||||
}
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
it('the moved tile sits in the hole (its grid place), and the slot takes its old place', () => {
|
||||
const app = openGrid(SIX.slice(0, 5));
|
||||
// [a b c / d e _]: c into the hole below it.
|
||||
dragTileOnto('s-c', slots()[0]);
|
||||
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', null, 's-d', 's-e', 's-c']);
|
||||
expect([tileEl('s-c').style.gridColumn, tileEl('s-c').style.gridRow]).toEqual(['5', '3']);
|
||||
expect([slots()[0].style.gridColumn, slots()[0].style.gridRow]).toEqual(['5', '1']);
|
||||
expect(slots()[0].classList.contains('tile--drop-target')).toBe(false);
|
||||
// A browser sends no drop on a refused target; one that did never reaches the slot's handler.
|
||||
const toSlot = vi.spyOn(app, 'dropSessionOnSlot');
|
||||
drop(slots()[0]);
|
||||
expect(toSlot).not.toHaveBeenCalled();
|
||||
toSlot.mockRestore();
|
||||
end('s-a');
|
||||
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']);
|
||||
expect(app.activeSessionId).toBe('s-b');
|
||||
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
|
||||
expect(localStore.has('codeman:tile-grid')).toBe(false);
|
||||
expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true);
|
||||
costsNothing();
|
||||
});
|
||||
|
||||
it('so does the tab of a tiled session; a session not tiled yet still joins there', () => {
|
||||
const app = openGrid(['s-a', 's-b', 's-c']);
|
||||
app.draggedTabId = 's-a';
|
||||
const o = over(slots()[0]);
|
||||
expect(o.dataTransfer.dropEffect).toBe('none');
|
||||
it('a tab of a tiled session moves the same way; a session not tiled yet joins in THAT cell', () => {
|
||||
const app = openGrid(SIX.slice(0, 5));
|
||||
app._tileGrid.cells = ['s-a', null, 's-b', 's-c', 's-d', 's-e'];
|
||||
app._applyTileLayout();
|
||||
app.draggedTabId = 's-e';
|
||||
drop(slots()[0]);
|
||||
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']);
|
||||
// dropSessionOnSlot itself refuses a tiled session too.
|
||||
app.dropSessionOnSlot('s-a');
|
||||
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']);
|
||||
|
||||
expect(app._tileGrid.cells).toEqual(['s-a', 's-e', 's-b', 's-c', 's-d', null]);
|
||||
app.draggedTabId = 's-other';
|
||||
expect(over(slots()[0]).dataTransfer.dropEffect).toBe('move');
|
||||
drop(slots()[0]);
|
||||
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c', 's-other']);
|
||||
expect(app._tileGrid.cells).toEqual(['s-a', 's-e', 's-b', 's-c', 's-d', 's-other']);
|
||||
expect(app.activeSessionId).toBe('s-other');
|
||||
});
|
||||
|
||||
it('while a tile is zoomed there is no slot, and a move into a hole is refused', () => {
|
||||
const app = openGrid(SIX.slice(0, 5));
|
||||
app.zoomTile('s-a');
|
||||
expect(slots()).toHaveLength(0);
|
||||
expect(app._moveTileToCell('s-b', 5)).toBe(false);
|
||||
expect(app._tileGrid.cells).toEqual([...SIX.slice(0, 5), null]);
|
||||
});
|
||||
});
|
||||
|
||||
it('Escape, or a drop anywhere else, cancels: dragend with no drop moves nothing and clears what was painted', () => {
|
||||
@@ -528,9 +563,9 @@ describe('Move Tile Left/Right/Up/Down (Ctrl+Shift+Arrows)', () => {
|
||||
return keydown[1];
|
||||
}
|
||||
|
||||
// The neighbour in each direction, by cell, written out by hand (null: an
|
||||
// edge). A partial last row: down from a cell above an empty one goes to the
|
||||
// last tile, the rule the focus chords follow.
|
||||
// The cell next to each cell in each direction, written out by hand (null:
|
||||
// an edge). A move goes to that cell: a swap when a tile is there, a move
|
||||
// into it when it is empty (the partial layouts below).
|
||||
const TABLES: Record<
|
||||
string,
|
||||
{ ids: string[]; cols: number; width?: number; next: Array<Record<Dir, number | null>> }
|
||||
@@ -565,28 +600,6 @@ describe('Move Tile Left/Right/Up/Down (Ctrl+Shift+Arrows)', () => {
|
||||
{ left: 4, right: null, up: 2, down: null },
|
||||
],
|
||||
},
|
||||
'2x2 with 3 tiles': {
|
||||
cols: 2,
|
||||
ids: ['s-a', 's-b', 's-c'],
|
||||
width: 1700,
|
||||
next: [
|
||||
{ left: null, right: 1, up: null, down: 2 },
|
||||
{ left: 0, right: null, up: null, down: 2 },
|
||||
{ left: null, right: null, up: 0, down: null },
|
||||
],
|
||||
},
|
||||
'3x2 with 5 tiles': {
|
||||
cols: 3,
|
||||
ids: SIX.slice(0, 5),
|
||||
width: 1700,
|
||||
next: [
|
||||
{ left: null, right: 1, up: null, down: 3 },
|
||||
{ left: 0, right: 2, up: null, down: 4 },
|
||||
{ left: 1, right: null, up: null, down: 4 },
|
||||
{ left: null, right: 4, up: 0, down: null },
|
||||
{ left: 3, right: null, up: 1, down: null },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
@@ -632,6 +645,71 @@ describe('Move Tile Left/Right/Up/Down (Ctrl+Shift+Arrows)', () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Owner: an empty cell can be any cell. With the hole in every cell in turn,
|
||||
// every tile and every direction: into the hole, or a swap, or nothing.
|
||||
const HOLED = [
|
||||
{ name: '2x2 with 3 tiles', ids: ['s-a', 's-b', 's-c'], adjacency: TABLES['2x2'].next },
|
||||
{ name: '3x2 with 5 tiles', ids: SIX.slice(0, 5), adjacency: TABLES['3x2'].next },
|
||||
];
|
||||
for (const layout of HOLED) {
|
||||
it(`${layout.name}: with the hole anywhere, every tile, every direction (into the hole, a swap, or nothing)`, () => {
|
||||
const bad: string[] = [];
|
||||
const size = layout.adjacency.length;
|
||||
for (let hole = 0; hole < size; hole++) {
|
||||
const cells: Array<string | null> = layout.ids.slice();
|
||||
cells.splice(hole, 0, null);
|
||||
for (let i = 0; i < size; i++) {
|
||||
if (i === hole) continue;
|
||||
for (const dir of Object.keys(ARROW) as Dir[]) {
|
||||
resetGridHarness();
|
||||
section.getBoundingClientRect = () => ({
|
||||
width: 1700,
|
||||
height: 1000,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 1700,
|
||||
bottom: 1000,
|
||||
});
|
||||
const moved = cells[i] as string;
|
||||
const app = openGrid(layout.ids, moved);
|
||||
app._tileGrid.cells = cells.slice();
|
||||
app._applyTileLayout();
|
||||
const costsNothing = snapshotCost();
|
||||
const e = chord(dir);
|
||||
handlerFor(app)(e);
|
||||
const want = cells.slice();
|
||||
const j = layout.adjacency[i][dir];
|
||||
if (j !== null) [want[i], want[j]] = [want[j], want[i]];
|
||||
const ok =
|
||||
e.preventDefault.mock.calls.length === 1 &&
|
||||
JSON.stringify(app._tileGrid.cells) === JSON.stringify(want) &&
|
||||
app.activeSessionId === moved &&
|
||||
app._tileGrid.focusedId === moved &&
|
||||
JSON.stringify(stored().ids) === JSON.stringify(want);
|
||||
if (!ok) bad.push(`hole ${hole}, cell ${i} ${dir}: got ${JSON.stringify(app._tileGrid.cells)}`);
|
||||
costsNothing();
|
||||
expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
it('a move into a hole across columns of different widths: only the moved tile fits, once', () => {
|
||||
section.getBoundingClientRect = () => ({ width: 1700, height: 1000, top: 0, left: 0, right: 1700, bottom: 1000 });
|
||||
const five = SIX.slice(0, 5);
|
||||
const app = openGrid(five, 's-b');
|
||||
app._tileGrid.colFr = [1, 1, 2];
|
||||
app._applyTileLayout();
|
||||
clearFits();
|
||||
// [a b c / d e _]: b (column 1) down into... e is there; first move e right into the hole.
|
||||
app._selectTiledSession('s-e', { auto: true });
|
||||
handlerFor(app)(chord('right'));
|
||||
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d', null, 's-e']);
|
||||
expect(fitCounts(five)).toEqual({ 's-a': 0, 's-b': 0, 's-c': 0, 's-d': 0, 's-e': 1 });
|
||||
});
|
||||
|
||||
it('focus stays on the moved tile through several moves, and the order is stored', () => {
|
||||
const app = openGrid(SIX, 's-a');
|
||||
const onKeydown = handlerFor(app);
|
||||
|
||||
Reference in New Issue
Block a user