import { act, fireEvent, render, screen, waitFor } 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 { useImageWorkspaceStore } from '@/stores/image-workspace'; import type { DesignGenerationTask, DesignGenerationTaskUpdatedEvent, DesignWorkspace, DesignWorkspaceBootstrap, } from '../../shared/image-workspace'; const fetchImageWorkspaceMock = vi.hoisted(() => vi.fn()); const fetchImageWorkspaceProjectMock = vi.hoisted(() => vi.fn()); const fetchImageWorkspaceTasksMock = vi.hoisted(() => vi.fn()); const sendImageWorkspaceMessageMock = vi.hoisted(() => vi.fn()); const confirmImageWorkspaceGenerationMock = vi.hoisted(() => vi.fn()); const openImageWorkspaceTaskEventsMock = vi.hoisted(() => vi.fn()); const resolveImageWorkspaceAssetUrlMock = 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); } } vi.mock('@/lib/image-workspace', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, fetchImageWorkspace: (...args: unknown[]) => fetchImageWorkspaceMock(...args), fetchImageWorkspaceProject: (...args: unknown[]) => fetchImageWorkspaceProjectMock(...args), fetchImageWorkspaceTasks: (...args: unknown[]) => fetchImageWorkspaceTasksMock(...args), sendImageWorkspaceMessage: (...args: unknown[]) => sendImageWorkspaceMessageMock(...args), confirmImageWorkspaceGeneration: (...args: unknown[]) => ( confirmImageWorkspaceGenerationMock(...args) ), openImageWorkspaceTaskEvents: (...args: unknown[]) => ( openImageWorkspaceTaskEventsMock(...args) ), resolveImageWorkspaceAssetUrl: (...args: unknown[]) => ( resolveImageWorkspaceAssetUrlMock(...args) ), }; }); const bootstrapFixture: DesignWorkspaceBootstrap = { capabilities: { conversation: true, generation: true, image: true, video: true, }, workspaces: [{ workspaceId: 'workspace-cloud', title: '云端角色设定', turnRevision: 1, viewRevision: 1, phase: 'awaiting_confirmation', brief: { version: 1, status: 'ready', medium: 'image', summary: '夜色中的机械城堡角色海报', ready: true, missingDecision: null, }, updatedAt: '2026-07-31T10:00:00Z', }], }; function workspaceFixture(): DesignWorkspace { return { ...bootstrapFixture.workspaces[0], 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: '夜色中的机械城堡角色海报', quotedDesignPoints: 1, expiresAt: '2026-07-31T11:00:00Z', }, turnRevision: 1, createdAt: '2026-07-31T10:00:01Z', }, ], }; } const taskFixture: DesignGenerationTask = { taskId: 'task-one', workspaceId: 'workspace-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(); fetchImageWorkspaceMock.mockResolvedValue(bootstrapFixture); fetchImageWorkspaceProjectMock.mockResolvedValue(workspaceFixture()); fetchImageWorkspaceTasksMock.mockResolvedValue([taskFixture]); sendImageWorkspaceMessageMock.mockResolvedValue(workspaceFixture()); confirmImageWorkspaceGenerationMock.mockResolvedValue({ ...workspaceFixture(), 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', ); }); 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', refreshToken: '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, refreshToken: null, user: null, }); expect(useImageWorkspaceStore.getState().status).toBe('auth-required'); useAuthStore.setState({ accessToken: 'fresh-access-token', refreshToken: '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 one fixed design Agent, a Quote, and the unified image/video task list', async () => { render(); expect(await screen.findByText('云端角色设定')).toBeInTheDocument(); expect(screen.getByTestId('image-workspace-conversation')) .toHaveTextContent('方向已经明确'); expect(screen.getByText('设计 Agent')).toBeInTheDocument(); expect(screen.getByTestId('design-quote-quote-one')).toHaveTextContent('1 设计点'); expect(screen.getByTestId('design-task-list')).toHaveTextContent('图片与视频任务统一展示'); expect(screen.getByTestId('design-task-task-one')).toHaveTextContent('已完成'); 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 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-pane-divider')).toHaveClass('lg:block'); } 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('image-workspace-conversation')) .toHaveClass('overflow-y-auto', 'overscroll-contain'); expect(screen.getByTestId('design-task-list')) .toHaveClass('lg:h-full', 'overflow-hidden'); expect(screen.getByTestId('design-task-scroll')) .toHaveClass('lg:h-[calc(100%-57px)]', 'overflow-y-auto', 'overscroll-contain'); }); 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.findByText('云端角色设定'); fireEvent.change(screen.getByLabelText('设计需求'), { target: { value: '把主角换成暖色轮廓光' }, }); fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' })); await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith( 'workspace-cloud', 1, '把主角换成暖色轮廓光', )); expect(confirmImageWorkspaceGenerationMock).not.toHaveBeenCalled(); }); it('refreshes tasks after an Agent turn creates a generation task', async () => { const queuedTask = { ...taskFixture, taskId: 'task-two', status: 'queued' as const, resultAssets: [], }; fetchImageWorkspaceTasksMock .mockResolvedValueOnce([]) .mockResolvedValueOnce([queuedTask]); render(); await screen.findByTestId('design-quote-quote-one'); const confirmationReply = workspaceFixture().messages[1].quickReplies[0]; fireEvent.change(screen.getByLabelText('设计需求'), { target: { value: confirmationReply }, }); fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' })); await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith( 'workspace-cloud', 1, confirmationReply, )); await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2)); expect(await screen.findByTestId('design-task-task-two')).toBeInTheDocument(); }); it('creates a generation task only through explicit Quote confirmation', async () => { fetchImageWorkspaceTasksMock .mockResolvedValueOnce([taskFixture]) .mockResolvedValueOnce([{ ...taskFixture, taskId: 'task-two', status: 'queued', resultAssets: [], }]); render(); await screen.findByTestId('design-quote-quote-one'); fireEvent.click(screen.getByRole('button', { name: '确认并开始生成' })); await waitFor(() => expect(confirmImageWorkspaceGenerationMock) .toHaveBeenCalledWith('workspace-cloud', 1, 'quote-one')); await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2)); }); 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')); 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(); }); });