message-image.client.spec.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. // @vitest-environment jsdom
  2. import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
  3. import { afterEach, describe, expect, it, vi } from 'vitest'
  4. import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
  5. import { AttachmentId } from '@deepseek-ai/dsh-attachment'
  6. import { EMPTY_CHAT_SNAPSHOT, type MessageImagesProps } from '@deepseek-ai/dsh-client-ui-chat/client'
  7. import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client'
  8. import { ImageGallery, MessageImage } from '../src/MessageImage.tsx'
  9. import type { MessageImageLabels } from '../src/MessageImage.tsx'
  10. import { MessageImages } from '../src/client/MessageImages.tsx'
  11. // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
  12. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
  13. const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
  14. afterEach(cleanup)
  15. const labels: MessageImageLabels = {
  16. image: '图片',
  17. open: '查看原图',
  18. openNamed: label => `${label},点击查看原图`,
  19. loading: '图片加载中…',
  20. loadFailed: '图片加载失败,点击重试',
  21. lightbox: { dialog: '原图预览', close: '关闭原图预览' },
  22. }
  23. const attachment = {
  24. attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
  25. mediaType: 'image/png' as const,
  26. bytes: 68,
  27. width: 640,
  28. height: 320,
  29. name: 'history.png',
  30. }
  31. type AttentionSnapshot = Parameters<Parameters<MessageImagesProps['useSessionStatus']>[0]>[0]
  32. type TrajectorySnapshot = Parameters<Parameters<MessageImagesProps['useTrajectory']>[0]>[0]
  33. const noAttention: AttentionSnapshot = new Map()
  34. const emptyTrajectory: TrajectorySnapshot = {
  35. eventNodes: [],
  36. eventLocations: new Map(),
  37. requests: [],
  38. callSchemas: new Map(),
  39. partial: null,
  40. runningCalls: [],
  41. }
  42. const useSessionStatus: MessageImagesProps['useSessionStatus'] = selector => selector(noAttention)
  43. const useConversation: MessageImagesProps['useConversation'] = selector => selector(EMPTY_CONVERSATION_SNAPSHOT)
  44. const useChat: MessageImagesProps['useChat'] = selector => selector(EMPTY_CHAT_SNAPSHOT)
  45. const useTrajectory: MessageImagesProps['useTrajectory'] = selector => selector(emptyTrajectory)
  46. describe('MessageImage', () => {
  47. it('renders a cached URL on the first frame while refreshing it', () => {
  48. const load = Object.assign(vi.fn(() => new Promise<string>(() => {})), {
  49. peek: vi.fn(() => 'blob:seeded'),
  50. })
  51. const view = render(<MessageImage image={{ attachment }} load={load} variant="single" labels={labels} />)
  52. expect(view.queryByText('图片加载中…')).toBeNull()
  53. expect((view.getByAltText('history.png') as HTMLImageElement).src).toContain('blob:seeded')
  54. expect(load).toHaveBeenCalledWith(attachment)
  55. })
  56. it('loads a session-authorized URL, bounds the thumbnail, and clicks into the original', async () => {
  57. const load = vi.fn().mockResolvedValue('blob:history')
  58. const view = render(<MessageImage image={{ attachment }} load={load} variant="single" labels={labels} />)
  59. const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
  60. expect(frame.getAttribute('style')).toContain('width: 240px')
  61. expect(frame.getAttribute('style')).toContain('height: 120px')
  62. expect(frame.getAttribute('title')).toBe('查看原图')
  63. await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
  64. expect(load).toHaveBeenCalledWith(attachment)
  65. fireEvent.click(frame)
  66. expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
  67. fireEvent.click(view.getByRole('button', { name: '关闭原图预览' }))
  68. expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
  69. })
  70. it('ignores a click while the thumbnail is still loading', () => {
  71. const load = vi.fn(() => new Promise<string>(() => {}))
  72. const view = render(<MessageImage image={{ attachment }} load={load} variant="single" labels={labels} />)
  73. const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
  74. expect(view.getByText('图片加载中…')).toBeTruthy()
  75. fireEvent.click(frame)
  76. expect(view.queryByRole('dialog')).toBeNull()
  77. })
  78. it('falls back to the image label for an unnamed attachment', async () => {
  79. const { name: _named, ...unnamed } = attachment
  80. const load = vi.fn().mockResolvedValue('blob:unnamed')
  81. const view = render(<MessageImage image={{ attachment: unnamed }} load={load} variant="single" labels={labels} />)
  82. await waitFor(() => { expect(view.getByAltText('图片')).toBeTruthy() })
  83. expect(view.getByRole('button', { name: '图片,点击查看原图' })).toBeTruthy()
  84. })
  85. it('surfaces a retry control when durable bytes cannot be read, including a failed retry', async () => {
  86. const load = vi.fn()
  87. .mockRejectedValueOnce(new Error('offline'))
  88. .mockRejectedValueOnce(new Error('still offline'))
  89. .mockResolvedValueOnce('blob:retry')
  90. const view = render(<MessageImage image={{ attachment }} load={load} variant="single" labels={labels} />)
  91. const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
  92. fireEvent.click(retry)
  93. const retryAgain = await view.findByRole('button', { name: '图片加载失败,点击重试' })
  94. fireEvent.click(retryAgain)
  95. await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
  96. expect(load).toHaveBeenCalledTimes(3)
  97. })
  98. it('clamps extreme aspect ratios and anchors the crop toward the informative edge', async () => {
  99. const load = vi.fn().mockResolvedValue('blob:ratio')
  100. const tall = render(
  101. <MessageImage image={{ attachment: { ...attachment, width: 100, height: 2000 } }} load={load} variant="single" labels={labels} />,
  102. )
  103. const tallFrame = tall.getByRole('button', { name: 'history.png,点击查看原图' })
  104. expect(tallFrame.getAttribute('style')).toContain('width: 60px')
  105. expect(tallFrame.getAttribute('style')).toContain('height: 240px')
  106. await waitFor(() => { expect(tall.getByAltText('history.png')).toBeTruthy() })
  107. expect(tall.getByAltText('history.png').style.objectPosition).toBe('center top')
  108. tall.unmount()
  109. const wide = render(
  110. <MessageImage image={{ attachment: { ...attachment, width: 4000, height: 100 } }} load={load} variant="single" labels={labels} />,
  111. )
  112. const wideFrame = wide.getByRole('button', { name: 'history.png,点击查看原图' })
  113. expect(wideFrame.getAttribute('style')).toContain('width: 240px')
  114. expect(wideFrame.getAttribute('style')).toContain('height: 60px')
  115. await waitFor(() => { expect(wide.getByAltText('history.png')).toBeTruthy() })
  116. expect(wide.getByAltText('history.png').style.objectPosition).toBe('left center')
  117. wide.unmount()
  118. const small = render(
  119. <MessageImage image={{ attachment: { ...attachment, width: 100, height: 100 } }} load={load} variant="single" labels={labels} />,
  120. )
  121. const smallFrame = small.getByRole('button', { name: 'history.png,点击查看原图' })
  122. expect(smallFrame.getAttribute('style')).toContain('width: 100px')
  123. expect(smallFrame.getAttribute('style')).toContain('height: 100px')
  124. })
  125. it('renders a tile at the fixed square without inline sizing', () => {
  126. const load = vi.fn(() => new Promise<string>(() => {}))
  127. const view = render(<MessageImage image={{ attachment }} load={load} variant="tile" labels={labels} />)
  128. const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
  129. expect(frame.getAttribute('data-variant')).toBe('tile')
  130. expect(frame.getAttribute('style')).toBeNull()
  131. })
  132. it.each(['tile', 'thumbnail'] as const)('keeps the %s variant on the failed-load retry control', async (variant) => {
  133. const load = vi.fn().mockRejectedValue(new Error('offline'))
  134. const view = render(<MessageImage image={{ attachment }} load={load} variant={variant} labels={labels} />)
  135. const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
  136. expect(retry.getAttribute('data-variant')).toBe(variant)
  137. })
  138. it.each([
  139. ['Chinese', labels],
  140. ['English', { ...labels, loading: 'Loading image…', loadFailed: 'Image failed to load; click to retry' }],
  141. ] as const)('labels thumbnail status icons and retries a failed read in %s', async (_locale, statusLabels) => {
  142. const pending = Promise.withResolvers<string>()
  143. const load = vi.fn().mockReturnValueOnce(pending.promise).mockResolvedValue('blob:retry')
  144. const view = render(<MessageImage image={{ attachment }} load={load} variant="thumbnail" labels={statusLabels} />)
  145. const loading = view.getByRole('button', { name: statusLabels.loading })
  146. expect(loading.getAttribute('title')).toBe(statusLabels.loading)
  147. expect(loading.getAttribute('aria-busy')).toBe('true')
  148. expect(loading.textContent).toBe('')
  149. expect(loading.querySelector('svg')).not.toBeNull()
  150. await act(async () => { pending.reject(new Error('offline')) })
  151. const retry = view.getByRole('button', { name: statusLabels.loadFailed })
  152. expect(retry.getAttribute('title')).toBe(statusLabels.loadFailed)
  153. expect(retry.textContent).toBe('')
  154. expect(retry.querySelector('svg')).not.toBeNull()
  155. fireEvent.click(retry)
  156. await view.findByAltText(attachment.name)
  157. const opener = view.getByRole('button', { name: statusLabels.openNamed(attachment.name) })
  158. expect(opener.getAttribute('aria-busy')).toBeNull()
  159. expect(opener.getAttribute('title')).toBe(statusLabels.open)
  160. expect(load).toHaveBeenCalledTimes(2)
  161. expect(load).toHaveBeenLastCalledWith(attachment)
  162. })
  163. it('uses the owner display name for an unnamed thumbnail and its original-image viewer', async () => {
  164. const { name: _name, ...unnamed } = attachment
  165. const load = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue('blob:thumbnail')
  166. const view = render(<ImageGallery images={[{ attachment: unnamed, label: 'Image 2' }]} load={load} align="start" thumbnail labels={labels} />)
  167. fireEvent.click(await view.findByRole('button', { name: labels.loadFailed }))
  168. const image = await view.findByAltText('Image 2')
  169. expect(image.closest('button')?.getAttribute('data-variant')).toBe('thumbnail')
  170. const opener = view.getByRole('button', { name: 'Image 2,点击查看原图' })
  171. opener.focus()
  172. fireEvent.click(opener)
  173. expect(view.getByRole('dialog')).toBeTruthy()
  174. fireEvent.keyDown(window, { key: 'Escape' })
  175. expect(view.queryByRole('dialog')).toBeNull()
  176. expect(document.activeElement).toBe(opener)
  177. expect(load).toHaveBeenLastCalledWith(unnamed)
  178. expect(unnamed).not.toHaveProperty('name')
  179. })
  180. it('ignores a load settling after unmount', async () => {
  181. let resolve: ((url: string) => void) | undefined
  182. const load = vi.fn(() => new Promise<string>((r) => { resolve = r }))
  183. const view = render(<MessageImage image={{ attachment }} load={load} variant="single" labels={labels} />)
  184. view.unmount()
  185. resolve?.('blob:late')
  186. await Promise.resolve()
  187. let reject: ((error: Error) => void) | undefined
  188. const failing = vi.fn(() => new Promise<string>((_r, rej) => { reject = rej }))
  189. const second = render(<MessageImage image={{ attachment }} load={failing} variant="single" labels={labels} />)
  190. second.unmount()
  191. reject?.(new Error('late failure'))
  192. await Promise.resolve()
  193. })
  194. })
  195. describe('MessageImage preview arm', () => {
  196. it('displays a local preview immediately, without the loader, sized by its probed dimensions', () => {
  197. const load = vi.fn()
  198. const view = render(
  199. <MessageImage
  200. image={{ preview: { url: 'blob:echo', name: 'echo.png', width: 640, height: 320 } }}
  201. load={load}
  202. variant="single"
  203. labels={labels}
  204. />,
  205. )
  206. expect(load).not.toHaveBeenCalled()
  207. const img = view.getByAltText('echo.png') as HTMLImageElement
  208. expect(img.src).toContain('blob:echo')
  209. const frame = img.closest('button') as HTMLButtonElement
  210. expect(frame.style.width).toBe('240px')
  211. expect(frame.style.height).toBe('120px')
  212. })
  213. it('sizes an unprobed lone preview as a square crop and falls back to the image label', () => {
  214. const load = vi.fn()
  215. const view = render(
  216. <MessageImage image={{ preview: { url: 'blob:unprobed' } }} load={load} variant="single" labels={labels} />,
  217. )
  218. const img = view.getByAltText('图片') as HTMLImageElement
  219. const frame = img.closest('button') as HTMLButtonElement
  220. expect(frame.style.width).toBe('240px')
  221. expect(frame.style.height).toBe('240px')
  222. })
  223. it('opens the lightbox from a preview thumbnail', () => {
  224. const view = render(
  225. <MessageImage image={{ preview: { url: 'blob:box' } }} load={vi.fn(async () => '')} variant="tile" labels={labels} />,
  226. )
  227. fireEvent.click(view.getByRole('button', { name: '图片,点击查看原图' }))
  228. expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
  229. })
  230. })
  231. describe('ImageGallery', () => {
  232. it('renders nothing without images and an aligned wrapping group with them', async () => {
  233. const load = vi.fn().mockResolvedValue('blob:gallery')
  234. const empty = render(<ImageGallery images={[]} load={load} align="start" labels={labels} />)
  235. expect(empty.container.firstChild).toBeNull()
  236. const view = render(
  237. <ImageGallery
  238. images={[{ attachment }, { preview: { url: 'blob:echo', name: 'echo.png' } }, { attachment }]}
  239. load={load}
  240. align="end"
  241. labels={labels}
  242. />,
  243. )
  244. expect(view.container.querySelector('[data-align="end"]')).not.toBeNull()
  245. await waitFor(() => { expect(view.getAllByAltText('history.png')).toHaveLength(2) })
  246. expect(view.getByAltText('echo.png')).toBeTruthy()
  247. })
  248. it('renders a lone image large and compact or grouped images as square tiles', () => {
  249. const load = vi.fn(() => new Promise<string>(() => {}))
  250. const lone = render(<ImageGallery images={[{ attachment }]} load={load} align="start" labels={labels} />)
  251. expect(lone.container.querySelectorAll('[data-variant="single"]')).toHaveLength(1)
  252. lone.unmount()
  253. const compact = render(
  254. <ImageGallery images={[{ attachment }]} load={load} align="end" compact labels={labels} />,
  255. )
  256. expect(compact.container.querySelectorAll('[data-variant="tile"]')).toHaveLength(1)
  257. compact.unmount()
  258. const several = render(
  259. <ImageGallery images={[{ attachment }, { attachment }, { attachment }]} load={load} align="end" labels={labels} />,
  260. )
  261. expect(several.container.querySelectorAll('[data-variant="tile"]')).toHaveLength(3)
  262. })
  263. it('renders the conversation slot entry with translated labels', async () => {
  264. const t = ((key: string, params?: Readonly<Record<string, unknown>>) => {
  265. const translated: Record<string, string> = {
  266. 'image.label': '图片',
  267. 'image.openOriginal': '查看原图',
  268. 'image.loading': '图片加载中…',
  269. 'image.loadFailed': '图片加载失败,点击重试',
  270. 'image.preview': '原图预览',
  271. 'image.closePreview': '关闭原图预览',
  272. }
  273. if (key === 'image.openOriginalLabel') {
  274. const label = params?.label
  275. return `${typeof label === 'string' ? label : ''},点击查看原图`
  276. }
  277. return translated[key] ?? key
  278. }) as MessageImagesProps['t']
  279. const loadImage = vi.fn().mockResolvedValue('blob:slot-image')
  280. const useSession: MessageImagesProps['useSession'] = () => {
  281. throw new Error('MessageImages does not read the session snapshot')
  282. }
  283. const useInput: MessageImagesProps['useInput'] = () => {
  284. throw new Error('MessageImages does not read the input snapshot')
  285. }
  286. const useSessions: MessageImagesProps['useSessions'] = () => {
  287. throw new Error('MessageImages does not read the session list snapshot')
  288. }
  289. const useWorkspaces: MessageImagesProps['useWorkspaces'] = () => {
  290. throw new Error('MessageImages does not read the workspace list snapshot')
  291. }
  292. const props: MessageImagesProps = {
  293. sessionId: 'message-images-test' as MessageImagesProps['sessionId'],
  294. useSession,
  295. useSessions,
  296. usePanelInfo, useSessionRetainInfo: () => undefined, useResource,
  297. useSessionStatus,
  298. useWorkspaces,
  299. useProjection: () => undefined,
  300. useConversation,
  301. useChat,
  302. useTrajectory,
  303. useInput,
  304. inputActions: {
  305. setDraft: vi.fn(),
  306. addAttachments: vi.fn(() => true),
  307. removeAttachment: vi.fn(),
  308. pruneAttachments: vi.fn(),
  309. submit: vi.fn(),
  310. },
  311. images: [{ attachment }],
  312. loadImage,
  313. align: 'end',
  314. t,
  315. }
  316. const view = render(<MessageImages {...props} />)
  317. await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
  318. expect(view.getByRole('button', { name: 'history.png,点击查看原图' })).toBeTruthy()
  319. expect(view.container.querySelector('[data-align="end"]')).not.toBeNull()
  320. })
  321. })