import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; 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 { designBootstrapFixture, designFormFixture, designQuoteFixture, designWorkspaceFixture, } from '../fixtures/design-workspace-v2'; const { toastErrorMock } = vi.hoisted(() => ({ toastErrorMock: vi.fn() })); vi.mock('sonner', () => ({ toast: { error: toastErrorMock, success: vi.fn() }, })); vi.mock('@/lib/image-workspace', async (importOriginal) => { const original = await importOriginal(); return { ...original, resolveImageWorkspaceAssetUrl: vi.fn().mockReturnValue(new Promise(() => undefined)), saveImageWorkspaceAsset: vi.fn().mockResolvedValue({ status: 'saved' }), uploadImageWorkspaceAsset: vi.fn(), }; }); function setViewport(desktop: boolean) { window.matchMedia = vi.fn().mockImplementation(() => ({ matches: desktop, media: '(min-width: 1024px)', onchange: null, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn(), })); } function prepareWorkspace(overrides = {}) { const workspace = designWorkspaceFixture(overrides); const actions = { load: vi.fn(), connectEvents: vi.fn(), disconnectEvents: vi.fn(), refreshWorkspace: vi.fn().mockResolvedValue(workspace), createProject: vi.fn().mockResolvedValue(workspace), renameProject: vi.fn().mockResolvedValue(workspace), deleteProject: vi.fn().mockResolvedValue({ workspaceId: 'workspace-1', deleted: true }), selectProject: vi.fn().mockResolvedValue(undefined), applyFieldOperations: vi.fn().mockResolvedValue(workspace), sendChat: vi.fn().mockResolvedValue(workspace), resolveDecisionPrompt: vi.fn().mockResolvedValue(workspace), requestQuote: vi.fn().mockResolvedValue(workspace), confirmGeneration: vi.fn().mockResolvedValue(workspace), retryOperation: vi.fn().mockResolvedValue(workspace), }; useImageWorkspaceStore.setState({ status: 'ready', bootstrap: designBootstrapFixture(workspace.workspace), activeWorkspaceId: workspace.workspace.workspaceId, workspace, eventState: 'connected', lastEventId: null, pendingOperations: {}, fieldDrafts: {}, chatDraft: '', assistantActivities: {}, assistantStreams: {}, quoteBlockers: [], error: null, ...actions, }); return { workspace, actions }; } describe('AI Design Canvas page', () => { beforeEach(() => { vi.clearAllMocks(); setViewport(false); useAuthStore.setState({ initialized: true, accessToken: 'token', expiresAt: Date.now() + 60_000, lastActiveAt: Date.now(), canRefresh: true, user: { username: 'canvas-tester', userId: 'canvas-user', tenantId: null, deptId: null, authorities: [], }, }); useImagePromptMuseumStore.setState({ pendingPrompt: null }); useImageWorkspaceStore.getState().reset(); }); it('keeps the loading shell until the first Workspace bootstrap settles', () => { const load = vi.fn(); useImageWorkspaceStore.setState({ status: 'idle', workspace: null, bootstrap: null, load }); render(); expect(screen.getByLabelText('正在加载 AI 设计')).toBeInTheDocument(); expect(screen.queryByTestId('image-workspace-empty-state')).not.toBeInTheDocument(); expect(load).toHaveBeenCalledOnce(); }); it('keeps conversation and the active editable plan together in the center', () => { prepareWorkspace(); render(); const conversation = screen.getByTestId('image-workspace-conversation'); expect(conversation).toHaveClass('bg-background'); expect(conversation).not.toHaveClass('bg-surface-subtle/45'); expect(within(conversation).getByTestId('design-message-composer')).toHaveClass('max-w-[50rem]'); expect(within(conversation).getByTestId('design-message-composer-surface')).toHaveClass('chat-composer-surface'); expect(within(conversation).queryByRole('heading', { name: '和 AI 一起完善创作' })).not.toBeInTheDocument(); expect(within(conversation).getByRole('heading', { name: '制作方案' })).toBeInTheDocument(); expect(within(conversation).getByRole('textbox', { name: '创作提示词' })).toHaveValue( '把便携咖啡机呈现为城市通勤中的精密工具', ); expect(within(conversation).getByRole('combobox', { name: '画幅' })).toHaveValue('3:4'); expect(screen.queryByText('Living Form')).not.toBeInTheDocument(); expect(screen.queryByText(/规格版本|编译器|生成策略|字段决策/)).not.toBeInTheDocument(); }); it('does not disable the current composer for another Workspace command', () => { prepareWorkspace(); useImageWorkspaceStore.setState({ chatDraft: '当前项目的新想法', pendingOperations: { 'operation-workspace-2': { id: 'operation-workspace-2', label: '发送创作想法', command: { kind: 'apply_input', workspaceId: 'workspace-2', sessionId: 'session-2', expectedDirectionRevision: 2, clientOperationId: 'operation-workspace-2', input: { kind: 'chat', message: '另一个项目还在处理' }, }, status: 'submitting', error: null, clearDraftPaths: [], clearChatDraft: true, baseRawTurnSequence: 1, }, }, }); render(); expect(screen.getByRole('textbox', { name: '告诉 AI 你想创作什么' })).toBeEnabled(); expect(screen.getByRole('button', { name: '发送' })).toBeEnabled(); }); it('places desktop production history in a full-height right rail', () => { setViewport(true); prepareWorkspace(); render(); const historyRail = screen.getByTestId('image-workspace-history-rail'); expect(historyRail).toHaveClass('h-full', 'w-64', 'border-l'); expect(screen.getByTestId('image-workspace-header')).toHaveClass('h-10'); expect(screen.getByTestId('image-workspace-history-header')).toHaveClass('h-10'); expect(within(historyRail).getByRole('heading', { name: '历史作品' })).toBeInTheDocument(); expect(within(historyRail).queryByText('当前设计项目的制作记录')).not.toBeInTheDocument(); expect(within(historyRail).getByText('还没有历史作品')).toBeInTheDocument(); expect(screen.queryByTestId('image-workspace-works-rail')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: '项目' })).not.toBeInTheDocument(); expect(screen.queryByText('实时同步')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: '刷新作品' })).not.toBeInTheDocument(); }); it('keeps an offered current plan actionable without a second header status row', () => { setViewport(true); prepareWorkspace({ form: designFormFixture({ activeQuotes: [designQuoteFixture()] }), }); render(); expect(screen.getByRole('button', { name: '确认并开始制作' })).toBeInTheDocument(); expect(screen.queryByText(/制作方案已准备好,确认前仍可修改/)).not.toBeInTheDocument(); expect(screen.getByTestId('image-workspace-history-rail')).toBeInTheDocument(); expect(screen.queryByTestId('image-workspace-works-rail')).not.toBeInTheDocument(); }); it('shows an incomplete meaningful plan and does not invent a selected aspect ratio', async () => { const form = designFormFixture(); form.specification.values.content.concept = null; form.specification.values.content.action = '一只狗和一只猫在草地上互相扑咬玩闹'; form.specification.values.output.aspect_ratio = null; form.specification.values.output.orientation = 'portrait'; const { actions } = prepareWorkspace({ form }); useImageWorkspaceStore.setState({ quoteBlockers: [{ code: 'aspect_ratio_required', message: 'Choose an aspect ratio before requesting a production quote.', severity: 'blocker', path: 'output.aspect_ratio', }], }); render(); expect(screen.getByRole('heading', { name: '制作方案' })).toBeInTheDocument(); expect(screen.getByText('先选择作品形状。')).toBeInTheDocument(); expect(screen.getByRole('combobox', { name: '画幅' })).toHaveValue(''); expect(screen.getByRole('button', { name: '准备制作方案' })).toBeDisabled(); fireEvent.change(screen.getByRole('combobox', { name: '画幅' }), { target: { value: '9:16' }, }); await waitFor(() => { expect(actions.applyFieldOperations).toHaveBeenCalledWith([ { kind: 'set', path: 'output.aspect_ratio', value: '9:16' }, { kind: 'set', path: 'output.orientation', value: 'portrait' }, ]); }); }); it('opens project navigation as a left sheet on compact layouts', () => { prepareWorkspace(); render(); fireEvent.click(screen.getByRole('button', { name: '项目' })); const rail = screen.getByTestId('image-workspace-works-rail'); expect(rail).toBeInTheDocument(); expect(within(rail).getByRole('heading', { name: '设计项目' })).toBeInTheDocument(); expect(rail.closest('[role="dialog"]')).toHaveClass('left-0'); expect(rail.closest('[role="dialog"]')).not.toHaveClass('right-0'); }); it('shows one unfinished assistant reply incrementally and replaces it with the canonical turn', async () => { const { workspace } = prepareWorkspace(); const firstChunk = '收到,我们要制作'; const unfinishedDraft = `${firstChunk}一张社团活动海报。`; useImageWorkspaceStore.setState({ assistantStreams: { 'operation-chat-1': firstChunk }, pendingOperations: { 'operation-chat-1': { id: 'operation-chat-1', label: '发送创作想法', command: { kind: 'apply_input', workspaceId: 'workspace-1', sessionId: 'session-1', expectedDirectionRevision: 4, clientOperationId: 'operation-chat-1', input: { kind: 'chat', message: '做一张社团活动海报' }, }, status: 'submitting', error: null, clearDraftPaths: [], clearChatDraft: true, baseRawTurnSequence: 1, }, }, }); render(); const conversation = screen.getByTestId('image-workspace-conversation'); expect(within(conversation).getByTestId('pending-design-chat-operation-chat-1')).toHaveTextContent('发送中'); expect(within(conversation).getByTestId('streaming-design-assistant-operation-chat-1')).toHaveTextContent(firstChunk); expect(within(conversation).queryByTestId('youth-creation-card')).not.toBeInTheDocument(); act(() => { useImageWorkspaceStore.setState({ assistantStreams: { 'operation-chat-1': unfinishedDraft } }); }); expect(within(conversation).getByTestId('streaming-design-assistant-operation-chat-1')).toHaveTextContent(unfinishedDraft); act(() => { useImageWorkspaceStore.setState({ workspace: { ...workspace, turns: [...workspace.turns, { turnId: 'turn-2', rawTurnSequence: 2, userMessage: '做一张社团活动海报', assistantMessage: unfinishedDraft, }], }, pendingOperations: {}, }); }); await waitFor(() => { expect(within(conversation).queryByTestId('streaming-design-assistant-operation-chat-1')).not.toBeInTheDocument(); }); expect(within(conversation).getAllByText(unfinishedDraft)).toHaveLength(1); expect(within(conversation).getByTestId('youth-creation-card')).toBeInTheDocument(); }); it('keeps each pending reply beside the user message that started it', () => { prepareWorkspace({ turns: [] }); useImageWorkspaceStore.setState({ pendingOperations: { 'operation-chat-1': { id: 'operation-chat-1', label: '发送创作想法', command: { kind: 'apply_input', workspaceId: 'workspace-1', sessionId: 'session-1', expectedDirectionRevision: 4, clientOperationId: 'operation-chat-1', input: { kind: 'chat', message: '先画一只小熊' }, }, status: 'unknown', error: '结果尚未确认', clearDraftPaths: [], clearChatDraft: true, baseRawTurnSequence: 1, }, 'operation-chat-2': { id: 'operation-chat-2', label: '发送创作想法', command: { kind: 'apply_input', workspaceId: 'workspace-1', sessionId: 'session-1', expectedDirectionRevision: 4, clientOperationId: 'operation-chat-2', input: { kind: 'chat', message: '再补充一只小兔子' }, }, status: 'submitting', error: null, clearDraftPaths: [], clearChatDraft: true, baseRawTurnSequence: 1, }, }, assistantStreams: { 'operation-chat-1': '小熊会站在月亮旁边。', }, }); render(); const conversation = screen.getByTestId('image-workspace-conversation'); const firstMessage = within(conversation).getByTestId('pending-design-chat-operation-chat-1'); const firstReply = within(conversation) .getByTestId('streaming-design-assistant-operation-chat-1'); const secondMessage = within(conversation).getByTestId('pending-design-chat-operation-chat-2'); expect(firstMessage.compareDocumentPosition(firstReply) & Node.DOCUMENT_POSITION_FOLLOWING) .toBeTruthy(); expect(firstReply.compareDocumentPosition(secondMessage) & Node.DOCUMENT_POSITION_FOLLOWING) .toBeTruthy(); }); it('shows public AI activity under the submitted message and collapses it beside the final turn', async () => { const message = '画一只在月球踢球的熊猫'; const { workspace } = prepareWorkspace({ turns: [] }); useImageWorkspaceStore.setState({ pendingOperations: { 'operation-chat-1': { id: 'operation-chat-1', label: '发送创作想法', command: { kind: 'apply_input', workspaceId: 'workspace-1', sessionId: 'session-1', expectedDirectionRevision: 4, clientOperationId: 'operation-chat-1', input: { kind: 'chat', message }, }, status: 'submitting', error: null, clearDraftPaths: [], clearChatDraft: true, baseRawTurnSequence: 1, }, }, assistantActivities: { 'operation-chat-1': { workspaceId: 'workspace-1', directionId: 'direction-1', status: 'active', turnId: null, steps: [ { stage: 'understanding', message: '正在听懂你刚补充的内容…' }, { stage: 'reviewing_context', message: '正在把它和前面的设计想法放在一起看…' }, ], }, }, }); render(); const conversation = screen.getByTestId('image-workspace-conversation'); const pending = within(conversation).getByTestId('pending-design-chat-operation-chat-1'); const activity = within(conversation).getByTestId('design-assistant-activity-operation-chat-1'); expect(pending.compareDocumentPosition(activity) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(within(activity).getByRole('button', { name: /AI 处理过程/ })) .toHaveAttribute('aria-expanded', 'true'); expect(within(activity).getByText('正在听懂你刚补充的内容…')).toBeInTheDocument(); expect(within(activity).getAllByText('正在把它和前面的设计想法放在一起看…')) .not.toHaveLength(0); expect(within(activity).queryByText(/思考过程|推理内容/)).not.toBeInTheDocument(); act(() => { useImageWorkspaceStore.setState({ workspace: { ...workspace, turns: [{ turnId: 'turn-2', rawTurnSequence: 2, userMessage: message, assistantMessage: '这个画面很有趣。你想让它更像漫画还是电影?', }], }, pendingOperations: {}, assistantActivities: { 'operation-chat-1': { workspaceId: 'workspace-1', directionId: 'direction-1', status: 'completed', turnId: 'turn-2', steps: [ { stage: 'understanding', message: '正在听懂你刚补充的内容…' }, { stage: 'reviewing_context', message: '正在把它和前面的设计想法放在一起看…' }, ], }, }, }); }); const completedActivity = within(conversation) .getByTestId('design-assistant-activity-operation-chat-1'); await waitFor(() => { expect(within(completedActivity).getByRole('button', { name: /AI 处理过程/ })) .toHaveAttribute('aria-expanded', 'false'); }); expect(within(completedActivity).queryByText('正在听懂你刚补充的内容…')) .not.toBeInTheDocument(); fireEvent.click(within(completedActivity).getByRole('button', { name: /AI 处理过程/ })); expect(within(completedActivity).getByText('正在听懂你刚补充的内容…')).toBeInTheDocument(); }); it('requests a Quote from the inline plan without starting a paid task', async () => { const { actions } = prepareWorkspace(); const quotedWorkspace = designWorkspaceFixture({ form: designFormFixture({ activeQuotes: [designQuoteFixture()] }), }); actions.requestQuote.mockImplementationOnce(async () => { useImageWorkspaceStore.setState({ workspace: quotedWorkspace }); return quotedWorkspace; }); render(); fireEvent.click(screen.getByRole('button', { name: '准备制作方案' })); expect(actions.requestQuote).toHaveBeenCalledOnce(); expect(actions.confirmGeneration).not.toHaveBeenCalled(); await waitFor(() => { expect(screen.getByRole('button', { name: '确认并开始制作' })).toBeInTheDocument(); }); expect(screen.getByText(/预计使用/)).toHaveTextContent('12'); }); it('shows a submitted message immediately and replaces it with the canonical turn', async () => { const message = '画一只在月球踢球的熊猫'; const { workspace } = prepareWorkspace({ turns: [] }); useImageWorkspaceStore.setState({ chatDraft: message, pendingOperations: { 'operation-chat-1': { id: 'operation-chat-1', label: '发送消息', command: { kind: 'apply_input', workspaceId: 'workspace-1', sessionId: 'session-1', expectedDirectionRevision: 4, clientOperationId: 'operation-chat-1', input: { kind: 'chat', message }, }, status: 'submitting', error: null, clearDraftPaths: [], clearChatDraft: true, baseRawTurnSequence: 1, }, }, }); render(); const conversation = screen.getByTestId('image-workspace-conversation'); expect(within(conversation).getByTestId('pending-design-chat-operation-chat-1')).toHaveTextContent(message); expect(within(conversation).getByRole('textbox', { name: '告诉 AI 你想创作什么' })).toHaveValue(''); act(() => { useImageWorkspaceStore.setState({ workspace: { ...workspace, turns: [{ turnId: 'turn-2', rawTurnSequence: 2, userMessage: message, assistantMessage: '听起来很有趣!你希望画面更像漫画,还是更像电影?', }], }, chatDraft: '', pendingOperations: {}, }); }); await waitFor(() => expect(within(conversation).getAllByText(message)).toHaveLength(1)); expect(within(conversation).queryByTestId('pending-design-chat-operation-chat-1')).not.toBeInTheDocument(); }); it('invites a free description and does not turn a legacy decision prompt into a form', () => { const workspace = designWorkspaceFixture({ form: designFormFixture({ decisionPrompts: [{ id: 'choice-1', kind: 'question', basedOnSpecificationRevisionId: 'specification-revision-3', targetPaths: ['visual.art_direction'], title: '画面想要什么感觉?', body: '请从下面选一个。', options: [{ id: 'cartoon', label: '有趣的卡通', description: null }], }], }), turns: [], }); prepareWorkspace(workspace); render(); const conversation = screen.getByTestId('image-workspace-conversation'); expect(within(conversation).getByRole('heading', { name: '你想让麦洛和你一起创作什么?' })).toBeInTheDocument(); expect(within(conversation).queryByText('画面想要什么感觉?')).not.toBeInTheDocument(); fireEvent.click(within(conversation).getByRole('button', { name: '我想做一张热闹的社团招新海报' })); expect(within(conversation).getByRole('textbox', { name: '告诉 AI 你想创作什么' })).toHaveValue( '我想做一张热闹的社团招新海报', ); }); it('sends compact production edits through the existing typed field operations', async () => { const { actions } = prepareWorkspace(); render(); fireEvent.change(screen.getByRole('combobox', { name: '画幅' }), { target: { value: '16:9' } }); await waitFor(() => { expect(actions.applyFieldOperations).toHaveBeenCalledWith([ { kind: 'set', path: 'output.aspect_ratio', value: '16:9' }, { kind: 'set', path: 'output.orientation', value: 'landscape' }, ]); }); }); it('confirms only the offered Quote identity and keeps provider details private', () => { const quote = designQuoteFixture(); quote.warnings = [{ code: 'reference_observation_fallback', message: 'Reference observations were not reviewed by the frozen compiler.', severity: 'warning', path: 'references', }]; const { actions } = prepareWorkspace({ form: designFormFixture({ activeQuotes: [quote] }), }); render(); expect(screen.getByText('有张参考图还没检查完成,AI 会谨慎使用它。')).toBeInTheDocument(); expect(screen.queryByText(/frozen compiler/i)).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '确认并开始制作' })); expect(actions.confirmGeneration).toHaveBeenCalledWith('quote-1'); }); it('prefills a museum Prompt without automatically submitting it', () => { const { actions } = prepareWorkspace(); useImagePromptMuseumStore.setState({ pendingPrompt: { promptId: 'prompt-1', title: '灵感', prompt: '做一张冷色调产品海报' }, }); render(); expect(screen.getByDisplayValue('做一张冷色调产品海报')).toBeInTheDocument(); expect(actions.sendChat).not.toHaveBeenCalled(); }); it('shows the safe reasoner failure instead of claiming the message was not sent', async () => { const { actions } = prepareWorkspace(); actions.sendChat.mockRejectedValueOnce(new ImageWorkspaceApiError( 502, 'design_reasoner_invalid', 'AI 没有整理好这次想法,请再试一次', )); render(); fireEvent.change(screen.getByRole('textbox', { name: '告诉 AI 你想创作什么' }), { target: { value: '画一只会做饭的机器人' }, }); fireEvent.click(screen.getByRole('button', { name: '发送' })); await waitFor(() => { expect(toastErrorMock).toHaveBeenCalledWith( 'AI 这次没听懂。你的内容还在输入框里,可以补充一个画面细节后再发送', ); }); }); it('keeps an unknown accepted operation recoverable without offering a duplicate action', () => { const { actions } = prepareWorkspace(); useImageWorkspaceStore.setState({ pendingOperations: { 'operation-1': { id: 'operation-1', label: '准备制作方案', command: { kind: 'request_quote', workspaceId: 'workspace-1', sessionId: 'session-1', expectedDirectionRevision: 4, specificationRevision: 3, clientOperationId: 'operation-1', }, status: 'unknown', error: '网络已断开', clearDraftPaths: [], clearChatDraft: false, baseRawTurnSequence: null, }, }, }); render(); expect(screen.getByText('还在确认刚才的操作')).toBeInTheDocument(); expect(screen.getByRole('button', { name: '准备制作方案' })).toBeDisabled(); expect(actions.requestQuote).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: '查看原来的结果' })); expect(actions.retryOperation).toHaveBeenCalledWith('operation-1'); }); it('opens project creation from an empty Canvas', async () => { useImageWorkspaceStore.setState({ status: 'ready', bootstrap: { ...designBootstrapFixture(), workspaces: [] }, activeWorkspaceId: null, workspace: null, error: null, load: vi.fn(), createProject: vi.fn(), }); render(); fireEvent.click(screen.getByRole('button', { name: '开始第一个作品' })); await waitFor(() => expect(screen.getByRole('heading', { name: '新建项目' })).toBeInTheDocument()); }); });