apply.client.spec.ts 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629
  1. /**
  2. * Model and operation-engine behavior: what each operation does to the tree, and
  3. * that applying its inverse returns the exact state it started from.
  4. */
  5. import { describe, expect, it } from 'vitest'
  6. import { applyOp, replay } from '../src/engine/operations.ts'
  7. import { createIdMinter, createInitialState } from '../src/engine/initial.ts'
  8. import { asPane, asSplit, asTab, childAt, fileTab, firstTab, seedTab } from './fixtures.client.ts'
  9. import {
  10. assertNever, dockPaneIds, findParent, findTabPane, firstDockPaneId, floatIndex, floatRect, getNode, getPane, getSplit,
  11. getTab, normalizeSizes, onlyTabId, replaceInParent, topRightPaneId,
  12. } from '../src/engine/tree.ts'
  13. import type { IdMinter } from '../src/engine/initial.ts'
  14. import type { LayoutOp, LayoutState, PaneId, PaneNode, TabId } from '../src/contract/types.ts'
  15. interface Fixture {
  16. readonly state: LayoutState
  17. readonly minter: IdMinter
  18. readonly paneId: PaneId
  19. readonly guideTabId: TabId
  20. }
  21. function fixture(): Fixture {
  22. const minter = createIdMinter()
  23. const state = createInitialState(minter, seedTab)
  24. const paneId = getPane(state, state.rootId).id
  25. const guideTabId = getPane(state, paneId).tabs[0]
  26. if (guideTabId === undefined) throw new Error('fixture: initial pane has no guide tab')
  27. return { state, minter, paneId, guideTabId }
  28. }
  29. /** Apply `op`, then its inverse, and require the original state back. */
  30. function expectRoundTrip(state: LayoutState, op: LayoutOp): LayoutState {
  31. const result = applyOp(state, op)
  32. const back = result.inverse.reduce((current, inverse) => applyOp(current, inverse).state, result.state)
  33. expect(back).toEqual(state)
  34. return result.state
  35. }
  36. /** Split `paneId` to the right, returning the state and the new pane id. */
  37. function split(
  38. state: LayoutState,
  39. minter: IdMinter,
  40. paneId: PaneId,
  41. axis: 'row' | 'column' = 'row',
  42. direction: 'before' | 'after' = 'after',
  43. ): { state: LayoutState; newPaneId: PaneId } {
  44. const newPaneId = minter.next('pane')
  45. const op: LayoutOp = { type: 'split', paneId, axis, direction, newPaneId, newSplitId: minter.next('split') }
  46. return { state: expectRoundTrip(state, op), newPaneId }
  47. }
  48. describe('initial state', () => {
  49. it('starts collapsed with one docked pane holding an active guide tab', () => {
  50. const { state, paneId, guideTabId } = fixture()
  51. expect(state.expanded).toBe(false)
  52. expect(dockPaneIds(state)).toEqual([paneId])
  53. expect(state.floats).toEqual([])
  54. expect(state.activePaneId).toBe(paneId)
  55. const pane = getPane(state, paneId)
  56. expect(pane.tabs).toEqual([guideTabId])
  57. expect(pane.activeTabId).toBe(guideTabId)
  58. expect(state.tabs[guideTabId]?.kind).toBe('seed')
  59. })
  60. })
  61. describe('split', () => {
  62. it('wraps the reference pane in a new split and adds an empty sibling', () => {
  63. const { state, minter, paneId } = fixture()
  64. const after = split(state, minter, paneId).state
  65. const root = getSplit(after, after.rootId)
  66. expect(root.axis).toBe('row')
  67. expect(root.children).toHaveLength(2)
  68. expect(root.children[0]).toBe(paneId)
  69. expect(root.sizes).toEqual([0.5, 0.5])
  70. expect(dockPaneIds(after)).toHaveLength(2)
  71. const sibling = getPane(after, childAt(root, 1))
  72. expect(sibling.tabs).toEqual([])
  73. expect(sibling.activeTabId).toBeUndefined()
  74. })
  75. it('puts the new pane before the reference pane when asked', () => {
  76. const { state, minter, paneId } = fixture()
  77. const after = split(state, minter, paneId, 'row', 'before').state
  78. expect(getSplit(after, after.rootId).children[1]).toBe(paneId)
  79. })
  80. it('joins an existing split of the same axis instead of nesting', () => {
  81. const { state, minter, paneId } = fixture()
  82. const first = split(state, minter, paneId)
  83. const second = split(first.state, minter, paneId)
  84. const root = getSplit(second.state, second.state.rootId)
  85. expect(root.children).toHaveLength(3)
  86. expect(root.sizes).toEqual([0.25, 0.25, 0.5])
  87. expect(dockPaneIds(second.state)).toHaveLength(3)
  88. })
  89. it('joins an existing split before the reference pane when asked', () => {
  90. const { state, minter, paneId } = fixture()
  91. const first = split(state, minter, paneId)
  92. const second = split(first.state, minter, paneId, 'row', 'before')
  93. const root = getSplit(second.state, second.state.rootId)
  94. expect(root.children).toEqual([second.newPaneId, paneId, first.newPaneId])
  95. expect(root.sizes).toEqual([0.25, 0.25, 0.5])
  96. })
  97. it('nests when the parent split runs on the other axis', () => {
  98. const { state, minter, paneId } = fixture()
  99. const first = split(state, minter, paneId)
  100. const second = split(first.state, minter, first.newPaneId, 'column')
  101. const root = getSplit(second.state, second.state.rootId)
  102. expect(root.axis).toBe('row')
  103. expect(root.children).toHaveLength(2)
  104. const nested = getSplit(second.state, childAt(root, 1))
  105. expect(nested.axis).toBe('column')
  106. expect(nested.children).toEqual([first.newPaneId, second.newPaneId])
  107. })
  108. it('refuses an unknown pane and a floating pane', () => {
  109. const { state, minter, paneId, guideTabId } = fixture()
  110. expect(() => applyOp(state, {
  111. type: 'split', paneId: asPane('nope'), axis: 'row', direction: 'after', newPaneId: asPane('p'), newSplitId: asSplit('s'),
  112. })).toThrow(/unknown node/)
  113. const floatId = minter.next('float')
  114. const floated = applyOp(state, {
  115. type: 'float', tabId: guideTabId, newPaneId: floatId, rect: { x: 0, y: 0, width: 100, height: 100 },
  116. }).state
  117. expect(() => applyOp(floated, {
  118. type: 'split', paneId: floatId, axis: 'row', direction: 'after', newPaneId: asPane('p'), newSplitId: asSplit('s'),
  119. })).toThrow(/docked pane/)
  120. expect(paneId).toBeDefined()
  121. })
  122. })
  123. describe('merge', () => {
  124. it('collapses a two-child split back into its surviving pane', () => {
  125. const { state, minter, paneId } = fixture()
  126. const after = split(state, minter, paneId)
  127. const merged = expectRoundTrip(after.state, { type: 'merge', paneId: after.newPaneId })
  128. expect(merged.rootId).toBe(paneId)
  129. expect(dockPaneIds(merged)).toEqual([paneId])
  130. expect(merged.nodes[after.newPaneId]).toBeUndefined()
  131. })
  132. it('drops one child of a wider split and renormalizes the rest', () => {
  133. const { state, minter, paneId } = fixture()
  134. const first = split(state, minter, paneId)
  135. const second = split(first.state, minter, paneId)
  136. const merged = expectRoundTrip(second.state, { type: 'merge', paneId: second.newPaneId })
  137. const root = getSplit(merged, merged.rootId)
  138. expect(root.children).toHaveLength(2)
  139. expect(root.sizes.reduce((sum, size) => sum + size, 0)).toBeCloseTo(1)
  140. })
  141. it('refuses a non-empty pane and the docked root pane', () => {
  142. const { state, minter, paneId } = fixture()
  143. expect(() => applyOp(state, { type: 'merge', paneId })).toThrow(/empty pane/)
  144. const after = split(state, minter, paneId)
  145. expect(() => applyOp(after.state, { type: 'merge', paneId: asPane(after.newPaneId + 'x') })).toThrow(/unknown node/)
  146. const emptied = applyOp(after.state, { type: 'closeTab', tabId: firstTab(getPane(after.state, paneId)) }).state
  147. const collapsed = applyOp(emptied, { type: 'merge', paneId }).state
  148. expect(() => applyOp(collapsed, { type: 'merge', paneId: after.newPaneId })).toThrow(/root pane/)
  149. })
  150. it('reseats focus when the merged pane held it', () => {
  151. const { state, minter, paneId } = fixture()
  152. const after = split(state, minter, paneId)
  153. const focused = applyOp(after.state, { type: 'focusPane', paneId: after.newPaneId }).state
  154. expect(focused.activePaneId).toBe(after.newPaneId)
  155. const merged = expectRoundTrip(focused, { type: 'merge', paneId: after.newPaneId })
  156. expect(merged.activePaneId).toBe(paneId)
  157. })
  158. })
  159. describe('tabs', () => {
  160. it('opens a tab into a pane and focuses it', () => {
  161. const { state, minter, paneId, guideTabId } = fixture()
  162. const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
  163. const after = expectRoundTrip(state, { type: 'openTab', paneId, tab, index: 0 })
  164. const pane = getPane(after, paneId)
  165. expect(pane.tabs).toEqual([tab.id, guideTabId])
  166. expect(pane.activeTabId).toBe(tab.id)
  167. expect(after.activePaneId).toBe(paneId)
  168. })
  169. it('refuses to reopen a live tab id', () => {
  170. const { state, paneId, guideTabId } = fixture()
  171. const clash = { id: guideTabId, kind: 'guide' as const, contentId: 'x', title: 'x' }
  172. expect(() => applyOp(state, { type: 'openTab', paneId, tab: clash, index: 0 })).toThrow(/already exists/)
  173. })
  174. it('closes the active tab onto its previous neighbour', () => {
  175. const { state, minter, paneId, guideTabId } = fixture()
  176. const first = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
  177. const second = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/b.txt', 'b.txt')
  178. let current = applyOp(state, { type: 'openTab', paneId, tab: first, index: 1 }).state
  179. current = applyOp(current, { type: 'openTab', paneId, tab: second, index: 2 }).state
  180. expect(getPane(current, paneId).tabs).toEqual([guideTabId, first.id, second.id])
  181. const after = expectRoundTrip(current, { type: 'closeTab', tabId: second.id })
  182. const pane = getPane(after, paneId)
  183. expect(pane.tabs).toEqual([guideTabId, first.id])
  184. expect(pane.activeTabId).toBe(first.id)
  185. expect(after.tabs[second.id]).toBeUndefined()
  186. })
  187. it('closes a tab that is not the active one without moving focus', () => {
  188. const { state, minter, paneId, guideTabId } = fixture()
  189. const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
  190. const opened = applyOp(state, { type: 'openTab', paneId, tab, index: 1 }).state
  191. const after = expectRoundTrip(opened, { type: 'closeTab', tabId: guideTabId })
  192. expect(getPane(after, paneId).tabs).toEqual([tab.id])
  193. expect(getPane(after, paneId).activeTabId).toBe(tab.id)
  194. })
  195. it('leaves an empty pane with no active tab', () => {
  196. const { state, paneId, guideTabId } = fixture()
  197. const after = expectRoundTrip(state, { type: 'closeTab', tabId: guideTabId })
  198. const pane = getPane(after, paneId)
  199. expect(pane.tabs).toEqual([])
  200. expect(pane.activeTabId).toBeUndefined()
  201. })
  202. it('moves a tab across panes and focuses the destination', () => {
  203. const { state, minter, paneId, guideTabId } = fixture()
  204. const after = split(state, minter, paneId)
  205. const moved = expectRoundTrip(after.state, { type: 'moveTab', tabId: guideTabId, toPaneId: after.newPaneId, index: 0 })
  206. expect(getPane(moved, paneId).tabs).toEqual([])
  207. expect(getPane(moved, after.newPaneId).tabs).toEqual([guideTabId])
  208. expect(moved.activePaneId).toBe(after.newPaneId)
  209. })
  210. it('moves a tab that is not the active one, leaving the source focus alone', () => {
  211. const { state, minter, paneId, guideTabId } = fixture()
  212. const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
  213. const opened = applyOp(state, { type: 'openTab', paneId, tab, index: 1 }).state
  214. const after = split(opened, minter, paneId)
  215. const moved = expectRoundTrip(after.state, { type: 'moveTab', tabId: guideTabId, toPaneId: after.newPaneId, index: 0 })
  216. expect(getPane(moved, paneId).tabs).toEqual([tab.id])
  217. expect(getPane(moved, paneId).activeTabId).toBe(tab.id)
  218. expect(getPane(moved, after.newPaneId).activeTabId).toBe(guideTabId)
  219. })
  220. it('refuses a cross-pane move onto the tab own pane', () => {
  221. const { state, paneId, guideTabId } = fixture()
  222. expect(() => applyOp(state, { type: 'moveTab', tabId: guideTabId, toPaneId: paneId, index: 0 }))
  223. .toThrow(/reorderTab/)
  224. })
  225. it('reorders inside one pane without changing the active tab', () => {
  226. const { state, minter, paneId, guideTabId } = fixture()
  227. const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
  228. const opened = applyOp(state, { type: 'openTab', paneId, tab, index: 1 }).state
  229. const after = expectRoundTrip(opened, { type: 'reorderTab', tabId: tab.id, index: 0 })
  230. expect(getPane(after, paneId).tabs).toEqual([tab.id, guideTabId])
  231. expect(getPane(after, paneId).activeTabId).toBe(tab.id)
  232. })
  233. })
  234. describe('focus', () => {
  235. it('focuses a tab and its pane', () => {
  236. const { state, minter, paneId, guideTabId } = fixture()
  237. const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
  238. const opened = applyOp(state, { type: 'openTab', paneId, tab, index: 1 }).state
  239. const after = expectRoundTrip(opened, { type: 'focusTab', tabId: guideTabId })
  240. expect(getPane(after, paneId).activeTabId).toBe(guideTabId)
  241. })
  242. it('raises a floating pane when its tab takes focus', () => {
  243. const { state, minter, paneId, guideTabId } = fixture()
  244. const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
  245. let current = applyOp(state, { type: 'openTab', paneId, tab, index: 1 }).state
  246. const firstFloat = minter.next('float')
  247. const secondFloat = minter.next('float')
  248. current = applyOp(current, {
  249. type: 'float', tabId: guideTabId, newPaneId: firstFloat, rect: { x: 0, y: 0, width: 100, height: 100 },
  250. }).state
  251. current = applyOp(current, {
  252. type: 'float', tabId: tab.id, newPaneId: secondFloat, rect: { x: 10, y: 10, width: 100, height: 100 },
  253. }).state
  254. const after = expectRoundTrip(current, { type: 'focusTab', tabId: guideTabId })
  255. expect(after.floats).toEqual([secondFloat, firstFloat])
  256. expect(after.activePaneId).toBe(firstFloat)
  257. })
  258. it('raises a floating pane when it takes focus', () => {
  259. const { state, minter, paneId, guideTabId } = fixture()
  260. const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
  261. let current = applyOp(state, { type: 'openTab', paneId, tab, index: 1 }).state
  262. const firstFloat = minter.next('float')
  263. const secondFloat = minter.next('float')
  264. current = applyOp(current, {
  265. type: 'float', tabId: guideTabId, newPaneId: firstFloat, rect: { x: 0, y: 0, width: 100, height: 100 },
  266. }).state
  267. current = applyOp(current, {
  268. type: 'float', tabId: tab.id, newPaneId: secondFloat, rect: { x: 10, y: 10, width: 100, height: 100 },
  269. }).state
  270. expect(current.floats).toEqual([firstFloat, secondFloat])
  271. const after = expectRoundTrip(current, { type: 'focusPane', paneId: firstFloat })
  272. expect(after.floats).toEqual([secondFloat, firstFloat])
  273. expect(after.activePaneId).toBe(firstFloat)
  274. })
  275. })
  276. describe('resize', () => {
  277. it('replaces divider sizes', () => {
  278. const { state, minter, paneId } = fixture()
  279. const after = split(state, minter, paneId)
  280. const resized = expectRoundTrip(after.state, { type: 'resize', splitId: getSplit(after.state, after.state.rootId).id, sizes: [0.3, 0.7] })
  281. expect(getSplit(resized, resized.rootId).sizes).toEqual([0.3, 0.7])
  282. })
  283. it('rejects the wrong count and non-positive sizes', () => {
  284. const { state, minter, paneId } = fixture()
  285. const after = split(state, minter, paneId)
  286. const splitId = getSplit(after.state, after.state.rootId).id
  287. expect(() => applyOp(after.state, { type: 'resize', splitId, sizes: [1] })).toThrow(/do not match/)
  288. expect(() => applyOp(after.state, { type: 'resize', splitId, sizes: [0, 1] })).toThrow(/above zero/)
  289. })
  290. })
  291. describe('floating panes', () => {
  292. it('takes a tab out of the docked tree onto the top of the z order', () => {
  293. const { state, minter, paneId, guideTabId } = fixture()
  294. const floatId = minter.next('float')
  295. const rect = { x: 40, y: 50, width: 300, height: 200 }
  296. const after = expectRoundTrip(state, { type: 'float', tabId: guideTabId, newPaneId: floatId, rect })
  297. expect(getPane(after, paneId).tabs).toEqual([])
  298. expect(after.floats).toEqual([floatId])
  299. expect(after.activePaneId).toBe(floatId)
  300. const pane = getPane(after, floatId)
  301. expect(pane.host).toBe('float')
  302. expect(pane.tabs).toEqual([guideTabId])
  303. expect(pane.rect).toEqual(rect)
  304. expect(findParent(after, floatId)).toBeUndefined()
  305. })
  306. it('returns a floating tab to a docked pane and drops the floating pane', () => {
  307. const { state, minter, paneId, guideTabId } = fixture()
  308. const floatId = minter.next('float')
  309. const floated = applyOp(state, {
  310. type: 'float', tabId: guideTabId, newPaneId: floatId, rect: { x: 0, y: 0, width: 100, height: 100 },
  311. }).state
  312. const docked = expectRoundTrip(floated, { type: 'unfloat', paneId: floatId, toPaneId: paneId, index: 0 })
  313. expect(docked.floats).toEqual([])
  314. expect(docked.nodes[floatId]).toBeUndefined()
  315. expect(getPane(docked, paneId).tabs).toEqual([guideTabId])
  316. expect(docked.activePaneId).toBe(paneId)
  317. })
  318. it('destroys the floating pane together with its only tab', () => {
  319. const { state, minter, paneId, guideTabId } = fixture()
  320. const floatId = minter.next('float')
  321. const floated = applyOp(state, {
  322. type: 'float', tabId: guideTabId, newPaneId: floatId, rect: { x: 0, y: 0, width: 100, height: 100 },
  323. }).state
  324. const closed = expectRoundTrip(floated, { type: 'closeTab', tabId: guideTabId })
  325. expect(closed.floats).toEqual([])
  326. expect(closed.nodes[floatId]).toBeUndefined()
  327. expect(closed.tabs[guideTabId]).toBeUndefined()
  328. expect(closed.activePaneId).toBe(paneId)
  329. })
  330. it('records net drag and resize results, focusing and raising the pane they reshape', () => {
  331. const { state, minter, paneId, guideTabId } = fixture()
  332. const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
  333. const floatId = minter.next('float')
  334. const topId = minter.next('float')
  335. let current = applyOp(state, { type: 'openTab', paneId, tab, index: 1 }).state
  336. current = applyOp(current, {
  337. type: 'float', tabId: guideTabId, newPaneId: floatId, rect: { x: 0, y: 0, width: 100, height: 100 },
  338. }).state
  339. current = applyOp(current, {
  340. type: 'float', tabId: tab.id, newPaneId: topId, rect: { x: 10, y: 10, width: 100, height: 100 },
  341. }).state
  342. current = applyOp(current, { type: 'focusPane', paneId }).state
  343. expect(current.floats).toEqual([floatId, topId])
  344. expect(current.activePaneId).toBe(paneId)
  345. // The lower, unfocused panel is dragged: it ends on top and focused; undo puts both facts back.
  346. const moved = expectRoundTrip(current, { type: 'moveFloat', paneId: floatId, x: 25, y: 35 })
  347. expect(getPane(moved, floatId).rect).toEqual({ x: 25, y: 35, width: 100, height: 100 })
  348. expect(moved.floats).toEqual([topId, floatId])
  349. expect(moved.activePaneId).toBe(floatId)
  350. const lowered = applyOp(moved, { type: 'focusPane', paneId: topId }).state
  351. const resized = expectRoundTrip(lowered, {
  352. type: 'resizeFloat', paneId: floatId, rect: { x: 25, y: 35, width: 420, height: 260 },
  353. })
  354. expect(getPane(resized, floatId).rect).toEqual({ x: 25, y: 35, width: 420, height: 260 })
  355. expect(resized.floats).toEqual([topId, floatId])
  356. expect(resized.activePaneId).toBe(floatId)
  357. expect(() => applyOp(moved, {
  358. type: 'resizeFloat', paneId: floatId, rect: { x: 0, y: 0, width: 0, height: 10 },
  359. })).toThrow(/above zero/)
  360. })
  361. it('refuses to float a tab that already floats', () => {
  362. const { state, minter, guideTabId } = fixture()
  363. const floatId = minter.next('float')
  364. const floated = applyOp(state, {
  365. type: 'float', tabId: guideTabId, newPaneId: floatId, rect: { x: 0, y: 0, width: 100, height: 100 },
  366. }).state
  367. expect(() => applyOp(floated, {
  368. type: 'float', tabId: guideTabId, newPaneId: minter.next('float'), rect: { x: 0, y: 0, width: 10, height: 10 },
  369. })).toThrow(/docked tab/)
  370. })
  371. })
  372. describe('presentation flags', () => {
  373. it('round-trips the collapsed flag', () => {
  374. const { state } = fixture()
  375. const after = expectRoundTrip(state, { type: 'setExpanded', expanded: true })
  376. expect(after.expanded).toBe(true)
  377. })
  378. it('round-trips the presentation mode', () => {
  379. const { state } = fixture()
  380. expect(state.mode).toBe('push')
  381. const after = expectRoundTrip(state, { type: 'setMode', mode: 'fullscreen' })
  382. expect(after.mode).toBe('fullscreen')
  383. // The two flags are independent: switching one leaves the other alone.
  384. expect(after.expanded).toBe(state.expanded)
  385. })
  386. it('starts in the presentation the embedder seeded', () => {
  387. const minter = createIdMinter()
  388. expect(createInitialState(minter, seedTab, 'fullscreen').mode).toBe('fullscreen')
  389. })
  390. })
  391. describe('tree readers', () => {
  392. /** The seeded pane floated out, leaving the root pane empty. */
  393. function floated(): { state: LayoutState; paneId: PaneId; floatId: PaneId; guideTabId: TabId } {
  394. const { state, minter, paneId, guideTabId } = fixture()
  395. const floatId = minter.next('float')
  396. const next = applyOp(state, {
  397. type: 'float', tabId: guideTabId, newPaneId: floatId, rect: { x: 0, y: 0, width: 100, height: 100 },
  398. }).state
  399. return { state: next, paneId, floatId, guideTabId }
  400. }
  401. it('throw on a dangling id or a node of the other kind', () => {
  402. const { state, minter, paneId } = fixture()
  403. const after = split(state, minter, paneId).state
  404. expect(() => getNode(after, asPane('nope'))).toThrow(/unknown node nope/)
  405. expect(() => getPane(after, after.rootId)).toThrow(/is not a pane/)
  406. expect(() => getSplit(after, paneId)).toThrow(/is not a split/)
  407. expect(() => getTab(after, asTab('nope'))).toThrow(/unknown tab nope/)
  408. expect(() => findTabPane(after, asTab('nope'))).toThrow(/has no pane/)
  409. expect(() => replaceInParent(after, asPane('nope'), paneId)).toThrow(/neither rooted nor parented/)
  410. })
  411. it('read a floating pane\'s rectangle, z position, and only tab, and refuse a docked one', () => {
  412. const { state, paneId, floatId, guideTabId } = floated()
  413. const pane = getPane(state, floatId)
  414. expect(floatRect(pane)).toEqual({ x: 0, y: 0, width: 100, height: 100 })
  415. expect(floatIndex(state, floatId)).toBe(0)
  416. expect(onlyTabId(pane)).toBe(guideTabId)
  417. const docked = getPane(state, paneId)
  418. expect(() => floatRect(docked)).toThrow(/is not floating/)
  419. expect(() => floatIndex(state, paneId)).toThrow(/not in the z order/)
  420. expect(() => onlyTabId(docked)).toThrow(/exactly one tab/)
  421. const two: PaneNode = { ...pane, tabs: [guideTabId, asTab('other')] }
  422. expect(() => onlyTabId(two)).toThrow(/exactly one tab/)
  423. })
  424. it('name the first and the top-right docked pane however the tree is divided', () => {
  425. const { state, minter, paneId } = fixture()
  426. expect(firstDockPaneId(state)).toBe(paneId)
  427. expect(topRightPaneId(state)).toBe(paneId)
  428. const right = split(state, minter, paneId)
  429. const below = split(right.state, minter, right.newPaneId, 'column')
  430. expect(firstDockPaneId(below.state)).toBe(paneId)
  431. expect(topRightPaneId(below.state)).toBe(right.newPaneId)
  432. const above = split(below.state, minter, paneId, 'column', 'before')
  433. expect(firstDockPaneId(above.state)).toBe(above.newPaneId)
  434. })
  435. it('normalize sizes without drifting an exact sum, and refuse a zero total', () => {
  436. const exact = [0.25, 0.75]
  437. expect(normalizeSizes(exact)).toEqual(exact)
  438. expect(normalizeSizes(exact)).not.toBe(exact)
  439. expect(normalizeSizes([1, 3])).toEqual([0.25, 0.75])
  440. expect(() => normalizeSizes([0, 0])).toThrow(/sum above zero/)
  441. })
  442. it('assertNever names the value a closed switch failed to handle', () => {
  443. expect(() => { assertNever('nope' as never, 'layout: thing') }).toThrow('layout: thing: unhandled "nope"')
  444. })
  445. })
  446. describe('operations refuse', () => {
  447. /** A second docked pane plus a floating one holding a content tab. */
  448. function mixed(): {
  449. state: LayoutState
  450. minter: IdMinter
  451. paneId: PaneId
  452. secondPaneId: PaneId
  453. floatId: PaneId
  454. guideTabId: TabId
  455. floatTabId: TabId
  456. } {
  457. const { state, minter, paneId, guideTabId } = fixture()
  458. const second = split(state, minter, paneId)
  459. const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
  460. const opened = applyOp(second.state, { type: 'openTab', paneId, tab, index: 1 }).state
  461. const floatId = minter.next('float')
  462. const floated = applyOp(opened, {
  463. type: 'float', tabId: tab.id, newPaneId: floatId, rect: { x: 0, y: 0, width: 100, height: 100 },
  464. }).state
  465. return { state: floated, minter, paneId, secondPaneId: second.newPaneId, floatId, guideTabId, floatTabId: tab.id }
  466. }
  467. it('opening or inserting a tab into a floating pane', () => {
  468. const { state, minter, floatId } = mixed()
  469. const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/b.txt', 'b.txt')
  470. expect(() => applyOp(state, { type: 'openTab', paneId: floatId, tab, index: 0 })).toThrow(/docked pane/)
  471. expect(() => applyOp(state, { type: 'insertTab', paneId: floatId, tab, index: 0 })).toThrow(/docked pane/)
  472. })
  473. it('moving a tab out of or into a floating pane, which is what unfloat and float are for', () => {
  474. const { state, paneId, floatId, guideTabId, floatTabId } = mixed()
  475. expect(() => applyOp(state, { type: 'moveTab', tabId: floatTabId, toPaneId: paneId, index: 0 })).toThrow(/use unfloat/)
  476. expect(() => applyOp(state, { type: 'moveTab', tabId: guideTabId, toPaneId: floatId, index: 0 })).toThrow(/target must be docked/)
  477. })
  478. it('returning a floating tab anywhere but a docked pane, and treating a docked pane as floating', () => {
  479. const { state, minter, paneId, floatId, guideTabId } = mixed()
  480. const otherFloat = minter.next('float')
  481. const twoFloats = applyOp(state, {
  482. type: 'float', tabId: guideTabId, newPaneId: otherFloat, rect: { x: 5, y: 5, width: 100, height: 100 },
  483. }).state
  484. expect(() => applyOp(twoFloats, { type: 'unfloat', paneId: floatId, toPaneId: otherFloat, index: 0 }))
  485. .toThrow(/target must be docked/)
  486. expect(() => applyOp(state, { type: 'unfloat', paneId, toPaneId: paneId, index: 0 })).toThrow(/is not floating/)
  487. expect(() => applyOp(state, { type: 'moveFloat', paneId, x: 1, y: 1 })).toThrow(/is not floating/)
  488. expect(() => applyOp(state, { type: 'resizeFloat', paneId, rect: { x: 0, y: 0, width: 10, height: 10 } }))
  489. .toThrow(/is not floating/)
  490. })
  491. it('creating a node under an id that is already taken', () => {
  492. const { state, minter, paneId, secondPaneId } = mixed()
  493. expect(() => applyOp(state, {
  494. type: 'split', paneId, axis: 'column', direction: 'after', newPaneId: secondPaneId, newSplitId: minter.next('split'),
  495. })).toThrow(/already exists/)
  496. expect(() => applyOp(state, {
  497. type: 'split', paneId, axis: 'column', direction: 'after', newPaneId: minter.next('pane'), newSplitId: asSplit(state.rootId),
  498. })).toThrow(/already exists/)
  499. })
  500. it('putting a pane back with records that do not match the pane or the split', () => {
  501. const { state, minter, paneId, secondPaneId, floatId } = mixed()
  502. const pane = getPane(state, secondPaneId)
  503. const stray = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/c.txt', 'c.txt')
  504. const fresh: PaneNode = { ...pane, id: minter.next('pane') }
  505. const parent = getSplit(state, state.rootId)
  506. expect(() => applyOp(state, {
  507. type: 'insertPane', pane, tabs: [], attach: { mode: 'child', parentId: parent.id, index: 1, sizes: parent.sizes },
  508. })).toThrow(/already exists/)
  509. expect(() => applyOp(state, {
  510. type: 'insertPane', pane: fresh, tabs: [stray], attach: { mode: 'child', parentId: parent.id, index: 1, sizes: parent.sizes },
  511. })).toThrow(/do not match the pane/)
  512. expect(() => applyOp(state, {
  513. type: 'insertPane', pane: fresh, tabs: [], attach: { mode: 'child', parentId: parent.id, index: 1, sizes: parent.sizes },
  514. })).toThrow(/sizes do not match the split/)
  515. expect(() => applyOp(state, {
  516. type: 'insertPane', pane: fresh, tabs: [], attach: { mode: 'wrap', targetId: paneId, split: parent },
  517. })).toThrow(/does not list the pane/)
  518. expect(() => applyOp(state, {
  519. type: 'insertPane', pane: fresh, tabs: [], attach: { mode: 'float', index: 0 },
  520. })).toThrow(/requires a floating pane/)
  521. expect(floatId).toBeDefined()
  522. })
  523. it('restoring focus facts that name a docked pane as floating or an unknown pane', () => {
  524. const { state, paneId } = mixed()
  525. expect(() => applyOp(state, { type: 'restoreFocus', activePaneId: paneId, floats: [paneId], paneActiveTabs: {} }))
  526. .toThrow(/as floating/)
  527. expect(() => applyOp(state, { type: 'restoreFocus', activePaneId: paneId, floats: [], paneActiveTabs: { [asPane('nope')]: undefined } }))
  528. .toThrow(/unknown node/)
  529. })
  530. })
  531. describe('inverses the engine itself never records', () => {
  532. it('refuse to return a docked pane with tab records: closeTab records those through insertTab', () => {
  533. const { state, minter, paneId } = fixture()
  534. const after = split(state, minter, paneId)
  535. const parent = getSplit(after.state, after.state.rootId)
  536. const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
  537. const pane: PaneNode = { kind: 'pane', id: minter.next('pane'), host: 'dock', tabs: [tab.id], activeTabId: tab.id, rect: undefined }
  538. expect(() => applyOp(after.state, {
  539. type: 'insertPane', pane, tabs: [tab], attach: { mode: 'child', parentId: parent.id, index: 2, sizes: [0.25, 0.25, 0.5] },
  540. })).toThrow(/returns a docked pane empty/)
  541. })
  542. it('merge away an empty floating pane and restore it at its z position', () => {
  543. const { state, minter } = fixture()
  544. const pane: PaneNode = {
  545. kind: 'pane', id: minter.next('float'), host: 'float', tabs: [], activeTabId: undefined, rect: { x: 1, y: 2, width: 50, height: 40 },
  546. }
  547. const inserted = expectRoundTrip(state, { type: 'insertPane', pane, tabs: [], attach: { mode: 'float', index: 0 } })
  548. expect(inserted.floats).toEqual([pane.id])
  549. const merged = expectRoundTrip(inserted, { type: 'merge', paneId: pane.id })
  550. expect(merged.floats).toEqual([])
  551. expect(merged.nodes[pane.id]).toBeUndefined()
  552. })
  553. })
  554. describe('replay', () => {
  555. it('rebuilds the same tree from the recorded operations', () => {
  556. const { state, minter, paneId, guideTabId } = fixture()
  557. const preview = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
  558. const secondPaneId = minter.next('pane')
  559. const floatId = minter.next('float')
  560. const ops: LayoutOp[] = [
  561. { type: 'setExpanded', expanded: true },
  562. { type: 'openTab', paneId, tab: preview, index: 1 },
  563. { type: 'split', paneId, axis: 'row', direction: 'after', newPaneId: secondPaneId, newSplitId: minter.next('split') },
  564. { type: 'moveTab', tabId: preview.id, toPaneId: secondPaneId, index: 0 },
  565. { type: 'focusTab', tabId: guideTabId },
  566. { type: 'float', tabId: preview.id, newPaneId: floatId, rect: { x: 10, y: 20, width: 200, height: 150 } },
  567. { type: 'moveFloat', paneId: floatId, x: 60, y: 70 },
  568. ]
  569. const direct = ops.reduce((current, op) => applyOp(current, op).state, state)
  570. expect(replay(state, ops)).toEqual(direct)
  571. expect(replay(state, ops)).toEqual(replay(state, ops))
  572. expect(findTabPane(direct, preview.id).id).toBe(floatId)
  573. })
  574. })