import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ImageWorkspaceApiError } from '@/lib/image-workspace'; import { ImageCanvas } from '@/pages/ImageCanvas'; import { useAuthStore } from '@/stores/auth'; import { useImagePromptMuseumStore } from '@/stores/image-prompt-museum'; import { useImageWorkspaceStore } from '@/stores/image-workspace'; import type { DesignAssistantDeltaEvent, DesignConversation, DesignGenerationQuote, DesignGenerationTask, DesignGenerationTaskUpdatedEvent, DesignWorkspace, DesignWorkspaceBootstrap, } from '../../shared/image-workspace'; const fetchImageWorkspaceMock = vi.hoisted(() => vi.fn()); const fetchImageWorkspaceProjectMock = vi.hoisted(() => vi.fn()); const fetchImageWorkspaceConversationMock = vi.hoisted(() => vi.fn()); const fetchImageWorkspaceTasksMock = vi.hoisted(() => vi.fn()); const sendImageWorkspaceMessageMock = vi.hoisted(() => vi.fn()); const confirmImageWorkspaceGenerationMock = vi.hoisted(() => vi.fn()); const updateImageWorkspaceGenerationQuoteMock = vi.hoisted(() => vi.fn()); const openImageWorkspaceTaskEventsMock = vi.hoisted(() => vi.fn()); const resolveImageWorkspaceAssetUrlMock = vi.hoisted(() => vi.fn()); const saveImageWorkspaceAssetMock = vi.hoisted(() => vi.fn()); const uploadImageWorkspaceAssetMock = vi.hoisted(() => vi.fn()); type EventListener = (event: MessageEvent) => void; class MockEventSource { onopen: ((event: Event) => void) | null = null; onerror: ((event: Event) => void) | null = null; readonly close = vi.fn(); private readonly listeners = new Map>(); addEventListener(type: string, listener: EventListener): void { const listeners = this.listeners.get(type) ?? new Set(); listeners.add(listener); this.listeners.set(type, listeners); } emit(type: string, payload: unknown): void { const event = { data: JSON.stringify(payload) } as MessageEvent; for (const listener of this.listeners.get(type) ?? []) listener(event); } } function deferred(): { promise: Promise; resolve(value: T): void } { let resolve!: (value: T) => void; const promise = new Promise((accept) => { resolve = accept; }); return { promise, resolve }; } vi.mock('@/lib/image-workspace', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, fetchImageWorkspace: (...args: unknown[]) => fetchImageWorkspaceMock(...args), fetchImageWorkspaceProject: (...args: unknown[]) => fetchImageWorkspaceProjectMock(...args), fetchImageWorkspaceConversation: (...args: unknown[]) => ( fetchImageWorkspaceConversationMock(...args) ), fetchImageWorkspaceTasks: (...args: unknown[]) => fetchImageWorkspaceTasksMock(...args), sendImageWorkspaceMessage: (...args: unknown[]) => sendImageWorkspaceMessageMock(...args), confirmImageWorkspaceGeneration: (...args: unknown[]) => ( confirmImageWorkspaceGenerationMock(...args) ), updateImageWorkspaceGenerationQuote: (...args: unknown[]) => ( updateImageWorkspaceGenerationQuoteMock(...args) ), openImageWorkspaceTaskEvents: (...args: unknown[]) => ( openImageWorkspaceTaskEventsMock(...args) ), resolveImageWorkspaceAssetUrl: (...args: unknown[]) => ( resolveImageWorkspaceAssetUrlMock(...args) ), saveImageWorkspaceAsset: (...args: unknown[]) => ( saveImageWorkspaceAssetMock(...args) ), uploadImageWorkspaceAsset: (...args: unknown[]) => ( uploadImageWorkspaceAssetMock(...args) ), }; }); const bootstrapFixture: DesignWorkspaceBootstrap = { capabilities: { conversation: true, generation: true, image: true, video: true, }, workspaces: [{ workspaceId: 'workspace-cloud', title: '云端角色设定', viewRevision: 1, conversationCount: 1, phase: 'awaiting_confirmation', updatedAt: '2026-07-31T10:00:00Z', }], }; function conversationFixture(): DesignConversation { return { conversationId: 'conversation-cloud', workspaceId: 'workspace-cloud', title: '主会话', turnRevision: 1, phase: 'awaiting_confirmation', brief: { version: 1, status: 'ready', medium: 'image', summary: '夜色中的机械城堡角色海报', ready: true, missingDecision: null, }, createdAt: '2026-07-31T10:00:00Z', updatedAt: '2026-07-31T10:00:00Z', messages: [ { id: 'message-user', role: 'user', kind: 'user', text: '做一张夜色中的机械城堡角色海报', quickReplies: [], generationQuote: null, turnRevision: 1, createdAt: '2026-07-31T10:00:00Z', }, { id: 'message-assistant', role: 'assistant', kind: 'confirmation', text: '方向已经明确,确认后开始生成。', quickReplies: ['确认生成', '继续调整'], generationQuote: { quoteId: 'quote-one', status: 'active', medium: 'image', briefVersion: 1, briefSummary: '夜色中的机械城堡角色海报', finalPrompt: '夜色中的机械城堡角色海报,电影感构图。', promptMode: 'guided', generationParameters: { model: 'image-model-one', resolution: '1024x1024', aspectRatio: '1:1', durationSeconds: null, }, generationOptions: { models: [{ value: 'image-model-one', label: '图片模型一', default: true, disabled: false, multiplier: 1, }], resolutions: [{ value: '1024x1024', label: '1024 × 1024', default: true, disabled: false, multiplier: 1, }, { value: '2048x2048', label: '2048 × 2048', default: false, disabled: false, multiplier: 1.5, }], durations: [], aspectRatios: [{ value: '1:1', label: '1:1', default: true, disabled: false, multiplier: 1, }, { value: '16:9', label: '16:9', default: false, disabled: false, multiplier: 1, }], }, pricing: { schema: 'design-pricing-v2', amount: 1, rounding: 'ceil', }, quotedDesignPoints: 1, expiresAt: '2026-07-31T11:00:00Z', }, turnRevision: 1, createdAt: '2026-07-31T10:00:01Z', }, ], }; } function workspaceFixture(): DesignWorkspace { const { messages: _messages, ...summary } = conversationFixture(); return { ...bootstrapFixture.workspaces[0], conversations: [summary], }; } const taskFixture: DesignGenerationTask = { taskId: 'task-one', workspaceId: 'workspace-cloud', conversationId: 'conversation-cloud', medium: 'image', status: 'succeeded', briefVersion: 1, briefSummary: '夜色中的机械城堡角色海报', quoteId: 'quote-old', quotedDesignPoints: 1, failureCode: null, resultAssets: [{ assetId: 'asset-one', workspaceId: 'workspace-cloud', mediaType: 'image', mimeType: 'image/png', width: 1024, height: 1024, durationMilliseconds: null, createdAt: '2026-07-31T09:00:00Z', contentPath: '/api/works/image-workspace/workspaces/workspace-cloud/assets/asset-one/content', }], createdAt: '2026-07-31T09:00:00Z', updatedAt: '2026-07-31T09:01:00Z', }; describe('ImageCanvas Workspace-first design experience', () => { let taskEventSource: MockEventSource; beforeEach(() => { vi.clearAllMocks(); useImageWorkspaceStore.getState().reset(); useImagePromptMuseumStore.getState().clearPendingPrompt(); fetchImageWorkspaceMock.mockResolvedValue(bootstrapFixture); fetchImageWorkspaceProjectMock.mockResolvedValue(workspaceFixture()); fetchImageWorkspaceConversationMock.mockResolvedValue(conversationFixture()); fetchImageWorkspaceTasksMock.mockResolvedValue([taskFixture]); sendImageWorkspaceMessageMock.mockResolvedValue(conversationFixture()); updateImageWorkspaceGenerationQuoteMock.mockImplementation(async ( _workspaceId: string, _quoteId: string, finalPrompt: string, generationParameters: DesignGenerationQuote['generationParameters'], ) => ({ ...conversationFixture().messages[1].generationQuote!, finalPrompt, generationParameters, quotedDesignPoints: 3, pricing: { schema: 'design-pricing-v2', amount: 3, rounding: 'ceil', }, })); confirmImageWorkspaceGenerationMock.mockResolvedValue({ ...conversationFixture(), turnRevision: 2, phase: 'shaping', }); taskEventSource = new MockEventSource(); openImageWorkspaceTaskEventsMock.mockResolvedValue( taskEventSource as unknown as EventSource, ); resolveImageWorkspaceAssetUrlMock.mockResolvedValue( 'http://127.0.0.1:13210/content?token=host', ); saveImageWorkspaceAssetMock.mockResolvedValue({ status: 'saved' }); uploadImageWorkspaceAssetMock.mockResolvedValue({ ...taskFixture.resultAssets[0], assetId: 'asset-uploaded', }); }); it('shows an honest unavailable state without fabricating projects', async () => { fetchImageWorkspaceMock.mockRejectedValueOnce(new ImageWorkspaceApiError( 501, 'IMAGE_WORKSPACE_UNAVAILABLE', 'AI 设计暂不可用', )); render(); expect(await screen.findByTestId('image-workspace-unavailable')) .toHaveTextContent('AI 设计暂不可用'); expect(screen.getByText(/暂时无法连接设计服务/)).toBeInTheDocument(); }); it('clears stale renderer auth when Main requires authentication', async () => { useAuthStore.setState({ initialized: true, loading: false, error: null, accessToken: 'expired-access-token', canRefresh: true, legacyRefreshToken: 'invalid-refresh-token', tokenType: 'Bearer', expiresAt: Date.now() - 1, user: { username: 'brother7', userId: '1', tenantId: null, deptId: null, authorities: [], }, }); fetchImageWorkspaceMock.mockRejectedValueOnce(new ImageWorkspaceApiError( 401, 'AUTH_REQUIRED', '请先登录后再使用 AI 设计', )); render(); await screen.findByTestId('image-workspace-unavailable'); expect(useAuthStore.getState()).toMatchObject({ accessToken: null, canRefresh: false, legacyRefreshToken: null, user: null, }); expect(useImageWorkspaceStore.getState().status).toBe('auth-required'); useAuthStore.setState({ accessToken: 'fresh-access-token', canRefresh: true, legacyRefreshToken: 'fresh-refresh-token', tokenType: 'Bearer', expiresAt: Date.now() + 60_000, user: { username: 'brother7', userId: '1', tenantId: null, deptId: null, authorities: [], }, }); await waitFor(() => expect(fetchImageWorkspaceMock).toHaveBeenCalledTimes(2)); await waitFor(() => expect(useImageWorkspaceStore.getState().status).toBe('ready')); }); it('renders the conversation messages edge-to-edge with the unified image/video task list', async () => { render(); expect(await screen.findByTestId('image-workspace-conversation')).toBeInTheDocument(); expect(screen.getByTestId('image-canvas-page')).toHaveClass('font-sans'); expect(screen.queryByTestId('image-canvas-conversation-select')).not.toBeInTheDocument(); expect(screen.getByTestId('image-workspace-conversation')) .toHaveTextContent('方向已经明确'); expect(screen.queryByText('设计 Agent', { exact: true })).not.toBeInTheDocument(); expect(screen.queryByText('你', { exact: true })).not.toBeInTheDocument(); const messageRows = screen.getAllByTestId('image-workspace-message'); expect(messageRows).toHaveLength(2); expect(messageRows[0]).toHaveClass('w-full'); expect(messageRows[1]).toHaveClass('w-full'); expect(messageRows[0].firstElementChild).toHaveClass('w-fit', 'max-w-[85%]'); expect(messageRows[0].querySelector('p')).toHaveClass('chat-message-body'); expect(messageRows[1].querySelector('p')).toHaveClass('chat-message-body'); expect(screen.getByTestId('design-quote-quote-one')).toHaveTextContent('1 设计点'); expect(screen.queryByTestId('design-quote-model')).not.toBeInTheDocument(); expect(screen.getByTestId('design-task-list')).toHaveTextContent('生成内容'); expect(screen.queryByText('图片与视频任务统一展示')).not.toBeInTheDocument(); const taskCard = screen.getByTestId('design-task-task-one'); expect(taskCard).toHaveTextContent('生图'); expect(taskCard.querySelector('time')).toHaveAttribute('datetime', taskFixture.createdAt); expect(taskCard).not.toHaveTextContent('已完成'); expect(screen.queryByRole('button', { name: '下载原图' })).not.toBeInTheDocument(); await waitFor(() => expect(screen.getByRole('img', { name: 'AI 设计生成缩略图' })) .toHaveAttribute('src', 'http://127.0.0.1:13210/content?token=host')); expect(screen.queryByText('生成设置')).not.toBeInTheDocument(); expect(screen.queryByText(/当前 Agent/)).not.toBeInTheDocument(); }); it('keeps task cards fixed-size and truncates long descriptions', async () => { const longDescription = '夜色中的机械城堡角色海报,加入冷暖对比和电影感轮廓光效果'; const compactDescription = `${Array.from(longDescription).slice(0, 24).join('')}…`; fetchImageWorkspaceTasksMock.mockResolvedValueOnce([{ ...taskFixture, taskId: 'task-long-description', briefSummary: longDescription, }]); render(); const taskCard = await screen.findByTestId('design-task-task-long-description'); expect(taskCard).toHaveClass('h-20', 'w-full'); expect(within(taskCard).getByRole('button')).toHaveClass('h-full'); expect(taskCard).toHaveTextContent(compactDescription); expect(taskCard).not.toHaveTextContent(longDescription); }); it('edits the final prompt and server-provided options before re-quoting', async () => { const requote = deferred(); updateImageWorkspaceGenerationQuoteMock.mockReset(); updateImageWorkspaceGenerationQuoteMock.mockReturnValueOnce(requote.promise); render(); const finalPrompt = await screen.findByTestId('design-quote-final-prompt'); fireEvent.change(finalPrompt, { target: { value: '用户最新编辑后的完整提示词' } }); fireEvent.change(screen.getByTestId('design-quote-resolution'), { target: { value: '2048x2048' }, }); fireEvent.change(screen.getByTestId('design-quote-aspect-ratio'), { target: { value: '16:9' }, }); await waitFor(() => expect(updateImageWorkspaceGenerationQuoteMock).toHaveBeenCalledWith( 'workspace-cloud', 'quote-one', '用户最新编辑后的完整提示词', { model: 'image-model-one', resolution: '2048x2048', aspectRatio: '16:9', durationSeconds: null, }, )); expect(screen.getByRole('button', { name: '重新报价中' })).toBeDisabled(); requote.resolve({ ...conversationFixture().messages[1].generationQuote!, finalPrompt: '用户最新编辑后的完整提示词', generationParameters: { model: 'image-model-one', resolution: '2048x2048', aspectRatio: '16:9', durationSeconds: null, }, quotedDesignPoints: 3, pricing: { schema: 'design-pricing-v2', amount: 3, rounding: 'ceil' }, }); await waitFor(() => expect(screen.getByTestId('design-quote-quote-one')) .toHaveTextContent('3 设计点')); expect(screen.getByTestId('design-quote-final-prompt')) .toHaveValue('用户最新编辑后的完整提示词'); const quoteCard = screen.getByTestId('design-quote-quote-one'); expect(quoteCard).toHaveTextContent('报价 3 设计点'); expect(within(quoteCard).getByTestId('design-quote-points')).toHaveTextContent('报价 3 设计点'); expect(within(quoteCard).queryByTestId('design-quote-model')).not.toBeInTheDocument(); expect(quoteCard).toHaveTextContent('继续调整'); expect(quoteCard).toHaveTextContent('清晰度'); expect(quoteCard).toHaveTextContent('画幅'); expect(quoteCard).not.toHaveTextContent('提示词模式:guided'); expect(quoteCard).not.toHaveTextContent('夜色中的机械城堡角色海报'); expect(screen.getByRole('button', { name: '确认并开始生成' })).toBeEnabled(); }); it('offers an actionable retry when re-quoting fails', async () => { updateImageWorkspaceGenerationQuoteMock.mockRejectedValueOnce( new ImageWorkspaceApiError(409, 'generation_quote_invalid', '当前生成方案已失效'), ); render(); fireEvent.change(await screen.findByTestId('design-quote-final-prompt'), { target: { value: '需要重新报价的提示词' }, }); const quoteCard = screen.getByTestId('design-quote-quote-one'); await waitFor(() => expect(updateImageWorkspaceGenerationQuoteMock).toHaveBeenCalled()); expect(await within(quoteCard).findByRole('alert')).toHaveTextContent('当前生成方案已失效'); expect(within(quoteCard).getByText(/请点击“重试报价”/)).toBeInTheDocument(); expect(within(quoteCard).getByRole('button', { name: '重试报价' })).toBeEnabled(); expect(within(quoteCard).getByRole('button', { name: '继续调整' })).toBeEnabled(); expect(within(quoteCard).getByRole('button', { name: '等待报价更新' })).toBeDisabled(); fireEvent.click(within(quoteCard).getByRole('button', { name: '重试报价' })); await waitFor(() => expect(updateImageWorkspaceGenerationQuoteMock).toHaveBeenCalledTimes(2)); await waitFor(() => expect(screen.getByRole('button', { name: '确认并开始生成' })).toBeEnabled()); }); it('uses the compact logo-only empty state for a new design conversation', async () => { fetchImageWorkspaceConversationMock.mockResolvedValueOnce({ ...conversationFixture(), messages: [], }); render(); const emptyState = await screen.findByTestId('image-workspace-empty-state'); expect(emptyState).toHaveTextContent('今天一起描绘什么?'); expect(within(emptyState).getByRole('img', { name: 'Makelore' })).toBeInTheDocument(); expect(within(emptyState).getAllByRole('img')).toHaveLength(1); expect(emptyState).not.toHaveTextContent('先聊清楚,再开始创作'); expect(emptyState).not.toHaveTextContent('告诉设计 Agent'); }); it('prefills the exact Prompt Museum text without sending it automatically', async () => { const prompt = 'Create a quiet editorial poster with generous negative space.'; useImagePromptMuseumStore.getState().setPendingPrompt({ promptId: 'prompt-museum-one', title: '编辑感产品海报', prompt, }); render(); const composer = await screen.findByLabelText('设计需求'); await waitFor(() => expect(composer).toHaveValue(prompt)); expect(sendImageWorkspaceMessageMock).not.toHaveBeenCalled(); expect(useImagePromptMuseumStore.getState().pendingPrompt).toBeNull(); }); it('keeps historical choices fixed after a later assistant turn', async () => { const conversation = conversationFixture(); conversation.messages[1] = { ...conversation.messages[1], quickReplies: ['暖色调', '冷色调'], generationQuote: null, }; conversation.messages.push( { id: 'message-follow-up-user', role: 'user', kind: 'user', text: '继续调整色彩', quickReplies: [], generationQuote: null, turnRevision: 2, createdAt: '2026-07-31T10:01:00Z', }, { id: 'message-follow-up-assistant', role: 'assistant', kind: 'choice', text: '请选择下一步方向。', quickReplies: ['增加对比度', '保持当前方案'], generationQuote: null, turnRevision: 2, createdAt: '2026-07-31T10:01:01Z', }, ); fetchImageWorkspaceConversationMock.mockResolvedValueOnce(conversation); render(); await screen.findByTestId('image-workspace-conversation'); expect(screen.queryByRole('button', { name: '暖色调' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: '冷色调' })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: '增加对比度' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '保持当前方案' })).toBeInTheDocument(); }); it.each(['consumed', 'expired', 'superseded'] as const)( 'hides quote quick replies when the quote is %s', async (status) => { const conversation = conversationFixture(); conversation.messages[1] = { ...conversation.messages[1], generationQuote: { ...conversation.messages[1].generationQuote!, status, }, }; fetchImageWorkspaceConversationMock.mockResolvedValueOnce(conversation); render(); await screen.findByTestId('image-workspace-conversation'); expect(screen.queryByRole('button', { name: '确认生成' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: '继续调整' })).not.toBeInTheDocument(); expect(screen.queryByTestId('design-quote-quote-one')).not.toBeInTheDocument(); }, ); it('hides quote quick replies when the quote payload is gone', async () => { const conversation = conversationFixture(); conversation.messages[1] = { ...conversation.messages[1], generationQuote: null, }; fetchImageWorkspaceConversationMock.mockResolvedValueOnce(conversation); render(); await screen.findByTestId('image-workspace-conversation'); expect(screen.queryByRole('button', { name: '确认生成' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: '继续调整' })).not.toBeInTheDocument(); }); it('opens a generated image at full size and saves it through Main', async () => { render(); const taskCard = await screen.findByTestId('design-task-task-one'); fireEvent.click(within(taskCard).getByRole('button', { name: '查看生图任务详情' })); const dialog = await screen.findByRole('dialog', { name: '生成内容详情' }); expect(dialog).toHaveTextContent('1024 × 1024'); expect(within(dialog).getByRole('img', { name: 'AI 设计大图预览' })) .toHaveAttribute('src', 'http://127.0.0.1:13210/content?token=host'); fireEvent.click(within(dialog).getByRole('button', { name: '下载原图' })); await waitFor(() => expect(saveImageWorkspaceAssetMock).toHaveBeenCalledWith( taskFixture.resultAssets[0], )); }); it('shows video tasks as compact cards and exposes video controls in details', async () => { const videoAsset = { ...taskFixture.resultAssets[0], assetId: 'video-asset-one', mediaType: 'video' as const, mimeType: 'video/mp4', durationMilliseconds: 4_000, }; const videoTask = { ...taskFixture, taskId: 'task-video-one', medium: 'video' as const, resultAssets: [videoAsset], }; fetchImageWorkspaceTasksMock.mockResolvedValueOnce([videoTask]); render(); const taskCard = await screen.findByTestId('design-task-task-video-one'); expect(taskCard).toHaveTextContent('视频'); fireEvent.click(within(taskCard).getByRole('button', { name: '查看视频任务详情' })); const dialog = await screen.findByRole('dialog', { name: '生成内容详情' }); expect(within(dialog).getByLabelText('AI 设计视频预览')).toBeInTheDocument(); fireEvent.click(within(dialog).getByRole('button', { name: '下载视频' })); await waitFor(() => expect(saveImageWorkspaceAssetMock).toHaveBeenCalledWith(videoAsset)); }); it('announces the image download progress while Main is saving', async () => { let finishSaving: ((value: { status: 'saved' }) => void) | undefined; saveImageWorkspaceAssetMock.mockReturnValue(new Promise((resolve) => { finishSaving = resolve; })); render(); const taskCard = await screen.findByTestId('design-task-task-one'); fireEvent.click(within(taskCard).getByRole('button', { name: '查看生图任务详情' })); const dialog = await screen.findByRole('dialog', { name: '生成内容详情' }); fireEvent.click(within(dialog).getByRole('button', { name: '下载原图' })); const savingButton = within(dialog).getByRole('button', { name: '正在下载生图' }); expect(savingButton).toBeDisabled(); expect(savingButton).toHaveAttribute('aria-busy', 'true'); finishSaving?.({ status: 'saved' }); await waitFor(() => expect( within(dialog).getByRole('button', { name: '下载原图' }), ).toBeEnabled()); }); it('keeps the task list visible in a 1404px desktop workspace', async () => { const previousWidth = window.innerWidth; Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1404 }); try { render(); await screen.findByTestId('image-workspace-conversation'); expect(screen.getByTestId('design-task-list')).toBeInTheDocument(); expect(screen.getByTestId('design-task-list')).toHaveClass('lg:border-l', 'lg:h-full'); } finally { Object.defineProperty(window, 'innerWidth', { configurable: true, value: previousWidth, }); } }); it('keeps conversation and task scrolling isolated', async () => { render(); await screen.findByTestId('image-workspace-conversation'); expect(screen.getByTestId('image-canvas-page')) .toHaveClass('h-full', 'overflow-hidden'); expect(screen.getByTestId('design-task-list')) .toHaveClass('lg:h-full', 'lg:flex', 'lg:flex-col', 'overflow-hidden'); expect(screen.getByTestId('image-workspace-conversation')) .toHaveClass('overflow-y-auto', 'overscroll-contain'); expect(screen.getByTestId('design-task-scroll')) .toHaveClass('lg:flex-1', 'lg:max-h-none', 'min-h-0', 'overflow-y-auto', 'overscroll-contain'); expect(screen.getByTestId('image-workspace-conversation-fade')) .toHaveClass('absolute', 'top-0', 'bg-gradient-to-b'); expect(screen.getByTestId('design-task-fade')).toBeInTheDocument(); expect(screen.getByTestId('design-task-list').firstElementChild).not.toHaveClass('border-b'); }); it('explains the project model before the first Workspace is created', async () => { fetchImageWorkspaceMock.mockResolvedValueOnce({ ...bootstrapFixture, workspaces: [], }); render(); expect(await screen.findByText('创建第一个设计项目')).toBeInTheDocument(); expect(screen.getByText(/持续保留与设计 Agent 的对话/)).toBeInTheDocument(); expect(screen.getByRole('button', { name: '新建设计项目' })).toBeInTheDocument(); }); it('sends text to the Agent with the current Turn revision instead of generating directly', async () => { render(); await screen.findByTestId('image-workspace-conversation'); fireEvent.change(screen.getByLabelText('设计需求'), { target: { value: '把主角换成暖色轮廓光' }, }); fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' })); await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith( 'workspace-cloud', 'conversation-cloud', 1, '把主角换成暖色轮廓光', expect.stringMatching(/^turn-/), )); expect(confirmImageWorkspaceGenerationMock).not.toHaveBeenCalled(); }); it('sends the design message with Enter', async () => { render(); await screen.findByTestId('image-workspace-conversation'); const composer = screen.getByLabelText('设计需求'); fireEvent.change(composer, { target: { value: '让画面增加一层晨雾' }, }); fireEvent.keyDown(composer, { key: 'Enter' }); await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith( 'workspace-cloud', 'conversation-cloud', 1, '让画面增加一层晨雾', expect.stringMatching(/^turn-/), )); expect(composer).toHaveValue(''); expect(screen.getByTestId('image-workspace-composer')) .toHaveClass('w-[calc(100%-2rem)]', 'max-w-[46rem]'); expect(screen.getByTestId('image-workspace-composer-surface')) .toHaveClass('rounded-lg', 'flex', 'min-w-0'); expect(screen.getByRole('button', { name: '上传参考图' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '语音输入' })).toBeDisabled(); expect(screen.queryByText('Enter 发送 · Shift+Enter 换行')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '上传参考图' })); expect(screen.queryByRole('dialog', { name: '选择视频首帧' })).not.toBeInTheDocument(); expect(screen.getByLabelText('选择参考图文件')).toBeInTheDocument(); }); it('uploads a reference image inline, lets the user mention it, and sends its Asset id', async () => { render(); await screen.findByTestId('image-workspace-conversation'); const file = new File(['image-bytes'], 'ocean-poster.png', { type: 'image/png' }); fireEvent.change(screen.getByLabelText('选择参考图文件'), { target: { files: [file] }, }); await waitFor(() => expect(uploadImageWorkspaceAssetMock) .toHaveBeenCalledWith('workspace-cloud', file)); const candidates = await screen.findByTestId('image-workspace-reference-candidates'); expect(candidates).toHaveTextContent('ocean-poster.png'); expect(screen.queryByRole('dialog', { name: '选择视频首帧' })).not.toBeInTheDocument(); fireEvent.click(within(candidates).getByRole('button', { name: '选择参考图 ocean-poster.png' })); expect(screen.getByLabelText('设计需求')).toHaveValue('@ocean-poster.png '); fireEvent.change(screen.getByLabelText('设计需求'), { target: { value: '@ocean-poster.png 请沿用这张图的构图' }, }); fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' })); await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith( 'workspace-cloud', 'conversation-cloud', 1, '@ocean-poster.png 请沿用这张图的构图', expect.stringMatching(/^turn-/), ['asset-uploaded'], )); }); it('opens the inline reference palette when the user types @', async () => { render(); await screen.findByTestId('image-workspace-conversation'); const file = new File(['image-bytes'], 'character-sheet.png', { type: 'image/png' }); fireEvent.change(screen.getByLabelText('选择参考图文件'), { target: { files: [file] }, }); await screen.findByTestId('image-workspace-reference-candidates'); const composer = screen.getByLabelText('设计需求'); fireEvent.change(composer, { target: { value: '请参考 @', selectionStart: 5, selectionEnd: 5 }, }); const palette = await screen.findByRole('listbox', { name: '选择参考图' }); expect(within(palette).getByRole('option', { name: /character-sheet\.png/ })).toBeInTheDocument(); fireEvent.click(within(palette).getByRole('option', { name: /character-sheet\.png/ })); expect(composer).toHaveValue('请参考 @character-sheet.png '); }); it('keeps Shift+Enter for newlines and ignores Enter during IME composition', async () => { render(); await screen.findByTestId('image-workspace-conversation'); const composer = screen.getByLabelText('设计需求'); fireEvent.change(composer, { target: { value: '正在输入中文设计描述' }, }); fireEvent.keyDown(composer, { key: 'Enter', shiftKey: true }); fireEvent.keyDown(composer, { key: 'Enter', isComposing: true }); expect(sendImageWorkspaceMessageMock).not.toHaveBeenCalled(); expect(composer).toHaveValue('正在输入中文设计描述'); }); it('renders the pending user turn and assistant deltas while the command is running', async () => { const response = deferred(); sendImageWorkspaceMessageMock.mockReturnValueOnce(response.promise); render(); await screen.findByTestId('image-workspace-conversation'); await waitFor(() => expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce()); fireEvent.change(screen.getByLabelText('设计需求'), { target: { value: '让画面更有海洋呼吸感' }, }); fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' })); expect(await screen.findByText('让画面更有海洋呼吸感')).toBeInTheDocument(); expect(screen.getByRole('status', { name: '设计 Agent 正在回复' })).toBeInTheDocument(); const pending = useImageWorkspaceStore.getState().pendingTurn!; act(() => { taskEventSource.emit('design.assistant.delta', { id: 'session-one:2', type: 'design.assistant.delta', workspaceId: 'workspace-cloud', conversationId: 'conversation-cloud', clientTurnId: pending.clientTurnId, turnRevision: pending.turnRevision, chunkIndex: 0, delta: '可以先增加', } satisfies DesignAssistantDeltaEvent); }); expect(screen.getByTestId('image-workspace-conversation')) .toHaveTextContent('可以先增加'); expect(screen.getByTestId('image-workspace-conversation')) .not.toHaveTextContent('留白和水流节奏'); act(() => { taskEventSource.emit('design.assistant.delta', { id: 'session-one:3', type: 'design.assistant.delta', workspaceId: 'workspace-cloud', conversationId: 'conversation-cloud', clientTurnId: pending.clientTurnId, turnRevision: pending.turnRevision, chunkIndex: 1, delta: '留白和水流节奏。', } satisfies DesignAssistantDeltaEvent); }); expect(screen.getByTestId('image-workspace-conversation')) .toHaveTextContent('可以先增加留白和水流节奏。'); act(() => response.resolve({ ...conversationFixture(), turnRevision: 2, })); await waitFor(() => expect(useImageWorkspaceStore.getState().pendingTurn).toBeNull()); }); it('reconciles a running Agent turn after returning to the conversation page', async () => { const response = deferred(); const canonical: DesignConversation = { ...conversationFixture(), turnRevision: 2, phase: 'shaping', messages: [{ id: 'message-latest', role: 'assistant', kind: 'reply', text: '已完成最新方向整理。', quickReplies: [], generationQuote: null, turnRevision: 2, createdAt: '2026-07-31T10:01:00Z', }], }; sendImageWorkspaceMessageMock.mockReturnValueOnce(response.promise); const firstRender = render(); await screen.findByTestId('image-workspace-conversation'); await waitFor(() => expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce()); await waitFor(() => expect(taskEventSource.onopen).not.toBeNull()); act(() => taskEventSource.onopen?.(new Event('open'))); fireEvent.change(screen.getByLabelText('设计需求'), { target: { value: '继续优化海报' }, }); fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' })); expect(await screen.findByText('继续优化海报')).toBeInTheDocument(); firstRender.unmount(); fetchImageWorkspaceConversationMock.mockResolvedValueOnce(canonical); render(); expect(await screen.findByText('继续优化海报')).toBeInTheDocument(); expect(await screen.findByText('已完成最新方向整理。')).toBeInTheDocument(); expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce(); expect(taskEventSource.close).not.toHaveBeenCalled(); await act(async () => { response.resolve(canonical); await response.promise; }); }); it('does not expose confirmation quick replies when no active Quote exists', async () => { const workspaceWithoutQuote = conversationFixture(); workspaceWithoutQuote.messages[1] = { ...workspaceWithoutQuote.messages[1], generationQuote: null, }; fetchImageWorkspaceMock.mockResolvedValueOnce({ ...bootstrapFixture, capabilities: { ...bootstrapFixture.capabilities, generation: false, image: false, video: false, }, }); fetchImageWorkspaceConversationMock.mockResolvedValueOnce(workspaceWithoutQuote); render(); await screen.findByTestId('image-workspace-conversation'); expect(screen.queryByRole('button', { name: '确认生成' })).not.toBeInTheDocument(); fireEvent.change(screen.getByLabelText('设计需求'), { target: { value: '确认生成' }, }); fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' })); expect(await screen.findByRole('alert')).toHaveTextContent('当前没有可确认的生成报价'); expect(screen.getByLabelText('设计需求')).toHaveValue(''); expect(sendImageWorkspaceMessageMock).not.toHaveBeenCalled(); expect(confirmImageWorkspaceGenerationMock).not.toHaveBeenCalled(); expect(screen.getByTestId('image-workspace-composer')) .toHaveTextContent('当前环境仅支持设计沟通'); }); it('creates a generation task only through explicit Quote confirmation', async () => { fetchImageWorkspaceTasksMock .mockResolvedValueOnce([taskFixture]) .mockResolvedValueOnce([{ ...taskFixture, taskId: 'task-two', quoteId: 'quote-one', status: 'queued', resultAssets: [], }]); render(); await screen.findByTestId('design-quote-quote-one'); fireEvent.click(screen.getByRole('button', { name: '确认并开始生成' })); await waitFor(() => expect(confirmImageWorkspaceGenerationMock) .toHaveBeenCalledWith( 'workspace-cloud', 'conversation-cloud', 1, 'quote-one', expect.stringMatching(/^turn-/), '夜色中的机械城堡角色海报,电影感构图。', { model: 'image-model-one', resolution: '1024x1024', aspectRatio: '1:1', durationSeconds: null, }, )); await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2)); expect(await screen.findByTestId('design-task-task-two')).toBeInTheDocument(); }); it('keeps the final confirmation prompt visible in the conversation', async () => { const confirmedConversation: DesignConversation = { ...conversationFixture(), turnRevision: 2, phase: 'shaping', messages: [{ id: 'message-confirmed', role: 'assistant', kind: 'reply', text: '设计任务已确认并开始执行。', quickReplies: [], generationQuote: null, turnRevision: 2, createdAt: '2026-07-31T10:01:00Z', }], }; confirmImageWorkspaceGenerationMock.mockResolvedValueOnce(confirmedConversation); fetchImageWorkspaceTasksMock .mockResolvedValueOnce([taskFixture]) .mockResolvedValueOnce([{ ...taskFixture, taskId: 'task-confirmed-visible', quoteId: 'quote-one', status: 'queued', resultAssets: [], }]); render(); await screen.findByTestId('design-quote-quote-one'); fireEvent.click(screen.getByRole('button', { name: '确认并开始生成' })); expect(await screen.findByText('确认生成')).toBeInTheDocument(); expect(screen.getByText('设计任务已确认并开始执行。')).toBeInTheDocument(); }); it('converts an exact composer confirmation into the structured Quote action', async () => { fetchImageWorkspaceTasksMock .mockResolvedValueOnce([taskFixture]) .mockResolvedValueOnce([{ ...taskFixture, taskId: 'task-confirmed-from-composer', quoteId: 'quote-one', status: 'queued', resultAssets: [], }]); render(); await screen.findByTestId('design-quote-quote-one'); fireEvent.change(screen.getByLabelText('设计需求'), { target: { value: '确认生成' }, }); fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' })); await waitFor(() => expect(confirmImageWorkspaceGenerationMock) .toHaveBeenCalledWith( 'workspace-cloud', 'conversation-cloud', 1, 'quote-one', expect.stringMatching(/^turn-/), '夜色中的机械城堡角色海报,电影感构图。', { model: 'image-model-one', resolution: '1024x1024', aspectRatio: '1:1', durationSeconds: null, }, )); expect(sendImageWorkspaceMessageMock).not.toHaveBeenCalled(); expect(await screen.findByTestId('design-task-task-confirmed-from-composer')) .toBeInTheDocument(); }); it('keeps longer design questions containing confirmation words as normal chat', async () => { render(); await screen.findByTestId('design-quote-quote-one'); const question = '请确认这个生成图的构图是不是还需要调整'; fireEvent.change(screen.getByLabelText('设计需求'), { target: { value: question }, }); fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' })); await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith( 'workspace-cloud', 'conversation-cloud', 1, question, expect.stringMatching(/^turn-/), )); expect(confirmImageWorkspaceGenerationMock).not.toHaveBeenCalled(); }); it('opens the successful work picker when the Agent asks for a video first frame', async () => { fetchImageWorkspaceConversationMock.mockResolvedValueOnce({ ...conversationFixture(), phase: 'shaping', brief: { version: 2, status: 'draft', medium: 'video', summary: '让海洋公益海报动起来', ready: false, missingDecision: '请选择首帧来源', }, messages: [ ...conversationFixture().messages, { id: 'message-video-source', role: 'assistant', kind: 'choice', text: '请选择视频首帧来源。', quickReplies: ['先生成一张首帧图片(推荐)', '从作品列表选择图片'], generationQuote: null, turnRevision: 2, createdAt: '2026-08-06T09:00:00Z', }, ], } satisfies DesignConversation); render(); fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' })); const picker = screen.getByRole('dialog', { name: '选择视频首帧' }); expect(await within(picker).findByRole('img', { name: '可选作品图片' })) .toHaveAttribute('src', 'http://127.0.0.1:13210/content?token=host'); expect(within(picker).getByRole('button', { name: '选择此图片' })).toBeEnabled(); expect(within(picker).getByLabelText('上传本地图片')).toBeInTheDocument(); expect(sendImageWorkspaceMessageMock).not.toHaveBeenCalled(); }); it('keeps the historical video picker behavior when the brief medium is not decided yet', async () => { fetchImageWorkspaceConversationMock.mockResolvedValueOnce({ ...conversationFixture(), phase: 'shaping', brief: { version: 2, status: 'draft', medium: null, summary: '', ready: false, missingDecision: '请选择首帧来源', }, messages: [ ...conversationFixture().messages, { id: 'message-undecided-source', role: 'assistant', kind: 'choice', text: '请选择视频首帧来源。', quickReplies: ['从作品列表选择图片'], generationQuote: null, turnRevision: 2, createdAt: '2026-08-12T09:00:00Z', }, ], turnRevision: 2, } satisfies DesignConversation); render(); fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' })); const picker = screen.getByRole('dialog', { name: '选择视频首帧' }); const selectButton = within(picker).getByRole('button', { name: '选择此图片' }); await waitFor(() => expect(selectButton).toBeEnabled()); fireEvent.click(selectButton); await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith( 'workspace-cloud', 'conversation-cloud', 2, '使用作品图片作为视频首帧', expect.stringMatching(/^turn-/), ['asset-one'], )); expect(screen.queryByRole('dialog', { name: '选择视频首帧' })) .not.toBeInTheDocument(); }); it('opens the image reference picker and submits a successful work Asset id', async () => { fetchImageWorkspaceConversationMock.mockResolvedValueOnce({ ...conversationFixture(), phase: 'shaping', brief: { version: 2, status: 'draft', medium: 'image', summary: '参考海洋公益海报生成新图', ready: false, missingDecision: '请选择参考图来源', }, messages: [ ...conversationFixture().messages, { id: 'message-image-source', role: 'assistant', kind: 'choice', text: '请选择图生图参考图。', quickReplies: ['从作品列表选择图片'], generationQuote: null, turnRevision: 2, createdAt: '2026-08-12T09:00:00Z', }, ], turnRevision: 2, } satisfies DesignConversation); render(); fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' })); const picker = screen.getByRole('dialog', { name: '选择图生图参考图' }); expect(within(picker).getByText(/作为图生图参考/)).toBeInTheDocument(); const selectButton = within(picker).getByRole('button', { name: '选择此图片' }); await waitFor(() => expect(selectButton).toBeEnabled()); fireEvent.click(selectButton); await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith( 'workspace-cloud', 'conversation-cloud', 2, '使用作品图片作为图生图参考图', expect.stringMatching(/^turn-/), ['asset-one'], )); expect(screen.queryByRole('dialog', { name: '选择图生图参考图' })) .not.toBeInTheDocument(); }); it('uploads a local image reference and submits its Asset id to the Agent', async () => { fetchImageWorkspaceConversationMock.mockResolvedValueOnce({ ...conversationFixture(), messages: [ ...conversationFixture().messages, { id: 'message-image-source', role: 'assistant', kind: 'choice', text: '请选择图生图参考图。', quickReplies: ['从作品列表选择图片'], generationQuote: null, turnRevision: 2, createdAt: '2026-08-12T09:00:00Z', }, ], turnRevision: 2, } satisfies DesignConversation); render(); fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' })); const picker = screen.getByRole('dialog', { name: '选择图生图参考图' }); const file = new File(['image-bytes'], 'reference.png', { type: 'image/png' }); fireEvent.change(within(picker).getByLabelText('上传本地图片'), { target: { files: [file] }, }); await waitFor(() => expect(uploadImageWorkspaceAssetMock) .toHaveBeenCalledWith('workspace-cloud', file)); await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith( 'workspace-cloud', 'conversation-cloud', 2, '使用上传图片作为图生图参考图', expect.stringMatching(/^turn-/), ['asset-uploaded'], )); await waitFor(() => expect( screen.queryByRole('dialog', { name: '选择图生图参考图' }), ).not.toBeInTheDocument()); }); it('submits a successful work Asset id when it is selected as the video first frame', async () => { const pendingSelection = deferred(); sendImageWorkspaceMessageMock.mockReturnValueOnce(pendingSelection.promise); fetchImageWorkspaceConversationMock.mockResolvedValueOnce({ ...conversationFixture(), brief: { ...conversationFixture().brief, medium: 'video', }, turnRevision: 2, messages: [ ...conversationFixture().messages, { id: 'message-video-source', role: 'assistant', kind: 'choice', text: '请选择视频首帧来源。', quickReplies: ['从作品列表选择图片'], generationQuote: null, turnRevision: 2, createdAt: '2026-08-06T09:00:00Z', }, ], } satisfies DesignConversation); render(); fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' })); const selectButton = within( screen.getByRole('dialog', { name: '选择视频首帧' }), ).getByRole('button', { name: '选择此图片' }); await waitFor(() => expect(selectButton).toBeEnabled()); fireEvent.click(selectButton); await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith( 'workspace-cloud', 'conversation-cloud', 2, '使用作品图片作为视频首帧', expect.stringMatching(/^turn-/), ['asset-one'], )); expect(screen.queryByRole('dialog', { name: '选择视频首帧' })).not.toBeInTheDocument(); await act(async () => { pendingSelection.resolve(conversationFixture()); await pendingSelection.promise; }); }); it('uploads a local first-frame image and submits its Asset id to the Agent', async () => { fetchImageWorkspaceConversationMock.mockResolvedValueOnce({ ...conversationFixture(), brief: { ...conversationFixture().brief, medium: 'video', }, messages: [ ...conversationFixture().messages, { id: 'message-video-source', role: 'assistant', kind: 'choice', text: '请选择视频首帧来源。', quickReplies: ['从作品列表选择图片'], generationQuote: null, turnRevision: 2, createdAt: '2026-08-06T09:00:00Z', }, ], turnRevision: 2, } satisfies DesignConversation); render(); fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' })); const picker = screen.getByRole('dialog', { name: '选择视频首帧' }); const file = new File(['image-bytes'], 'ocean-poster.png', { type: 'image/png' }); fireEvent.change(within(picker).getByLabelText('上传本地图片'), { target: { files: [file] }, }); await waitFor(() => expect(uploadImageWorkspaceAssetMock) .toHaveBeenCalledWith('workspace-cloud', file)); await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith( 'workspace-cloud', 'conversation-cloud', 2, '使用上传的图片作为视频首帧', expect.stringMatching(/^turn-/), ['asset-uploaded'], )); await waitFor(() => expect( screen.queryByRole('dialog', { name: '选择视频首帧' }), ).not.toBeInTheDocument()); }); it('renders policy-blocked generation tasks with a user-facing design message', async () => { fetchImageWorkspaceTasksMock.mockResolvedValue([{ ...taskFixture, status: 'failed', failureCode: 'policy_blocked', resultAssets: [], }]); render(); const taskCard = await screen.findByTestId('design-task-task-one'); expect(within(taskCard).queryByRole('alert')).not.toBeInTheDocument(); fireEvent.click(within(taskCard).getByRole('button', { name: '查看生图任务详情' })); const alert = await screen.findByRole('alert'); expect(alert).toHaveTextContent('内容可能涉及版权或安全风险,请调整设计后重试'); expect(alert).not.toHaveTextContent('policy_blocked'); }); it('renders a new task pushed by the design event stream without repeated polling', async () => { const queuedTask = { ...taskFixture, taskId: 'task-delayed', status: 'queued' as const, resultAssets: [], }; fetchImageWorkspaceTasksMock.mockResolvedValue([]); render(); await screen.findByTestId('design-quote-quote-one'); await waitFor(() => expect(openImageWorkspaceTaskEventsMock) .toHaveBeenCalledWith('workspace-cloud', 'conversation-cloud')); await waitFor(() => expect(taskEventSource.onopen).not.toBeNull()); act(() => { taskEventSource.onopen?.(new Event('open')); taskEventSource.emit('design.generation_task.updated', { id: 'session-one:2', type: 'design.generation_task.updated', workspaceId: 'workspace-cloud', workspaceViewRevision: 2, generationTask: queuedTask, } satisfies DesignGenerationTaskUpdatedEvent); }); expect(await screen.findByTestId('design-task-task-delayed')) .toBeInTheDocument(); expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledOnce(); }); });