Files
deepseek-harness/packages/client/ui-attachment/tests/message-image.client.spec.tsx
creatixchu 2c9036b102 feat(web): whole-page image drop, projected intake limits, and thumbnail tiling
Second alignment step for #2248: document-level drag intake behind the new
DropOverlay atom, lightbox close icon and shared dialog mask, DeepSeek Chat
thumbnail rules (single 240px long edge with ratio clamp, 64px tiles, merged
consecutive assistant images), image limits raised to 20/10MiB/100MiB and
published to clients as the imageLimits projection, whole-batch intake
pre-check with product-copy banners, and attachment-error reasons mapped to
localized copy.
2026-08-12 12:31:52 +08:00

151 lines
7.5 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} variant="single" 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} variant="single" 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} variant="single" 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} variant="single" 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('clamps extreme aspect ratios and anchors the crop toward the informative edge', async () => {
const load = vi.fn().mockResolvedValue('blob:ratio')
const tall = render(
<MessageImage attachment={{ ...attachment, width: 100, height: 2000 }} load={load} variant="single" labels={labels} />,
)
const tallFrame = tall.getByRole('button', { name: 'history.png点击查看原图' })
expect(tallFrame.getAttribute('style')).toContain('width: 60px')
expect(tallFrame.getAttribute('style')).toContain('height: 240px')
await waitFor(() => { expect(tall.getByAltText('history.png')).toBeTruthy() })
expect(tall.getByAltText('history.png').style.objectPosition).toBe('center top')
tall.unmount()
const wide = render(
<MessageImage attachment={{ ...attachment, width: 4000, height: 100 }} load={load} variant="single" labels={labels} />,
)
const wideFrame = wide.getByRole('button', { name: 'history.png点击查看原图' })
expect(wideFrame.getAttribute('style')).toContain('width: 240px')
expect(wideFrame.getAttribute('style')).toContain('height: 60px')
await waitFor(() => { expect(wide.getByAltText('history.png')).toBeTruthy() })
expect(wide.getByAltText('history.png').style.objectPosition).toBe('left center')
wide.unmount()
const small = render(
<MessageImage attachment={{ ...attachment, width: 100, height: 100 }} load={load} variant="single" labels={labels} />,
)
const smallFrame = small.getByRole('button', { name: 'history.png点击查看原图' })
expect(smallFrame.getAttribute('style')).toContain('width: 100px')
expect(smallFrame.getAttribute('style')).toContain('height: 100px')
})
it('renders a tile at the fixed square without inline sizing', () => {
const load = vi.fn(() => new Promise<string>(() => {}))
const view = render(<MessageImage attachment={attachment} load={load} variant="tile" labels={labels} />)
const frame = view.getByRole('button', { name: 'history.png点击查看原图' })
expect(frame.getAttribute('data-variant')).toBe('tile')
expect(frame.getAttribute('style')).toBeNull()
})
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} variant="single" 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} variant="single" 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) })
})
it('renders a lone image large and several images as square tiles', () => {
const load = vi.fn(() => new Promise<string>(() => {}))
const lone = render(<ImageGallery images={[{ attachment }]} load={load} align="start" labels={labels} />)
expect(lone.container.querySelectorAll('[data-variant="single"]')).toHaveLength(1)
lone.unmount()
const several = render(
<ImageGallery images={[{ attachment }, { attachment }, { attachment }]} load={load} align="end" labels={labels} />,
)
expect(several.container.querySelectorAll('[data-variant="tile"]')).toHaveLength(3)
})
})