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

659 lines
26 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().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());
});
});