Files
web-terminal/test/grid-presets.test.ts
Yaojia Wang 007e598802 feat(grid): v3b/c — resizable splitters + saved layout presets
Resizable splitters: draggable gutters between grid tracks adjust per-layout
column/row fractions (fr units), applied as an inline grid-template and persisted
(web-terminal:grid-splits). The fraction math (adjustSplit — trades delta between
adjacent tracks, clamps to a minimum, conserves the total) is a pure, unit-tested
helper in grid-layout.ts; the drag translates pixel motion into an fr delta.

Saved presets: public/grid-presets.ts — a toolbar dropdown to save the current
layout + its split under a name, re-apply it in one click, or delete it
(web-terminal:grid-presets). Desktop-only, like the layout toggle.

- grid-layout.ts: layoutTracks, defaultSplit, adjustSplit, tracksToTemplate,
  load/saveGridSplits, splitForLayout (validates stored shape).
- tabs.ts: renderGutters/beginGutterDrag/setSplit; applyLayout sets the inline
  template; gridArrangement()/applyGridPreset() hooks.
- main.ts: mount the presets dropdown. style.css: gutters + presets menu.
- tests: splitter math + persistence + a stubbed drag repro (1.2fr/0.8fr); presets
  persistence + dropdown (save/apply/delete/close). typecheck+build clean, 1612 pass.

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

134 lines
4.4 KiB
TypeScript

// @vitest-environment jsdom
/**
* test/grid-presets.test.ts — saved split-grid layout presets + dropdown.
*
* Covers persistence (round-trip, garbage tolerance, malformed drop) and the
* dropdown behavior: empty state, save current, apply (calls back), delete.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { loadPresets, savePresets, mountGridPresets, type GridPreset } from '../public/grid-presets.js'
const KEY = 'web-terminal:grid-presets'
const CURRENT: { layout: GridPreset['layout']; split: GridPreset['split'] } = {
layout: 'grid-4',
split: { cols: [1.5, 0.5], rows: [1, 1] },
}
beforeEach(() => localStorage.clear())
describe('grid-presets: persistence', () => {
it('round-trips presets through localStorage', () => {
const list: GridPreset[] = [{ name: 'wide-left', layout: 'grid-4', split: CURRENT.split }]
savePresets(list)
expect(loadPresets()).toEqual(list)
})
it('returns [] for missing or garbage storage', () => {
expect(loadPresets()).toEqual([])
localStorage.setItem(KEY, 'not json')
expect(loadPresets()).toEqual([])
})
it('drops malformed entries', () => {
localStorage.setItem(
KEY,
JSON.stringify([
{ name: 'ok', layout: 'grid-4', split: { cols: [1, 1], rows: [1, 1] } },
{ name: 'bad-layout', layout: 'nope', split: {} },
{ nope: true },
]),
)
const out = loadPresets()
expect(out).toHaveLength(1)
expect(out[0]!.name).toBe('ok')
})
})
describe('grid-presets: dropdown', () => {
let toolbar: HTMLElement
let apply: ReturnType<typeof vi.fn>
const mount = () => {
apply = vi.fn()
return mountGridPresets(toolbar, { current: () => CURRENT, apply })
}
beforeEach(() => {
toolbar = document.createElement('div')
document.body.replaceChildren(toolbar)
})
const openMenu = (): HTMLElement => {
toolbar.querySelector<HTMLButtonElement>('.grid-presets-btn')!.click()
return toolbar.querySelector<HTMLElement>('.grid-presets-menu')!
}
it('shows an empty state when there are no presets', () => {
mount()
const menu = openMenu()
expect(menu.hidden).toBe(false)
expect(menu.querySelector('.gp-empty')).not.toBeNull()
})
it('saves the current arrangement under a typed name', () => {
mount()
openMenu()
const input = toolbar.querySelector<HTMLInputElement>('.gp-name')!
input.value = 'wide-left'
toolbar.querySelector<HTMLButtonElement>('.gp-save-btn')!.click()
expect(loadPresets()).toEqual([{ name: 'wide-left', layout: 'grid-4', split: CURRENT.split }])
expect(toolbar.querySelector('.gp-apply-name')?.textContent).toBe('wide-left')
})
it('does not save a blank name', () => {
mount()
openMenu()
toolbar.querySelector<HTMLButtonElement>('.gp-save-btn')!.click()
expect(loadPresets()).toEqual([])
})
it('applies a preset (calls back) and closes the menu', () => {
savePresets([{ name: 'p1', layout: 'grid-4', split: CURRENT.split }])
mount()
const menu = openMenu()
toolbar.querySelector<HTMLButtonElement>('.gp-apply')!.click()
expect(apply).toHaveBeenCalledWith({ name: 'p1', layout: 'grid-4', split: CURRENT.split })
expect(menu.hidden).toBe(true)
})
it('deletes a preset', () => {
savePresets([{ name: 'p1', layout: 'grid-4', split: CURRENT.split }])
mount()
openMenu()
toolbar.querySelector<HTMLButtonElement>('.gp-del')!.click()
expect(loadPresets()).toEqual([])
expect(toolbar.querySelector('.gp-empty')).not.toBeNull()
})
it('closes on an outside pointerdown', () => {
mount()
const menu = openMenu()
expect(menu.hidden).toBe(false)
document.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
expect(menu.hidden).toBe(true)
})
it('closes on Escape', () => {
mount()
const menu = openMenu()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
expect(menu.hidden).toBe(true)
})
it('replaces a same-named preset instead of duplicating', () => {
savePresets([{ name: 'dup', layout: 'split-2', split: { cols: [1, 1], rows: [1] } }])
mount()
openMenu()
const input = toolbar.querySelector<HTMLInputElement>('.gp-name')!
input.value = 'dup'
toolbar.querySelector<HTMLButtonElement>('.gp-save-btn')!.click()
const out = loadPresets()
expect(out).toHaveLength(1)
expect(out[0]!.layout).toBe('grid-4') // overwritten with the current arrangement
})
})