Files
deepseek-harness/packages/client/ui-dockkit/tests/controller.client.spec.ts
T

361 lines
17 KiB
TypeScript

/**
* Controller behavior: the interaction vocabulary the UI calls, including tab
* identity (focus an existing tab, or open an explicit copy), the guide tab a
* split seats, drop resolution, floating, and the notification contract.
*/
import { describe, expect, it } from 'vitest'
import { DockController } from '../src/engine/controller.ts'
import { dockPaneCount, FLOAT_DEFAULT_SIZE } from '../src/engine/constraints.ts'
import { SEED_CONTENT_ID, childAt, firstTab, seededController } from './fixtures.client.ts'
import { dockPaneIds, findTabPane, getPane, getSplit } from '../src/engine/tree.ts'
import type { LayoutState, PaneId } from '../src/contract/types.ts'
/** Tab titles of one pane, in strip order. */
function titles(state: LayoutState, paneId: PaneId): string[] {
return getPane(state, paneId).tabs.map(id => state.tabs[id]?.title ?? '?')
}
/** A controller expanded once, with its first pane id. */
function expanded(): { controller: DockController; paneId: PaneId } {
const controller = seededController()
controller.setExpanded(true)
const { state } = controller.getSnapshot()
return { controller, paneId: getPane(state, state.rootId).id }
}
describe('expand', () => {
it('starts collapsed with a guide tab already seated', () => {
const controller = seededController()
const snapshot = controller.getSnapshot()
expect(snapshot.state.expanded).toBe(false)
expect(snapshot.canUndo).toBe(false)
expect(snapshot.canSplit).toBe(true)
expect(titles(snapshot.state, getPane(snapshot.state, snapshot.state.rootId).id)).toHaveLength(1)
const guide = Object.values(snapshot.state.tabs)[0]
expect(guide?.contentId).toBe(SEED_CONTENT_ID)
})
it('records a toggle and ignores a redundant set', () => {
const controller = seededController()
controller.toggleExpanded()
expect(controller.getSnapshot().state.expanded).toBe(true)
expect(controller.ops).toHaveLength(1)
controller.setExpanded(true)
expect(controller.ops).toHaveLength(1)
controller.toggleExpanded()
expect(controller.getSnapshot().state.expanded).toBe(false)
expect(controller.ops).toHaveLength(2)
})
})
describe('presentation', () => {
it('records a mode switch once and ignores a redundant one', () => {
const controller = seededController()
controller.setMode('fullscreen')
expect(controller.getSnapshot().state.mode).toBe('fullscreen')
controller.setMode('fullscreen')
expect(controller.ops.map(op => op.type)).toEqual(['setMode'])
})
})
describe('splitting', () => {
it('splits the active pane to the right and seats a guide tab there', () => {
const { controller, paneId } = expanded()
expect(controller.splitPane()).toBe(true)
const state = controller.getSnapshot().state
const root = getSplit(state, state.rootId)
expect(root.axis).toBe('row')
const newPaneId = getPane(state, childAt(root, 1)).id
expect(newPaneId).not.toBe(paneId)
expect(titles(state, newPaneId)).toHaveLength(1)
expect(state.tabs[firstTab(getPane(state, newPaneId))]?.contentId).toBe(SEED_CONTENT_ID)
expect(controller.ops.map(op => op.type)).toEqual(['setExpanded', 'split', 'openTab'])
})
it('seats the pane factory\'s tab at the end of a strip from the add control, and nothing without one', () => {
const { controller, paneId } = expanded()
expect(controller.addTab(paneId)).toBe(true)
expect(titles(controller.getSnapshot().state, paneId)).toEqual(['Start', 'Start'])
const bare = new DockController({ makeInitialTab: id => ({ id, kind: 'seed', contentId: SEED_CONTENT_ID, title: 'Start' }) })
expect(bare.addTab(getPane(bare.getSnapshot().state, bare.getSnapshot().state.rootId).id)).toBe(false)
})
it('names the pane a new tab lands in: the active pane, or the first docked one while a panel floats', () => {
const { controller, paneId } = expanded()
expect(controller.activeDockPaneId()).toBe(paneId)
const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
const floatId = controller.floatTab(tabId)
expect(controller.getSnapshot().state.activePaneId).toBe(floatId)
expect(controller.activeDockPaneId()).toBe(paneId)
})
it('stops at four panes without recording anything', () => {
const { controller } = expanded()
expect(controller.splitPane()).toBe(true)
expect(controller.splitPane()).toBe(true)
expect(controller.splitPane()).toBe(true)
expect(dockPaneCount(controller.getSnapshot().state)).toBe(4)
const before = controller.ops.length
expect(controller.splitPane()).toBe(false)
expect(controller.ops).toHaveLength(before)
expect(controller.getSnapshot().canSplit).toBe(false)
})
})
describe('tab identity', () => {
it('focuses the existing tab when the same content is opened twice', () => {
const { controller, paneId } = expanded()
const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
controller.splitPane()
const again = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
expect(again).toBe(first)
const state = controller.getSnapshot().state
expect(getPane(state, paneId).activeTabId).toBe(first)
expect(state.activePaneId).toBe(paneId)
expect(Object.values(state.tabs).filter(tab => tab.contentId === 'dsh-resource://file/session/s/a.txt')).toHaveLength(1)
})
it('opens an explicit second copy beside the original', () => {
const { controller, paneId } = expanded()
const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
const copy = controller.duplicateTab(first)
expect(copy).not.toBe(first)
const state = controller.getSnapshot().state
expect(state.tabs[copy]?.contentId).toBe('dsh-resource://file/session/s/a.txt')
expect(getPane(state, paneId).tabs.indexOf(copy)).toBe(getPane(state, paneId).tabs.indexOf(first) + 1)
})
it('closes a tab and leaves the pane in place', () => {
const { controller, paneId } = expanded()
const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
controller.closeTab(tabId)
const state = controller.getSnapshot().state
expect(state.tabs[tabId]).toBeUndefined()
expect(dockPaneIds(state)).toEqual([paneId])
})
})
describe('drops', () => {
it('moves a tab into the pane under the pointer', () => {
const { controller, paneId } = expanded()
const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
controller.splitPane()
const target = getSplit(controller.getSnapshot().state, controller.getSnapshot().state.rootId).children[1]
if (target === undefined) throw new Error('expected a second pane')
expect(controller.dropTab(tabId, getPane(controller.getSnapshot().state, target).id, 'center')).toBe(true)
const state = controller.getSnapshot().state
expect(getPane(state, target).tabs).toContain(tabId)
expect(getPane(state, paneId).tabs).not.toContain(tabId)
expect(state.activePaneId).toBe(target)
})
it('rejects a centre drop on the tab own pane', () => {
const { controller, paneId } = expanded()
const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
const before = controller.ops.length
expect(controller.dropTab(tabId, paneId, 'center')).toBe(false)
expect(controller.ops).toHaveLength(before)
})
it('splits on an edge drop and lands the tab in the new pane', () => {
const { controller, paneId } = expanded()
const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
expect(controller.dropTab(tabId, paneId, 'bottom')).toBe(true)
const state = controller.getSnapshot().state
const root = getSplit(state, state.rootId)
expect(root.axis).toBe('column')
const created = root.children[1]
if (created === undefined) throw new Error('expected a split child')
expect(getPane(state, created).tabs).toEqual([tabId])
expect(dockPaneCount(state)).toBe(2)
})
it('refuses an edge drop once the grid is full', () => {
const { controller, paneId } = expanded()
const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
controller.splitPane()
controller.splitPane()
controller.splitPane()
const before = controller.ops.length
expect(controller.dropTab(tabId, paneId, 'right')).toBe(false)
expect(controller.ops).toHaveLength(before)
})
it('reorders inside one pane', () => {
const { controller, paneId } = expanded()
const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
controller.reorderTab(first, 0)
expect(getPane(controller.getSnapshot().state, paneId).tabs[0]).toBe(first)
})
})
describe('explicit tab placement', () => {
it('reorders when the slot is in the tab own pane', () => {
const { controller, paneId } = expanded()
const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
expect(controller.placeTab(first, paneId, 0)).toBe(true)
expect(getPane(controller.getSnapshot().state, paneId).tabs[0]).toBe(first)
expect(controller.ops.at(-1)?.type).toBe('reorderTab')
})
it('reports no change when the slot is where the tab already sits', () => {
const { controller, paneId } = expanded()
const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
const index = getPane(controller.getSnapshot().state, paneId).tabs.indexOf(tabId)
const before = controller.ops.length
expect(controller.placeTab(tabId, paneId, index)).toBe(false)
expect(controller.ops).toHaveLength(before)
})
it('moves across panes into the requested slot', () => {
const { controller, paneId } = expanded()
const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
controller.splitPane()
const target = getSplit(controller.getSnapshot().state, controller.getSnapshot().state.rootId).children[1]
if (target === undefined) throw new Error('expected a second pane')
expect(controller.placeTab(tabId, getPane(controller.getSnapshot().state, target).id, 0)).toBe(true)
const state = controller.getSnapshot().state
expect(getPane(state, target).tabs[0]).toBe(tabId)
expect(getPane(state, paneId).tabs).not.toContain(tabId)
})
it('returns a floating tab when placed into a docked strip', () => {
const { controller, paneId } = expanded()
const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
const floatId = controller.floatTab(tabId)
expect(controller.placeTab(tabId, paneId, 0)).toBe(true)
const state = controller.getSnapshot().state
expect(state.floats).toEqual([])
expect(state.nodes[floatId]).toBeUndefined()
expect(getPane(state, paneId).tabs[0]).toBe(tabId)
})
it('refuses a floating pane as the destination', () => {
const { controller } = expanded()
const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'text-preview' })
const floatId = controller.floatTab(first)
const before = controller.ops.length
expect(controller.placeTab(second, floatId, 0)).toBe(false)
expect(controller.ops).toHaveLength(before)
})
})
describe('floating', () => {
it('takes a tab out and cascades each new panel', () => {
const { controller, paneId } = expanded()
const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'text-preview' })
const firstFloat = controller.floatTab(first)
const secondFloat = controller.floatTab(second)
const state = controller.getSnapshot().state
expect(state.floats).toEqual([firstFloat, secondFloat])
const one = getPane(state, firstFloat)
const two = getPane(state, secondFloat)
expect(one.rect?.width).toBe(FLOAT_DEFAULT_SIZE.width)
expect(two.rect?.x).toBeGreaterThan(one.rect?.x ?? 0)
expect(getPane(state, paneId).tabs).toHaveLength(1)
})
it('is unaffected by collapsing the column', () => {
const { controller } = expanded()
const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
const floatId = controller.floatTab(tabId)
controller.setExpanded(false)
const state = controller.getSnapshot().state
expect(state.expanded).toBe(false)
expect(state.floats).toEqual([floatId])
expect(getPane(state, floatId).tabs).toEqual([tabId])
})
it('sends a floating panel back into the grid', () => {
const { controller, paneId } = expanded()
const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
const floatId = controller.floatTab(tabId)
controller.unfloatPane(floatId)
const state = controller.getSnapshot().state
expect(state.floats).toEqual([])
expect(findTabPane(state, tabId).id).toBe(paneId)
})
it('treats a centre drop from a floating panel as a return', () => {
const { controller, paneId } = expanded()
const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
const floatId = controller.floatTab(tabId)
expect(controller.dropTab(tabId, paneId, 'center')).toBe(true)
const state = controller.getSnapshot().state
expect(state.floats).toEqual([])
expect(state.nodes[floatId]).toBeUndefined()
expect(findTabPane(state, tabId).id).toBe(paneId)
})
it('records net drag and resize results', () => {
const { controller } = expanded()
const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
const floatId = controller.floatTab(tabId)
controller.moveFloat(floatId, 300, 210)
controller.resizeFloat(floatId, { x: 300, y: 210, width: 500, height: 400 })
expect(getPane(controller.getSnapshot().state, floatId).rect)
.toEqual({ x: 300, y: 210, width: 500, height: 400 })
expect(controller.ops.map(op => op.type).slice(-2)).toEqual(['moveFloat', 'resizeFloat'])
})
it('raises a panel to the top when focused', () => {
const { controller } = expanded()
const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'text-preview' })
const firstFloat = controller.floatTab(first)
const secondFloat = controller.floatTab(second)
controller.focusPane(firstFloat)
expect(controller.getSnapshot().state.floats).toEqual([secondFloat, firstFloat])
})
})
describe('dividers', () => {
it('clamps a drag that would starve a pane', () => {
const { controller } = expanded()
controller.splitPane()
const splitId = getSplit(controller.getSnapshot().state, controller.getSnapshot().state.rootId).id
controller.resizeSplit(splitId, [0.01, 0.99])
const sizes = getSplit(controller.getSnapshot().state, splitId).sizes
expect(sizes[0]).toBeGreaterThan(0.05)
expect(sizes.reduce((sum, size) => sum + size, 0)).toBeCloseTo(1)
})
})
describe('notification', () => {
it('notifies subscribers and keeps the snapshot stable between changes', () => {
const controller = seededController()
let notifications = 0
const dispose = controller.subscribe(() => { notifications += 1 })
const before = controller.getSnapshot()
expect(controller.getSnapshot()).toBe(before)
controller.setExpanded(true)
expect(notifications).toBe(1)
expect(controller.getSnapshot()).not.toBe(before)
controller.setExpanded(true)
expect(notifications).toBe(1)
dispose()
controller.setExpanded(false)
expect(notifications).toBe(1)
})
it('steps one compound command as one entry, however many operations it recorded', () => {
expect(seededController().undo()).toBe(false)
const { controller } = expanded()
const before = controller.ops.length
controller.splitPane()
// The split and the seeded tab are two operations of one intent.
expect(controller.ops.length - before).toBe(2)
expect(controller.getSnapshot().canUndo).toBe(true)
expect(controller.undo()).toBe(true)
expect(dockPaneCount(controller.getSnapshot().state)).toBe(1)
expect(controller.redo()).toBe(true)
expect(dockPaneCount(controller.getSnapshot().state)).toBe(2)
expect(controller.redo()).toBe(false)
})
})