controller.client.spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. /**
  2. * Controller behavior: the interaction vocabulary the UI calls, including tab
  3. * identity (focus an existing tab, or open an explicit copy), the guide tab a
  4. * split seats, drop resolution, floating, and the notification contract.
  5. */
  6. import { describe, expect, it } from 'vitest'
  7. import { DockController } from '../src/engine/controller.ts'
  8. import { dockPaneCount, FLOAT_DEFAULT_SIZE } from '../src/engine/constraints.ts'
  9. import { SEED_CONTENT_ID, childAt, firstTab, seededController } from './fixtures.client.ts'
  10. import { dockPaneIds, findTabPane, getPane, getSplit } from '../src/engine/tree.ts'
  11. import type { LayoutState, PaneId } from '../src/contract/types.ts'
  12. /** Tab titles of one pane, in strip order. */
  13. function titles(state: LayoutState, paneId: PaneId): string[] {
  14. return getPane(state, paneId).tabs.map(id => state.tabs[id]?.title ?? '?')
  15. }
  16. /** A controller expanded once, with its first pane id. */
  17. function expanded(): { controller: DockController; paneId: PaneId } {
  18. const controller = seededController()
  19. controller.setExpanded(true)
  20. const { state } = controller.getSnapshot()
  21. return { controller, paneId: getPane(state, state.rootId).id }
  22. }
  23. describe('expand', () => {
  24. it('starts collapsed with a guide tab already seated', () => {
  25. const controller = seededController()
  26. const snapshot = controller.getSnapshot()
  27. expect(snapshot.state.expanded).toBe(false)
  28. expect(snapshot.canUndo).toBe(false)
  29. expect(snapshot.canSplit).toBe(true)
  30. expect(titles(snapshot.state, getPane(snapshot.state, snapshot.state.rootId).id)).toHaveLength(1)
  31. const guide = Object.values(snapshot.state.tabs)[0]
  32. expect(guide?.contentId).toBe(SEED_CONTENT_ID)
  33. })
  34. it('records a toggle and ignores a redundant set', () => {
  35. const controller = seededController()
  36. controller.toggleExpanded()
  37. expect(controller.getSnapshot().state.expanded).toBe(true)
  38. expect(controller.ops).toHaveLength(1)
  39. controller.setExpanded(true)
  40. expect(controller.ops).toHaveLength(1)
  41. controller.toggleExpanded()
  42. expect(controller.getSnapshot().state.expanded).toBe(false)
  43. expect(controller.ops).toHaveLength(2)
  44. })
  45. })
  46. describe('presentation', () => {
  47. it('records a mode switch once and ignores a redundant one', () => {
  48. const controller = seededController()
  49. controller.setMode('fullscreen')
  50. expect(controller.getSnapshot().state.mode).toBe('fullscreen')
  51. controller.setMode('fullscreen')
  52. expect(controller.ops.map(op => op.type)).toEqual(['setMode'])
  53. })
  54. })
  55. describe('splitting', () => {
  56. it('splits the active pane to the right and seats a guide tab there', () => {
  57. const { controller, paneId } = expanded()
  58. expect(controller.splitPane()).toBe(true)
  59. const state = controller.getSnapshot().state
  60. const root = getSplit(state, state.rootId)
  61. expect(root.axis).toBe('row')
  62. const newPaneId = getPane(state, childAt(root, 1)).id
  63. expect(newPaneId).not.toBe(paneId)
  64. expect(titles(state, newPaneId)).toHaveLength(1)
  65. expect(state.tabs[firstTab(getPane(state, newPaneId))]?.contentId).toBe(SEED_CONTENT_ID)
  66. expect(controller.ops.map(op => op.type)).toEqual(['setExpanded', 'split', 'openTab'])
  67. })
  68. it('seats the pane factory\'s tab at the end of a strip from the add control, and nothing without one', () => {
  69. const { controller, paneId } = expanded()
  70. expect(controller.addTab(paneId)).toBe(true)
  71. expect(titles(controller.getSnapshot().state, paneId)).toEqual(['Start', 'Start'])
  72. const bare = new DockController({ makeInitialTab: id => ({ id, kind: 'seed', contentId: SEED_CONTENT_ID, title: 'Start' }) })
  73. expect(bare.addTab(getPane(bare.getSnapshot().state, bare.getSnapshot().state.rootId).id)).toBe(false)
  74. })
  75. it('names the pane a new tab lands in: the active pane, or the first docked one while a panel floats', () => {
  76. const { controller, paneId } = expanded()
  77. expect(controller.activeDockPaneId()).toBe(paneId)
  78. const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
  79. const floatId = controller.floatTab(tabId)
  80. expect(controller.getSnapshot().state.activePaneId).toBe(floatId)
  81. expect(controller.activeDockPaneId()).toBe(paneId)
  82. })
  83. it('stops at four panes without recording anything', () => {
  84. const { controller } = expanded()
  85. expect(controller.splitPane()).toBe(true)
  86. expect(controller.splitPane()).toBe(true)
  87. expect(controller.splitPane()).toBe(true)
  88. expect(dockPaneCount(controller.getSnapshot().state)).toBe(4)
  89. const before = controller.ops.length
  90. expect(controller.splitPane()).toBe(false)
  91. expect(controller.ops).toHaveLength(before)
  92. expect(controller.getSnapshot().canSplit).toBe(false)
  93. })
  94. })
  95. describe('tab identity', () => {
  96. it('focuses the existing tab when the same content is opened twice', () => {
  97. const { controller, paneId } = expanded()
  98. const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
  99. controller.splitPane()
  100. const again = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
  101. expect(again).toBe(first)
  102. const state = controller.getSnapshot().state
  103. expect(getPane(state, paneId).activeTabId).toBe(first)
  104. expect(state.activePaneId).toBe(paneId)
  105. expect(Object.values(state.tabs).filter(tab => tab.contentId === 'dsh-resource://file/session/s/a.txt')).toHaveLength(1)
  106. })
  107. it('opens an explicit second copy beside the original', () => {
  108. const { controller, paneId } = expanded()
  109. const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
  110. const copy = controller.duplicateTab(first)
  111. expect(copy).not.toBe(first)
  112. const state = controller.getSnapshot().state
  113. expect(state.tabs[copy]?.contentId).toBe('dsh-resource://file/session/s/a.txt')
  114. expect(getPane(state, paneId).tabs.indexOf(copy)).toBe(getPane(state, paneId).tabs.indexOf(first) + 1)
  115. })
  116. it('closes a tab and leaves the pane in place', () => {
  117. const { controller, paneId } = expanded()
  118. const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
  119. controller.closeTab(tabId)
  120. const state = controller.getSnapshot().state
  121. expect(state.tabs[tabId]).toBeUndefined()
  122. expect(dockPaneIds(state)).toEqual([paneId])
  123. })
  124. })
  125. describe('drops', () => {
  126. it('moves a tab into the pane under the pointer', () => {
  127. const { controller, paneId } = expanded()
  128. const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
  129. controller.splitPane()
  130. const target = getSplit(controller.getSnapshot().state, controller.getSnapshot().state.rootId).children[1]
  131. if (target === undefined) throw new Error('expected a second pane')
  132. expect(controller.dropTab(tabId, getPane(controller.getSnapshot().state, target).id, 'center')).toBe(true)
  133. const state = controller.getSnapshot().state
  134. expect(getPane(state, target).tabs).toContain(tabId)
  135. expect(getPane(state, paneId).tabs).not.toContain(tabId)
  136. expect(state.activePaneId).toBe(target)
  137. })
  138. it('rejects a centre drop on the tab own pane', () => {
  139. const { controller, paneId } = expanded()
  140. const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
  141. const before = controller.ops.length
  142. expect(controller.dropTab(tabId, paneId, 'center')).toBe(false)
  143. expect(controller.ops).toHaveLength(before)
  144. })
  145. it('splits on an edge drop and lands the tab in the new pane', () => {
  146. const { controller, paneId } = expanded()
  147. const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
  148. expect(controller.dropTab(tabId, paneId, 'bottom')).toBe(true)
  149. const state = controller.getSnapshot().state
  150. const root = getSplit(state, state.rootId)
  151. expect(root.axis).toBe('column')
  152. const created = root.children[1]
  153. if (created === undefined) throw new Error('expected a split child')
  154. expect(getPane(state, created).tabs).toEqual([tabId])
  155. expect(dockPaneCount(state)).toBe(2)
  156. })
  157. it('refuses an edge drop once the grid is full', () => {
  158. const { controller, paneId } = expanded()
  159. const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
  160. controller.splitPane()
  161. controller.splitPane()
  162. controller.splitPane()
  163. const before = controller.ops.length
  164. expect(controller.dropTab(tabId, paneId, 'right')).toBe(false)
  165. expect(controller.ops).toHaveLength(before)
  166. })
  167. it('reorders inside one pane', () => {
  168. const { controller, paneId } = expanded()
  169. const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
  170. controller.reorderTab(first, 0)
  171. expect(getPane(controller.getSnapshot().state, paneId).tabs[0]).toBe(first)
  172. })
  173. })
  174. describe('explicit tab placement', () => {
  175. it('reorders when the slot is in the tab own pane', () => {
  176. const { controller, paneId } = expanded()
  177. const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
  178. expect(controller.placeTab(first, paneId, 0)).toBe(true)
  179. expect(getPane(controller.getSnapshot().state, paneId).tabs[0]).toBe(first)
  180. expect(controller.ops.at(-1)?.type).toBe('reorderTab')
  181. })
  182. it('reports no change when the slot is where the tab already sits', () => {
  183. const { controller, paneId } = expanded()
  184. const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
  185. const index = getPane(controller.getSnapshot().state, paneId).tabs.indexOf(tabId)
  186. const before = controller.ops.length
  187. expect(controller.placeTab(tabId, paneId, index)).toBe(false)
  188. expect(controller.ops).toHaveLength(before)
  189. })
  190. it('moves across panes into the requested slot', () => {
  191. const { controller, paneId } = expanded()
  192. const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
  193. controller.splitPane()
  194. const target = getSplit(controller.getSnapshot().state, controller.getSnapshot().state.rootId).children[1]
  195. if (target === undefined) throw new Error('expected a second pane')
  196. expect(controller.placeTab(tabId, getPane(controller.getSnapshot().state, target).id, 0)).toBe(true)
  197. const state = controller.getSnapshot().state
  198. expect(getPane(state, target).tabs[0]).toBe(tabId)
  199. expect(getPane(state, paneId).tabs).not.toContain(tabId)
  200. })
  201. it('returns a floating tab when placed into a docked strip', () => {
  202. const { controller, paneId } = expanded()
  203. const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
  204. const floatId = controller.floatTab(tabId)
  205. expect(controller.placeTab(tabId, paneId, 0)).toBe(true)
  206. const state = controller.getSnapshot().state
  207. expect(state.floats).toEqual([])
  208. expect(state.nodes[floatId]).toBeUndefined()
  209. expect(getPane(state, paneId).tabs[0]).toBe(tabId)
  210. })
  211. it('refuses a floating pane as the destination', () => {
  212. const { controller } = expanded()
  213. const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
  214. const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'text-preview' })
  215. const floatId = controller.floatTab(first)
  216. const before = controller.ops.length
  217. expect(controller.placeTab(second, floatId, 0)).toBe(false)
  218. expect(controller.ops).toHaveLength(before)
  219. })
  220. })
  221. describe('floating', () => {
  222. it('takes a tab out and cascades each new panel', () => {
  223. const { controller, paneId } = expanded()
  224. const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
  225. const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'text-preview' })
  226. const firstFloat = controller.floatTab(first)
  227. const secondFloat = controller.floatTab(second)
  228. const state = controller.getSnapshot().state
  229. expect(state.floats).toEqual([firstFloat, secondFloat])
  230. const one = getPane(state, firstFloat)
  231. const two = getPane(state, secondFloat)
  232. expect(one.rect?.width).toBe(FLOAT_DEFAULT_SIZE.width)
  233. expect(two.rect?.x).toBeGreaterThan(one.rect?.x ?? 0)
  234. expect(getPane(state, paneId).tabs).toHaveLength(1)
  235. })
  236. it('is unaffected by collapsing the column', () => {
  237. const { controller } = expanded()
  238. const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
  239. const floatId = controller.floatTab(tabId)
  240. controller.setExpanded(false)
  241. const state = controller.getSnapshot().state
  242. expect(state.expanded).toBe(false)
  243. expect(state.floats).toEqual([floatId])
  244. expect(getPane(state, floatId).tabs).toEqual([tabId])
  245. })
  246. it('sends a floating panel back into the grid', () => {
  247. const { controller, paneId } = expanded()
  248. const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
  249. const floatId = controller.floatTab(tabId)
  250. controller.unfloatPane(floatId)
  251. const state = controller.getSnapshot().state
  252. expect(state.floats).toEqual([])
  253. expect(findTabPane(state, tabId).id).toBe(paneId)
  254. })
  255. it('treats a centre drop from a floating panel as a return', () => {
  256. const { controller, paneId } = expanded()
  257. const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
  258. const floatId = controller.floatTab(tabId)
  259. expect(controller.dropTab(tabId, paneId, 'center')).toBe(true)
  260. const state = controller.getSnapshot().state
  261. expect(state.floats).toEqual([])
  262. expect(state.nodes[floatId]).toBeUndefined()
  263. expect(findTabPane(state, tabId).id).toBe(paneId)
  264. })
  265. it('records net drag and resize results', () => {
  266. const { controller } = expanded()
  267. const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
  268. const floatId = controller.floatTab(tabId)
  269. controller.moveFloat(floatId, 300, 210)
  270. controller.resizeFloat(floatId, { x: 300, y: 210, width: 500, height: 400 })
  271. expect(getPane(controller.getSnapshot().state, floatId).rect)
  272. .toEqual({ x: 300, y: 210, width: 500, height: 400 })
  273. expect(controller.ops.map(op => op.type).slice(-2)).toEqual(['moveFloat', 'resizeFloat'])
  274. })
  275. it('raises a panel to the top when focused', () => {
  276. const { controller } = expanded()
  277. const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
  278. const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'text-preview' })
  279. const firstFloat = controller.floatTab(first)
  280. const secondFloat = controller.floatTab(second)
  281. controller.focusPane(firstFloat)
  282. expect(controller.getSnapshot().state.floats).toEqual([secondFloat, firstFloat])
  283. })
  284. })
  285. describe('dividers', () => {
  286. it('clamps a drag that would starve a pane', () => {
  287. const { controller } = expanded()
  288. controller.splitPane()
  289. const splitId = getSplit(controller.getSnapshot().state, controller.getSnapshot().state.rootId).id
  290. controller.resizeSplit(splitId, [0.01, 0.99])
  291. const sizes = getSplit(controller.getSnapshot().state, splitId).sizes
  292. expect(sizes[0]).toBeGreaterThan(0.05)
  293. expect(sizes.reduce((sum, size) => sum + size, 0)).toBeCloseTo(1)
  294. })
  295. })
  296. describe('notification', () => {
  297. it('notifies subscribers and keeps the snapshot stable between changes', () => {
  298. const controller = seededController()
  299. let notifications = 0
  300. const dispose = controller.subscribe(() => { notifications += 1 })
  301. const before = controller.getSnapshot()
  302. expect(controller.getSnapshot()).toBe(before)
  303. controller.setExpanded(true)
  304. expect(notifications).toBe(1)
  305. expect(controller.getSnapshot()).not.toBe(before)
  306. controller.setExpanded(true)
  307. expect(notifications).toBe(1)
  308. dispose()
  309. controller.setExpanded(false)
  310. expect(notifications).toBe(1)
  311. })
  312. it('steps one compound command as one entry, however many operations it recorded', () => {
  313. expect(seededController().undo()).toBe(false)
  314. const { controller } = expanded()
  315. const before = controller.ops.length
  316. controller.splitPane()
  317. // The split and the seeded tab are two operations of one intent.
  318. expect(controller.ops.length - before).toBe(2)
  319. expect(controller.getSnapshot().canUndo).toBe(true)
  320. expect(controller.undo()).toBe(true)
  321. expect(dockPaneCount(controller.getSnapshot().state)).toBe(1)
  322. expect(controller.redo()).toBe(true)
  323. expect(dockPaneCount(controller.getSnapshot().state)).toBe(2)
  324. expect(controller.redo()).toBe(false)
  325. })
  326. })