app-frame.client.spec.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. // @vitest-environment jsdom
  2. /**
  3. * AppFrame interaction spec under the four-share props form: real layout
  4. * store instance (createLayoutStore().create() — the test-sanctioned engine
  5. * path), a recording renderSlot stub, and a SessionProvider component stub
  6. * (the real one is framework-wired to the renderer host; its own behavior is
  7. * ui-renderer's spec territory). Drag sequences (pointer capture + rAF flush),
  8. * concession response to viewport change, and details staying mounted at
  9. * zero width are the preserved behavior assertions. jsdom has no layout
  10. * engine, so the frame width comes from a mocked getBoundingClientRect and
  11. * resizes are driven through the ResizeObserver stub.
  12. */
  13. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  14. import { act, cleanup, render } from '@testing-library/react'
  15. import { useSyncExternalStore } from 'react'
  16. import { AppFrame } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx'
  17. import type { AppFrameProps } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx'
  18. import { SIDEBAR_COLLAPSED } from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts'
  19. import { createLayoutStore } from '@deepseek-ai/dsh-client-ui-layout/src/client/stores.ts'
  20. import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
  21. import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
  22. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  23. // Session selection controls for the SessionProvider and useSessions stubs.
  24. const selectedSession = { current: 's-test' as SessionId | undefined }
  25. const selectedSessionBlank = { current: false }
  26. const selectedSessionTitle = { current: undefined as string | undefined }
  27. const workspacesReady = { current: true }
  28. type AttentionSnapshot = Parameters<Parameters<AppFrameProps['useSessionPendingInteraction']>[0]>[0]
  29. const noAttention: AttentionSnapshot = new Map()
  30. const useSessionPendingInteraction: AppFrameProps['useSessionPendingInteraction'] = selector => selector(noAttention)
  31. // Provider contract stub fed through the standard seat prop (the renderer
  32. // injects the real one in production): session mode renders children and
  33. // empty mode runs the empty branch.
  34. const SessionProviderStub: AppFrameProps['SessionProvider'] = ({ children, empty }) =>
  35. selectedSession.current === undefined ? <>{empty?.() ?? null}</> : <>{children}</>
  36. /** Observer stub: captures the callback so tests can fire resizes manually. */
  37. let fireResize: (() => void) | null = null
  38. class ResizeObserverStub {
  39. #cb: ResizeObserverCallback
  40. constructor(cb: ResizeObserverCallback) { this.#cb = cb }
  41. observe(): void { fireResize = () => { this.#cb([], this) } }
  42. unobserve(): void {}
  43. disconnect(): void { fireResize = null }
  44. }
  45. let frameWidth = 1920
  46. /** Test-local selector hook over a framework-neutral store instance. */
  47. function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) {
  48. return function useSelector<S>(sel: (s: T) => S): S { return sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot)) }
  49. }
  50. function mountFrame() {
  51. window.innerWidth = frameWidth // first-render viewport source before the observer fires
  52. const instance = createLayoutStore().create()
  53. const slotCalls: { key: string; props: unknown }[] = []
  54. const renderSlot = ((key: string, owner: object) => {
  55. slotCalls.push({ key, props: owner })
  56. if (key === 'sidebar') return <div data-testid="sidebar-content" />
  57. if (key === 'conversation') return <div data-testid="center-content" />
  58. if (key === 'details') return <div data-testid="details-content" />
  59. if (key === 'conversation.empty') return <div data-testid="empty-content" />
  60. return <div data-testid="other-content" />
  61. }) as AppFrameProps['renderSlot']
  62. const useSessions = ((sel: (s: SessionListState) => unknown) => {
  63. const current = selectedSession.current
  64. const sessionState = {
  65. ids: current === undefined ? [] : [current],
  66. byId: current === undefined
  67. ? {}
  68. : {
  69. [current]: {
  70. id: current,
  71. displayTitle: 'Test',
  72. running: false,
  73. blank: selectedSessionBlank.current,
  74. updatedAt: 1,
  75. ...(selectedSessionTitle.current === undefined ? {} : { title: selectedSessionTitle.current }),
  76. },
  77. },
  78. current,
  79. phase: 'ready',
  80. } as SessionListState
  81. return sel(sessionState)
  82. }) as never
  83. const workspaceState: WorkspaceSnapshot = {
  84. items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
  85. ...(workspacesReady.current ? {} : { state: 'loading' as const, phase: 'pending' as const }),
  86. }
  87. const element = () => (
  88. <AppFrame
  89. useStore={hookOf(instance)}
  90. actions={instance.actions}
  91. renderSlot={renderSlot}
  92. useSessions={useSessions}
  93. useSessionPendingInteraction={useSessionPendingInteraction}
  94. useWorkspaces={((sel: (s: WorkspaceSnapshot) => unknown) => sel(workspaceState)) as never}
  95. SessionProvider={SessionProviderStub}
  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 { instance, frame, slotCalls, rerenderFrame: () => { utils.rerender(element()) }, ...utils }
  102. }
  103. function tracks(frame: HTMLElement): number[] {
  104. const m = /^(\d+)px minmax\(0, 1fr\) (\d+)px$/.exec(frame.style.gridTemplateColumns)
  105. if (m === null) throw new Error(`unexpected template: ${frame.style.gridTemplateColumns}`)
  106. return [Number(m[1]), Number(m[2])]
  107. }
  108. function drag(handle: Element, fromX: number, toX: number): void {
  109. const down = new PointerEvent('pointerdown', { pointerId: 1, clientX: fromX, bubbles: true })
  110. const move = new PointerEvent('pointermove', { pointerId: 1, clientX: toX, bubbles: true })
  111. const up = new PointerEvent('pointerup', { pointerId: 1, clientX: toX, bubbles: true })
  112. act(() => { handle.dispatchEvent(down) })
  113. act(() => { handle.dispatchEvent(move); vi.advanceTimersByTime(20) })
  114. act(() => { handle.dispatchEvent(up) })
  115. }
  116. beforeEach(() => {
  117. frameWidth = 1920
  118. selectedSession.current = 's-test' as SessionId
  119. selectedSessionBlank.current = false
  120. selectedSessionTitle.current = undefined
  121. workspacesReady.current = true
  122. vi.useFakeTimers()
  123. vi.stubGlobal('ResizeObserver', ResizeObserverStub)
  124. vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => setTimeout(() => { cb(0) }, 16) as unknown as number)
  125. vi.stubGlobal('cancelAnimationFrame', (h: number) => { clearTimeout(h) })
  126. window.innerWidth = frameWidth
  127. Element.prototype.getBoundingClientRect = function () {
  128. return { width: frameWidth, height: 1080, top: 0, left: 0, right: frameWidth, bottom: 1080, x: 0, y: 0, toJSON: () => ({}) }
  129. }
  130. // jsdom lacks pointer capture: emulate per-element so hasPointerCapture gates pass.
  131. const captured = new WeakSet<Element>()
  132. Element.prototype.setPointerCapture = function () { captured.add(this) }
  133. Element.prototype.releasePointerCapture = function () { captured.delete(this) }
  134. Element.prototype.hasPointerCapture = function () { return captured.has(this) }
  135. })
  136. afterEach(() => {
  137. cleanup()
  138. document.title = ''
  139. vi.useRealTimers()
  140. vi.unstubAllGlobals()
  141. vi.unstubAllEnvs()
  142. })
  143. describe('AppFrame', () => {
  144. it('localizes the product title when the build does not supply one', () => {
  145. mountFrame()
  146. expect(document.title).toBe('DSH Local Build')
  147. })
  148. it('projects the selected durable Session title', () => {
  149. vi.stubEnv('DSH_CLIENT_TITLE', 'Product')
  150. selectedSessionTitle.current = 'First'
  151. const { rerenderFrame } = mountFrame()
  152. expect(document.title).toBe('First — Product')
  153. selectedSessionTitle.current = 'Revised'
  154. act(() => { rerenderFrame() })
  155. expect(document.title).toBe('Revised — Product')
  156. selectedSession.current = undefined
  157. act(() => { rerenderFrame() })
  158. expect(document.title).toBe('Product')
  159. })
  160. it('renders three tracks from store state', () => {
  161. const { frame } = mountFrame()
  162. expect(tracks(frame)).toEqual([280, 0])
  163. })
  164. it('renders the session pair with empty owner shares (sessionId is framework-standard)', () => {
  165. const { slotCalls, getByTestId } = mountFrame()
  166. expect(getByTestId('center-content')).toBeTruthy()
  167. expect(getByTestId('details-content')).toBeTruthy()
  168. const keys = slotCalls.map(c => c.key)
  169. expect(keys).toContain('conversation')
  170. expect(keys).toContain('details')
  171. expect(keys).not.toContain('conversation.empty')
  172. expect(slotCalls.find(c => c.key === 'conversation')!.props).toEqual({})
  173. expect(slotCalls.find(c => c.key === 'details')!.props).toEqual({})
  174. })
  175. it('keeps the conversation slot mounted while no session is current', () => {
  176. // No current session: the session-maybe conversation shell owns the New
  177. // Session view itself — the center column renders it unconditionally.
  178. selectedSession.current = undefined
  179. const { slotCalls, getByTestId, queryByTestId } = mountFrame()
  180. expect(getByTestId('center-content')).toBeTruthy()
  181. expect(slotCalls.map(c => c.key)).toContain('conversation')
  182. expect(queryByTestId('details-content')).toBeNull()
  183. expect(slotCalls.map(c => c.key)).toContain('details')
  184. })
  185. it('renders both column occupants before baselines settle (no loading gate)', () => {
  186. // No loading gate: a bare loading status reads worse than the shell's own
  187. // pending rendering — both occupants mount from first paint.
  188. workspacesReady.current = false
  189. const { slotCalls } = mountFrame()
  190. expect(slotCalls.map(c => c.key)).toContain('conversation')
  191. expect(slotCalls.map(c => c.key)).toContain('details')
  192. })
  193. it('ignores unselected states and closes only when the Session id changes', () => {
  194. const { frame, instance, rerenderFrame } = mountFrame()
  195. expect(tracks(frame)).toEqual([280, 0])
  196. act(() => { instance.actions.openDetails() })
  197. expect(tracks(frame)).toEqual([280, 360])
  198. selectedSession.current = 's-next' as SessionId
  199. act(() => { rerenderFrame() })
  200. expect(tracks(frame)).toEqual([280, 0])
  201. act(() => { instance.actions.openDetails() })
  202. selectedSession.current = 's-blank' as SessionId
  203. selectedSessionBlank.current = true
  204. act(() => { rerenderFrame() })
  205. expect(tracks(frame)).toEqual([280, 0])
  206. expect(instance.getSnapshot().details).toBe(360)
  207. selectedSession.current = 's-next' as SessionId
  208. selectedSessionBlank.current = false
  209. act(() => { rerenderFrame() })
  210. expect(tracks(frame)).toEqual([280, 360])
  211. selectedSession.current = undefined
  212. act(() => { rerenderFrame() })
  213. expect(tracks(frame)).toEqual([280, 0])
  214. selectedSession.current = 's-test' as SessionId
  215. act(() => { rerenderFrame() })
  216. expect(tracks(frame)).toEqual([280, 0])
  217. })
  218. it('keeps details closed when the first Session materializes', () => {
  219. selectedSession.current = undefined
  220. const { frame, instance, rerenderFrame } = mountFrame()
  221. expect(tracks(frame)).toEqual([280, 0])
  222. expect(instance.getSnapshot().details).toBe(0)
  223. selectedSession.current = 's-first' as SessionId
  224. act(() => { rerenderFrame() })
  225. expect(tracks(frame)).toEqual([280, 0])
  226. })
  227. it('sidebar slot receives live concession output as owner props', () => {
  228. const { slotCalls } = mountFrame()
  229. expect(slotCalls.find(c => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 280 })
  230. })
  231. it('sidebar drag widens through rAF-batched pointer moves', () => {
  232. const { frame } = mountFrame()
  233. const handles = frame.querySelectorAll('[class*="handle"]')
  234. drag(handles[0]!, 280, 350)
  235. expect(tracks(frame)[0]).toBe(350)
  236. })
  237. it('details drag widens leftward (negative dx grows the panel)', () => {
  238. const { frame, instance } = mountFrame()
  239. act(() => { instance.actions.openDetails() })
  240. const handles = frame.querySelectorAll('[class*="handle"]')
  241. drag(handles[1]!, 1560, 1500)
  242. expect(tracks(frame)[1]).toBe(420)
  243. })
  244. it('drag base is the rendered (concession-clamped) width, not the preference', () => {
  245. frameWidth = 1250 // step-2 squeeze: details renders 330 while preference is 360
  246. const { frame, instance } = mountFrame()
  247. act(() => { instance.actions.openDetails() })
  248. expect(tracks(frame)).toEqual([280, 330])
  249. const handles = frame.querySelectorAll('[class*="handle"]')
  250. drag(handles[1]!, 920, 930) // shrink by 10 from the rendered width
  251. expect(instance.getSnapshot().details).toBe(320)
  252. })
  253. it('details column stays mounted at zero width', () => {
  254. const { frame, getByTestId } = mountFrame()
  255. expect(tracks(frame)).toEqual([280, 0])
  256. expect(getByTestId('details-content')).toBeTruthy()
  257. expect(frame.hasAttribute('data-details-collapsed')).toBe(true)
  258. })
  259. it('closed sidebar keeps its compact rail with mounted slot content and collapsed owner props', () => {
  260. const { frame, instance, slotCalls, getByTestId } = mountFrame()
  261. act(() => { instance.actions.toggleSidebar() })
  262. expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 0])
  263. expect(getByTestId('sidebar-content')).toBeTruthy()
  264. expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true)
  265. const lastSidebarCall = slotCalls.filter(c => c.key === 'sidebar').at(-1)!
  266. expect(lastSidebarCall.props).toEqual({ collapsed: true, width: SIDEBAR_COLLAPSED })
  267. })
  268. it('viewport shrink triggers the concession chain via ResizeObserver', () => {
  269. const { frame, instance } = mountFrame()
  270. act(() => { instance.actions.openDetails() })
  271. frameWidth = 1250
  272. act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
  273. expect(tracks(frame)).toEqual([280, 330])
  274. frameWidth = 1920
  275. act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
  276. expect(tracks(frame)).toEqual([280, 360])
  277. })
  278. it('drag handles disappear for collapsed columns', () => {
  279. const { frame, instance } = mountFrame()
  280. expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1)
  281. act(() => { instance.actions.openDetails() })
  282. expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(2)
  283. act(() => { instance.actions.closeDetails() })
  284. expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1)
  285. act(() => { instance.actions.toggleSidebar() })
  286. expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(0)
  287. })
  288. })
  289. describe('AppFrame — narrow-viewport auto-collapse', () => {
  290. it('mounts collapsed below the breakpoint with no sidebar handle', () => {
  291. frameWidth = 980
  292. const { frame, slotCalls } = mountFrame()
  293. expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 0])
  294. expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true)
  295. expect(slotCalls.filter(c => c.key === 'sidebar').at(-1)!.props).toEqual({ collapsed: true, width: SIDEBAR_COLLAPSED })
  296. expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(0)
  297. })
  298. it('narrow toggle re-expands over the squeezed center and back', () => {
  299. frameWidth = 980
  300. const { frame, instance } = mountFrame()
  301. act(() => { instance.actions.toggleSidebar() })
  302. expect(tracks(frame)).toEqual([280, 0])
  303. expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(false)
  304. expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1)
  305. act(() => { instance.actions.toggleSidebar() })
  306. expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 0])
  307. })
  308. it('a wide-closed preference re-expands at the contract default while narrow', () => {
  309. frameWidth = 1920
  310. const { frame, instance } = mountFrame()
  311. act(() => { instance.actions.toggleSidebar() }) // close while wide: preference 0
  312. frameWidth = 980
  313. act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
  314. act(() => { instance.actions.toggleSidebar() })
  315. expect(tracks(frame)).toEqual([280, 0])
  316. expect(instance.getSnapshot().sidebar).toBe(0) // preference untouched
  317. })
  318. it('shrinking across the breakpoint auto-collapses; re-widening restores the drag width', () => {
  319. const { frame, instance } = mountFrame()
  320. act(() => { instance.actions.setSidebar(400) })
  321. frameWidth = 980
  322. act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
  323. expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 0])
  324. frameWidth = 1920
  325. act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
  326. expect(tracks(frame)).toEqual([400, 0])
  327. })
  328. })
  329. describe('AppFrame — guard branches', () => {
  330. it('pointer moves without capture are ignored (no width write)', () => {
  331. const { frame, instance } = mountFrame()
  332. const handle = frame.querySelectorAll('[class*="handle"]')[0]!
  333. const before = instance.getSnapshot().sidebar
  334. // Move + up without a preceding pointerdown: hasPointerCapture is false.
  335. act(() => {
  336. handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 9, clientX: 500, bubbles: true }))
  337. vi.advanceTimersByTime(20)
  338. handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 9, clientX: 500, bubbles: true }))
  339. })
  340. expect(instance.getSnapshot().sidebar).toBe(before)
  341. })
  342. it('two moves inside one frame coalesce through the pending rAF', () => {
  343. const { frame, instance } = mountFrame()
  344. const handle = frame.querySelectorAll('[class*="handle"]')[0]!
  345. act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 280, bubbles: true })) })
  346. act(() => {
  347. // Two moves before the frame flushes: the second must ride the pending
  348. // rAF (frame.current ??= guard), and the flush sees the latest x.
  349. handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, clientX: 320, bubbles: true }))
  350. handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, clientX: 340, bubbles: true }))
  351. vi.advanceTimersByTime(20)
  352. })
  353. act(() => { handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, clientX: 340, bubbles: true })) })
  354. expect(instance.getSnapshot().sidebar).toBe(340)
  355. })
  356. it('pointerup with a pending rAF cancels it and commits the final position', () => {
  357. const { frame, instance } = mountFrame()
  358. const handle = frame.querySelectorAll('[class*="handle"]')[0]!
  359. act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 280, bubbles: true })) })
  360. act(() => {
  361. handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, clientX: 360, bubbles: true }))
  362. // No timer advance: the rAF is still pending when pointerup arrives.
  363. handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, clientX: 360, bubbles: true }))
  364. })
  365. expect(instance.getSnapshot().sidebar).toBe(360)
  366. })
  367. it('zero-width resize reports are ignored (display:none window)', () => {
  368. const { frame } = mountFrame()
  369. frameWidth = 0
  370. act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
  371. // Track template still reflects the last non-zero viewport.
  372. expect(tracks(frame)).toEqual([280, 0])
  373. })
  374. })
  375. describe('AppFrame — unmount with an in-flight resize frame', () => {
  376. it('cancels the pending rAF on unmount (no post-unmount setState)', () => {
  377. const { unmount } = mountFrame()
  378. frameWidth = 800
  379. act(() => { fireResize?.() }) // rAF scheduled, NOT flushed
  380. unmount()
  381. // Flushing after unmount must be a no-op (the frame was cancelled).
  382. expect(() => { vi.advanceTimersByTime(20) }).not.toThrow()
  383. })
  384. it('double resize inside one frame rides the pending rAF (??= guard)', () => {
  385. const { frame, instance } = mountFrame()
  386. act(() => { instance.actions.openDetails() })
  387. frameWidth = 1250
  388. act(() => { fireResize?.(); fireResize?.(); vi.advanceTimersByTime(20) })
  389. expect(tracks(frame)).toEqual([280, 330])
  390. })
  391. })