/* @vitest-environment jsdom */ import { act, fireEvent, render, screen, waitFor, within, } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { EditorAgentConversationPanelView } from './EditorAgentConversationPanelView'; import { useImageCanvasContextStore } from './useImageCanvasContextStore.ts'; import type { EditorAgentConversationClient } from './useEditorAgentConversation'; const createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn()); const probeImageFileDimensionsMock = vi.hoisted(() => vi.fn()); vi.mock('../../services/image-editor/editorProjectClient', async () => { const actual = await vi.importActual< typeof import('../../services/image-editor/editorProjectClient') >('../../services/image-editor/editorProjectClient'); return { ...actual, createEditorProjectResource: createEditorProjectResourceMock, }; }); vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({ uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock, })); vi.mock('./ImageCanvasFileModel', async () => { const actual = await vi.importActual( './ImageCanvasFileModel', ); return { ...actual, probeImageFileDimensions: probeImageFileDimensionsMock, }; }); function createClient(): EditorAgentConversationClient { return { listConversations: vi.fn().mockResolvedValue([ { conversationId: 'conversation-1', projectId: 'project-1', title: '角色参考', createdAt: '2026-07-03T00:00:00.000Z', updatedAt: '2026-07-03T00:00:00.000Z', }, ]), createConversation: vi.fn().mockResolvedValue({ conversationId: 'conversation-2', projectId: 'project-1', title: '新对话', messages: [], createdAt: '2026-07-03T00:01:00.000Z', updatedAt: '2026-07-03T00:01:00.000Z', }), getConversation: vi.fn().mockResolvedValue({ conversationId: 'conversation-1', projectId: 'project-1', title: '角色参考', messages: [ { id: 'assistant-old', role: 'assistant', kind: 'chat', text: '已经看到画布内容', attachments: [], generations: [], status: 'completed', createdAt: '2026-07-03T00:00:10.000Z', }, ], createdAt: '2026-07-03T00:00:00.000Z', updatedAt: '2026-07-03T00:00:10.000Z', }), deleteConversation: vi.fn().mockResolvedValue({ conversationId: 'conversation-1', projectId: 'project-1', title: '角色参考', messages: [], createdAt: '2026-07-03T00:00:00.000Z', updatedAt: '2026-07-03T00:00:00.000Z', }), streamMessage: vi .fn() .mockImplementation(async (_conversationId, _payload, options) => { options.onEvent?.({ event: 'stage', data: { conversationId: _conversationId, stage: 'responding' }, }); options.onEvent?.({ event: 'message_delta', data: { conversationId: _conversationId, messageId: 'assistant-stream', role: 'assistant', kind: 'chat', textDelta: '收到,我会参考这张图。', }, }); options.onEvent?.({ event: 'done', data: { conversationId: _conversationId, title: null }, }); }), }; } describe('EditorAgentConversationPanelView', () => { beforeEach(() => { useImageCanvasContextStore.getState().setProjectId('project-1'); uploadEditorMediaAssetFileMock.mockReset(); uploadEditorMediaAssetFileMock.mockResolvedValue({ src: '/generated/pasted.png', objectKey: 'generated-character-drafts/editor/agent-paste/image/pasted.png', assetObjectId: 'asset-object-pasted', legacyPublicPath: '/generated/pasted.png', }); probeImageFileDimensionsMock.mockReset(); probeImageFileDimensionsMock.mockResolvedValue({ width: 320, height: 240 }); createEditorProjectResourceMock.mockReset(); createEditorProjectResourceMock.mockResolvedValue({ resourceId: 'resource-pasted', imageSrc: '/generated/pasted.png', objectKey: 'generated-character-drafts/editor/agent-paste/image/pasted.png', label: '粘贴图片', width: 320, height: 240, }); }); it('manages conversations, attachments and message sending inside the panel', async () => { const client = createClient(); render( , ); await waitFor(() => { expect(screen.getByText('已经看到画布内容')).toBeTruthy(); }); fireEvent.click(screen.getByRole('button', { name: '新建对话' })); await waitFor(() => { expect(client.createConversation).toHaveBeenCalledWith('project-1', {}); }); fireEvent.click(screen.getByRole('button', { name: '添加附件' })); const attachmentDialog = screen.getByRole('dialog', { name: '选择图片附件', }); expect( within(attachmentDialog).queryByRole('checkbox', { name: '选择画布图片 未入库图层', }), ).toBeNull(); fireEvent.click( within(attachmentDialog).getByRole('checkbox', { name: '选择画布图片 角色图层', }), ); fireEvent.click( within(attachmentDialog).getByRole('button', { name: '应用' }), ); expect(screen.getByText('角色图层')).toBeTruthy(); fireEvent.change(screen.getByLabelText('发送给画布 Agent'), { target: { value: '参考附件做像素风' }, }); fireEvent.click(screen.getByRole('button', { name: '发送' })); await waitFor(() => { expect(screen.getByText('收到,我会参考这张图。')).toBeTruthy(); }); expect(client.streamMessage).toHaveBeenCalledWith( 'conversation-2', expect.objectContaining({ text: '参考附件做像素风', attachments: [ expect.objectContaining({ source: 'canvas_resource', referenceId: 'resource-1', }), ], }), expect.any(Object), ); }); it('uploads pasted images as canvas attachments before sending', async () => { const client = createClient(); render( , ); await waitFor(() => { expect(screen.getByText('已经看到画布内容')).toBeTruthy(); }); const pastedImage = new File(['pasted-image'], 'pasted.png', { type: 'image/png', }); const input = screen.getByLabelText('发送给画布 Agent'); fireEvent.paste(input, { clipboardData: { files: [pastedImage], }, }); await waitFor(() => { expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith( pastedImage, 'image', expect.objectContaining({ entityId: 'project-1', metadata: { source: 'agent-input-paste' }, }), ); }); expect(probeImageFileDimensionsMock).toHaveBeenCalledWith(pastedImage); expect(createEditorProjectResourceMock).toHaveBeenCalledWith( 'project-1', expect.objectContaining({ imageSrc: '/generated/pasted.png', objectKey: 'generated-character-drafts/editor/agent-paste/image/pasted.png', assetObjectId: 'asset-object-pasted', width: 320, height: 240, sourceType: 'uploaded', }), ); await waitFor(() => { expect(screen.getByText('粘贴图片')).toBeTruthy(); }); fireEvent.click(screen.getByRole('button', { name: '发送' })); await waitFor(() => { expect(client.streamMessage).toHaveBeenCalledWith( 'conversation-1', expect.objectContaining({ text: '', attachments: [ expect.objectContaining({ source: 'canvas_resource', referenceId: 'resource-pasted', imageSrc: '/generated/pasted.png', width: 320, height: 240, }), ], }), expect.any(Object), ); }); }); it('sends selected attachments even when the text input is empty', async () => { const client = createClient(); render( , ); await waitFor(() => { expect(screen.getByText('已经看到画布内容')).toBeTruthy(); }); fireEvent.click(screen.getByRole('button', { name: '添加附件' })); const attachmentDialog = screen.getByRole('dialog', { name: '选择图片附件', }); fireEvent.click( within(attachmentDialog).getByRole('checkbox', { name: '选择画布图片 角色图层', }), ); fireEvent.click( within(attachmentDialog).getByRole('button', { name: '应用' }), ); fireEvent.click(screen.getByRole('button', { name: '发送' })); await waitFor(() => { expect(client.streamMessage).toHaveBeenCalledWith( 'conversation-1', expect.objectContaining({ text: '', attachments: [ expect.objectContaining({ source: 'canvas_resource', referenceId: 'resource-1', }), ], }), expect.any(Object), ); }); }); it('keeps streamed reply and generating state when the panel is collapsed and reopened', async () => { const client = createClient(); let finishStream = () => {}; vi.mocked(client.streamMessage).mockImplementation( async (conversationId, _payload, options) => { options.onEvent?.({ event: 'message_delta', data: { conversationId, messageId: 'assistant-stream', role: 'assistant', kind: 'chat', textDelta: '我来生成图片。', }, }); options.onEvent?.({ event: 'stage', data: { conversationId, stage: 'generating' }, }); options.onEvent?.({ event: 'tool_started', data: { conversationId, messageId: 'assistant-stream', toolCallId: 'tool-call-generating', toolName: 'generate_image', taskId: 'task-generating', model: 'gpt-image-2', }, }); await new Promise((resolve) => { finishStream = resolve; }); }, ); const { rerender } = render( , ); await waitFor(() => { expect(screen.getByText('已经看到画布内容')).toBeTruthy(); }); fireEvent.change(screen.getByLabelText('发送给画布 Agent'), { target: { value: '生成一张图片' }, }); fireEvent.click(screen.getByRole('button', { name: '发送' })); await waitFor(() => { expect(screen.getByText('我来生成图片。')).toBeTruthy(); }); expect(screen.getByText('生成中')).toBeTruthy(); expect(screen.getByRole('button', { name: '停止' })).toBeTruthy(); rerender( , ); expect(screen.getByRole('button', { name: '打开画布 Agent' })).toBeTruthy(); rerender( , ); expect(screen.getByText('我来生成图片。')).toBeTruthy(); expect(screen.getByText('生成中')).toBeTruthy(); expect(screen.getByRole('button', { name: '停止' })).toBeTruthy(); await act(async () => { finishStream(); }); }); it('keeps wheel scrolling inside the message history and input', async () => { const client = createClient(); const parentWheel = vi.fn(); render(
, ); await waitFor(() => { expect(screen.getByText('已经看到画布内容')).toBeTruthy(); }); const messageWheel = new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 120, }); fireEvent( screen.getByRole('log', { name: '画布 Agent 消息流' }), messageWheel, ); expect(parentWheel).not.toHaveBeenCalled(); expect(messageWheel.defaultPrevented).toBe(false); const inputWheel = new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 120, }); fireEvent(screen.getByLabelText('发送给画布 Agent'), inputWheel); expect(parentWheel).not.toHaveBeenCalled(); expect(inputWheel.defaultPrevented).toBe(false); }); it('confirms conversation deletion from an independent dialog', async () => { const client = createClient(); render( , ); await waitFor(() => { expect(screen.getByText('已经看到画布内容')).toBeTruthy(); }); fireEvent.click(screen.getByRole('button', { name: '删除当前对话' })); const confirmDialog = screen.getByRole('dialog', { name: '删除对话' }); fireEvent.click( within(confirmDialog).getByRole('button', { name: '确认删除' }), ); await waitFor(() => { expect(client.deleteConversation).toHaveBeenCalledWith('conversation-1'); }); }); });