sequence.client.spec.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. /**
  2. * Sequence behavior: linear history, exact inverses, the focus-run undo step, and
  3. * the redo branch a new operation discards.
  4. */
  5. import { describe, expect, it } from 'vitest'
  6. import { replay } from '../src/engine/operations.ts'
  7. import { createIdMinter, createInitialState } from '../src/engine/initial.ts'
  8. import { asPane, asTab, fileTab, seedTab } from './fixtures.client.ts'
  9. import { EMPTY_HISTORY, isFocusOp, record, Sequencer } from '../src/engine/sequence.ts'
  10. import { dockPaneIds, getPane, getSplit } from '../src/engine/tree.ts'
  11. import type { LayoutState, PaneId, TabId } from '../src/contract/types.ts'
  12. interface Fixture {
  13. readonly initial: LayoutState
  14. readonly sequencer: Sequencer
  15. readonly minter: ReturnType<typeof createIdMinter>
  16. readonly paneId: PaneId
  17. readonly guideTabId: TabId
  18. }
  19. function fixture(): Fixture {
  20. const minter = createIdMinter()
  21. const initial = createInitialState(minter, seedTab)
  22. const guideTabId = getPane(initial, initial.rootId).tabs[0]
  23. if (guideTabId === undefined) throw new Error('fixture: initial pane has no guide tab')
  24. return { initial, sequencer: new Sequencer(initial), minter, paneId: getPane(initial, initial.rootId).id, guideTabId }
  25. }
  26. describe('isFocusOp', () => {
  27. it('names the operations that only move focus', () => {
  28. expect(isFocusOp({ type: 'focusPane', paneId: asPane('p') })).toBe(true)
  29. expect(isFocusOp({ type: 'focusTab', tabId: asTab('t') })).toBe(true)
  30. expect(isFocusOp({ type: 'restoreFocus', activePaneId: asPane('p'), floats: [], paneActiveTabs: {} })).toBe(true)
  31. expect(isFocusOp({ type: 'setExpanded', expanded: true })).toBe(false)
  32. })
  33. })
  34. describe('recording', () => {
  35. it('starts with nothing to step through', () => {
  36. const { sequencer, initial } = fixture()
  37. expect(sequencer.state).toBe(initial)
  38. expect(sequencer.canUndo).toBe(false)
  39. expect(sequencer.canRedo).toBe(false)
  40. expect(sequencer.undo()).toBe(false)
  41. expect(sequencer.redo()).toBe(false)
  42. })
  43. it('records every operation, focus moves included', () => {
  44. const { sequencer, minter, paneId, guideTabId } = fixture()
  45. const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
  46. sequencer.dispatch({ type: 'openTab', paneId, tab, index: 1 })
  47. sequencer.dispatch({ type: 'focusTab', tabId: guideTabId })
  48. sequencer.dispatch({ type: 'focusTab', tabId: tab.id })
  49. expect(sequencer.ops.map(op => op.type)).toEqual(['openTab', 'focusTab', 'focusTab'])
  50. expect(sequencer.cursor).toBe(3)
  51. })
  52. it('exposes the recorded sequence as plain history, and records nothing for an empty intent', () => {
  53. const { sequencer, initial, paneId } = fixture()
  54. expect(sequencer.history).toBe(EMPTY_HISTORY)
  55. sequencer.dispatch({ type: 'focusPane', paneId })
  56. expect(sequencer.history.entries).toHaveLength(1)
  57. expect(sequencer.history.cursor).toBe(1)
  58. const unchanged = record(EMPTY_HISTORY, initial, [])
  59. expect(unchanged.history).toBe(EMPTY_HISTORY)
  60. expect(unchanged.state).toBe(initial)
  61. expect(sequencer.dispatchAll([])).toBe(sequencer.state)
  62. expect(sequencer.history.entries).toHaveLength(1)
  63. })
  64. it('leaves the sequence untouched when an operation is invalid', () => {
  65. const { sequencer } = fixture()
  66. expect(() => sequencer.dispatch({ type: 'focusPane', paneId: asPane('nope') })).toThrow(/unknown node/)
  67. expect(sequencer.ops).toHaveLength(0)
  68. expect(sequencer.canUndo).toBe(false)
  69. })
  70. })
  71. describe('stepping back and forward', () => {
  72. it('returns to the exact previous state', () => {
  73. const { sequencer, initial, minter, paneId } = fixture()
  74. const newPaneId = minter.next('pane')
  75. sequencer.dispatch({
  76. type: 'split', paneId, axis: 'row', direction: 'after', newPaneId, newSplitId: minter.next('split'),
  77. })
  78. expect(dockPaneIds(sequencer.state)).toHaveLength(2)
  79. expect(sequencer.undo()).toBe(true)
  80. expect(sequencer.state).toEqual(initial)
  81. expect(sequencer.canUndo).toBe(false)
  82. expect(sequencer.canRedo).toBe(true)
  83. expect(sequencer.redo()).toBe(true)
  84. expect(dockPaneIds(sequencer.state)).toHaveLength(2)
  85. })
  86. it('collapses a run of consecutive focus moves into one step', () => {
  87. const { sequencer, minter, paneId, guideTabId } = fixture()
  88. const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
  89. sequencer.dispatch({ type: 'openTab', paneId, tab, index: 1 })
  90. const afterOpen = sequencer.state
  91. sequencer.dispatch({ type: 'focusTab', tabId: guideTabId })
  92. sequencer.dispatch({ type: 'focusTab', tabId: tab.id })
  93. sequencer.dispatch({ type: 'focusTab', tabId: guideTabId })
  94. expect(sequencer.cursor).toBe(4)
  95. expect(sequencer.undo()).toBe(true)
  96. expect(sequencer.cursor).toBe(1)
  97. expect(sequencer.state).toEqual(afterOpen)
  98. expect(sequencer.redo()).toBe(true)
  99. expect(sequencer.cursor).toBe(4)
  100. expect(getPane(sequencer.state, paneId).activeTabId).toBe(guideTabId)
  101. })
  102. it('steps one structural operation even when focus moves precede it', () => {
  103. const { sequencer, paneId, guideTabId } = fixture()
  104. sequencer.dispatch({ type: 'focusPane', paneId })
  105. sequencer.dispatch({ type: 'focusTab', tabId: guideTabId })
  106. sequencer.dispatch({ type: 'setExpanded', expanded: true })
  107. expect(sequencer.undo()).toBe(true)
  108. expect(sequencer.cursor).toBe(2)
  109. expect(sequencer.state.expanded).toBe(false)
  110. expect(sequencer.undo()).toBe(true)
  111. expect(sequencer.cursor).toBe(0)
  112. })
  113. it('undoes a whole session back to the initial state', () => {
  114. const { sequencer, initial, minter, paneId, guideTabId } = fixture()
  115. const newPaneId = minter.next('pane')
  116. const floatId = minter.next('float')
  117. sequencer.dispatch({ type: 'setExpanded', expanded: true })
  118. sequencer.dispatch({
  119. type: 'split', paneId, axis: 'row', direction: 'after', newPaneId, newSplitId: minter.next('split'),
  120. })
  121. sequencer.dispatch({ type: 'moveTab', tabId: guideTabId, toPaneId: newPaneId, index: 0 })
  122. sequencer.dispatch({
  123. type: 'float', tabId: guideTabId, newPaneId: floatId, rect: { x: 5, y: 6, width: 200, height: 100 },
  124. })
  125. sequencer.dispatch({ type: 'moveFloat', paneId: floatId, x: 50, y: 60 })
  126. while (sequencer.undo()) { /* step back to the beginning */ }
  127. expect(sequencer.state).toEqual(initial)
  128. expect(sequencer.cursor).toBe(0)
  129. })
  130. })
  131. describe('a floating panel\'s drag as one step', () => {
  132. it('raises and focuses the panel with the move, and steps both back and forward together', () => {
  133. const { sequencer, minter, paneId, guideTabId } = fixture()
  134. const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
  135. const lower = minter.next('float')
  136. const upper = minter.next('float')
  137. sequencer.dispatch({ type: 'openTab', paneId, tab, index: 1 })
  138. sequencer.dispatch({ type: 'float', tabId: guideTabId, newPaneId: lower, rect: { x: 0, y: 0, width: 200, height: 100 } })
  139. sequencer.dispatch({ type: 'float', tabId: tab.id, newPaneId: upper, rect: { x: 20, y: 20, width: 200, height: 100 } })
  140. sequencer.dispatch({ type: 'focusPane', paneId })
  141. const before = sequencer.state
  142. expect(before.floats).toEqual([lower, upper])
  143. sequencer.dispatch({ type: 'moveFloat', paneId: lower, x: 50, y: 60 })
  144. expect(sequencer.state.floats).toEqual([upper, lower])
  145. expect(sequencer.state.activePaneId).toBe(lower)
  146. // One step back restores the rectangle, the z order, and the focus; the
  147. // preceding focus-only entry is its own step.
  148. expect(sequencer.undo()).toBe(true)
  149. expect(sequencer.state).toEqual(before)
  150. expect(sequencer.redo()).toBe(true)
  151. expect(sequencer.state.floats).toEqual([upper, lower])
  152. expect(getPane(sequencer.state, lower).rect).toEqual({ x: 50, y: 60, width: 200, height: 100 })
  153. expect(sequencer.state.activePaneId).toBe(lower)
  154. })
  155. })
  156. describe('linear history', () => {
  157. it('drops the redo branch when a new operation lands after an undo', () => {
  158. const { sequencer, minter, paneId } = fixture()
  159. sequencer.dispatch({ type: 'setExpanded', expanded: true })
  160. sequencer.dispatch({
  161. type: 'split', paneId, axis: 'row', direction: 'after', newPaneId: minter.next('pane'), newSplitId: minter.next('split'),
  162. })
  163. expect(sequencer.undo()).toBe(true)
  164. expect(sequencer.canRedo).toBe(true)
  165. sequencer.dispatch({ type: 'focusPane', paneId })
  166. expect(sequencer.canRedo).toBe(false)
  167. expect(sequencer.ops.map(op => op.type)).toEqual(['setExpanded', 'focusPane'])
  168. expect(dockPaneIds(sequencer.state)).toHaveLength(1)
  169. })
  170. it('keeps the applied prefix replayable at any cursor', () => {
  171. const { sequencer, initial, minter, paneId, guideTabId } = fixture()
  172. const newPaneId = minter.next('pane')
  173. sequencer.dispatch({ type: 'setExpanded', expanded: true })
  174. sequencer.dispatch({
  175. type: 'split', paneId, axis: 'row', direction: 'after', newPaneId, newSplitId: minter.next('split'),
  176. })
  177. sequencer.dispatch({ type: 'moveTab', tabId: guideTabId, toPaneId: newPaneId, index: 0 })
  178. sequencer.dispatch({ type: 'resize', splitId: getSplit(sequencer.state, sequencer.state.rootId).id, sizes: [0.7, 0.3] })
  179. sequencer.undo()
  180. sequencer.undo()
  181. expect(replay(initial, sequencer.ops.slice(0, sequencer.cursor))).toEqual(sequencer.state)
  182. sequencer.redo()
  183. expect(replay(initial, sequencer.ops.slice(0, sequencer.cursor))).toEqual(sequencer.state)
  184. })
  185. })