app-frame.client.spec.tsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592
  1. // @vitest-environment jsdom
  2. /** Frame interactions with a real store and explicitly driven browser measurements. */
  3. import type { GlobalStandardProps, RenderOpts } from '@deepseek-ai/dsh-client-ui-slots'
  4. import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
  5. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  6. import { act, cleanup, render } from '@testing-library/react'
  7. import { AppFrame } from '../src/client/AppFrame.tsx'
  8. import type { AppFrameProps } from '../src/client/AppFrame.tsx'
  9. import type { MainPanelId, RightbarOwnerProps, SidebarOwnerProps } from '../src/client/index.ts'
  10. import { createLayoutStore } from '../src/client/stores.ts'
  11. import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
  12. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  13. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource']
  14. let selectedSession: SessionId | undefined
  15. let selectedSessionTitle: string | undefined
  16. let workspacesReady = true
  17. type AttentionSnapshot = Parameters<Parameters<AppFrameProps['useSessionPendingInteraction']>[0]>[0]
  18. const noAttention: AttentionSnapshot = new Map()
  19. const useSessionPendingInteraction: AppFrameProps['useSessionPendingInteraction'] = selector => selector(noAttention)
  20. let observers: ResizeObserverStub[]
  21. class ResizeObserverStub {
  22. disconnected = false
  23. constructor(private callback: ResizeObserverCallback) { observers.push(this) }
  24. observe(): void {}
  25. unobserve(): void {}
  26. disconnect(): void { this.disconnected = true }
  27. fire(): void { this.callback([], this) }
  28. }
  29. let frameWidth: number
  30. let animationFrames: Map<number, FrameRequestCallback>
  31. let nextFrame: number
  32. let originalTitle: string
  33. const restoreProperties: (() => void)[] = []
  34. function replaceProperty<T extends object, K extends keyof T>(target: T, key: K, value: T[K]): void {
  35. const descriptor = Object.getOwnPropertyDescriptor(target, key)
  36. restoreProperties.push(() => {
  37. if (descriptor === undefined) Reflect.deleteProperty(target, key)
  38. else Object.defineProperty(target, key, descriptor)
  39. })
  40. Object.defineProperty(target, key, { configurable: true, writable: true, value })
  41. }
  42. /** Flush one browser frame without depending on the worker's timer cadence. */
  43. function flushFrames(): void {
  44. for (const [id, callback] of [...animationFrames]) {
  45. if (!animationFrames.delete(id)) continue
  46. callback(0)
  47. }
  48. }
  49. function resize(width: number): void {
  50. frameWidth = width
  51. act(() => {
  52. for (const observer of observers) if (!observer.disconnected) observer.fire()
  53. flushFrames()
  54. })
  55. }
  56. function mountFrame(windowWidth = frameWidth) {
  57. vi.stubGlobal('innerWidth', windowWidth)
  58. const instance = createLayoutStore().create()
  59. const slotCalls: { key: string; props: object; options: RenderOpts | undefined }[] = []
  60. const renderSlot: AppFrameProps['renderSlot'] = (key, owner, options) => {
  61. slotCalls.push({ key, props: owner, options })
  62. return <div data-testid={`${key}-content`} data-entry-key={options?.entryKey} />
  63. }
  64. const useSessions: AppFrameProps['useSessions'] = sel => sel({
  65. ids: selectedSession === undefined ? [] : [selectedSession],
  66. byId: selectedSession === undefined ? {} : {
  67. [selectedSession]: {
  68. id: selectedSession, displayTitle: 'Test', running: false, blank: false, updatedAt: 1,
  69. ...(selectedSessionTitle === undefined ? {} : { title: selectedSessionTitle }),
  70. },
  71. },
  72. current: selectedSession,
  73. phase: 'ready',
  74. subagentsByParent: {},
  75. jobsBySession: {},
  76. currentAddress: undefined,
  77. })
  78. const workspaceState: WorkspaceSnapshot = {
  79. items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
  80. ...(workspacesReady ? {} : { state: 'loading' as const, phase: 'pending' as const }),
  81. }
  82. const useStore = bindSnapshotSelector(instance)
  83. const usePanelInfo = bindSnapshotSelector({
  84. getSnapshot: () => instance.getSnapshot().panelInfo,
  85. subscribe: listener => instance.subscribe(listener),
  86. })
  87. const element = () => (
  88. <AppFrame
  89. useStore={useStore}
  90. actions={instance.actions}
  91. renderSlot={renderSlot}
  92. useSessions={useSessions}
  93. usePanelInfo={usePanelInfo}
  94. useSessionPendingInteraction={useSessionPendingInteraction}
  95. useResource={useResource}
  96. useWorkspaces={sel => sel(workspaceState)}
  97. t={key => key === 'brand.localBuild' ? 'DSH Local Build' : key}
  98. />
  99. )
  100. const utils = render(element())
  101. const frame = utils.container.firstElementChild as HTMLElement
  102. return {
  103. ...utils, instance, frame, slotCalls,
  104. rerenderFrame: () => { utils.rerender(element()) },
  105. rightOwner: () => slotCalls.findLast(c => c.key === 'rightbar')!.props as RightbarOwnerProps,
  106. sidebarOwner: () => slotCalls.findLast(c => c.key === 'sidebar')!.props as SidebarOwnerProps,
  107. }
  108. }
  109. function tracks(frame: HTMLElement): number[] {
  110. const match = /^([\d.]+)px minmax\(0, 1fr\) ([\d.]+)px$/.exec(frame.style.gridTemplateColumns)
  111. if (match === null) throw new Error(`unexpected template: ${frame.style.gridTemplateColumns}`)
  112. return [Number(match[1]), Number(match[2])]
  113. }
  114. function handleFor(frame: HTMLElement, side: 'sidebar' | 'rightbar'): HTMLElement {
  115. const handle = frame.querySelector<HTMLElement>(`[data-side="${side}"]`)
  116. if (handle === null) throw new Error(`missing ${side} resize handle`)
  117. return handle
  118. }
  119. function pointer(handle: Element, type: string, clientX: number, pointerId = 1, button = 0): void {
  120. act(() => { handle.dispatchEvent(new PointerEvent(type, { pointerId, clientX, button, bubbles: true })) })
  121. }
  122. function drag(handle: Element, fromX: number, toX: number): void {
  123. pointer(handle, 'pointerdown', fromX)
  124. pointer(handle, 'pointermove', toX)
  125. act(flushFrames)
  126. pointer(handle, 'pointerup', toX)
  127. }
  128. beforeEach(() => {
  129. originalTitle = document.title
  130. frameWidth = 1920
  131. selectedSession = 's-test' as SessionId
  132. selectedSessionTitle = undefined
  133. workspacesReady = true
  134. observers = []
  135. animationFrames = new Map()
  136. nextFrame = 1
  137. vi.stubEnv('DSH_CLIENT_TITLE', undefined)
  138. vi.stubGlobal('ResizeObserver', ResizeObserverStub)
  139. vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
  140. const id = nextFrame++
  141. animationFrames.set(id, callback)
  142. return id
  143. })
  144. vi.stubGlobal('cancelAnimationFrame', (id: number) => { animationFrames.delete(id) })
  145. vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(() => ({
  146. width: frameWidth, height: 1080, top: 0, left: 0, right: frameWidth, bottom: 1080,
  147. x: 0, y: 0, toJSON: () => ({}),
  148. }))
  149. // jsdom has no pointer capture. Each element retains the actual pointer id,
  150. // and teardown restores absent methods as well as existing descriptors.
  151. const captured = new WeakMap<Element, number>()
  152. replaceProperty(Element.prototype, 'setPointerCapture', function (this: Element, id: number) { captured.set(this, id) })
  153. replaceProperty(Element.prototype, 'releasePointerCapture', function (this: Element) { captured.delete(this) })
  154. replaceProperty(Element.prototype, 'hasPointerCapture', function (this: Element, id: number) { return captured.get(this) === id })
  155. })
  156. afterEach(() => {
  157. try {
  158. cleanup()
  159. } finally {
  160. for (const restore of restoreProperties.splice(0).reverse()) restore()
  161. document.title = originalTitle
  162. vi.restoreAllMocks()
  163. vi.unstubAllGlobals()
  164. vi.unstubAllEnvs()
  165. }
  166. })
  167. describe('AppFrame', () => {
  168. it('localizes the product title without a configured build title', () => {
  169. mountFrame()
  170. expect(document.title).toBe('DSH Local Build')
  171. })
  172. it('follows the selected durable Session title', () => {
  173. vi.stubEnv('DSH_CLIENT_TITLE', 'Product')
  174. selectedSessionTitle = 'First'
  175. const { rerenderFrame } = mountFrame()
  176. expect(document.title).toBe('First — Product')
  177. selectedSessionTitle = 'Revised'
  178. rerenderFrame()
  179. expect(document.title).toBe('Revised — Product')
  180. selectedSession = undefined
  181. rerenderFrame()
  182. expect(document.title).toBe('Product')
  183. })
  184. it('renders owner props for the default sidebar and prospective right panel', () => {
  185. const { frame, rightOwner, sidebarOwner, slotCalls } = mountFrame()
  186. expect(tracks(frame)).toEqual([280, 0])
  187. expect(sidebarOwner()).toEqual({ collapsed: false, width: 280 })
  188. expect(rightOwner()).toEqual({ width: 864, viewportWidth: 1920, canShow: true })
  189. expect(slotCalls.find(c => c.key === 'main')).toEqual({ key: 'main', props: {}, options: { entryKey: 'conversation' } })
  190. })
  191. it('renders the main, sidebar, and root-scoped rightbar outlets without a current Session', () => {
  192. selectedSession = undefined
  193. const { frame, getByTestId } = mountFrame()
  194. expect(getByTestId('main-content').getAttribute('data-entry-key')).toBe('conversation')
  195. expect(getByTestId('sidebar-content')).toBeTruthy()
  196. expect(getByTestId('rightbar-content')).toBeTruthy()
  197. expect(tracks(frame)).toEqual([280, 0])
  198. })
  199. it('renders both occupants before workspace baselines settle', () => {
  200. workspacesReady = false
  201. const { getByTestId } = mountFrame()
  202. expect(getByTestId('main-content').getAttribute('data-entry-key')).toBe('conversation')
  203. expect(getByTestId('rightbar-content')).toBeTruthy()
  204. })
  205. it('keeps the closed sidebar mounted at its 56px rail without a handle', () => {
  206. const { frame, instance, sidebarOwner, getByTestId } = mountFrame()
  207. act(() => { instance.actions.toggleSidebar() })
  208. expect(tracks(frame)).toEqual([56, 0])
  209. expect(sidebarOwner()).toEqual({ collapsed: true, width: 56 })
  210. expect(getByTestId('sidebar-content')).toBeTruthy()
  211. expect(frame.querySelector('[data-side="sidebar"]')).toBeNull()
  212. })
  213. it('switches only the keyed main outlet when the active panel changes', () => {
  214. selectedSessionTitle = 'Session title'
  215. const { instance, frame, slotCalls, getByTestId } = mountFrame()
  216. const sessionId = selectedSession
  217. const layoutInfo = instance.getSnapshot().layoutInfo
  218. for (const panelId of ['panel-a' as MainPanelId, 'panel-b' as MainPanelId, null]) {
  219. slotCalls.length = 0
  220. act(() => { instance.actions.selectPanel(panelId) })
  221. expect(slotCalls).toEqual([{ key: 'main', props: {}, options: { entryKey: panelId ?? 'conversation' } }])
  222. expect(getByTestId('main-content').getAttribute('data-entry-key')).toBe(panelId ?? 'conversation')
  223. expect(instance.getSnapshot().panelInfo).toEqual({ activePanelId: panelId })
  224. expect(instance.getSnapshot().layoutInfo).toBe(layoutInfo)
  225. expect(tracks(frame)).toEqual([280, 0])
  226. expect(selectedSession).toBe(sessionId)
  227. expect(document.title).toBe(panelId === null ? 'Session title — DSH Local Build' : 'DSH Local Build')
  228. }
  229. })
  230. })
  231. describe('AppFrame normal width concessions', () => {
  232. it('measures the frame, not the window, before choosing the first-open preference', () => {
  233. frameWidth = 1000
  234. const { instance, rightOwner } = mountFrame(1920)
  235. expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1000)
  236. expect(rightOwner()).toEqual({ width: 450, viewportWidth: 1000, canShow: true })
  237. act(() => { instance.actions.openRightbar(true, false) })
  238. expect(instance.getSnapshot().layoutInfo.rightbar).toBe(450)
  239. resize(1920)
  240. expect(rightOwner().width).toBe(450)
  241. })
  242. it('shrinks the right panel to 300px, drops its track, and only then squeezes center', () => {
  243. const { frame, instance, rightOwner } = mountFrame()
  244. act(() => { instance.actions.setSidebar(420); instance.actions.openRightbar(true, false) })
  245. resize(1200)
  246. expect(tracks(frame)).toEqual([420, 380])
  247. expect(rightOwner()).toEqual({ width: 380, viewportWidth: 1200, canShow: true })
  248. resize(1120)
  249. expect(tracks(frame)).toEqual([420, 300])
  250. resize(1119)
  251. expect(tracks(frame)).toEqual([420, 0])
  252. expect(rightOwner()).toEqual({ width: 0, viewportWidth: 1119, canShow: false })
  253. expect(frame.querySelector('[data-side="rightbar"]')).toBeNull()
  254. expect(instance.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbar: 864 })
  255. act(() => { instance.actions.closeRightbar() })
  256. resize(455)
  257. expect(tracks(frame)).toEqual([56, 0])
  258. resize(1920)
  259. expect(tracks(frame)).toEqual([420, 0])
  260. })
  261. it('uses the post-collapse left rail to permit a narrow first opening', () => {
  262. frameWidth = 800
  263. const { frame, instance, rightOwner } = mountFrame()
  264. act(() => { instance.actions.toggleSidebar() })
  265. expect(tracks(frame)).toEqual([280, 0])
  266. expect(rightOwner()).toEqual({ width: 344, viewportWidth: 800, canShow: true })
  267. act(() => { instance.actions.openRightbar(true, false) })
  268. expect(tracks(frame)).toEqual([56, 344])
  269. expect(instance.getSnapshot().layoutInfo).toMatchObject({ narrowExpanded: false, rightbar: 360 })
  270. expect(rightOwner().canShow).toBe(true)
  271. })
  272. it.each([[756, 300, true], [755, 0, false]] as const)('reports eligibility at %ipx', (width, rightbar, canShow) => {
  273. frameWidth = width
  274. const { instance, rightOwner } = mountFrame()
  275. act(() => { instance.actions.toggleSidebar() })
  276. expect(rightOwner()).toEqual({ width: rightbar, viewportWidth: width, canShow })
  277. })
  278. it('does not anticipate another left collapse after the right panel is already shown', () => {
  279. frameWidth = 800
  280. const { instance, rightOwner } = mountFrame()
  281. act(() => { instance.actions.openRightbar(true, false); instance.actions.toggleSidebar() })
  282. expect(rightOwner().canShow).toBe(false)
  283. })
  284. it('auto-collapses only below 1024px and preserves the wide sidebar preference', () => {
  285. const { frame, instance } = mountFrame()
  286. act(() => { instance.actions.setSidebar(400) })
  287. resize(1024)
  288. expect(tracks(frame)[0]).toBe(400)
  289. resize(1023)
  290. expect(tracks(frame)[0]).toBe(56)
  291. act(() => { instance.actions.toggleSidebar() })
  292. expect(tracks(frame)[0]).toBe(400)
  293. resize(980)
  294. expect(tracks(frame)[0]).toBe(400)
  295. act(() => { instance.actions.toggleSidebar() })
  296. expect(tracks(frame)[0]).toBe(56)
  297. resize(1920)
  298. expect(tracks(frame)[0]).toBe(400)
  299. })
  300. it('re-expands a wide-closed sidebar at the default width while narrow', () => {
  301. const { frame, instance } = mountFrame()
  302. act(() => { instance.actions.toggleSidebar() })
  303. resize(980)
  304. act(() => { instance.actions.toggleSidebar() })
  305. expect(tracks(frame)[0]).toBe(280)
  306. expect(instance.getSnapshot().layoutInfo.sidebar).toBe(0)
  307. })
  308. })
  309. describe('AppFrame right panel presentation', () => {
  310. it('releases the fullscreen track with the instant marker while clearing fullscreen', () => {
  311. const { frame, instance } = mountFrame()
  312. act(() => { instance.actions.openRightbar(true, true) })
  313. expect(tracks(frame)).toEqual([280, 864])
  314. act(() => { instance.actions.closeRightbar() })
  315. expect(tracks(frame)).toEqual([280, 0])
  316. expect(frame.dataset.rightbarFullscreen).toBeUndefined()
  317. expect(frame.dataset.rightbarInstant).toBe('true')
  318. expect(frame.querySelector('[data-side="rightbar"]')).toBeNull()
  319. act(() => { instance.actions.closeRightbar() })
  320. expect(frame.dataset.rightbarInstant).toBe('true')
  321. })
  322. it('marks restoration instant without suppressing the following normal close', () => {
  323. const { frame, instance } = mountFrame()
  324. act(() => { instance.actions.openRightbar(true, true) })
  325. act(() => { instance.actions.openRightbar(true, false) })
  326. expect(tracks(frame)).toEqual([280, 864])
  327. expect(frame.dataset.rightbarFullscreen).toBeUndefined()
  328. expect(frame.dataset.rightbarInstant).toBe('true')
  329. act(() => { instance.actions.closeRightbar() })
  330. expect(tracks(frame)).toEqual([280, 0])
  331. expect(frame.dataset.rightbarFullscreen).toBeUndefined()
  332. expect(frame.dataset.rightbarInstant).toBeUndefined()
  333. })
  334. it.each(['setSidebar', 'toggleSidebar', 'setRightbar', 'viewport', 'open'] as const)('reenables normal transitions after %s', (action) => {
  335. const { frame, instance } = mountFrame()
  336. act(() => { instance.actions.openRightbar(true, true); instance.actions.closeRightbar() })
  337. expect(frame.dataset.rightbarInstant).toBe('true')
  338. act(() => {
  339. if (action === 'viewport') resize(1800)
  340. else if (action === 'open') instance.actions.openRightbar(true, false)
  341. else if (action === 'toggleSidebar') instance.actions.toggleSidebar()
  342. else instance.actions[action](350)
  343. })
  344. expect(frame.dataset.rightbarInstant).toBeUndefined()
  345. expect(frame.dataset.rightbarFullscreen).toBeUndefined()
  346. })
  347. it('keeps fullscreen suppression independent from resetting the instant marker', () => {
  348. const { frame, instance } = mountFrame()
  349. act(() => { instance.actions.openRightbar(true, true) })
  350. act(() => { instance.actions.setSidebar(350) })
  351. expect(frame.dataset.rightbarInstant).toBeUndefined()
  352. expect(frame.dataset.rightbarFullscreen).toBe('true')
  353. })
  354. it('preserves normal tracks through fullscreen and hides the outer resize handle', () => {
  355. const { frame, instance, rightOwner } = mountFrame()
  356. act(() => { instance.actions.openRightbar(true, false) })
  357. expect(tracks(frame)).toEqual([280, 864])
  358. expect(handleFor(frame, 'rightbar').style.left).toBe('1056px')
  359. act(() => { instance.actions.openRightbar(true, true) })
  360. expect(tracks(frame)).toEqual([280, 864])
  361. expect(rightOwner().width).toBe(864)
  362. expect(frame.dataset.rightbarFullscreen).toBe('true')
  363. expect(frame.querySelector('[data-side="rightbar"]')).toBeNull()
  364. act(() => { instance.actions.openRightbar(true, false) })
  365. expect(tracks(frame)).toEqual([280, 864])
  366. expect(handleFor(frame, 'rightbar').style.left).toBe('1056px')
  367. expect(frame.dataset.rightbarFullscreen).toBeUndefined()
  368. act(() => { instance.actions.closeRightbar() })
  369. expect(tracks(frame)).toEqual([280, 0])
  370. expect(frame.querySelector('[data-side="rightbar"]')).toBeNull()
  371. expect(frame.dataset.rightbarFullscreen).toBeUndefined()
  372. })
  373. it('inserts a fullscreen track and its transition-suppression marker in the same render', () => {
  374. const { frame, instance } = mountFrame()
  375. expect(tracks(frame)).toEqual([280, 0])
  376. expect(frame.dataset.rightbarFullscreen).toBeUndefined()
  377. act(() => { instance.actions.openRightbar(true, true) })
  378. expect(tracks(frame)).toEqual([280, 864])
  379. expect(frame.dataset.rightbarFullscreen).toBe('true')
  380. expect(frame.querySelector('[data-side="rightbar"]')).toBeNull()
  381. act(() => { instance.actions.openRightbar(true, false) })
  382. expect(tracks(frame)).toEqual([280, 864])
  383. expect(frame.dataset.rightbarFullscreen).toBeUndefined()
  384. })
  385. it('retains fullscreen without a track when normal columns cannot fit', () => {
  386. frameWidth = 700
  387. const { frame, instance, rightOwner } = mountFrame()
  388. act(() => { instance.actions.openRightbar(false, true) })
  389. expect(tracks(frame)).toEqual([56, 0])
  390. expect(rightOwner()).toEqual({ width: 0, viewportWidth: 700, canShow: false })
  391. expect(instance.getSnapshot().layoutInfo.rightbarShown).toBe(true)
  392. expect(frame.querySelector('[data-side="rightbar"]')).toBeNull()
  393. })
  394. it('keeps resolved panel width independent of the requested track', () => {
  395. const { frame, instance, rightOwner } = mountFrame()
  396. act(() => { instance.actions.openRightbar(false, false) })
  397. resize(1100)
  398. expect(tracks(frame)).toEqual([280, 0])
  399. expect(rightOwner().width).toBe(420)
  400. drag(handleFor(frame, 'rightbar'), 680, 690)
  401. expect(instance.getSnapshot().layoutInfo.rightbar).toBe(410)
  402. expect(rightOwner().width).toBe(410)
  403. expect(tracks(frame)[1]).toBe(0)
  404. })
  405. })
  406. describe('AppFrame pointer resizing', () => {
  407. it('updates columns during the gesture and freezes the drag-start width', () => {
  408. const { frame, instance } = mountFrame()
  409. const handle = handleFor(frame, 'sidebar')
  410. pointer(handle, 'pointerdown', 280)
  411. expect(frame.dataset.dragging).toBe('true')
  412. pointer(handle, 'pointermove', 320)
  413. pointer(handle, 'pointermove', 340)
  414. expect(animationFrames.size).toBe(1)
  415. act(flushFrames)
  416. expect(tracks(frame)[0]).toBe(340)
  417. pointer(handle, 'pointermove', 360)
  418. act(flushFrames)
  419. expect(tracks(frame)[0]).toBe(360)
  420. pointer(handle, 'pointerup', 360)
  421. expect(instance.getSnapshot().layoutInfo.sidebar).toBe(360)
  422. expect(frame.dataset.dragging).toBeUndefined()
  423. expect(handle.hasPointerCapture(1)).toBe(false)
  424. })
  425. it('starts a conceded right drag at its actual width, shared by panel and track', () => {
  426. const { frame, instance, rightOwner } = mountFrame()
  427. act(() => { instance.actions.openRightbar(true, false) })
  428. resize(1100)
  429. const handle = handleFor(frame, 'rightbar')
  430. expect(rightOwner().width).toBe(420)
  431. expect(tracks(frame)[1]).toBe(420)
  432. expect(handle.style.left).toBe('680px')
  433. drag(handle, 680, 690)
  434. expect(instance.getSnapshot().layoutInfo.rightbar).toBe(410)
  435. expect(rightOwner().width).toBe(410)
  436. expect(tracks(frame)[1]).toBe(410)
  437. expect(handle.style.left).toBe('690px')
  438. })
  439. it('widens to the 70% limit and shrinks to 300px through pointer input', () => {
  440. frameWidth = 3000
  441. const { frame, instance, rightOwner } = mountFrame()
  442. act(() => { instance.actions.toggleSidebar(); instance.actions.openRightbar(true, false) })
  443. drag(handleFor(frame, 'rightbar'), 1650, 0)
  444. expect(rightOwner().width).toBe(2100)
  445. expect(tracks(frame)[1]).toBe(2100)
  446. drag(handleFor(frame, 'rightbar'), 900, 3000)
  447. expect(rightOwner().width).toBe(300)
  448. expect(tracks(frame)[1]).toBe(300)
  449. })
  450. it('commits the pointerup coordinate and cancels its pending animation frame', () => {
  451. const { frame, instance } = mountFrame()
  452. const handle = handleFor(frame, 'sidebar')
  453. pointer(handle, 'pointerdown', 280)
  454. pointer(handle, 'pointermove', 320)
  455. pointer(handle, 'pointerup', 360)
  456. expect(instance.getSnapshot().layoutInfo.sidebar).toBe(360)
  457. expect(animationFrames.size).toBe(0)
  458. act(flushFrames)
  459. expect(instance.getSnapshot().layoutInfo.sidebar).toBe(360)
  460. })
  461. it('ignores uncaptured motion, secondary buttons, and a second pointer', () => {
  462. const { frame, instance } = mountFrame()
  463. const handle = handleFor(frame, 'sidebar')
  464. pointer(handle, 'pointermove', 500, 9)
  465. pointer(handle, 'pointerup', 500, 9)
  466. pointer(handle, 'pointercancel', 500, 9)
  467. pointer(handle, 'pointerdown', 500, 9, 2)
  468. expect(frame.dataset.dragging).toBeUndefined()
  469. pointer(handle, 'pointerdown', 280)
  470. pointer(handle, 'pointerdown', 500, 9)
  471. pointer(handle, 'pointermove', 500, 9)
  472. pointer(handle, 'pointerup', 500, 9)
  473. expect(animationFrames.size).toBe(0)
  474. expect(instance.getSnapshot().layoutInfo.sidebar).toBe(280)
  475. pointer(handle, 'pointerup', 300)
  476. expect(instance.getSnapshot().layoutInfo.sidebar).toBe(300)
  477. })
  478. it.each(['pointercancel', 'lostpointercapture'])('ends %s without committing queued motion', (event) => {
  479. const { frame, instance } = mountFrame()
  480. const handle = handleFor(frame, 'sidebar')
  481. pointer(handle, 'pointerdown', 280)
  482. pointer(handle, 'pointermove', 340)
  483. if (event === 'lostpointercapture') handle.releasePointerCapture(1)
  484. pointer(handle, event, 340)
  485. act(flushFrames)
  486. expect(instance.getSnapshot().layoutInfo.sidebar).toBe(280)
  487. expect(animationFrames.size).toBe(0)
  488. expect(frame.dataset.dragging).toBeUndefined()
  489. expect(handle.hasPointerCapture(1)).toBe(false)
  490. })
  491. it.each(['fullscreen', 'close', 'unmount'])('cancels a pending drag on %s', (change) => {
  492. const { frame, instance, unmount } = mountFrame()
  493. act(() => { instance.actions.openRightbar(true, false) })
  494. const handle = handleFor(frame, 'rightbar')
  495. pointer(handle, 'pointerdown', 1056)
  496. pointer(handle, 'pointermove', 1000)
  497. act(() => {
  498. if (change === 'fullscreen') instance.actions.openRightbar(true, true)
  499. else if (change === 'close') instance.actions.closeRightbar()
  500. else unmount()
  501. })
  502. const settled = instance.getSnapshot()
  503. act(flushFrames)
  504. expect(instance.getSnapshot()).toBe(settled)
  505. expect(instance.getSnapshot().layoutInfo.rightbar).toBe(864)
  506. expect(animationFrames.size).toBe(0)
  507. expect(handle.hasPointerCapture(1)).toBe(false)
  508. if (change !== 'unmount') expect(frame.dataset.dragging).toBeUndefined()
  509. })
  510. })
  511. describe('AppFrame frame measurement lifecycle', () => {
  512. it('coalesces observer reports and publishes the latest frame measurement', () => {
  513. const { instance, rightOwner } = mountFrame()
  514. const observer = observers.at(-1)!
  515. act(() => {
  516. frameWidth = 900
  517. observer.fire()
  518. frameWidth = 1200
  519. observer.fire()
  520. })
  521. expect(animationFrames.size).toBe(1)
  522. expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1920)
  523. act(flushFrames)
  524. expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1200)
  525. expect(rightOwner().viewportWidth).toBe(1200)
  526. expect(instance.getSnapshot().layoutInfo.rightbar).toBeNull()
  527. })
  528. it('retains the last positive measurement while the frame is hidden', () => {
  529. const { instance, rightOwner } = mountFrame()
  530. resize(0)
  531. expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1920)
  532. expect(rightOwner().viewportWidth).toBe(1920)
  533. })
  534. it('disconnects the observer and prevents queued or late reports after unmount', () => {
  535. const { instance, unmount } = mountFrame()
  536. const observer = observers.at(-1)!
  537. frameWidth = 800
  538. act(() => { observer.fire() })
  539. expect(animationFrames.size).toBe(1)
  540. unmount()
  541. expect(observer.disconnected).toBe(true)
  542. expect(animationFrames.size).toBe(0)
  543. act(() => { observer.fire(); flushFrames() })
  544. expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1920)
  545. expect(animationFrames.size).toBe(0)
  546. })
  547. })