659 lines
26 KiB
TypeScript
659 lines
26 KiB
TypeScript
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().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(<ImageCanvas />);
|
||
|
||
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(<ImageCanvas />);
|
||
|
||
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(<ImageCanvas />);
|
||
|
||
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(<ImageCanvas />);
|
||
|
||
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(<ImageCanvas />);
|
||
|
||
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(<ImageCanvas />);
|
||
|
||
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(<ImageCanvas />);
|
||
|
||
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(<ImageCanvas />);
|
||
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(<ImageCanvas />);
|
||
|
||
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(<ImageCanvas />);
|
||
|
||
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(<ImageCanvas />);
|
||
|
||
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(<ImageCanvas />);
|
||
|
||
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(<ImageCanvas />);
|
||
|
||
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(<ImageCanvas />);
|
||
|
||
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(<ImageCanvas />);
|
||
|
||
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(<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 这次没听懂。你的内容还在输入框里,可以补充一个画面细节后再发送',
|
||
);
|
||
});
|
||
});
|
||
|
||
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(<ImageCanvas />);
|
||
|
||
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(<ImageCanvas />);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '开始第一个作品' }));
|
||
await waitFor(() => expect(screen.getByRole('heading', { name: '新建项目' })).toBeInTheDocument());
|
||
});
|
||
});
|