app-frame.client.spec.tsx 26 KB

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