| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203 |
- /**
- * Sequence behavior: linear history, exact inverses, the focus-run undo step, and
- * the redo branch a new operation discards.
- */
- import { describe, expect, it } from 'vitest'
- import { replay } from '../src/engine/operations.ts'
- import { createIdMinter, createInitialState } from '../src/engine/initial.ts'
- import { asPane, asTab, fileTab, seedTab } from './fixtures.client.ts'
- import { EMPTY_HISTORY, isFocusOp, record, Sequencer } from '../src/engine/sequence.ts'
- import { dockPaneIds, getPane, getSplit } from '../src/engine/tree.ts'
- import type { LayoutState, PaneId, TabId } from '../src/contract/types.ts'
- interface Fixture {
- readonly initial: LayoutState
- readonly sequencer: Sequencer
- readonly minter: ReturnType<typeof createIdMinter>
- readonly paneId: PaneId
- readonly guideTabId: TabId
- }
- function fixture(): Fixture {
- const minter = createIdMinter()
- const initial = createInitialState(minter, seedTab)
- const guideTabId = getPane(initial, initial.rootId).tabs[0]
- if (guideTabId === undefined) throw new Error('fixture: initial pane has no guide tab')
- return { initial, sequencer: new Sequencer(initial), minter, paneId: getPane(initial, initial.rootId).id, guideTabId }
- }
- describe('isFocusOp', () => {
- it('names the operations that only move focus', () => {
- expect(isFocusOp({ type: 'focusPane', paneId: asPane('p') })).toBe(true)
- expect(isFocusOp({ type: 'focusTab', tabId: asTab('t') })).toBe(true)
- expect(isFocusOp({ type: 'restoreFocus', activePaneId: asPane('p'), floats: [], paneActiveTabs: {} })).toBe(true)
- expect(isFocusOp({ type: 'setExpanded', expanded: true })).toBe(false)
- })
- })
- describe('recording', () => {
- it('starts with nothing to step through', () => {
- const { sequencer, initial } = fixture()
- expect(sequencer.state).toBe(initial)
- expect(sequencer.canUndo).toBe(false)
- expect(sequencer.canRedo).toBe(false)
- expect(sequencer.undo()).toBe(false)
- expect(sequencer.redo()).toBe(false)
- })
- it('records every operation, focus moves included', () => {
- const { sequencer, minter, paneId, guideTabId } = fixture()
- const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
- sequencer.dispatch({ type: 'openTab', paneId, tab, index: 1 })
- sequencer.dispatch({ type: 'focusTab', tabId: guideTabId })
- sequencer.dispatch({ type: 'focusTab', tabId: tab.id })
- expect(sequencer.ops.map(op => op.type)).toEqual(['openTab', 'focusTab', 'focusTab'])
- expect(sequencer.cursor).toBe(3)
- })
- it('exposes the recorded sequence as plain history, and records nothing for an empty intent', () => {
- const { sequencer, initial, paneId } = fixture()
- expect(sequencer.history).toBe(EMPTY_HISTORY)
- sequencer.dispatch({ type: 'focusPane', paneId })
- expect(sequencer.history.entries).toHaveLength(1)
- expect(sequencer.history.cursor).toBe(1)
- const unchanged = record(EMPTY_HISTORY, initial, [])
- expect(unchanged.history).toBe(EMPTY_HISTORY)
- expect(unchanged.state).toBe(initial)
- expect(sequencer.dispatchAll([])).toBe(sequencer.state)
- expect(sequencer.history.entries).toHaveLength(1)
- })
- it('leaves the sequence untouched when an operation is invalid', () => {
- const { sequencer } = fixture()
- expect(() => sequencer.dispatch({ type: 'focusPane', paneId: asPane('nope') })).toThrow(/unknown node/)
- expect(sequencer.ops).toHaveLength(0)
- expect(sequencer.canUndo).toBe(false)
- })
- })
- describe('stepping back and forward', () => {
- it('returns to the exact previous state', () => {
- const { sequencer, initial, minter, paneId } = fixture()
- const newPaneId = minter.next('pane')
- sequencer.dispatch({
- type: 'split', paneId, axis: 'row', direction: 'after', newPaneId, newSplitId: minter.next('split'),
- })
- expect(dockPaneIds(sequencer.state)).toHaveLength(2)
- expect(sequencer.undo()).toBe(true)
- expect(sequencer.state).toEqual(initial)
- expect(sequencer.canUndo).toBe(false)
- expect(sequencer.canRedo).toBe(true)
- expect(sequencer.redo()).toBe(true)
- expect(dockPaneIds(sequencer.state)).toHaveLength(2)
- })
- it('collapses a run of consecutive focus moves into one step', () => {
- const { sequencer, minter, paneId, guideTabId } = fixture()
- const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
- sequencer.dispatch({ type: 'openTab', paneId, tab, index: 1 })
- const afterOpen = sequencer.state
- sequencer.dispatch({ type: 'focusTab', tabId: guideTabId })
- sequencer.dispatch({ type: 'focusTab', tabId: tab.id })
- sequencer.dispatch({ type: 'focusTab', tabId: guideTabId })
- expect(sequencer.cursor).toBe(4)
- expect(sequencer.undo()).toBe(true)
- expect(sequencer.cursor).toBe(1)
- expect(sequencer.state).toEqual(afterOpen)
- expect(sequencer.redo()).toBe(true)
- expect(sequencer.cursor).toBe(4)
- expect(getPane(sequencer.state, paneId).activeTabId).toBe(guideTabId)
- })
- it('steps one structural operation even when focus moves precede it', () => {
- const { sequencer, paneId, guideTabId } = fixture()
- sequencer.dispatch({ type: 'focusPane', paneId })
- sequencer.dispatch({ type: 'focusTab', tabId: guideTabId })
- sequencer.dispatch({ type: 'setExpanded', expanded: true })
- expect(sequencer.undo()).toBe(true)
- expect(sequencer.cursor).toBe(2)
- expect(sequencer.state.expanded).toBe(false)
- expect(sequencer.undo()).toBe(true)
- expect(sequencer.cursor).toBe(0)
- })
- it('undoes a whole session back to the initial state', () => {
- const { sequencer, initial, minter, paneId, guideTabId } = fixture()
- const newPaneId = minter.next('pane')
- const floatId = minter.next('float')
- sequencer.dispatch({ type: 'setExpanded', expanded: true })
- sequencer.dispatch({
- type: 'split', paneId, axis: 'row', direction: 'after', newPaneId, newSplitId: minter.next('split'),
- })
- sequencer.dispatch({ type: 'moveTab', tabId: guideTabId, toPaneId: newPaneId, index: 0 })
- sequencer.dispatch({
- type: 'float', tabId: guideTabId, newPaneId: floatId, rect: { x: 5, y: 6, width: 200, height: 100 },
- })
- sequencer.dispatch({ type: 'moveFloat', paneId: floatId, x: 50, y: 60 })
- while (sequencer.undo()) { /* step back to the beginning */ }
- expect(sequencer.state).toEqual(initial)
- expect(sequencer.cursor).toBe(0)
- })
- })
- describe('a floating panel\'s drag as one step', () => {
- it('raises and focuses the panel with the move, and steps both back and forward together', () => {
- const { sequencer, minter, paneId, guideTabId } = fixture()
- const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
- const lower = minter.next('float')
- const upper = minter.next('float')
- sequencer.dispatch({ type: 'openTab', paneId, tab, index: 1 })
- sequencer.dispatch({ type: 'float', tabId: guideTabId, newPaneId: lower, rect: { x: 0, y: 0, width: 200, height: 100 } })
- sequencer.dispatch({ type: 'float', tabId: tab.id, newPaneId: upper, rect: { x: 20, y: 20, width: 200, height: 100 } })
- sequencer.dispatch({ type: 'focusPane', paneId })
- const before = sequencer.state
- expect(before.floats).toEqual([lower, upper])
- sequencer.dispatch({ type: 'moveFloat', paneId: lower, x: 50, y: 60 })
- expect(sequencer.state.floats).toEqual([upper, lower])
- expect(sequencer.state.activePaneId).toBe(lower)
- // One step back restores the rectangle, the z order, and the focus; the
- // preceding focus-only entry is its own step.
- expect(sequencer.undo()).toBe(true)
- expect(sequencer.state).toEqual(before)
- expect(sequencer.redo()).toBe(true)
- expect(sequencer.state.floats).toEqual([upper, lower])
- expect(getPane(sequencer.state, lower).rect).toEqual({ x: 50, y: 60, width: 200, height: 100 })
- expect(sequencer.state.activePaneId).toBe(lower)
- })
- })
- describe('linear history', () => {
- it('drops the redo branch when a new operation lands after an undo', () => {
- const { sequencer, minter, paneId } = fixture()
- sequencer.dispatch({ type: 'setExpanded', expanded: true })
- sequencer.dispatch({
- type: 'split', paneId, axis: 'row', direction: 'after', newPaneId: minter.next('pane'), newSplitId: minter.next('split'),
- })
- expect(sequencer.undo()).toBe(true)
- expect(sequencer.canRedo).toBe(true)
- sequencer.dispatch({ type: 'focusPane', paneId })
- expect(sequencer.canRedo).toBe(false)
- expect(sequencer.ops.map(op => op.type)).toEqual(['setExpanded', 'focusPane'])
- expect(dockPaneIds(sequencer.state)).toHaveLength(1)
- })
- it('keeps the applied prefix replayable at any cursor', () => {
- const { sequencer, initial, minter, paneId, guideTabId } = fixture()
- const newPaneId = minter.next('pane')
- sequencer.dispatch({ type: 'setExpanded', expanded: true })
- sequencer.dispatch({
- type: 'split', paneId, axis: 'row', direction: 'after', newPaneId, newSplitId: minter.next('split'),
- })
- sequencer.dispatch({ type: 'moveTab', tabId: guideTabId, toPaneId: newPaneId, index: 0 })
- sequencer.dispatch({ type: 'resize', splitId: getSplit(sequencer.state, sequencer.state.rootId).id, sizes: [0.7, 0.3] })
- sequencer.undo()
- sequencer.undo()
- expect(replay(initial, sequencer.ops.slice(0, sequencer.cursor))).toEqual(sequencer.state)
- sequencer.redo()
- expect(replay(initial, sequencer.ops.slice(0, sequencer.cursor))).toEqual(sequencer.state)
- })
- })
|