message-image.client.spec.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. // @vitest-environment jsdom
  2. import { afterEach, describe, expect, it, vi } from 'vitest'
  3. import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
  4. import { AttachmentId } from '@deepseek-ai/dsh-attachment'
  5. import { EMPTY_CHAT_SNAPSHOT, type MessageImagesProps } from '@deepseek-ai/dsh-client-ui-chat/client'
  6. import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client'
  7. import { ImageGallery, MessageImage } from '../src/MessageImage.tsx'
  8. import type { MessageImageLabels } from '../src/MessageImage.tsx'
  9. import { MessageImages } from '../src/client/MessageImages.tsx'
  10. afterEach(cleanup)
  11. const labels: MessageImageLabels = {
  12. image: '图片',
  13. open: '查看原图',
  14. openNamed: label => `${label},点击查看原图`,
  15. loading: '图片加载中…',
  16. loadFailed: '图片加载失败,点击重试',
  17. lightbox: { dialog: '原图预览', close: '关闭原图预览' },
  18. }
  19. const attachment = {
  20. attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
  21. mediaType: 'image/png' as const,
  22. bytes: 68,
  23. width: 640,
  24. height: 320,
  25. name: 'history.png',
  26. }
  27. type AttentionSnapshot = Parameters<Parameters<MessageImagesProps['useSessionPendingInteraction']>[0]>[0]
  28. type TrajectorySnapshot = Parameters<Parameters<MessageImagesProps['useTrajectory']>[0]>[0]
  29. const noAttention: AttentionSnapshot = new Map()
  30. const emptyTrajectory: TrajectorySnapshot = {
  31. eventNodes: [],
  32. eventLocations: new Map(),
  33. requests: [],
  34. callSchemas: new Map(),
  35. partial: null,
  36. runningCalls: [],
  37. }
  38. const useSessionPendingInteraction: MessageImagesProps['useSessionPendingInteraction'] = selector => selector(noAttention)
  39. const useConversation: MessageImagesProps['useConversation'] = selector => selector(EMPTY_CONVERSATION_SNAPSHOT)
  40. const useChat: MessageImagesProps['useChat'] = selector => selector(EMPTY_CHAT_SNAPSHOT)
  41. const useTrajectory: MessageImagesProps['useTrajectory'] = selector => selector(emptyTrajectory)
  42. describe('MessageImage', () => {
  43. it('loads a session-authorized URL, bounds the thumbnail, and clicks into the original', async () => {
  44. const load = vi.fn().mockResolvedValue('blob:history')
  45. const view = render(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
  46. const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
  47. expect(frame.getAttribute('style')).toContain('width: 240px')
  48. expect(frame.getAttribute('style')).toContain('height: 120px')
  49. expect(frame.getAttribute('title')).toBe('查看原图')
  50. await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
  51. expect(load).toHaveBeenCalledWith(attachment)
  52. fireEvent.click(frame)
  53. expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
  54. fireEvent.click(view.getByRole('button', { name: '关闭原图预览' }))
  55. expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
  56. })
  57. it('ignores a click while the thumbnail is still loading', () => {
  58. const load = vi.fn(() => new Promise<string>(() => {}))
  59. const view = render(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
  60. const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
  61. expect(view.getByText('图片加载中…')).toBeTruthy()
  62. fireEvent.click(frame)
  63. expect(view.queryByRole('dialog')).toBeNull()
  64. })
  65. it('falls back to the image label for an unnamed attachment', async () => {
  66. const { name: _named, ...unnamed } = attachment
  67. const load = vi.fn().mockResolvedValue('blob:unnamed')
  68. const view = render(<MessageImage attachment={unnamed} load={load} variant="single" labels={labels} />)
  69. await waitFor(() => { expect(view.getByAltText('图片')).toBeTruthy() })
  70. expect(view.getByRole('button', { name: '图片,点击查看原图' })).toBeTruthy()
  71. })
  72. it('surfaces a retry control when durable bytes cannot be read, including a failed retry', async () => {
  73. const load = vi.fn()
  74. .mockRejectedValueOnce(new Error('offline'))
  75. .mockRejectedValueOnce(new Error('still offline'))
  76. .mockResolvedValueOnce('blob:retry')
  77. const view = render(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
  78. const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
  79. fireEvent.click(retry)
  80. const retryAgain = await view.findByRole('button', { name: '图片加载失败,点击重试' })
  81. fireEvent.click(retryAgain)
  82. await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
  83. expect(load).toHaveBeenCalledTimes(3)
  84. })
  85. it('clamps extreme aspect ratios and anchors the crop toward the informative edge', async () => {
  86. const load = vi.fn().mockResolvedValue('blob:ratio')
  87. const tall = render(
  88. <MessageImage attachment={{ ...attachment, width: 100, height: 2000 }} load={load} variant="single" labels={labels} />,
  89. )
  90. const tallFrame = tall.getByRole('button', { name: 'history.png,点击查看原图' })
  91. expect(tallFrame.getAttribute('style')).toContain('width: 60px')
  92. expect(tallFrame.getAttribute('style')).toContain('height: 240px')
  93. await waitFor(() => { expect(tall.getByAltText('history.png')).toBeTruthy() })
  94. expect(tall.getByAltText('history.png').style.objectPosition).toBe('center top')
  95. tall.unmount()
  96. const wide = render(
  97. <MessageImage attachment={{ ...attachment, width: 4000, height: 100 }} load={load} variant="single" labels={labels} />,
  98. )
  99. const wideFrame = wide.getByRole('button', { name: 'history.png,点击查看原图' })
  100. expect(wideFrame.getAttribute('style')).toContain('width: 240px')
  101. expect(wideFrame.getAttribute('style')).toContain('height: 60px')
  102. await waitFor(() => { expect(wide.getByAltText('history.png')).toBeTruthy() })
  103. expect(wide.getByAltText('history.png').style.objectPosition).toBe('left center')
  104. wide.unmount()
  105. const small = render(
  106. <MessageImage attachment={{ ...attachment, width: 100, height: 100 }} load={load} variant="single" labels={labels} />,
  107. )
  108. const smallFrame = small.getByRole('button', { name: 'history.png,点击查看原图' })
  109. expect(smallFrame.getAttribute('style')).toContain('width: 100px')
  110. expect(smallFrame.getAttribute('style')).toContain('height: 100px')
  111. })
  112. it('renders a tile at the fixed square without inline sizing', () => {
  113. const load = vi.fn(() => new Promise<string>(() => {}))
  114. const view = render(<MessageImage attachment={attachment} load={load} variant="tile" labels={labels} />)
  115. const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
  116. expect(frame.getAttribute('data-variant')).toBe('tile')
  117. expect(frame.getAttribute('style')).toBeNull()
  118. })
  119. it('keeps the tile variant on the failed-load retry control', async () => {
  120. const load = vi.fn().mockRejectedValue(new Error('offline'))
  121. const view = render(<MessageImage attachment={attachment} load={load} variant="tile" labels={labels} />)
  122. const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
  123. expect(retry.getAttribute('data-variant')).toBe('tile')
  124. })
  125. it('ignores a load settling after unmount', async () => {
  126. let resolve: ((url: string) => void) | undefined
  127. const load = vi.fn(() => new Promise<string>((r) => { resolve = r }))
  128. const view = render(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
  129. view.unmount()
  130. resolve?.('blob:late')
  131. await Promise.resolve()
  132. let reject: ((error: Error) => void) | undefined
  133. const failing = vi.fn(() => new Promise<string>((_r, rej) => { reject = rej }))
  134. const second = render(<MessageImage attachment={attachment} load={failing} variant="single" labels={labels} />)
  135. second.unmount()
  136. reject?.(new Error('late failure'))
  137. await Promise.resolve()
  138. })
  139. })
  140. describe('ImageGallery', () => {
  141. it('renders nothing without images and an aligned wrapping group with them', async () => {
  142. const load = vi.fn().mockResolvedValue('blob:gallery')
  143. const empty = render(<ImageGallery images={[]} load={load} align="start" labels={labels} />)
  144. expect(empty.container.firstChild).toBeNull()
  145. const view = render(
  146. <ImageGallery images={[{ attachment }, { attachment }]} load={load} align="end" labels={labels} />,
  147. )
  148. expect(view.container.querySelector('[data-align="end"]')).not.toBeNull()
  149. await waitFor(() => { expect(view.getAllByAltText('history.png')).toHaveLength(2) })
  150. })
  151. it('renders a lone image large and several images as square tiles', () => {
  152. const load = vi.fn(() => new Promise<string>(() => {}))
  153. const lone = render(<ImageGallery images={[{ attachment }]} load={load} align="start" labels={labels} />)
  154. expect(lone.container.querySelectorAll('[data-variant="single"]')).toHaveLength(1)
  155. lone.unmount()
  156. const several = render(
  157. <ImageGallery images={[{ attachment }, { attachment }, { attachment }]} load={load} align="end" labels={labels} />,
  158. )
  159. expect(several.container.querySelectorAll('[data-variant="tile"]')).toHaveLength(3)
  160. })
  161. it('renders the conversation slot entry with translated labels', async () => {
  162. const t = ((key: string, params?: Readonly<Record<string, unknown>>) => {
  163. const translated: Record<string, string> = {
  164. 'image.label': '图片',
  165. 'image.openOriginal': '查看原图',
  166. 'image.loading': '图片加载中…',
  167. 'image.loadFailed': '图片加载失败,点击重试',
  168. 'image.preview': '原图预览',
  169. 'image.closePreview': '关闭原图预览',
  170. }
  171. if (key === 'image.openOriginalLabel') {
  172. const label = params?.label
  173. return `${typeof label === 'string' ? label : ''},点击查看原图`
  174. }
  175. return translated[key] ?? key
  176. }) as MessageImagesProps['t']
  177. const loadImage = vi.fn().mockResolvedValue('blob:slot-image')
  178. const useSession: MessageImagesProps['useSession'] = () => {
  179. throw new Error('MessageImages does not read the session snapshot')
  180. }
  181. const useInput: MessageImagesProps['useInput'] = () => {
  182. throw new Error('MessageImages does not read the input snapshot')
  183. }
  184. const useSessions: MessageImagesProps['useSessions'] = () => {
  185. throw new Error('MessageImages does not read the session list snapshot')
  186. }
  187. const useWorkspaces: MessageImagesProps['useWorkspaces'] = () => {
  188. throw new Error('MessageImages does not read the workspace list snapshot')
  189. }
  190. const props: MessageImagesProps = {
  191. sessionId: 'message-images-test' as MessageImagesProps['sessionId'],
  192. useSession,
  193. useSessions,
  194. useSessionPendingInteraction,
  195. useWorkspaces,
  196. useProjection: () => undefined,
  197. useConversation,
  198. useChat,
  199. useTrajectory,
  200. useInput,
  201. inputActions: {
  202. setDraft: vi.fn(),
  203. addImages: vi.fn(() => true),
  204. removeImage: vi.fn(),
  205. pruneImages: vi.fn(),
  206. submit: vi.fn(),
  207. },
  208. images: [{ attachment }],
  209. loadImage,
  210. align: 'end',
  211. t,
  212. }
  213. const view = render(<MessageImages {...props} />)
  214. await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
  215. expect(view.getByRole('button', { name: 'history.png,点击查看原图' })).toBeTruthy()
  216. expect(view.container.querySelector('[data-align="end"]')).not.toBeNull()
  217. })
  218. })