// @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, layoutTracks, defaultSplit, adjustSplit, tracksToTemplate, splitForLayout, loadGridSplits, saveGridSplits, } 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: resizable splitters (v3)', () => { beforeEach(() => localStorage.clear()) it('layoutTracks gives each layout its column/row counts', () => { expect(layoutTracks('single')).toEqual({ cols: 1, rows: 1 }) expect(layoutTracks('split-2')).toEqual({ cols: 2, rows: 1 }) expect(layoutTracks('row-3')).toEqual({ cols: 3, rows: 1 }) expect(layoutTracks('grid-4')).toEqual({ cols: 2, rows: 2 }) expect(layoutTracks('grid-6')).toEqual({ cols: 3, rows: 2 }) }) it('defaultSplit is equal fractions matching the track counts', () => { expect(defaultSplit('grid-4')).toEqual({ cols: [1, 1], rows: [1, 1] }) expect(defaultSplit('grid-6')).toEqual({ cols: [1, 1, 1], rows: [1, 1] }) }) it('adjustSplit trades the delta between adjacent tracks, conserving the total', () => { expect(adjustSplit([1, 1], 0, 0.5)).toEqual([1.5, 0.5]) expect(adjustSplit([1, 1, 1], 1, 0.4)).toEqual([1, 1.4, 0.6]) }) it('adjustSplit clamps both tracks to the minimum (no collapse)', () => { const out = adjustSplit([1, 1], 0, 5, 0.3) // huge delta expect(out[1]).toBeCloseTo(0.3) expect(out[0]! + out[1]!).toBeCloseTo(2) // total preserved expect(Math.min(...out)).toBeGreaterThanOrEqual(0.3) }) it('adjustSplit ignores an out-of-range gutter and never mutates the input', () => { const input = [1, 1] expect(adjustSplit(input, 1, 0.5)).toEqual([1, 1]) // gutter 1 has no right neighbor expect(input).toEqual([1, 1]) // untouched }) it('tracksToTemplate renders an fr template', () => { expect(tracksToTemplate([1, 1.4])).toBe('1fr 1.4fr') }) it('splitForLayout returns the stored split only when its shape matches', () => { const good = { 'grid-4': { cols: [1.3, 0.7], rows: [1, 1] } } expect(splitForLayout(good, 'grid-4')).toEqual({ cols: [1.3, 0.7], rows: [1, 1] }) // wrong column count for grid-4 → falls back to equal default expect(splitForLayout({ 'grid-4': { cols: [1, 1, 1], rows: [1, 1] } }, 'grid-4')).toEqual( defaultSplit('grid-4'), ) expect(splitForLayout({}, 'split-2')).toEqual(defaultSplit('split-2')) }) it('saveGridSplits + loadGridSplits round-trip', () => { saveGridSplits({ 'grid-4': { cols: [1.5, 0.5], rows: [1, 1] } }) expect(loadGridSplits()).toEqual({ 'grid-4': { cols: [1.5, 0.5], rows: [1, 1] } }) }) it('loadGridSplits tolerates garbage', () => { localStorage.setItem('web-terminal:grid-splits', 'not json') expect(loadGridSplits()).toEqual({}) }) }) 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('.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('.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('.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('.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') }) })