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.
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
// @vitest-environment jsdom
|
||||
// AttachmentRail behavior in the jsdom lane: item rendering and callbacks,
|
||||
// arrow paging over stubbed scroll geometry (jsdom lays nothing out), the
|
||||
// vertical-wheel pan, and the new-item end reveal.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { AttachmentRail } from '../src/AttachmentRail.tsx'
|
||||
import type { AttachmentRailItem, AttachmentRailLabels } from '../src/AttachmentRail.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const labels: AttachmentRailLabels = {
|
||||
group: '待发送图片',
|
||||
open: '查看原图',
|
||||
scrollLeft: '向左滚动图片',
|
||||
scrollRight: '向右滚动图片',
|
||||
}
|
||||
|
||||
function item(id: string): AttachmentRailItem {
|
||||
return { id, previewUrl: `blob:${id}`, alt: `${id}.png`, removeLabel: `移除图片 ${id}.png` }
|
||||
}
|
||||
|
||||
/** Stub the rail's scroll geometry (jsdom reports 0 for every metric). */
|
||||
function stubGeometry(rail: HTMLElement, { scrollWidth, clientWidth }: { scrollWidth: number; clientWidth: number }) {
|
||||
Object.defineProperty(rail, 'scrollWidth', { value: scrollWidth, configurable: true })
|
||||
Object.defineProperty(rail, 'clientWidth', { value: clientWidth, configurable: true })
|
||||
let scrollLeft = 0
|
||||
Object.defineProperty(rail, 'scrollLeft', {
|
||||
configurable: true,
|
||||
get: () => scrollLeft,
|
||||
set: (value: number) => { scrollLeft = value },
|
||||
})
|
||||
const scrollBy = vi.fn((options: { left: number }) => {
|
||||
scrollLeft = Math.max(0, Math.min(scrollWidth - clientWidth, scrollLeft + options.left))
|
||||
})
|
||||
rail.scrollBy = scrollBy as unknown as typeof rail.scrollBy
|
||||
return { scrollBy, setScrollLeft: (value: number) => { scrollLeft = value } }
|
||||
}
|
||||
|
||||
describe('AttachmentRail', () => {
|
||||
it('renders thumbnails in order and routes open and remove clicks', () => {
|
||||
const onOpen = vi.fn()
|
||||
const onRemove = vi.fn()
|
||||
const items = [item('a'), item('b')]
|
||||
const view = render(<AttachmentRail items={items} labels={labels} onOpen={onOpen} onRemove={onRemove} />)
|
||||
const rail = view.getByRole('group', { name: '待发送图片' })
|
||||
expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toEqual(['a.png', 'b.png'])
|
||||
fireEvent.click(view.getAllByTitle('查看原图')[0]!)
|
||||
expect(onOpen).toHaveBeenCalledWith(items[0])
|
||||
fireEvent.click(view.getByRole('button', { name: '移除图片 b.png' }))
|
||||
expect(onRemove).toHaveBeenCalledWith(items[1])
|
||||
})
|
||||
|
||||
it('shows edge arrows from scroll geometry and pages a viewport at a time', () => {
|
||||
const view = render(
|
||||
<AttachmentRail items={[item('a'), item('b'), item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
|
||||
)
|
||||
const rail = view.getByRole('group', { name: '待发送图片' })
|
||||
const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
|
||||
// No arrows until geometry is observed (mount saw jsdom's zero metrics).
|
||||
expect(view.queryByLabelText('向右滚动图片')).toBeNull()
|
||||
fireEvent.scroll(rail)
|
||||
// Same-edges scroll takes the memoized-state path.
|
||||
fireEvent.scroll(rail)
|
||||
expect(view.queryByLabelText('向左滚动图片')).toBeNull()
|
||||
const right = view.getByLabelText('向右滚动图片')
|
||||
// clientWidth 200 - 64 < the 200 floor: pages by the floor.
|
||||
fireEvent.click(right)
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: 200, behavior: 'smooth' })
|
||||
fireEvent.scroll(rail)
|
||||
// Scrolled to the far edge: only the left arrow remains.
|
||||
expect(view.queryByLabelText('向右滚动图片')).toBeNull()
|
||||
fireEvent.click(view.getByLabelText('向左滚动图片'))
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: -200, behavior: 'smooth' })
|
||||
fireEvent.scroll(rail)
|
||||
expect(view.queryByLabelText('向左滚动图片')).toBeNull()
|
||||
expect(view.getByLabelText('向右滚动图片')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows both arrows mid-scroll and recomputes on window resize', () => {
|
||||
const view = render(
|
||||
<AttachmentRail items={[item('a'), item('b'), item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
|
||||
)
|
||||
const rail = view.getByRole('group', { name: '待发送图片' })
|
||||
const { setScrollLeft } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
|
||||
setScrollLeft(100)
|
||||
fireEvent(window, new Event('resize'))
|
||||
expect(view.getByLabelText('向左滚动图片')).toBeTruthy()
|
||||
expect(view.getByLabelText('向右滚动图片')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('pans horizontally on a vertical wheel with clamped travel', () => {
|
||||
const view = render(
|
||||
<AttachmentRail items={[item('a'), item('b')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
|
||||
)
|
||||
const rail = view.getByRole('group', { name: '待发送图片' })
|
||||
const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
|
||||
fireEvent.wheel(rail, { deltaY: 30 })
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: 30, behavior: 'auto' })
|
||||
fireEvent.wheel(rail, { deltaY: 500 })
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: 60, behavior: 'auto' })
|
||||
fireEvent.wheel(rail, { deltaY: -500 })
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: -60, behavior: 'auto' })
|
||||
// A trackpad pan (deltaX) and a zero-delta wheel keep native behavior.
|
||||
fireEvent.wheel(rail, { deltaX: 12, deltaY: 30 })
|
||||
fireEvent.wheel(rail, { deltaY: 0 })
|
||||
expect(scrollBy).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('reveals the rail end when an item is added, not when one is removed', () => {
|
||||
const first = [item('a'), item('b')]
|
||||
const view = render(
|
||||
<AttachmentRail items={first} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
|
||||
)
|
||||
const rail = view.getByRole('group', { name: '待发送图片' })
|
||||
stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
|
||||
view.rerender(
|
||||
<AttachmentRail items={[...first, item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
|
||||
)
|
||||
expect(rail.scrollLeft).toBe(200)
|
||||
view.rerender(
|
||||
<AttachmentRail items={first} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
|
||||
)
|
||||
// Removal keeps the position; only growth jumps to the end.
|
||||
expect(rail.scrollLeft).toBe(200)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { ImageLightbox } from '../src/ImageLightbox.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const labels = { dialog: '原图预览', close: '关闭原图预览' }
|
||||
|
||||
describe('ImageLightbox', () => {
|
||||
it('focuses its close control, closes by button and Escape, and restores focus', () => {
|
||||
const opener = document.createElement('button')
|
||||
document.body.appendChild(opener)
|
||||
opener.focus()
|
||||
const onClose = vi.fn()
|
||||
const view = render(<ImageLightbox src="blob:original" alt="原图" labels={labels} onClose={onClose} />)
|
||||
const close = view.getByRole('button', { name: '关闭原图预览' })
|
||||
expect(document.activeElement).toBe(close)
|
||||
fireEvent.keyDown(window, { key: 'a' })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
fireEvent.click(close)
|
||||
expect(onClose).toHaveBeenCalledTimes(2)
|
||||
view.unmount()
|
||||
expect(document.activeElement).toBe(opener)
|
||||
opener.remove()
|
||||
})
|
||||
|
||||
it('tolerates a focus owner it cannot restore (no active element at mount)', () => {
|
||||
// jsdom always reports body as the fallback active element; stub the
|
||||
// element-less state a detached focus can leave.
|
||||
Object.defineProperty(document, 'activeElement', { configurable: true, get: () => null })
|
||||
try {
|
||||
const view = render(<ImageLightbox src="blob:original" alt="原图" labels={labels} onClose={vi.fn()} />)
|
||||
view.unmount()
|
||||
} finally {
|
||||
delete (document as { activeElement?: unknown }).activeElement
|
||||
}
|
||||
})
|
||||
|
||||
it('closes on a backdrop press but not on a press over the image', () => {
|
||||
const onClose = vi.fn()
|
||||
const view = render(<ImageLightbox src="blob:original" alt="原图" labels={labels} onClose={onClose} />)
|
||||
fireEvent.mouseDown(view.getByRole('img'))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
fireEvent.mouseDown(view.getByRole('dialog', { name: '原图预览' }))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import * as AttachmentInvariant from '@deepseek-ai/dsh-client-ui-attachment/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
describe('invariant companion', () => {
|
||||
it('registers under the package name with an empty installer', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await expect(ctx.plugin(AttachmentInvariant).await()).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
// @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) })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user