Files
makelore/tests/unit/image-canvas-page.test.tsx

427 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<typeof import('@/lib/image-workspace')>();
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(<ImageCanvas />);
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('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,
},
},
});
render(<ImageCanvas />);
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).queryByRole('button', { name: '下一步:看看制作方案' }))
.not.toBeInTheDocument();
expect(within(conversation).queryByTestId('design-assistant-progress')).not.toBeInTheDocument();
expect(within(conversation).queryByText('AI 正在整理你的想法')).not.toBeInTheDocument();
expect(within(conversation).getByText('我已经整理了用途、受众和初步概念,请确认右侧建议。')).toBeInTheDocument();
const streamingReply = within(conversation)
.getByTestId('streaming-design-assistant-operation-chat-1');
expect(streamingReply).toHaveTextContent(firstChunk);
act(() => {
useImageWorkspaceStore.setState({
assistantStreams: { 'operation-chat-1': unfinishedDraft },
});
});
expect(streamingReply).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).getByRole('button', { name: '下一步:看看制作方案' }))
.toBeInTheDocument();
});
it('opens the mobile quote confirmation from the ready conversation 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(<ImageCanvas />);
const conversation = screen.getByTestId('image-workspace-conversation');
fireEvent.click(within(conversation).getByRole('button', { name: '下一步:看看制作方案' }));
expect(actions.requestQuote).toHaveBeenCalledOnce();
expect(actions.confirmGeneration).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByRole('heading', { name: '制作方案准备好了' })).toBeInTheDocument();
});
expect(screen.getByTestId('design-offered-quote')).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(<ImageCanvas />);
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(<ImageCanvas />);
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(<ImageCanvas />);
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(<ImageCanvas />);
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(<ImageCanvas />);
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(<ImageCanvas />);
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(<ImageCanvas />);
expect(within(screen.getByTestId('image-workspace-conversation'))
.getByRole('button', { name: '正在准备方案' })).toBeDisabled();
openMobileCreationCard();
expect(screen.getByText('还在确认刚才的操作')).toBeInTheDocument();
expect(screen.getByText(/不会重复制作或重复扣费/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: '看看制作方案', exact: true })).toBeDisabled();
expect(actions.requestQuote).not.toHaveBeenCalled();
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(<ImageCanvas />);
fireEvent.click(screen.getByRole('button', { name: '开始第一个创作' }));
expect(listener).toHaveBeenCalledOnce();
window.removeEventListener('niancode:image-workspace:create-project', listener);
});
});