app-frame.spec.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  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 render-prop SessionProvider stub
  6. * (the real one is framework-wired to the renderer host; its own behavior is
  7. * web-react'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 {
  21. SessionId, SessionListState, WorkspaceListState,
  22. } from '@deepseek-ai/dsh-client-runtime/client'
  23. // Session-mode switch for the SessionProvider stub prop.
  24. const sessionMode = { current: true }
  25. const baselinesReady = { current: true }
  26. // Render-prop contract stub fed through the standard seat prop (the renderer
  27. // injects the real one in production): session mode runs children(id), empty
  28. // mode runs the empty branch — the frame must work against exactly this
  29. // shape. Typed as the seat's own component type so the branded sessionId
  30. // parameter stays contract-checked.
  31. const SessionProviderStub: AppFrameProps['SessionProvider'] = ({ children, empty }) =>
  32. sessionMode.current ? <>{children('s-test' as Parameters<typeof children>[0])}</> : <>{empty?.() ?? null}</>
  33. /** Observer stub: captures the callback so tests can fire resizes manually. */
  34. let fireResize: (() => void) | null = null
  35. class ResizeObserverStub {
  36. #cb: ResizeObserverCallback
  37. constructor(cb: ResizeObserverCallback) { this.#cb = cb }
  38. observe(): void { fireResize = () => { this.#cb([], this) } }
  39. unobserve(): void {}
  40. disconnect(): void { fireResize = null }
  41. }
  42. let frameWidth = 1920
  43. /** Test-local selector hook over a framework-neutral store instance. */
  44. function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) {
  45. return function useSelector<S>(sel: (s: T) => S): S { return sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot)) }
  46. }
  47. function mountFrame() {
  48. window.innerWidth = frameWidth // first-render viewport source before the observer fires
  49. const instance = createLayoutStore().create()
  50. instance.actions.openDetails() // seed: sidebar at default 280, details open at default 360
  51. const slotCalls: { key: string; props: unknown }[] = []
  52. const renderSlot = ((key: string, owner: object) => {
  53. slotCalls.push({ key, props: owner })
  54. if (key === 'sidebar') return <div data-testid="sidebar-content" />
  55. if (key === 'conversation') return <div data-testid="center-content" />
  56. if (key === 'details') return <div data-testid="details-content" />
  57. if (key === 'conversation.empty') return <div data-testid="empty-content" />
  58. return <div data-testid="other-content" />
  59. }) as AppFrameProps['renderSlot']
  60. const sessionId = 's-test' as SessionId
  61. const sessionState = {
  62. ids: sessionMode.current ? [sessionId] : [],
  63. byId: sessionMode.current
  64. ? { [sessionId]: { id: sessionId, displayTitle: 'Test', running: false, blank: false, updatedAt: 1 } }
  65. : {},
  66. current: sessionMode.current ? sessionId : undefined,
  67. phase: 'ready',
  68. } as SessionListState
  69. const useSessions = ((sel: (s: SessionListState) => unknown) => sel(sessionState)) as never
  70. const workspaceState: WorkspaceListState = {
  71. items: [], state: 'idle', phase: 'ready', error: null,
  72. baselinesReady: baselinesReady.current, recentWorkspaceId: undefined,
  73. }
  74. const utils = render(
  75. <AppFrame
  76. useStore={hookOf(instance) as never}
  77. actions={instance.actions}
  78. renderSlot={renderSlot}
  79. useSessions={useSessions}
  80. useWorkspaces={((sel: (s: WorkspaceListState) => unknown) => sel(workspaceState)) as never}
  81. SessionProvider={SessionProviderStub}
  82. />,
  83. )
  84. const frame = utils.container.firstElementChild as HTMLElement
  85. return { instance, frame, slotCalls, ...utils }
  86. }
  87. function tracks(frame: HTMLElement): number[] {
  88. const m = /^(\d+)px minmax\(0, 1fr\) (\d+)px$/.exec(frame.style.gridTemplateColumns)
  89. if (m === null) throw new Error(`unexpected template: ${frame.style.gridTemplateColumns}`)
  90. return [Number(m[1]), Number(m[2])]
  91. }
  92. function drag(handle: Element, fromX: number, toX: number): void {
  93. const down = new PointerEvent('pointerdown', { pointerId: 1, clientX: fromX, bubbles: true })
  94. const move = new PointerEvent('pointermove', { pointerId: 1, clientX: toX, bubbles: true })
  95. const up = new PointerEvent('pointerup', { pointerId: 1, clientX: toX, bubbles: true })
  96. act(() => { handle.dispatchEvent(down) })
  97. act(() => { handle.dispatchEvent(move); vi.advanceTimersByTime(20) })
  98. act(() => { handle.dispatchEvent(up) })
  99. }
  100. beforeEach(() => {
  101. frameWidth = 1920
  102. sessionMode.current = true
  103. baselinesReady.current = true
  104. localStorage.clear() // the layout store persists; instances must not bleed across tests
  105. vi.useFakeTimers()
  106. vi.stubGlobal('ResizeObserver', ResizeObserverStub)
  107. vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => setTimeout(() => { cb(0) }, 16) as unknown as number)
  108. vi.stubGlobal('cancelAnimationFrame', (h: number) => { clearTimeout(h) })
  109. window.innerWidth = frameWidth
  110. Element.prototype.getBoundingClientRect = function () {
  111. return { width: frameWidth, height: 1080, top: 0, left: 0, right: frameWidth, bottom: 1080, x: 0, y: 0, toJSON: () => ({}) }
  112. }
  113. // jsdom lacks pointer capture: emulate per-element so hasPointerCapture gates pass.
  114. const captured = new WeakSet<Element>()
  115. Element.prototype.setPointerCapture = function () { captured.add(this) }
  116. Element.prototype.releasePointerCapture = function () { captured.delete(this) }
  117. Element.prototype.hasPointerCapture = function () { return captured.has(this) }
  118. })
  119. afterEach(() => {
  120. cleanup()
  121. vi.useRealTimers()
  122. vi.unstubAllGlobals()
  123. })
  124. describe('AppFrame', () => {
  125. it('renders three tracks from store state', () => {
  126. const { frame } = mountFrame()
  127. expect(tracks(frame)).toEqual([280, 360])
  128. })
  129. it('renders the session pair with empty owner shares (sessionId is framework-standard)', () => {
  130. const { slotCalls, getByTestId } = mountFrame()
  131. expect(getByTestId('center-content')).toBeTruthy()
  132. expect(getByTestId('details-content')).toBeTruthy()
  133. const keys = slotCalls.map(c => c.key)
  134. expect(keys).toContain('conversation')
  135. expect(keys).toContain('details')
  136. expect(keys).not.toContain('conversation.empty')
  137. expect(slotCalls.find(c => c.key === 'conversation')!.props).toEqual({})
  138. expect(slotCalls.find(c => c.key === 'details')!.props).toEqual({})
  139. })
  140. it('keeps the conversation slot mounted while no session is current', () => {
  141. // No current session: the session-maybe conversation shell owns the New
  142. // Session view itself — the center column renders it unconditionally.
  143. sessionMode.current = false
  144. const { slotCalls, getByTestId } = mountFrame()
  145. expect(getByTestId('center-content')).toBeTruthy()
  146. expect(slotCalls.map(c => c.key)).toContain('conversation')
  147. })
  148. it('renders both column occupants before baselines settle (no loading gate)', () => {
  149. // User ruling: the bare loading status looked worse than the shell's own
  150. // pending rendering — both occupants mount from first paint.
  151. baselinesReady.current = false
  152. const { slotCalls } = mountFrame()
  153. expect(slotCalls.map(c => c.key)).toContain('conversation')
  154. expect(slotCalls.map(c => c.key)).toContain('details')
  155. })
  156. it('sidebar slot receives live concession output as owner props', () => {
  157. const { slotCalls } = mountFrame()
  158. expect(slotCalls.find(c => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 280 })
  159. })
  160. it('sidebar drag widens through rAF-batched pointer moves', () => {
  161. const { frame } = mountFrame()
  162. const handles = frame.querySelectorAll('[class*="handle"]')
  163. drag(handles[0]!, 280, 350)
  164. expect(tracks(frame)[0]).toBe(350)
  165. })
  166. it('details drag widens leftward (negative dx grows the panel)', () => {
  167. const { frame } = mountFrame()
  168. const handles = frame.querySelectorAll('[class*="handle"]')
  169. drag(handles[1]!, 1560, 1500)
  170. expect(tracks(frame)[1]).toBe(420)
  171. })
  172. it('drag base is the rendered (concession-clamped) width, not the preference', () => {
  173. frameWidth = 1250 // step-2 squeeze: details renders 330 while preference is 360
  174. const { frame, instance } = mountFrame()
  175. expect(tracks(frame)).toEqual([280, 330])
  176. const handles = frame.querySelectorAll('[class*="handle"]')
  177. drag(handles[1]!, 920, 930) // shrink by 10 from the rendered width
  178. expect(instance.getSnapshot().details).toBe(320)
  179. })
  180. it('details column stays mounted at zero width', () => {
  181. const { frame, instance, getByTestId } = mountFrame()
  182. act(() => { instance.actions.closeDetails() })
  183. expect(tracks(frame)).toEqual([280, 0])
  184. expect(getByTestId('details-content')).toBeTruthy()
  185. expect(frame.hasAttribute('data-details-collapsed')).toBe(true)
  186. })
  187. it('closed sidebar keeps its compact rail with mounted slot content and collapsed owner props', () => {
  188. const { frame, instance, slotCalls, getByTestId } = mountFrame()
  189. act(() => { instance.actions.toggleSidebar() })
  190. expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 360])
  191. expect(getByTestId('sidebar-content')).toBeTruthy()
  192. expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true)
  193. const lastSidebarCall = slotCalls.filter(c => c.key === 'sidebar').at(-1)!
  194. expect(lastSidebarCall.props).toEqual({ collapsed: true, width: SIDEBAR_COLLAPSED })
  195. })
  196. it('viewport shrink triggers the concession chain via ResizeObserver', () => {
  197. const { frame } = mountFrame()
  198. frameWidth = 1250
  199. act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
  200. expect(tracks(frame)).toEqual([280, 330])
  201. frameWidth = 1920
  202. act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
  203. expect(tracks(frame)).toEqual([280, 360])
  204. })
  205. it('drag handles disappear for collapsed columns', () => {
  206. const { frame, instance } = mountFrame()
  207. expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(2)
  208. act(() => { instance.actions.closeDetails() })
  209. expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1)
  210. act(() => { instance.actions.toggleSidebar() })
  211. expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(0)
  212. })
  213. })
  214. describe('AppFrame — guard branches', () => {
  215. it('pointer moves without capture are ignored (no width write)', () => {
  216. const { frame, instance } = mountFrame()
  217. const handle = frame.querySelectorAll('[class*="handle"]')[0]!
  218. const before = instance.getSnapshot().sidebar
  219. // Move + up without a preceding pointerdown: hasPointerCapture is false.
  220. act(() => {
  221. handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 9, clientX: 500, bubbles: true }))
  222. vi.advanceTimersByTime(20)
  223. handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 9, clientX: 500, bubbles: true }))
  224. })
  225. expect(instance.getSnapshot().sidebar).toBe(before)
  226. })
  227. it('two moves inside one frame coalesce through the pending rAF', () => {
  228. const { frame, instance } = mountFrame()
  229. const handle = frame.querySelectorAll('[class*="handle"]')[0]!
  230. act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 280, bubbles: true })) })
  231. act(() => {
  232. // Two moves before the frame flushes: the second must ride the pending
  233. // rAF (frame.current ??= guard), and the flush sees the latest x.
  234. handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, clientX: 320, bubbles: true }))
  235. handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, clientX: 340, bubbles: true }))
  236. vi.advanceTimersByTime(20)
  237. })
  238. act(() => { handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, clientX: 340, bubbles: true })) })
  239. expect(instance.getSnapshot().sidebar).toBe(340)
  240. })
  241. it('pointerup with a pending rAF cancels it and commits the final position', () => {
  242. const { frame, instance } = mountFrame()
  243. const handle = frame.querySelectorAll('[class*="handle"]')[0]!
  244. act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 280, bubbles: true })) })
  245. act(() => {
  246. handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, clientX: 360, bubbles: true }))
  247. // No timer advance: the rAF is still pending when pointerup arrives.
  248. handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, clientX: 360, bubbles: true }))
  249. })
  250. expect(instance.getSnapshot().sidebar).toBe(360)
  251. })
  252. it('zero-width resize reports are ignored (display:none window)', () => {
  253. const { frame } = mountFrame()
  254. frameWidth = 0
  255. act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
  256. // Track template still reflects the last non-zero viewport.
  257. expect(tracks(frame)).toEqual([280, 360])
  258. })
  259. })
  260. describe('AppFrame — unmount with an in-flight resize frame', () => {
  261. it('cancels the pending rAF on unmount (no post-unmount setState)', () => {
  262. const { unmount } = mountFrame()
  263. frameWidth = 800
  264. act(() => { fireResize?.() }) // rAF scheduled, NOT flushed
  265. unmount()
  266. // Flushing after unmount must be a no-op (the frame was cancelled).
  267. expect(() => { vi.advanceTimersByTime(20) }).not.toThrow()
  268. })
  269. it('double resize inside one frame rides the pending rAF (??= guard)', () => {
  270. const { frame } = mountFrame()
  271. frameWidth = 1250
  272. act(() => { fireResize?.(); fireResize?.(); vi.advanceTimersByTime(20) })
  273. expect(tracks(frame)).toEqual([280, 330])
  274. })
  275. })