Single-click original preview in the composer rail and chat history; remove control inside the thumbnail, revealed on hover/focus (always on touch); hidden-scrollbar rail overflow paged by edge arrows with wheel panning and end-reveal on add; image-intake rejections and prompt failures announce as a transient top-center toast instead of inline strips. The attachment atoms move to a new zero-cordis package @deepseek-ai/dsh-client-ui-attachment (rail, message gallery, lightbox), seeded as a platform module; the toast is a ui-primitives atom. Strings arrive as label props bridged from the conversation dictionary.
104 lines
4.7 KiB
TypeScript
104 lines
4.7 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
|
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
|
import { ImageGallery, MessageImage } from '../src/MessageImage.tsx'
|
|
import type { MessageImageLabels } from '../src/MessageImage.tsx'
|
|
|
|
afterEach(cleanup)
|
|
|
|
const labels: MessageImageLabels = {
|
|
image: '图片',
|
|
open: '查看原图',
|
|
openNamed: label => `${label},点击查看原图`,
|
|
loading: '图片加载中…',
|
|
loadFailed: '图片加载失败,点击重试',
|
|
lightbox: { dialog: '原图预览', close: '关闭原图预览' },
|
|
}
|
|
|
|
const attachment = {
|
|
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
|
|
mediaType: 'image/png' as const,
|
|
bytes: 68,
|
|
width: 640,
|
|
height: 320,
|
|
name: 'history.png',
|
|
}
|
|
|
|
describe('MessageImage', () => {
|
|
it('loads a session-authorized URL, bounds the thumbnail, and clicks into the original', async () => {
|
|
const load = vi.fn().mockResolvedValue('blob:history')
|
|
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
|
|
const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
|
|
expect(frame.getAttribute('style')).toContain('width: 240px')
|
|
expect(frame.getAttribute('style')).toContain('height: 120px')
|
|
expect(frame.getAttribute('title')).toBe('查看原图')
|
|
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
|
|
expect(load).toHaveBeenCalledWith(attachment)
|
|
fireEvent.click(frame)
|
|
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
|
|
fireEvent.click(view.getByRole('button', { name: '关闭原图预览' }))
|
|
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
|
|
})
|
|
|
|
it('ignores a click while the thumbnail is still loading', () => {
|
|
const load = vi.fn(() => new Promise<string>(() => {}))
|
|
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
|
|
const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
|
|
expect(view.getByText('图片加载中…')).toBeTruthy()
|
|
fireEvent.click(frame)
|
|
expect(view.queryByRole('dialog')).toBeNull()
|
|
})
|
|
|
|
it('falls back to the image label for an unnamed attachment', async () => {
|
|
const { name: _named, ...unnamed } = attachment
|
|
const load = vi.fn().mockResolvedValue('blob:unnamed')
|
|
const view = render(<MessageImage attachment={unnamed} load={load} labels={labels} />)
|
|
await waitFor(() => { expect(view.getByAltText('图片')).toBeTruthy() })
|
|
expect(view.getByRole('button', { name: '图片,点击查看原图' })).toBeTruthy()
|
|
})
|
|
|
|
it('surfaces a retry control when durable bytes cannot be read, including a failed retry', async () => {
|
|
const load = vi.fn()
|
|
.mockRejectedValueOnce(new Error('offline'))
|
|
.mockRejectedValueOnce(new Error('still offline'))
|
|
.mockResolvedValueOnce('blob:retry')
|
|
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
|
|
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
|
|
fireEvent.click(retry)
|
|
const retryAgain = await view.findByRole('button', { name: '图片加载失败,点击重试' })
|
|
fireEvent.click(retryAgain)
|
|
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
|
|
expect(load).toHaveBeenCalledTimes(3)
|
|
})
|
|
|
|
it('ignores a load settling after unmount', async () => {
|
|
let resolve: ((url: string) => void) | undefined
|
|
const load = vi.fn(() => new Promise<string>((r) => { resolve = r }))
|
|
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
|
|
view.unmount()
|
|
resolve?.('blob:late')
|
|
await Promise.resolve()
|
|
let reject: ((error: Error) => void) | undefined
|
|
const failing = vi.fn(() => new Promise<string>((_r, rej) => { reject = rej }))
|
|
const second = render(<MessageImage attachment={attachment} load={failing} labels={labels} />)
|
|
second.unmount()
|
|
reject?.(new Error('late failure'))
|
|
await Promise.resolve()
|
|
})
|
|
})
|
|
|
|
describe('ImageGallery', () => {
|
|
it('renders nothing without images and an aligned wrapping group with them', async () => {
|
|
const load = vi.fn().mockResolvedValue('blob:gallery')
|
|
const empty = render(<ImageGallery images={[]} load={load} align="start" labels={labels} />)
|
|
expect(empty.container.firstChild).toBeNull()
|
|
const view = render(
|
|
<ImageGallery images={[{ attachment }, { attachment }]} load={load} align="end" labels={labels} />,
|
|
)
|
|
expect(view.container.querySelector('[data-align="end"]')).not.toBeNull()
|
|
await waitFor(() => { expect(view.getAllByAltText('history.png')).toHaveLength(2) })
|
|
})
|
|
})
|