Files
deepseek-harness/packages/client/ui-attachment/tests/message-image.spec.tsx
creatixchu e611e825b1 feat(web): align attachment display with DeepSeek Chat via ui-attachment atoms
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.
2026-08-11 17:01:29 +08:00

104 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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) })
})
})