Files
web-terminal/test/grid-layout.test.ts
Yaojia Wang 5475b661ae feat(grid): split-grid v2 — 1×3/2×3, drag-to-quadrant, Ctrl+` cycle, maximize
Builds on the v1 watch board:
- Two more layouts: row-3 (1×3) and grid-6 (2×3). A single `grid` marker class on
  #term now carries the shared cell chrome so it no longer enumerates each lay-*.
- Ctrl+` cycles the focused quadrant (Ctrl+Shift+` reverses); matchFocusCycleKey
  is an exported pure helper so it's unit-tested. No-op / passthrough in single mode.
- Per-quadrant ⛶ maximize: the focused cell expands to fill the grid as an absolute
  overlay while siblings stay live behind it; follows focus, resets on layout
  change / tab close.
- Drag a tab from the tab bar onto a quadrant to assign it there (reuses the
  existing tab-drag dragIndex); grid-only.

Adversarial review (3 lenses → per-finding verify, incl. a headless-Chrome repro)
caught a HIGH: maximizing via `grid-column/row: 1/-1` shoved siblings into implicit
rows — a strip instead of fullscreen AND a spurious resize to backgrounded live
PTYs. Fixed with an absolute-overlay (`position:absolute; inset:0`), then verified
in real Chromium (Playwright): maximized cell fills #term, siblings 0px size delta.
Also: silence a covered pane's pending pulse under maximize; coarse-pointer target
for .cell-max. Verified: typecheck + build:web clean, 1579 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:49:39 +02:00

205 lines
7.5 KiB
TypeScript

// @vitest-environment jsdom
/**
* test/grid-layout.test.ts — split-grid layout logic + toolbar toggle.
*
* Covers the pure layout math (capacity, visible set, isVisible), the desktop
* matchMedia gate, localStorage persistence (incl. the narrow-screen force-single
* rule), and the segmented toggle control (pressed state, click → setLayout,
* hide-when-too-narrow, and the auto-fallback-to-single on window narrow).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
type GridLayout,
GRID_LAYOUTS,
GRID_MIN_WIDTH,
layoutCapacity,
visibleIndices,
isVisibleIndex,
gridAllowed,
loadGridLayout,
saveGridLayout,
mountGridToggle,
matchFocusCycleKey,
} from '../public/grid-layout.js'
const KEY = 'web-terminal:grid-layout'
/** Install a matchMedia stub whose `(min-width: N)` matches iff width >= N.
* Returns a fire() to simulate a viewport resize crossing the threshold. */
function stubMatchMedia(width: number): { setWidth: (w: number) => void } {
let current = width
const lists: Array<{ q: string; list: { matches: boolean }; cbs: Array<() => void> }> = []
const parse = (q: string): number => Number(/min-width:\s*(\d+)px/.exec(q)?.[1] ?? '0')
window.matchMedia = ((q: string) => {
const cbs: Array<() => void> = []
const list = {
get matches() {
return current >= parse(q)
},
media: q,
addEventListener: (_t: string, cb: () => void) => cbs.push(cb),
removeEventListener: (_t: string, cb: () => void) => {
const i = cbs.indexOf(cb)
if (i >= 0) cbs.splice(i, 1)
},
addListener: (cb: () => void) => cbs.push(cb),
removeListener: () => {},
dispatchEvent: () => true,
onchange: null,
}
lists.push({ q, list, cbs })
return list
}) as unknown as typeof window.matchMedia
return {
setWidth: (w: number) => {
current = w
for (const { cbs } of lists) for (const cb of cbs) cb()
},
}
}
describe('grid-layout: pure logic', () => {
it('layoutCapacity maps each layout to its pane count', () => {
expect(layoutCapacity('single')).toBe(1)
expect(layoutCapacity('split-2')).toBe(2)
expect(layoutCapacity('row-3')).toBe(3)
expect(layoutCapacity('grid-4')).toBe(4)
expect(layoutCapacity('grid-6')).toBe(6)
})
it('GRID_LAYOUTS is ordered by capacity', () => {
expect(GRID_LAYOUTS).toEqual(['single', 'split-2', 'row-3', 'grid-4', 'grid-6'])
})
it('visibleIndices honors the higher-capacity layouts', () => {
expect(visibleIndices(9, 'row-3')).toEqual([0, 1, 2])
expect(visibleIndices(9, 'grid-6')).toEqual([0, 1, 2, 3, 4, 5])
expect(visibleIndices(4, 'grid-6')).toEqual([0, 1, 2, 3])
})
it('matchFocusCycleKey recognizes Ctrl+` (fwd) and Ctrl+Shift+` (rev), else null', () => {
const k = (init: KeyboardEventInit): KeyboardEvent => new KeyboardEvent('keydown', init)
expect(matchFocusCycleKey(k({ ctrlKey: true, key: '`' }))).toBe(1)
expect(matchFocusCycleKey(k({ ctrlKey: true, shiftKey: true, key: '`' }))).toBe(-1)
expect(matchFocusCycleKey(k({ ctrlKey: true, code: 'Backquote' }))).toBe(1)
expect(matchFocusCycleKey(k({ key: '`' }))).toBeNull() // no ctrl
expect(matchFocusCycleKey(k({ ctrlKey: true, metaKey: true, key: '`' }))).toBeNull() // meta excluded
expect(matchFocusCycleKey(k({ ctrlKey: true, altKey: true, key: '`' }))).toBeNull() // alt excluded
expect(matchFocusCycleKey(k({ ctrlKey: true, key: 'a' }))).toBeNull() // wrong key
})
it('visibleIndices returns the first min(tabCount, capacity) indices', () => {
expect(visibleIndices(0, 'grid-4')).toEqual([])
expect(visibleIndices(3, 'grid-4')).toEqual([0, 1, 2])
expect(visibleIndices(9, 'grid-4')).toEqual([0, 1, 2, 3])
expect(visibleIndices(9, 'split-2')).toEqual([0, 1])
expect(visibleIndices(9, 'single')).toEqual([0])
})
it('isVisibleIndex: single mode shows only the active pane', () => {
expect(isVisibleIndex(2, 2, 5, 'single')).toBe(true)
expect(isVisibleIndex(0, 2, 5, 'single')).toBe(false)
})
it('isVisibleIndex: grid shows the first-N regardless of which is active', () => {
expect(isVisibleIndex(0, 3, 9, 'grid-4')).toBe(true)
expect(isVisibleIndex(3, 3, 9, 'grid-4')).toBe(true)
expect(isVisibleIndex(4, 3, 9, 'grid-4')).toBe(false) // off-board
expect(isVisibleIndex(2, 0, 9, 'split-2')).toBe(false)
})
})
describe('grid-layout: gate + persistence', () => {
beforeEach(() => {
localStorage.clear()
})
afterEach(() => {
vi.restoreAllMocks()
// @ts-expect-error — reset the stub between tests
delete window.matchMedia
})
it('gridAllowed is true at/above GRID_MIN_WIDTH and false below', () => {
stubMatchMedia(GRID_MIN_WIDTH)
expect(gridAllowed()).toBe(true)
stubMatchMedia(GRID_MIN_WIDTH - 1)
expect(gridAllowed()).toBe(false)
})
it('saveGridLayout + loadGridLayout round-trips on a wide screen', () => {
stubMatchMedia(1440)
saveGridLayout('grid-4')
expect(localStorage.getItem(KEY)).toBe('grid-4')
expect(loadGridLayout()).toBe('grid-4')
})
it('loadGridLayout forces single on a narrow screen (no grid on a phone)', () => {
stubMatchMedia(1440)
saveGridLayout('grid-4')
stubMatchMedia(800) // window shrank / opened on a small device
expect(loadGridLayout()).toBe('single')
})
it('loadGridLayout ignores a garbage stored value', () => {
stubMatchMedia(1440)
localStorage.setItem(KEY, 'not-a-layout')
expect(loadGridLayout()).toBe('single')
})
})
describe('grid-layout: mountGridToggle', () => {
let toolbar: HTMLElement
beforeEach(() => {
localStorage.clear()
toolbar = document.createElement('div')
document.body.appendChild(toolbar)
})
afterEach(() => {
toolbar.remove()
// @ts-expect-error — reset the stub
delete window.matchMedia
})
it('renders one button per layout with the current one pressed', () => {
stubMatchMedia(1440)
let layout: GridLayout = 'split-2'
mountGridToggle(toolbar, { getLayout: () => layout, setLayout: (l) => (layout = l) })
const btns = toolbar.querySelectorAll<HTMLButtonElement>('.grid-toggle-btn')
expect(btns).toHaveLength(GRID_LAYOUTS.length)
const pressed = [...btns].filter((b) => b.getAttribute('aria-pressed') === 'true')
expect(pressed).toHaveLength(1)
expect(pressed[0]!.dataset.layout).toBe('split-2')
})
it('clicking a button calls setLayout with that layout', () => {
stubMatchMedia(1440)
const setLayout = vi.fn()
mountGridToggle(toolbar, { getLayout: () => 'single', setLayout })
const grid = toolbar.querySelector<HTMLButtonElement>('.grid-toggle-btn[data-layout="grid-4"]')!
grid.click()
expect(setLayout).toHaveBeenCalledWith('grid-4')
})
it('is hidden below the desktop threshold', () => {
stubMatchMedia(800)
mountGridToggle(toolbar, { getLayout: () => 'single', setLayout: vi.fn() })
const seg = toolbar.querySelector<HTMLElement>('.grid-toggle')!
expect(seg.style.display).toBe('none')
})
it('forces single + hides itself when the window narrows past the threshold', () => {
const mm = stubMatchMedia(1440)
let layout: GridLayout = 'grid-4'
const setLayout = vi.fn((l: GridLayout) => {
layout = l
})
mountGridToggle(toolbar, { getLayout: () => layout, setLayout })
const seg = toolbar.querySelector<HTMLElement>('.grid-toggle')!
expect(seg.style.display).toBe('inline-flex')
mm.setWidth(700) // simulate a resize below the threshold
expect(setLayout).toHaveBeenCalledWith('single')
expect(seg.style.display).toBe('none')
})
})