planner.client.spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. /**
  2. * The two properties the intent layer must not lose in refactoring.
  3. *
  4. * Determinism: a planner reads only the state and the minter it is handed, so the
  5. * same inputs plan the same operations and replay stays exact. Nothing here may
  6. * reach a clock or a random source.
  7. *
  8. * One undo behaviour: focus-run merging and redo-branch discarding live in the
  9. * pure history functions, so the stateful `Sequencer` embedding and an embedder
  10. * driving `record`/`stepBack` directly cannot drift apart.
  11. */
  12. import { describe, expect, it } from 'vitest'
  13. import { applyOp } from '../src/engine/operations.ts'
  14. import { DockController } from '../src/engine/controller.ts'
  15. import { createIdMinter, createInitialState } from '../src/engine/initial.ts'
  16. import {
  17. activeDockPaneId, findContentTab, findPaneContentTab, planDropTab, planDuplicateTab, planFloatTab, planOpenContent,
  18. planPlaceTab, planSetExpanded, planSetMode, planSettle, planSplitPane, planUnfloatPane, planAddTab,
  19. } from '../src/engine/planner.ts'
  20. import {
  21. EMPTY_HISTORY, record, recordedOps, stepBack, stepForward, type History,
  22. } from '../src/engine/sequence.ts'
  23. import { dockPaneIds, findTabPane, getPane, getSplit } from '../src/engine/tree.ts'
  24. import type { LayoutOp, LayoutState, PaneId, TabId } from '../src/contract/types.ts'
  25. import type { Mint } from '../src/engine/planner.ts'
  26. import { asTab, fileTab, firstTab, seedTab, seededState } from './fixtures.client.ts'
  27. /** A deep snapshot, for proving a planner left its input alone. */
  28. function frozen(state: LayoutState): string {
  29. return JSON.stringify(state)
  30. }
  31. /** Apply a plan the way any embedder does, and return the resulting state. */
  32. function applyAll(state: LayoutState, ops: readonly LayoutOp[]): LayoutState {
  33. return ops.reduce((current, op) => applyOp(current, op).state, state)
  34. }
  35. describe('planner determinism', () => {
  36. it('plans the same operations from the same state and a fresh minter', () => {
  37. const first = seededState()
  38. const second = seededState()
  39. const planOne = planSplitPane(first.state, first.minter.next, undefined, seedTab)
  40. const planTwo = planSplitPane(second.state, second.minter.next, undefined, seedTab)
  41. expect(planOne).toEqual(planTwo)
  42. expect(planOne.length).toBeGreaterThan(0)
  43. })
  44. it('leaves the state it was handed untouched', () => {
  45. const { state, minter } = seededState()
  46. const before = frozen(state)
  47. const mint = minter.next
  48. planSetExpanded(state, true)
  49. planSplitPane(state, mint, undefined, seedTab)
  50. planOpenContent(state, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
  51. planFloatTab(state, mint, firstTab(getPane(state, state.rootId)))
  52. expect(frozen(state)).toBe(before)
  53. })
  54. it('plans nothing for an intent that changes nothing', () => {
  55. const { state, minter } = seededState()
  56. const mint = minter.next
  57. const tabId = getPane(state, state.rootId).tabs[0]
  58. if (tabId === undefined) throw new Error('fixture: seeded tab missing')
  59. // Already collapsed; already in that mode; already in that slot; centre
  60. // release on its own pane.
  61. expect(planSetExpanded(state, false)).toEqual([])
  62. expect(planSetMode(state, 'push')).toEqual([])
  63. expect(planSetMode(state, 'fullscreen')).toEqual([{ type: 'setMode', mode: 'fullscreen' }])
  64. expect(planPlaceTab(state, tabId, getPane(state, state.rootId).id, 0)).toEqual([])
  65. expect(planDropTab(state, mint, tabId, getPane(state, state.rootId).id, 'center')).toEqual([])
  66. })
  67. it('refuses to plan past the pane budget', () => {
  68. const seeded = seededState()
  69. let { state } = seeded
  70. const { minter } = seeded
  71. const mint = minter.next
  72. for (let index = 0; index < 3; index += 1) {
  73. state = applyAll(state, planSplitPane(state, mint, state.activePaneId, seedTab))
  74. }
  75. expect(planSplitPane(state, mint, state.activePaneId, seedTab)).toEqual([])
  76. // The root is a split once panes exist, so address a real pane.
  77. const firstPane = dockPaneIds(state)[0]
  78. if (firstPane === undefined) throw new Error('fixture: no docked pane')
  79. const tabId = getPane(state, firstPane).tabs[0]
  80. if (tabId === undefined) throw new Error('fixture: seeded tab missing')
  81. expect(planDropTab(state, mint, tabId, firstPane, 'right')).toEqual([])
  82. })
  83. })
  84. describe('planPlaceTab', () => {
  85. /** One docked pane holding three tabs, in strip order. */
  86. function threeTabs(): { state: LayoutState; paneId: PaneId; tabs: readonly [TabId, TabId, TabId] } {
  87. const { state, minter } = seededState()
  88. const mint = minter.next
  89. const paneId = getPane(state, state.rootId).id
  90. const b = planOpenContent(state, mint, { contentId: 'dsh-resource://file/session/s/b.txt', title: 'b', kind: 'file' })
  91. const withB = applyAll(state, b.ops)
  92. const c = planOpenContent(withB, mint, { contentId: 'dsh-resource://file/session/s/c.txt', title: 'c', kind: 'file' })
  93. const withC = applyAll(withB, c.ops)
  94. const a = getPane(withC, paneId).tabs[0]
  95. if (a === undefined) throw new Error('fixture: seeded tab missing')
  96. return { state: withC, paneId, tabs: [a, b.tabId, c.tabId] }
  97. }
  98. /** Strip order after placing `tabId` at caret slot `index` in its own pane. */
  99. function after(tabId: TabId, index: number): readonly TabId[] {
  100. const { state, paneId } = threeTabs()
  101. return getPane(applyAll(state, planPlaceTab(state, tabId, paneId, index)), paneId).tabs
  102. }
  103. it('reads the slot as the caret over the strip as drawn, the dragged chip included', () => {
  104. const { tabs: [a, b, c] } = threeTabs()
  105. // Rightward: the caret between b and c is slot 2, and a lands between them.
  106. expect(after(a, 2)).toEqual([b, a, c])
  107. expect(after(a, 3)).toEqual([b, c, a])
  108. // Leftward: the chip sits after the caret, so the slot is the final index.
  109. expect(after(c, 0)).toEqual([c, a, b])
  110. expect(after(c, 1)).toEqual([a, c, b])
  111. })
  112. it('plans nothing for the slot on either side of the dragged chip', () => {
  113. const { state, paneId, tabs: [a, b, c] } = threeTabs()
  114. expect(planPlaceTab(state, a, paneId, 0)).toEqual([])
  115. expect(planPlaceTab(state, a, paneId, 1)).toEqual([])
  116. expect(planPlaceTab(state, b, paneId, 1)).toEqual([])
  117. expect(planPlaceTab(state, b, paneId, 2)).toEqual([])
  118. expect(planPlaceTab(state, c, paneId, 2)).toEqual([])
  119. expect(planPlaceTab(state, c, paneId, 3)).toEqual([])
  120. })
  121. })
  122. describe('planDropTab on the tab\'s own pane', () => {
  123. it('plans nothing for a pane\'s only tab released on any of its own edges without a factory', () => {
  124. const { state, minter } = seededState()
  125. const mint = minter.next
  126. const tabId = getPane(state, state.rootId).tabs[0]
  127. if (tabId === undefined) throw new Error('fixture: seeded tab missing')
  128. for (const zone of ['left', 'right', 'top', 'bottom'] as const) {
  129. expect(planDropTab(state, mint, tabId, getPane(state, state.rootId).id, zone)).toEqual([])
  130. }
  131. })
  132. it('splits on a sole tab\'s own edge when a factory backfills the pane it vacates', () => {
  133. const { state, minter } = seededState()
  134. const mint = minter.next
  135. const paneId = getPane(state, state.rootId).id
  136. const tabId = getPane(state, paneId).tabs[0]
  137. if (tabId === undefined) throw new Error('fixture: seeded tab missing')
  138. const ops = planDropTab(state, mint, tabId, paneId, 'right', seedTab)
  139. expect(ops.map(op => op.type)).toEqual(['split', 'openTab', 'moveTab'])
  140. const split = applyAll(state, ops)
  141. const [home, destination] = dockPaneIds(split)
  142. if (home === undefined || destination === undefined) throw new Error('expected two panes')
  143. expect(getPane(split, home).tabs).toHaveLength(1)
  144. expect(getPane(split, home).tabs[0]).not.toBe(tabId)
  145. expect(getPane(split, destination).tabs).toEqual([tabId])
  146. // The backfill seats first, so the moved tab ends focused.
  147. expect(split.activePaneId).toBe(destination)
  148. })
  149. it('splits without a backfill when the pane keeps another tab, factory or not', () => {
  150. const { state, minter } = seededState()
  151. const mint = minter.next
  152. const opened = planOpenContent(state, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
  153. const two = applyAll(state, opened.ops)
  154. const target = getPane(two, two.rootId).id
  155. expect(planDropTab(two, mint, opened.tabId, target, 'right')
  156. .map(op => op.type)).toEqual(['split', 'moveTab'])
  157. const ops = planDropTab(two, mint, opened.tabId, target, 'right', seedTab)
  158. expect(ops.map(op => op.type)).toEqual(['split', 'moveTab'])
  159. const split = applyAll(two, ops)
  160. expect(dockPaneIds(split)).toHaveLength(2)
  161. expect(getPane(split, two.rootId).tabs).toHaveLength(1)
  162. })
  163. })
  164. describe('floating panes as planner arguments', () => {
  165. /** A docked pane holding the seed, plus one content tab floated out of it. */
  166. function withFloat(): { state: LayoutState; mint: Mint; floatId: PaneId; tabId: TabId } {
  167. const { state, minter } = seededState()
  168. const mint = minter.next
  169. const opened = planOpenContent(state, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
  170. const docked = applyAll(state, opened.ops)
  171. const floated = planFloatTab(docked, mint, opened.tabId)
  172. return { state: applyAll(docked, floated.ops), mint, floatId: floated.paneId, tabId: opened.tabId }
  173. }
  174. it('plans nothing into a floating pane: no split, no seeded tab, no drop', () => {
  175. const { state, mint, floatId } = withFloat()
  176. expect(planSplitPane(state, mint, floatId, seedTab)).toEqual([])
  177. expect(planAddTab(state, mint, floatId, seedTab)).toEqual([])
  178. const seeded = getPane(state, state.rootId).tabs[0]
  179. if (seeded === undefined) throw new Error('fixture: seeded tab missing')
  180. expect(planDropTab(state, mint, seeded, floatId, 'center')).toEqual([])
  181. })
  182. it('copies a floating tab into the active docked pane, at its end', () => {
  183. const { state, mint, tabId } = withFloat()
  184. expect(state.activePaneId).not.toBe(state.rootId)
  185. expect(activeDockPaneId(state)).toBe(state.rootId)
  186. const copy = planDuplicateTab(state, mint, tabId)
  187. expect(copy.ops[0]).toMatchObject({ type: 'openTab', paneId: state.rootId, index: 1 })
  188. expect(getPane(applyAll(state, copy.ops), state.rootId).tabs.at(-1)).toBe(copy.tabId)
  189. })
  190. })
  191. describe('planSettle', () => {
  192. /** Two seeded docked panes side by side. */
  193. function twoPanes(): { state: LayoutState; mint: Mint; left: PaneId; right: PaneId } {
  194. const { state, minter } = seededState()
  195. const mint = minter.next
  196. const split = applyAll(state, planSplitPane(state, mint, undefined, seedTab))
  197. const [left, right] = dockPaneIds(split)
  198. if (left === undefined || right === undefined) throw new Error('fixture: expected two panes')
  199. return { state: split, mint, left, right }
  200. }
  201. it('plans nothing while every docked pane holds a tab', () => {
  202. const { state, mint } = twoPanes()
  203. expect(planSettle(state, mint, seedTab)).toEqual([])
  204. })
  205. it('merges away each pane an intent emptied, one after another', () => {
  206. const { state, mint, left, right } = twoPanes()
  207. const third = applyAll(state, planSplitPane(state, mint, right, seedTab))
  208. const emptied = dockPaneIds(third).flatMap(id => getPane(third, id).tabs)
  209. .filter(tabId => findTabPane(third, tabId).id !== left)
  210. .reduce((current, tabId) => applyAll(current, [{ type: 'closeTab', tabId }]), third)
  211. const ops = planSettle(emptied, mint, seedTab)
  212. expect(ops.map(op => op.type)).toEqual(['merge', 'merge'])
  213. expect(dockPaneIds(applyAll(emptied, ops))).toEqual([left])
  214. })
  215. it('reseeds an emptied root pane through the factory, and leaves it empty without one', () => {
  216. const { state, minter } = seededState()
  217. const mint = minter.next
  218. const tabId = getPane(state, state.rootId).tabs[0]
  219. if (tabId === undefined) throw new Error('fixture: seeded tab missing')
  220. const emptied = applyAll(state, [{ type: 'closeTab', tabId }])
  221. const reseeded = planSettle(emptied, mint, seedTab)
  222. expect(reseeded.map(op => op.type)).toEqual(['openTab'])
  223. expect(getPane(applyAll(emptied, reseeded), emptied.rootId).tabs).toHaveLength(1)
  224. expect(planSettle(emptied, mint)).toEqual([])
  225. })
  226. })
  227. describe('planOpenContent placement and identity', () => {
  228. it('identifies content by (kind, contentId): the same address under another kind opens another tab', () => {
  229. const { state, minter } = seededState()
  230. const mint = minter.next
  231. const first = planOpenContent(state, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
  232. const opened = applyAll(state, first.ops)
  233. expect(findContentTab(opened, 'dsh-resource://file/session/s/a.txt')).toBe(first.tabId)
  234. expect(findContentTab(opened, 'dsh-resource://file/session/s/a.txt', 'file')).toBe(first.tabId)
  235. expect(findContentTab(opened, 'dsh-resource://file/session/s/a.txt', 'hex')).toBeUndefined()
  236. // The pane-level lookup answers for one pane only.
  237. const [pane] = dockPaneIds(opened)
  238. if (pane === undefined) throw new Error('expected a docked pane')
  239. expect(findPaneContentTab(opened, pane, 'dsh-resource://file/session/s/a.txt')).toBe(first.tabId)
  240. expect(findPaneContentTab(opened, pane, 'dsh-resource://file/session/s/a.txt', 'hex')).toBeUndefined()
  241. expect(findPaneContentTab(opened, pane, 'dsh-resource://file/session/s/nowhere.txt')).toBeUndefined()
  242. const again = planOpenContent(opened, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
  243. expect(again.ops.map(op => op.type)).toEqual(['focusTab'])
  244. const other = planOpenContent(opened, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'hex' })
  245. expect(other.ops.map(op => op.type)).toEqual(['openTab'])
  246. })
  247. it('opens another tab when told not to reveal the existing one', () => {
  248. const { state, minter } = seededState()
  249. const mint = minter.next
  250. const opened = applyAll(state, planOpenContent(state, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }).ops)
  251. const copy = planOpenContent(opened, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file', revealIfOpened: false })
  252. expect(copy.ops.map(op => op.type)).toEqual(['openTab'])
  253. const both = applyAll(opened, copy.ops)
  254. expect(Object.values(both.tabs).filter(tab => tab.contentId === 'dsh-resource://file/session/s/a.txt')).toHaveLength(2)
  255. })
  256. it('seats a new tab at an explicit strip slot', () => {
  257. const { state, minter } = seededState()
  258. const mint = minter.next
  259. const paneId = getPane(state, state.rootId).id
  260. const planned = planOpenContent(state, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file', paneId, index: 0 })
  261. expect(planned.ops[0]).toMatchObject({ type: 'openTab', paneId, index: 0 })
  262. expect(getPane(applyAll(state, planned.ops), paneId).tabs[0]).toBe(planned.tabId)
  263. })
  264. })
  265. describe('planAddTab', () => {
  266. it('seats the factory\'s tab at the end of the pane, and plans nothing without a factory', () => {
  267. const { state, minter } = seededState()
  268. const mint = minter.next
  269. const paneId = getPane(state, state.rootId).id
  270. const ops = planAddTab(state, mint, paneId, seedTab)
  271. expect(ops.map(op => op.type)).toEqual(['openTab'])
  272. expect(ops[0]).toMatchObject({ paneId, index: getPane(state, paneId).tabs.length })
  273. expect(planAddTab(state, mint, paneId)).toEqual([])
  274. })
  275. })
  276. describe('one undo behaviour across both embeddings', () => {
  277. /** Drive a script of intents through the pure history functions. */
  278. function driveByHand(): { history: History; state: LayoutState } {
  279. const minter = createIdMinter()
  280. const mint = minter.next
  281. let state = createInitialState(minter, seedTab)
  282. let history = EMPTY_HISTORY
  283. const run = (ops: readonly LayoutOp[]): void => {
  284. const stepped = record(history, state, ops)
  285. history = stepped.history
  286. state = stepped.state
  287. }
  288. run(planSetExpanded(state, true))
  289. run(planSplitPane(state, mint, undefined, seedTab))
  290. const opened = planOpenContent(state, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
  291. run(opened.ops)
  292. const copy = planDuplicateTab(state, mint, opened.tabId)
  293. run(copy.ops)
  294. run([{ type: 'focusTab', tabId: opened.tabId }])
  295. run([{ type: 'focusTab', tabId: copy.tabId }])
  296. run([{ type: 'focusTab', tabId: opened.tabId }])
  297. return { history, state }
  298. }
  299. /** The same script through the stateful embedding. */
  300. function driveByController(): DockController {
  301. const controller = new DockController({ makeInitialTab: seedTab, makePaneTab: seedTab })
  302. controller.setExpanded(true)
  303. controller.splitPane()
  304. const opened = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
  305. const copy = controller.duplicateTab(opened)
  306. controller.focusTab(opened)
  307. controller.focusTab(copy)
  308. controller.focusTab(opened)
  309. return controller
  310. }
  311. it('records the same sequence either way', () => {
  312. const byHand = driveByHand()
  313. const controller = driveByController()
  314. expect(recordedOps(byHand.history)).toEqual(controller.ops)
  315. expect(byHand.state).toEqual(controller.getSnapshot().state)
  316. })
  317. it('merges a focus run into one step in both embeddings', () => {
  318. const byHand = driveByHand()
  319. const controller = driveByController()
  320. const stepped = stepBack(byHand.history, byHand.state)
  321. if (stepped === undefined) throw new Error('expected a step back')
  322. expect(controller.undo()).toBe(true)
  323. // Three consecutive focus moves collapse to one step on both paths.
  324. expect(byHand.history.cursor - stepped.history.cursor).toBe(3)
  325. expect(stepped.history.cursor).toBe(controller.getSnapshot().cursor)
  326. expect(stepped.state).toEqual(controller.getSnapshot().state)
  327. const forward = stepForward(stepped.history, stepped.state)
  328. if (forward === undefined) throw new Error('expected a step forward')
  329. expect(controller.redo()).toBe(true)
  330. expect(forward.history.cursor).toBe(controller.getSnapshot().cursor)
  331. expect(forward.state).toEqual(controller.getSnapshot().state)
  332. })
  333. it('discards the redo branch on both paths when a new operation lands', () => {
  334. const byHand = driveByHand()
  335. const controller = driveByController()
  336. const stepped = stepBack(byHand.history, byHand.state)
  337. if (stepped === undefined) throw new Error('expected a step back')
  338. expect(controller.undo()).toBe(true)
  339. const reopened = record(stepped.history, stepped.state, [{ type: 'setExpanded', expanded: false }])
  340. controller.setExpanded(false)
  341. expect(recordedOps(reopened.history)).toEqual(controller.ops)
  342. expect(stepForward(reopened.history, reopened.state)).toBeUndefined()
  343. expect(controller.getSnapshot().canRedo).toBe(false)
  344. })
  345. })
  346. describe('planned intents survive replay', () => {
  347. it('rebuilds the same tree from the operations a planner produced', () => {
  348. const { state, minter } = seededState()
  349. const mint = minter.next
  350. const ops: LayoutOp[] = []
  351. let current = state
  352. const push = (planned: readonly LayoutOp[]): void => {
  353. ops.push(...planned)
  354. current = applyAll(current, planned)
  355. }
  356. push(planSetExpanded(current, true))
  357. push(planSplitPane(current, mint, undefined, seedTab))
  358. const opened = planOpenContent(current, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
  359. push(opened.ops)
  360. const second = getSplit(current, current.rootId).children[1]
  361. if (second === undefined) throw new Error('expected a second pane')
  362. push(planPlaceTab(current, opened.tabId, getPane(current, second).id, 0))
  363. const floated = planFloatTab(current, mint, opened.tabId)
  364. push(floated.ops)
  365. push(planUnfloatPane(current, floated.paneId))
  366. expect(applyAll(state, ops)).toEqual(current)
  367. expect(fileTab(asTab('t'), 'dsh-resource://file/session/s/a.txt', 'a.txt').kind).toBe('file')
  368. })
  369. })