|
|
@@ -14,7 +14,9 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
|
|
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
|
|
import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
|
|
import { SessionInputShell } from '../src/client/input/facade.ts'
|
|
|
-import type { ComposerAttachment } from '../src/client/contract/slots.ts'
|
|
|
+import type {
|
|
|
+ ComposerAttachment, ComposerAttachmentsOwnerProps,
|
|
|
+} from '../src/client/contract/slots.ts'
|
|
|
import type { DraftAttachmentId } from '../src/client/input/contract.ts'
|
|
|
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
|
|
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
|
|
@@ -204,6 +206,12 @@ function bench(over?: BenchOptions) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+function attachmentOwner(slotCalls: readonly { key: string; owner: unknown }[]): ComposerAttachmentsOwnerProps {
|
|
|
+ const call = slotCalls.find(candidate => candidate.key === 'conversation.input.attachments')
|
|
|
+ if (call === undefined) throw new Error('attachment slot was not rendered')
|
|
|
+ return call.owner as ComposerAttachmentsOwnerProps
|
|
|
+}
|
|
|
+
|
|
|
describe('image draft rail', () => {
|
|
|
it('collects clipboard files while preserving text from a mixed paste', () => {
|
|
|
const addImages = vi.fn(() => null)
|
|
|
@@ -222,41 +230,6 @@ describe('image draft rail', () => {
|
|
|
expect(shell.snapshot.draft).toBe('同时粘贴的文字')
|
|
|
})
|
|
|
|
|
|
- it('accepts a drop anywhere on the page under the full-page overlay', () => {
|
|
|
- const addImages = vi.fn(() => null)
|
|
|
- const { view } = bench({ addImages })
|
|
|
- const image = new File([Uint8Array.of(1)], 'dropped.png', { type: 'image/png' })
|
|
|
- const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' }
|
|
|
- // The drag never touches the composer card: the listeners are page-wide.
|
|
|
- expect(fireEvent.dragEnter(document.body, { dataTransfer })).toBe(false)
|
|
|
- expect(view.getByRole('status').textContent).toContain('图片拖动到此处即可添加')
|
|
|
- expect(fireEvent.dragOver(document.body, { dataTransfer })).toBe(false)
|
|
|
- expect(dataTransfer.dropEffect).toBe('copy')
|
|
|
- expect(fireEvent.drop(document.body, { dataTransfer })).toBe(false)
|
|
|
- expect(addImages).toHaveBeenCalledWith([image])
|
|
|
- expect(view.queryByRole('status')).toBeNull()
|
|
|
- })
|
|
|
-
|
|
|
- it('keeps text drags native and hides the overlay when the drag leaves or ends', () => {
|
|
|
- const addImages = vi.fn(() => null)
|
|
|
- const { view } = bench({ addImages })
|
|
|
- // A text drag carries no Files type: no overlay, native behavior stays.
|
|
|
- fireEvent.dragEnter(document.body, { dataTransfer: { types: ['text/plain'], files: [], dropEffect: 'none' } })
|
|
|
- expect(view.queryByRole('status')).toBeNull()
|
|
|
- const dataTransfer = { types: ['Files'], files: [], dropEffect: 'none' }
|
|
|
- fireEvent.dragEnter(document.body, { dataTransfer })
|
|
|
- expect(view.getByRole('status')).toBeTruthy()
|
|
|
- fireEvent.dragLeave(document.body, { dataTransfer })
|
|
|
- expect(view.queryByRole('status')).toBeNull()
|
|
|
- // An aborted drag (Escape) fires dragend without a balancing leave.
|
|
|
- fireEvent.dragEnter(document.body, { dataTransfer })
|
|
|
- fireEvent.dragEnter(document.querySelector('textarea')!, { dataTransfer })
|
|
|
- expect(view.getByRole('status')).toBeTruthy()
|
|
|
- fireEvent.dragEnd(window, { dataTransfer })
|
|
|
- expect(view.queryByRole('status')).toBeNull()
|
|
|
- expect(addImages).not.toHaveBeenCalled()
|
|
|
- })
|
|
|
-
|
|
|
it('pre-checks projected limits at intake: whole-batch refusal with product copy, none added', () => {
|
|
|
const limits = {
|
|
|
maxImageBytes: 1024 * 1024,
|
|
|
@@ -266,18 +239,18 @@ describe('image draft rail', () => {
|
|
|
mediaTypes: ['image/png'] as const,
|
|
|
}
|
|
|
const png = (bytes: number, name: string) => new File([new ArrayBuffer(bytes)], name, { type: 'image/png' })
|
|
|
- const drop = (files: File[]) => {
|
|
|
- fireEvent.drop(document.body, { dataTransfer: { types: ['Files'], files, dropEffect: 'none' } })
|
|
|
+ const intake = (result: ReturnType<typeof bench>, files: File[]) => {
|
|
|
+ act(() => { attachmentOwner(result.slotCalls).onAddImages(files) })
|
|
|
}
|
|
|
// Count: three at once over a two-image limit → the whole batch refused.
|
|
|
const overCount = bench({ addImages: vi.fn(() => null), imageLimits: limits })
|
|
|
- drop([png(8, 'a.png'), png(8, 'b.png'), png(8, 'c.png')])
|
|
|
+ intake(overCount, [png(8, 'a.png'), png(8, 'b.png'), png(8, 'c.png')])
|
|
|
expect(overCount.view.getByRole('alert').textContent).toContain('一条消息最多添加 2 张图片')
|
|
|
expect(overCount.props.addImages).not.toHaveBeenCalled()
|
|
|
cleanup()
|
|
|
// Per-file bytes.
|
|
|
const overFile = bench({ addImages: vi.fn(() => null), imageLimits: limits })
|
|
|
- drop([png(1024 * 1024 + 1, 'big.png')])
|
|
|
+ intake(overFile, [png(1024 * 1024 + 1, 'big.png')])
|
|
|
expect(overFile.view.getByRole('alert').textContent).toContain('单张图片不能超过 1MB')
|
|
|
expect(overFile.props.addImages).not.toHaveBeenCalled()
|
|
|
cleanup()
|
|
|
@@ -285,21 +258,21 @@ describe('image draft rail', () => {
|
|
|
const held = new File([new ArrayBuffer(1024 * 1024 * 1.5)], 'held.png', { type: 'image/png' })
|
|
|
const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file: held, previewUrl: 'blob:held' }
|
|
|
const overTotal = bench({ addImages: vi.fn(() => null), imageLimits: limits, attachments: [attachment] })
|
|
|
- drop([png(1024 * 1024, 'more.png')])
|
|
|
+ intake(overTotal, [png(1024 * 1024, 'more.png')])
|
|
|
expect(overTotal.view.getByRole('alert').textContent).toContain('图片总大小超过 2MB')
|
|
|
expect(overTotal.props.addImages).not.toHaveBeenCalled()
|
|
|
cleanup()
|
|
|
// Within every limit: the batch passes through to addImages.
|
|
|
const within = bench({ addImages: vi.fn(() => null), imageLimits: limits })
|
|
|
const fits = png(16, 'fits.png')
|
|
|
- drop([fits])
|
|
|
+ intake(within, [fits])
|
|
|
expect(within.props.addImages).toHaveBeenCalledWith([fits])
|
|
|
expect(within.view.queryByRole('alert')).toBeNull()
|
|
|
})
|
|
|
|
|
|
it('announces the format problem before any limit when the batch holds a non-image', () => {
|
|
|
const addImages = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片')
|
|
|
- const { view } = bench({
|
|
|
+ const result = bench({
|
|
|
addImages,
|
|
|
imageLimits: {
|
|
|
maxImageBytes: 8,
|
|
|
@@ -314,13 +287,13 @@ describe('image draft rail', () => {
|
|
|
new File([new ArrayBuffer(64)], 'a.pdf', { type: 'application/pdf' }),
|
|
|
new File([new ArrayBuffer(64)], 'b.pdf', { type: 'application/pdf' }),
|
|
|
]
|
|
|
- fireEvent.drop(document.body, { dataTransfer: { types: ['Files'], files, dropEffect: 'none' } })
|
|
|
+ act(() => { attachmentOwner(result.slotCalls).onAddImages(files) })
|
|
|
expect(addImages).toHaveBeenCalledWith(files)
|
|
|
- expect(view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片')
|
|
|
+ expect(result.view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片')
|
|
|
})
|
|
|
|
|
|
- it('shows the projected limits in the drop overlay desc line', () => {
|
|
|
- const { view } = bench({
|
|
|
+ it('projects display-ready limits into the attachment slot', () => {
|
|
|
+ const result = bench({
|
|
|
addImages: vi.fn(() => null),
|
|
|
imageLimits: {
|
|
|
maxImageBytes: 5 * 1024 * 1024,
|
|
|
@@ -330,8 +303,7 @@ describe('image draft rail', () => {
|
|
|
mediaTypes: ['image/png'] as const,
|
|
|
},
|
|
|
})
|
|
|
- fireEvent.dragEnter(document.body, { dataTransfer: { types: ['Files'], files: [], dropEffect: 'none' } })
|
|
|
- expect(view.getByRole('status').textContent).toContain('最多 20 张,每张 5MB')
|
|
|
+ expect(attachmentOwner(result.slotCalls).dropLimits).toEqual({ count: 20, size: '5MB' })
|
|
|
})
|
|
|
|
|
|
it('announces server attachment rejections as product copy, other codes as developer text', () => {
|
|
|
@@ -351,41 +323,25 @@ describe('image draft rail', () => {
|
|
|
expect(other.view.getByRole('alert').textContent).toContain('boom (internal)')
|
|
|
})
|
|
|
|
|
|
- it('shows the blocked overlay and refuses the drop while the composer is locked', () => {
|
|
|
- const addImages = vi.fn(() => null)
|
|
|
- const { view } = bench({ addImages, inert: true })
|
|
|
- const image = new File([Uint8Array.of(1)], 'dropped.png', { type: 'image/png' })
|
|
|
- const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'copy' }
|
|
|
- fireEvent.dragEnter(document.body, { dataTransfer })
|
|
|
- expect(view.getByRole('status').textContent).toContain('当前无法添加图片')
|
|
|
- fireEvent.dragOver(document.body, { dataTransfer })
|
|
|
- expect(dataTransfer.dropEffect).toBe('none')
|
|
|
- fireEvent.drop(document.body, { dataTransfer })
|
|
|
- expect(addImages).not.toHaveBeenCalled()
|
|
|
- expect(view.queryByRole('status')).toBeNull()
|
|
|
- })
|
|
|
-
|
|
|
- it('sends an image-only draft and removes its thumbnail', () => {
|
|
|
+ it('marks the attachment slot unavailable while the composer is locked', () => {
|
|
|
+ const result = bench({ addImages: vi.fn(() => null), inert: true })
|
|
|
+ expect(attachmentOwner(result.slotCalls).canAcceptDrop).toBe(false)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('sends an image-only draft and exposes removal through the attachment slot', () => {
|
|
|
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
|
|
|
const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' }
|
|
|
- const { view, textarea, sink, removeImage } = bench({ attachments: [attachment] })
|
|
|
+ const result = bench({ attachments: [attachment] })
|
|
|
+ const { view, textarea, sink, removeImage } = result
|
|
|
expect((view.getByRole('button', { name: '发送消息' }) as HTMLButtonElement).disabled).toBe(false)
|
|
|
fireEvent.keyDown(textarea, { key: 'Enter' })
|
|
|
expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue')
|
|
|
- fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' }))
|
|
|
+ const owner = attachmentOwner(result.slotCalls)
|
|
|
+ expect(owner.attachments).toEqual([attachment])
|
|
|
+ owner.onRemoveImage(attachment.id)
|
|
|
expect(removeImage).toHaveBeenCalledWith('draft-1')
|
|
|
})
|
|
|
|
|
|
- it('opens the original image on a single click and closes it with Escape', () => {
|
|
|
- const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
|
|
|
- const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' }
|
|
|
- const { view } = bench({ attachments: [attachment] })
|
|
|
- fireEvent.click(view.getByTitle('查看原图'))
|
|
|
- expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
|
|
|
- fireEvent.keyDown(window, { key: 'Escape' })
|
|
|
- expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
|
|
|
- })
|
|
|
-
|
|
|
it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => {
|
|
|
vi.useFakeTimers()
|
|
|
try {
|
|
|
@@ -411,13 +367,15 @@ describe('image draft rail', () => {
|
|
|
}
|
|
|
})
|
|
|
|
|
|
- it('announces a rejected drop through the same toast', () => {
|
|
|
+ it('announces a rejected attachment-slot intake through the same toast', () => {
|
|
|
const addImages = vi.fn(() => '图片读取服务不可用')
|
|
|
- const { view } = bench({ addImages })
|
|
|
- const card = view.container.querySelector('[class*="card"]')!
|
|
|
- const dataTransfer = { types: ['Files'], files: [new File([Uint8Array.of(1)], 'x.png', { type: 'image/png' })], dropEffect: 'none' }
|
|
|
- fireEvent.drop(card, { dataTransfer })
|
|
|
- expect(view.getByRole('alert').textContent).toContain('图片读取服务不可用')
|
|
|
+ const result = bench({ addImages })
|
|
|
+ act(() => {
|
|
|
+ attachmentOwner(result.slotCalls).onAddImages([
|
|
|
+ new File([Uint8Array.of(1)], 'x.png', { type: 'image/png' }),
|
|
|
+ ])
|
|
|
+ })
|
|
|
+ expect(result.view.getByRole('alert').textContent).toContain('图片读取服务不可用')
|
|
|
})
|
|
|
})
|
|
|
|
|
|
@@ -1271,7 +1229,7 @@ describe('command launcher chrome and control seats', () => {
|
|
|
expect(view.queryByLabelText(/^访问模式/)).toBeNull()
|
|
|
// Every seat dispatched, nothing rendered.
|
|
|
expect(slotCalls.map(c => c.key)).toEqual([
|
|
|
- 'conversation.input.plan', 'conversation.input.model',
|
|
|
+ 'conversation.input.attachments', 'conversation.input.plan', 'conversation.input.model',
|
|
|
])
|
|
|
expect(view.queryByLabelText('Plan mode')).toBeNull()
|
|
|
expect(view.queryByLabelText('Model')).toBeNull()
|
|
|
@@ -1418,10 +1376,14 @@ describe('command launcher chrome and control seats', () => {
|
|
|
expect(view.getByTestId('plan-entry')).toBeTruthy()
|
|
|
expect(view.getByTestId('model-entry')).toBeTruthy()
|
|
|
// The bar hands its chrome disable state to the filling entry.
|
|
|
- expect(slotCalls.every(c => (c.owner as { locked: boolean }).locked)).toBe(true)
|
|
|
+ const controls = slotCalls.filter(call => call.key !== 'conversation.input.attachments')
|
|
|
+ expect(controls.every(c => (c.owner as { locked: boolean }).locked)).toBe(true)
|
|
|
+ expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(false)
|
|
|
cleanup()
|
|
|
const live = bench({ running: true })
|
|
|
- expect(live.slotCalls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true)
|
|
|
+ const liveControls = live.slotCalls.filter(call => call.key !== 'conversation.input.attachments')
|
|
|
+ expect(liveControls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true)
|
|
|
+ expect(attachmentOwner(live.slotCalls).canAcceptDrop).toBe(true)
|
|
|
})
|
|
|
|
|
|
it('disabled locks the Access chip and command launcher (running does not)', () => {
|