Merge remote-tracking branch 'origin/master' into fix/stats
This commit is contained in:
@@ -9,7 +9,7 @@ import { useEffect } from 'react'
|
||||
import type {
|
||||
AssistantMessageNode, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot,
|
||||
ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolCallBlock, ToolResultNode, TurnErrorNode,
|
||||
UserMessageNode, WorkspaceListState,
|
||||
TurnMaxTokensNode, UserMessageNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import {
|
||||
@@ -28,7 +28,7 @@ import { AssistantNodeView } from '../src/client/chat/AssistantNodeView.tsx'
|
||||
import { CommandNodeView, ManualCompactionNodeView } from '../src/client/chat/CommandNodeView.tsx'
|
||||
import {
|
||||
CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView,
|
||||
UnknownNodeView, UserMessageNodeView,
|
||||
TurnMaxTokensNodeView, UnknownNodeView, UserMessageNodeView,
|
||||
} from '../src/client/chat/MessageItem.tsx'
|
||||
import { TurnTailNodeView } from '../src/client/chat/TurnTailNodeView.tsx'
|
||||
import { formatRunDuration } from '../src/client/chat/message-chrome.ts'
|
||||
@@ -108,6 +108,9 @@ const turnError = (seq: number, code?: string): TurnErrorNode => ({
|
||||
message: seq === 2 ? 'API key is invalid' : 'plugin exploded',
|
||||
...(code === undefined ? {} : { code }),
|
||||
})
|
||||
const turnMaxTokens = (seq: number): TurnMaxTokensNode => ({
|
||||
kind: 'turn-max-tokens', seq, time: seq * 1_000, turn: 1, step: 0,
|
||||
})
|
||||
const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
|
||||
kind: 'tool-result', seq, time: seq * 1_000, callId,
|
||||
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
|
||||
@@ -217,6 +220,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
return <RetryNodeView {...nodeProps<'model-retry'>()} />
|
||||
case 'turn-error':
|
||||
return <TurnErrorNodeView {...nodeProps<'turn-error'>()} />
|
||||
case 'turn-max-tokens':
|
||||
return <TurnMaxTokensNodeView {...nodeProps<'turn-max-tokens'>()} />
|
||||
case 'turn-tail':
|
||||
return (
|
||||
<TurnTailNodeView
|
||||
@@ -585,6 +590,16 @@ describe('ChatView', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('renders the max-tokens notice with localized guidance, distinct from turn errors', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'try'), assistant(2, 'truncated'), turnMaxTokens(3)] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const statuses = view.getAllByRole('status')
|
||||
expect(statuses.map(status => status.textContent)).toEqual([
|
||||
'已达到输出 token 上限回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。',
|
||||
])
|
||||
expect(view.queryByText('本轮运行失败')).toBeNull()
|
||||
})
|
||||
|
||||
it('hands the trajectory callback to the Tool seat', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [toolResult(3, 'a')],
|
||||
|
||||
@@ -14,6 +14,7 @@ import { messageDefinition } from '../src/client/conversation-nodes/message.ts'
|
||||
import { retryDefinition } from '../src/client/conversation-nodes/retry.ts'
|
||||
import { toolDefinition } from '../src/client/conversation-nodes/tool.ts'
|
||||
import { turnErrorDefinition } from '../src/client/conversation-nodes/turn-error.ts'
|
||||
import { turnMaxTokensDefinition } from '../src/client/conversation-nodes/turn-max-tokens.ts'
|
||||
import { turnTailDefinition } from '../src/client/conversation-nodes/turn-tail.ts'
|
||||
import type {
|
||||
AssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, TurnTailChatData,
|
||||
@@ -29,6 +30,7 @@ const DEFINITIONS: readonly ConversationNodeDefinition[] = [
|
||||
compactionDefinition,
|
||||
retryDefinition,
|
||||
turnErrorDefinition,
|
||||
turnMaxTokensDefinition,
|
||||
turnTailDefinition,
|
||||
]
|
||||
|
||||
@@ -812,6 +814,75 @@ describe('built-in conversation node Definitions', () => {
|
||||
expect(node(snapshot(value), 'turn-error')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('materializes a max-tokens notice and keeps completed and error turns clean', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'step/start', { turn: 1, step: 1 }),
|
||||
at(3, 'assistant/message', {
|
||||
turn: 1, step: 1, message: assistantMessage('a1', 'truncated answer'),
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(4, 'step/end', { turn: 1, step: 1 }),
|
||||
at(5, 'turn/end', { turn: 1, reason: { kind: 'max-tokens' } }),
|
||||
])
|
||||
const notice = node(snapshot(value), 'turn-max-tokens')
|
||||
expect(notice?.data).toMatchObject({ kind: 'turn-max-tokens', seq: 5, turn: 1, step: 1 })
|
||||
expect(node(snapshot(value), 'turn-error')).toBeUndefined()
|
||||
// The tail stays the turn's last node so its branch action survives; the
|
||||
// notice slots between the truncated closing Assistant and the tail.
|
||||
const tail = node(snapshot(value), 'turn-tail')
|
||||
expect(notice?.anchorSeq).toBeLessThan(tail?.anchorSeq ?? Number.NEGATIVE_INFINITY)
|
||||
expect(notice?.anchorSeq).toBeGreaterThan(3)
|
||||
|
||||
const completed = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
|
||||
])
|
||||
expect(node(snapshot(completed), 'turn-max-tokens')).toBeUndefined()
|
||||
|
||||
const failed = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'turn/end', {
|
||||
turn: 1,
|
||||
reason: { kind: 'error', error: { code: 'TRANSPORT', message: 'failed' } },
|
||||
}),
|
||||
])
|
||||
expect(node(snapshot(failed), 'turn-max-tokens')).toBeUndefined()
|
||||
expect(node(snapshot(failed), 'turn-error')).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps the max-tokens notice when the window starts after the owning turn/start', () => {
|
||||
const value = assembler([
|
||||
at(9, 'turn/end', { turn: 3, reason: { kind: 'max-tokens' } }),
|
||||
], true)
|
||||
const notice = node(snapshot(value), 'turn-max-tokens')
|
||||
expect(notice?.data).toMatchObject({ kind: 'turn-max-tokens', seq: 9, turn: 3 })
|
||||
})
|
||||
|
||||
it('pins the max-tokens Definition edges the engine cannot reach', () => {
|
||||
// The engine only hands start the single matched turn/end and never emits
|
||||
// update Matches for this kind; these direct calls pin the declared
|
||||
// behavior of both required Definition members anyway.
|
||||
const match = (seq: number, type: string, data: unknown) => ({
|
||||
event: { seq, time: seq * 1_000, type, data },
|
||||
view: undefined,
|
||||
role: 'start',
|
||||
location: undefined,
|
||||
}) as unknown as Parameters<typeof turnMaxTokensDefinition.start>[1]
|
||||
const context = (state: unknown, matches: unknown[] = []) => ({
|
||||
key: 'k', kind: 'turn-max-tokens', id: '1', matches, start: undefined, state, current: new Map(),
|
||||
}) as unknown as Parameters<NonNullable<typeof turnMaxTokensDefinition.buildViewNode>>[0]
|
||||
const reader = { previous: () => undefined }
|
||||
|
||||
expect(() => turnMaxTokensDefinition.start(context(undefined), match(1, 'turn/start', { turn: 1 }), reader))
|
||||
.toThrow('turn-max-tokens start requires a max-tokens turn/end')
|
||||
const state = { turn: 1, seq: 5, time: 5_000 }
|
||||
expect(turnMaxTokensDefinition.update(
|
||||
context(state) as Parameters<typeof turnMaxTokensDefinition.update>[0],
|
||||
match(6, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
|
||||
)).toBe(state)
|
||||
expect(turnMaxTokensDefinition.buildViewNode?.(context(undefined))).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves nested Tools and manual compaction evidence when their start events are outside the window', () => {
|
||||
const value = assembler([
|
||||
at(12, 'tool/code-dispatch-start', {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { attachmentErrorText, imageSizeText } from '../src/client/image-labels.ts'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -25,6 +26,40 @@ const attachment = {
|
||||
name: 'history.png',
|
||||
}
|
||||
|
||||
describe('attachment rejection copy', () => {
|
||||
const limits = {
|
||||
maxImageBytes: 5 * 1024 * 1024,
|
||||
maxImagesPerMessage: 20,
|
||||
maxMessageImageBytes: 100 * 1024 * 1024,
|
||||
maxImagePixels: 40_000_000,
|
||||
mediaTypes: ['image/png'] as const,
|
||||
}
|
||||
|
||||
it('renders megabytes without a trailing fraction unless one exists', () => {
|
||||
expect(imageSizeText(10 * 1024 * 1024)).toBe('10MB')
|
||||
expect(imageSizeText(2.5 * 1024 * 1024)).toBe('2.5MB')
|
||||
})
|
||||
|
||||
it('maps user-solvable reasons to limit-naming copy', () => {
|
||||
expect(attachmentErrorText(t, 'MODEL_DOES_NOT_SUPPORT_IMAGES')).toBe('当前模型不支持图片,请切换支持图片的模型')
|
||||
expect(attachmentErrorText(t, 'SUBAGENT_IMAGE_UNSUPPORTED')).toBe('子智能体会话暂不支持图片')
|
||||
expect(attachmentErrorText(t, 'IMAGE_TOO_MANY_PIXELS')).toBe('图片分辨率过大,请压缩后重试')
|
||||
expect(attachmentErrorText(t, 'INVALID_IMAGE')).toBe('仅支持 PNG、JPG、WebP、GIF 格式的图片')
|
||||
expect(attachmentErrorText(t, 'IMAGE_TYPE_MISMATCH')).toBe('仅支持 PNG、JPG、WebP、GIF 格式的图片')
|
||||
expect(attachmentErrorText(t, 'TOO_MANY_IMAGES', limits)).toBe('一条消息最多添加 20 张图片')
|
||||
expect(attachmentErrorText(t, 'IMAGE_TOO_LARGE', limits)).toBe('单张图片不能超过 5MB')
|
||||
expect(attachmentErrorText(t, 'IMAGES_TOO_LARGE', limits)).toBe('图片总大小超过 100MB,请移除部分图片')
|
||||
expect(attachmentErrorText(enT, 'TOO_MANY_IMAGES', limits)).toBe('A message can include up to 20 images')
|
||||
})
|
||||
|
||||
it('folds unknown reasons and limit reasons without projected limits into the send-failed line', () => {
|
||||
expect(attachmentErrorText(t, 'INVALID_IMAGE_BASE64')).toBe('图片发送失败(INVALID_IMAGE_BASE64),请重新添加图片后再试')
|
||||
expect(attachmentErrorText(t, 'TOO_MANY_IMAGES')).toBe('图片发送失败(TOO_MANY_IMAGES),请重新添加图片后再试')
|
||||
expect(attachmentErrorText(t, 'IMAGE_TOO_LARGE')).toBe('图片发送失败(IMAGE_TOO_LARGE),请重新添加图片后再试')
|
||||
expect(attachmentErrorText(t, 'IMAGES_TOO_LARGE')).toBe('图片发送失败(IMAGES_TOO_LARGE),请重新添加图片后再试')
|
||||
})
|
||||
})
|
||||
|
||||
describe('assistant images through the label bridge', () => {
|
||||
it('resolves zh dictionary strings and opens the lightbox on a single click', async () => {
|
||||
const view = render(
|
||||
@@ -60,6 +95,27 @@ describe('assistant images through the label bridge', () => {
|
||||
expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('merges consecutive image blocks into one tiled gallery, split by text', async () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[
|
||||
{ kind: 'image', attachment },
|
||||
{ kind: 'image', attachment },
|
||||
{ kind: 'text', text: 'between' },
|
||||
{ kind: 'image', attachment },
|
||||
]}
|
||||
streaming={false}
|
||||
loadImage={() => Promise.resolve('blob:grouped')}
|
||||
/>,
|
||||
)
|
||||
await view.findAllByAltText('history.png')
|
||||
const galleries = view.container.querySelectorAll('[data-align="start"]')
|
||||
expect(galleries).toHaveLength(2)
|
||||
expect(galleries[0]?.querySelectorAll('[data-variant="tile"]')).toHaveLength(2)
|
||||
expect(galleries[1]?.querySelectorAll('[data-variant="single"]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps assistant images at their original position between text blocks', async () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
|
||||
@@ -56,6 +56,14 @@ interface BenchOptions {
|
||||
/** Hot text-ref lexicon (injects a minimal slash stub exposing only lexicon()). */
|
||||
lexicon?: ReadonlyMap<'/' | '@', readonly string[]>
|
||||
permissions?: { options: { value: string; name: string; description?: string }[]; currentValue: string }
|
||||
/** The `imageLimits` projection value (absent = no attachment service). */
|
||||
imageLimits?: {
|
||||
maxImageBytes: number
|
||||
maxImagesPerMessage: number
|
||||
maxMessageImageBytes: number
|
||||
maxImagePixels: number
|
||||
mediaTypes: readonly ('image/png' | 'image/jpeg' | 'image/webp' | 'image/gif')[]
|
||||
}
|
||||
draft?: string
|
||||
running?: boolean
|
||||
subagent?: Exclude<ConversationSnapshot['subagent'], null>
|
||||
@@ -146,7 +154,9 @@ function bench(over?: BenchOptions) {
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})),
|
||||
useProjection: ((key: string, selector?: (v: unknown) => unknown) =>
|
||||
(selector ?? (v => v))(key === 'permissions' ? over?.permissions : key === 'plan' ? over?.plan : undefined)),
|
||||
(selector ?? (v => v))(key === 'permissions'
|
||||
? over?.permissions
|
||||
: key === 'plan' ? over?.plan : key === 'imageLimits' ? over?.imageLimits : undefined)),
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
@@ -212,18 +222,147 @@ describe('image draft rail', () => {
|
||||
expect(shell.snapshot.draft).toBe('同时粘贴的文字')
|
||||
})
|
||||
|
||||
it('accepts file drops and prevents browser navigation', () => {
|
||||
it('accepts a drop anywhere on the page under the full-page overlay', () => {
|
||||
const addImages = vi.fn(() => null)
|
||||
const { view } = bench({ addImages })
|
||||
const card = view.container.querySelector('[class*="card"]')!
|
||||
const image = new File([Uint8Array.of(1)], 'dropped.png', { type: 'image/png' })
|
||||
const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' }
|
||||
expect(fireEvent.dragEnter(card, { dataTransfer })).toBe(false)
|
||||
expect(view.getByRole('status').textContent).toContain('松开以添加图片')
|
||||
expect(fireEvent.dragOver(card, { dataTransfer })).toBe(false)
|
||||
// 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(card, { dataTransfer })).toBe(false)
|
||||
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,
|
||||
maxImagesPerMessage: 2,
|
||||
maxMessageImageBytes: 2 * 1024 * 1024,
|
||||
maxImagePixels: 40_000_000,
|
||||
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' } })
|
||||
}
|
||||
// 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')])
|
||||
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')])
|
||||
expect(overFile.view.getByRole('alert').textContent).toContain('单张图片不能超过 1MB')
|
||||
expect(overFile.props.addImages).not.toHaveBeenCalled()
|
||||
cleanup()
|
||||
// Aggregate bytes across the existing rail plus the new batch.
|
||||
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')])
|
||||
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])
|
||||
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({
|
||||
addImages,
|
||||
imageLimits: {
|
||||
maxImageBytes: 8,
|
||||
maxImagesPerMessage: 1,
|
||||
maxMessageImageBytes: 8,
|
||||
maxImagePixels: 40_000_000,
|
||||
mediaTypes: ['image/png'] as const,
|
||||
},
|
||||
})
|
||||
// Oversized AND over-count AND wrong type: the format rejection wins.
|
||||
const files = [
|
||||
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' } })
|
||||
expect(addImages).toHaveBeenCalledWith(files)
|
||||
expect(view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片')
|
||||
})
|
||||
|
||||
it('shows the projected limits in the drop overlay desc line', () => {
|
||||
const { view } = bench({
|
||||
addImages: vi.fn(() => null),
|
||||
imageLimits: {
|
||||
maxImageBytes: 5 * 1024 * 1024,
|
||||
maxImagesPerMessage: 20,
|
||||
maxMessageImageBytes: 100 * 1024 * 1024,
|
||||
maxImagePixels: 40_000_000,
|
||||
mediaTypes: ['image/png'] as const,
|
||||
},
|
||||
})
|
||||
fireEvent.dragEnter(document.body, { dataTransfer: { types: ['Files'], files: [], dropEffect: 'none' } })
|
||||
expect(view.getByRole('status').textContent).toContain('最多 20 张,每张 5MB')
|
||||
})
|
||||
|
||||
it('announces server attachment rejections as product copy, other codes as developer text', () => {
|
||||
const attachmentError = (reason: string): ConversationSnapshot['promptError'] => ({
|
||||
op: 'send',
|
||||
error: { code: 'attachment-error', message: 'raw wire text', details: { reason } },
|
||||
})
|
||||
const model = bench({ promptError: attachmentError('MODEL_DOES_NOT_SUPPORT_IMAGES') })
|
||||
expect(model.view.getByRole('alert').textContent).toContain('当前模型不支持图片,请切换支持图片的模型')
|
||||
cleanup()
|
||||
const unknown = bench({ promptError: attachmentError('ATTACHMENT_NOT_REFERENCED') })
|
||||
expect(unknown.view.getByRole('alert').textContent).toContain('图片发送失败(ATTACHMENT_NOT_REFERENCED)')
|
||||
cleanup()
|
||||
const other = bench({
|
||||
promptError: { op: 'send', error: { code: 'internal', message: 'boom', details: {} } },
|
||||
})
|
||||
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', () => {
|
||||
@@ -250,7 +389,7 @@ describe('image draft rail', () => {
|
||||
it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const addImages = vi.fn(() => '不支持的图片格式:text/plain')
|
||||
const addImages = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片')
|
||||
const { view, textarea } = bench({ addImages })
|
||||
const paste = () => {
|
||||
fireEvent.paste(textarea, {
|
||||
@@ -261,12 +400,12 @@ describe('image draft rail', () => {
|
||||
})
|
||||
}
|
||||
paste()
|
||||
expect(view.getByRole('alert').textContent).toContain('不支持的图片格式:text/plain')
|
||||
expect(view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片')
|
||||
act(() => { vi.advanceTimersByTime(4000) })
|
||||
expect(view.queryByRole('alert')).toBeNull()
|
||||
// The identical rejection re-announces: the toast is keyed per show.
|
||||
paste()
|
||||
expect(view.getByRole('alert').textContent).toContain('不支持的图片格式:text/plain')
|
||||
expect(view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
|
||||
@@ -337,6 +337,8 @@ describe('ConversationRoot resident composer', () => {
|
||||
expect(host?.contains(header)).toBe(false)
|
||||
expect(host?.contains(seat)).toBe(true)
|
||||
expect(seat?.contains(textarea)).toBe(true)
|
||||
expect(b.slotCalls).toContain('conversation.session.header.actions')
|
||||
expect(b.slotCalls).toContain('conversation.session.header.utilities')
|
||||
})
|
||||
|
||||
it('sticky composer seat wraps the whole overlay chain, not only the fallback stack', () => {
|
||||
|
||||
Reference in New Issue
Block a user