app-frame.spec.tsx 13 KB

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