Files
makelore/tests/unit/image-canvas-page.test.tsx
2026-09-07 15:09:12 +08:00

434 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().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: '',
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(within(conversation).getByRole('heading', { name: '和 AI 一起完善创作' })).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('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', 'border-l');
expect(within(historyRail).getByRole('heading', { name: '历史作品' })).toBeInTheDocument();
expect(within(historyRail).getByText('还没有历史作品')).toBeInTheDocument();
expect(screen.queryByTestId('image-workspace-works-rail')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '项目' })).not.toBeInTheDocument();
});
it('prioritizes an offered current plan over older completed-work status', () => {
setViewport(true);
prepareWorkspace({
form: designFormFixture({ activeQuotes: [designQuoteFixture()] }),
});
render(<ImageCanvas />);
expect(screen.getByText(/制作方案已准备好,确认前仍可修改/)).toBeInTheDocument();
expect(screen.getByTestId('image-workspace-history-rail')).toBeInTheDocument();
expect(screen.queryByTestId('image-workspace-works-rail')).not.toBeInTheDocument();
});
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,
},
},
});
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('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,
},
},
});
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,
},
},
});
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());
});
});