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().mockResolvedValue('http://127.0.0.1/asset'), saveImageWorkspaceAsset: vi.fn().mockResolvedValue({ status: 'saved' }), uploadImageWorkspaceAsset: 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), 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: '', assistantStreams: {}, quoteBlockers: [], error: null, ...actions, }); return { workspace, actions }; } function openMobileCreationCard() { fireEvent.click(screen.getByRole('button', { name: /^当前想法与作品/ })); } describe('youth AI Design Canvas page', () => { beforeEach(() => { vi.clearAllMocks(); useAuthStore.setState({ initialized: true, accessToken: 'token', expiresAt: Date.now() + 60_000, }); useImagePromptMuseumStore.setState({ pendingPrompt: null }); useImageWorkspaceStore.getState().reset(); }); it('starts with a guided conversation and keeps the structured summary secondary', () => { prepareWorkspace(); render(); expect(screen.getByTestId('image-workspace-conversation')).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '和 AI 聊聊你的想法' })).toBeInTheDocument(); expect(screen.getByText('像和同学讲故事一样说就好。AI 会先听懂,再一次问一个最重要的问题。')).toBeInTheDocument(); openMobileCreationCard(); const card = screen.getByTestId('youth-creation-card'); expect(card).toBeInTheDocument(); expect(within(card).getByRole('heading', { name: 'AI 听懂的想法' })).toBeInTheDocument(); expect(screen.getByText('做一张便携咖啡机的小红书主视觉')).toBeInTheDocument(); expect(screen.queryByText('Living Form')).not.toBeInTheDocument(); expect(screen.queryByText('目标与用途')).not.toBeInTheDocument(); expect(screen.queryByText(/规格版本|编译器|生成策略|字段决策/)).not.toBeInTheDocument(); }); it('keeps unfinished AI output out of the conversation while send status stays visible', () => { prepareWorkspace(); const unfinishedDraft = '收到,我们要收到,我们要制作一张社团活动海报。'; useImageWorkspaceStore.setState({ assistantStreams: { 'operation-chat-1': unfinishedDraft }, 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, }, }, }); render(); const conversation = screen.getByTestId('image-workspace-conversation'); const pendingMessage = within(conversation).getByTestId('pending-design-chat-operation-chat-1'); expect(within(pendingMessage).getByText('做一张社团活动海报')).toBeInTheDocument(); expect(within(pendingMessage).getByText('发送中')).toBeInTheDocument(); expect(within(conversation).queryByTestId('design-assistant-progress')).not.toBeInTheDocument(); expect(within(conversation).queryByText('AI 正在整理你的想法')).not.toBeInTheDocument(); expect(within(conversation).getByText('我已经整理了用途、受众和初步概念,请确认右侧建议。')).toBeInTheDocument(); expect(within(conversation).queryByText(unfinishedDraft)).not.toBeInTheDocument(); }); 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, }, }, }); render(); const conversation = screen.getByTestId('image-workspace-conversation'); const pendingMessage = within(conversation).getByTestId('pending-design-chat-operation-chat-1'); expect(within(pendingMessage).getByText(message)).toBeInTheDocument(); expect(within(pendingMessage).getByText('发送中')).toBeInTheDocument(); expect(within(conversation).queryByRole('heading', { name: '先把脑中的画面说出来' })) .not.toBeInTheDocument(); expect(within(conversation).getByRole('textbox', { name: '告诉 AI 你想创作什么' })) .toHaveValue(''); act(() => { useImageWorkspaceStore.setState((state) => ({ pendingOperations: { ...state.pendingOperations, 'operation-chat-1': { ...state.pendingOperations['operation-chat-1']!, status: 'unknown', }, }, })); }); expect(within(pendingMessage).getByText('正在确认')).toBeInTheDocument(); 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(); expect(within(conversation).queryByText('发送中')).not.toBeInTheDocument(); expect(within(conversation).getByText(/更像漫画,还是更像电影/)).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 }, { id: 'cinema', label: '像电影一样', description: null }, ], }], }), turns: [], }); prepareWorkspace(workspace); render(); const conversation = screen.getByTestId('image-workspace-conversation'); expect(within(conversation).getByRole('heading', { name: '先把脑中的画面说出来' })).toBeInTheDocument(); expect(within(conversation).getByText(/想画什么、发生在哪里/)).toBeInTheDocument(); expect(within(conversation).queryByText('画面想要什么感觉?')).not.toBeInTheDocument(); expect(within(conversation).queryByRole('button', { name: '有趣的卡通' })).not.toBeInTheDocument(); fireEvent.click(within(conversation).getByRole('button', { name: '我想做一张热闹的社团招新海报' })); expect(within(conversation).getByRole('textbox', { name: '告诉 AI 你想创作什么' })).toHaveValue( '我想做一张热闹的社团招新海报', ); }); it('sends high-impact fine adjustments through the existing typed field operations', () => { const { actions } = prepareWorkspace(); render(); openMobileCreationCard(); fireEvent.click(screen.getByRole('button', { name: '手动调整(可选)' })); fireEvent.click(screen.getByRole('button', { name: '电脑横屏' })); expect(actions.applyFieldOperations).toHaveBeenCalledWith( [ { kind: 'set', path: 'output.aspect_ratio', value: '16:9' }, { kind: 'set', path: 'output.orientation', value: 'landscape' }, ], [], ); }); it('shows a plain-language production confirmation and confirms only the Quote identity', () => { const quote = designQuoteFixture(); quote.warnings = [{ code: 'reference_observation_fallback', message: 'Reference observations were not reviewed by the frozen compiler.', severity: 'warning', path: 'references', }]; const workspace = designWorkspaceFixture({ form: designFormFixture({ activeQuotes: [quote] }), }); const { actions } = prepareWorkspace(workspace); render(); openMobileCreationCard(); expect(screen.getByRole('heading', { name: '制作方案准备好了' })).toBeInTheDocument(); expect(screen.getByText('12')).toBeInTheDocument(); expect(screen.getByText('有张参考图还没检查完成,AI 会谨慎使用它。')).toBeInTheDocument(); expect(screen.queryByText(/frozen compiler/i)).not.toBeInTheDocument(); expect(screen.queryByDisplayValue(/prompt/i)).not.toBeInTheDocument(); expect(screen.queryByText(/规格版本|报价已冻结|生成方式|不可变/)).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '花 12 设计点开始制作' })); 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 这次没听懂。你的内容还在输入框里,可以补充一个画面细节后再发送', ); }); expect(toastErrorMock).not.toHaveBeenCalledWith('消息没有发出去,请再试一次'); }); it('explains an unknown accepted result without suggesting a second production', () => { 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, }, }, }); render(); openMobileCreationCard(); expect(screen.getByText('还在确认刚才的操作')).toBeInTheDocument(); expect(screen.getByText(/不会重复制作或重复扣费/)).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '查看原来的结果' })); expect(actions.retryOperation).toHaveBeenCalledWith('operation-1'); }); it('opens project creation when the account has no Design Workspace', () => { useImageWorkspaceStore.setState({ status: 'ready', bootstrap: { ...designBootstrapFixture(), workspaces: [] }, activeWorkspaceId: null, workspace: null, error: null, load: vi.fn(), }); const listener = vi.fn(); window.addEventListener('niancode:image-workspace:create-project', listener); render(); fireEvent.click(screen.getByRole('button', { name: '开始第一个创作' })); expect(listener).toHaveBeenCalledOnce(); window.removeEventListener('niancode:image-workspace:create-project', listener); }); });