Files
makelore/tests/unit/image-canvas-page.test.tsx
inman 01bee3188b
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
feat: integrate learning module
2026-08-16 20:52:16 +08:00

1414 lines
55 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 { MemoryRouter } from 'react-router-dom';
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 type {
DesignAssistantDeltaEvent,
DesignConversation,
DesignGenerationQuote,
DesignGenerationTask,
DesignGenerationTaskUpdatedEvent,
DesignWorkspace,
DesignWorkspaceBootstrap,
} from '../../shared/image-workspace';
const fetchImageWorkspaceMock = vi.hoisted(() => vi.fn());
const fetchImageWorkspaceProjectMock = vi.hoisted(() => vi.fn());
const fetchImageWorkspaceConversationMock = vi.hoisted(() => vi.fn());
const fetchImageWorkspaceTasksMock = vi.hoisted(() => vi.fn());
const sendImageWorkspaceMessageMock = vi.hoisted(() => vi.fn());
const confirmImageWorkspaceGenerationMock = vi.hoisted(() => vi.fn());
const updateImageWorkspaceGenerationQuoteMock = vi.hoisted(() => vi.fn());
const openImageWorkspaceTaskEventsMock = vi.hoisted(() => vi.fn());
const resolveImageWorkspaceAssetUrlMock = vi.hoisted(() => vi.fn());
const saveImageWorkspaceAssetMock = vi.hoisted(() => vi.fn());
const uploadImageWorkspaceAssetMock = vi.hoisted(() => vi.fn());
type EventListener = (event: MessageEvent<string>) => void;
class MockEventSource {
onopen: ((event: Event) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
readonly close = vi.fn();
private readonly listeners = new Map<string, Set<EventListener>>();
addEventListener(type: string, listener: EventListener): void {
const listeners = this.listeners.get(type) ?? new Set<EventListener>();
listeners.add(listener);
this.listeners.set(type, listeners);
}
emit(type: string, payload: unknown): void {
const event = { data: JSON.stringify(payload) } as MessageEvent<string>;
for (const listener of this.listeners.get(type) ?? []) listener(event);
}
}
function deferred<T>(): { promise: Promise<T>; resolve(value: T): void } {
let resolve!: (value: T) => void;
const promise = new Promise<T>((accept) => {
resolve = accept;
});
return { promise, resolve };
}
vi.mock('@/lib/image-workspace', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/image-workspace')>();
return {
...actual,
fetchImageWorkspace: (...args: unknown[]) => fetchImageWorkspaceMock(...args),
fetchImageWorkspaceProject: (...args: unknown[]) => fetchImageWorkspaceProjectMock(...args),
fetchImageWorkspaceConversation: (...args: unknown[]) => (
fetchImageWorkspaceConversationMock(...args)
),
fetchImageWorkspaceTasks: (...args: unknown[]) => fetchImageWorkspaceTasksMock(...args),
sendImageWorkspaceMessage: (...args: unknown[]) => sendImageWorkspaceMessageMock(...args),
confirmImageWorkspaceGeneration: (...args: unknown[]) => (
confirmImageWorkspaceGenerationMock(...args)
),
updateImageWorkspaceGenerationQuote: (...args: unknown[]) => (
updateImageWorkspaceGenerationQuoteMock(...args)
),
openImageWorkspaceTaskEvents: (...args: unknown[]) => (
openImageWorkspaceTaskEventsMock(...args)
),
resolveImageWorkspaceAssetUrl: (...args: unknown[]) => (
resolveImageWorkspaceAssetUrlMock(...args)
),
saveImageWorkspaceAsset: (...args: unknown[]) => (
saveImageWorkspaceAssetMock(...args)
),
uploadImageWorkspaceAsset: (...args: unknown[]) => (
uploadImageWorkspaceAssetMock(...args)
),
};
});
const bootstrapFixture: DesignWorkspaceBootstrap = {
capabilities: {
conversation: true,
generation: true,
image: true,
video: true,
},
workspaces: [{
workspaceId: 'workspace-cloud',
title: '云端角色设定',
viewRevision: 1,
conversationCount: 1,
phase: 'awaiting_confirmation',
updatedAt: '2026-07-31T10:00:00Z',
}],
};
function conversationFixture(): DesignConversation {
return {
conversationId: 'conversation-cloud',
workspaceId: 'workspace-cloud',
title: '主会话',
turnRevision: 1,
phase: 'awaiting_confirmation',
brief: {
version: 1,
status: 'ready',
medium: 'image',
summary: '夜色中的机械城堡角色海报',
ready: true,
missingDecision: null,
},
createdAt: '2026-07-31T10:00:00Z',
updatedAt: '2026-07-31T10:00:00Z',
messages: [
{
id: 'message-user',
role: 'user',
kind: 'user',
text: '做一张夜色中的机械城堡角色海报',
quickReplies: [],
generationQuote: null,
turnRevision: 1,
createdAt: '2026-07-31T10:00:00Z',
},
{
id: 'message-assistant',
role: 'assistant',
kind: 'confirmation',
text: '方向已经明确,确认后开始生成。',
quickReplies: ['确认生成', '继续调整'],
generationQuote: {
quoteId: 'quote-one',
status: 'active',
medium: 'image',
briefVersion: 1,
briefSummary: '夜色中的机械城堡角色海报',
finalPrompt: '夜色中的机械城堡角色海报,电影感构图。',
promptMode: 'guided',
generationParameters: {
model: 'image-model-one',
resolution: '1024x1024',
aspectRatio: '1:1',
durationSeconds: null,
},
generationOptions: {
models: [{
value: 'image-model-one',
label: '图片模型一',
default: true,
disabled: false,
multiplier: 1,
}],
resolutions: [{
value: '1024x1024',
label: '1024 × 1024',
default: true,
disabled: false,
multiplier: 1,
}, {
value: '2048x2048',
label: '2048 × 2048',
default: false,
disabled: false,
multiplier: 1.5,
}],
durations: [],
aspectRatios: [{
value: '1:1',
label: '1:1',
default: true,
disabled: false,
multiplier: 1,
}, {
value: '16:9',
label: '16:9',
default: false,
disabled: false,
multiplier: 1,
}],
},
pricing: {
schema: 'design-pricing-v2',
amount: 1,
rounding: 'ceil',
},
quotedDesignPoints: 1,
expiresAt: '2026-07-31T11:00:00Z',
},
turnRevision: 1,
createdAt: '2026-07-31T10:00:01Z',
},
],
};
}
function workspaceFixture(): DesignWorkspace {
const { messages: _messages, ...summary } = conversationFixture();
return {
...bootstrapFixture.workspaces[0],
conversations: [summary],
};
}
const taskFixture: DesignGenerationTask = {
taskId: 'task-one',
workspaceId: 'workspace-cloud',
conversationId: 'conversation-cloud',
medium: 'image',
status: 'succeeded',
briefVersion: 1,
briefSummary: '夜色中的机械城堡角色海报',
quoteId: 'quote-old',
quotedDesignPoints: 1,
failureCode: null,
resultAssets: [{
assetId: 'asset-one',
workspaceId: 'workspace-cloud',
mediaType: 'image',
mimeType: 'image/png',
width: 1024,
height: 1024,
durationMilliseconds: null,
createdAt: '2026-07-31T09:00:00Z',
contentPath: '/api/works/image-workspace/workspaces/workspace-cloud/assets/asset-one/content',
}],
createdAt: '2026-07-31T09:00:00Z',
updatedAt: '2026-07-31T09:01:00Z',
};
describe('ImageCanvas Workspace-first design experience', () => {
let taskEventSource: MockEventSource;
beforeEach(() => {
vi.clearAllMocks();
useImageWorkspaceStore.getState().reset();
useImagePromptMuseumStore.getState().clearPendingPrompt();
fetchImageWorkspaceMock.mockResolvedValue(bootstrapFixture);
fetchImageWorkspaceProjectMock.mockResolvedValue(workspaceFixture());
fetchImageWorkspaceConversationMock.mockResolvedValue(conversationFixture());
fetchImageWorkspaceTasksMock.mockResolvedValue([taskFixture]);
sendImageWorkspaceMessageMock.mockResolvedValue(conversationFixture());
updateImageWorkspaceGenerationQuoteMock.mockImplementation(async (
_workspaceId: string,
_quoteId: string,
finalPrompt: string,
generationParameters: DesignGenerationQuote['generationParameters'],
) => ({
...conversationFixture().messages[1].generationQuote!,
finalPrompt,
generationParameters,
quotedDesignPoints: 3,
pricing: {
schema: 'design-pricing-v2',
amount: 3,
rounding: 'ceil',
},
}));
confirmImageWorkspaceGenerationMock.mockResolvedValue({
...conversationFixture(),
turnRevision: 2,
phase: 'shaping',
});
taskEventSource = new MockEventSource();
openImageWorkspaceTaskEventsMock.mockResolvedValue(
taskEventSource as unknown as EventSource,
);
resolveImageWorkspaceAssetUrlMock.mockResolvedValue(
'http://127.0.0.1:13210/content?token=host',
);
saveImageWorkspaceAssetMock.mockResolvedValue({ status: 'saved' });
uploadImageWorkspaceAssetMock.mockResolvedValue({
...taskFixture.resultAssets[0],
assetId: 'asset-uploaded',
});
});
it('shows an honest unavailable state without fabricating projects', async () => {
fetchImageWorkspaceMock.mockRejectedValueOnce(new ImageWorkspaceApiError(
501,
'IMAGE_WORKSPACE_UNAVAILABLE',
'AI 设计暂不可用',
));
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
expect(await screen.findByTestId('image-workspace-unavailable'))
.toHaveTextContent('AI 设计暂不可用');
expect(screen.getByText(/暂时无法连接设计服务/)).toBeInTheDocument();
});
it('clears stale renderer auth when Main requires authentication', async () => {
useAuthStore.setState({
initialized: true,
loading: false,
error: null,
accessToken: 'expired-access-token',
canRefresh: true,
legacyRefreshToken: 'invalid-refresh-token',
tokenType: 'Bearer',
expiresAt: Date.now() - 1,
user: {
username: 'brother7',
userId: '1',
tenantId: null,
deptId: null,
authorities: [],
},
});
fetchImageWorkspaceMock.mockRejectedValueOnce(new ImageWorkspaceApiError(
401,
'AUTH_REQUIRED',
'请先登录后再使用 AI 设计',
));
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('image-workspace-unavailable');
expect(useAuthStore.getState()).toMatchObject({
accessToken: null,
canRefresh: false,
legacyRefreshToken: null,
user: null,
});
expect(useImageWorkspaceStore.getState().status).toBe('auth-required');
useAuthStore.setState({
accessToken: 'fresh-access-token',
canRefresh: true,
legacyRefreshToken: 'fresh-refresh-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 60_000,
user: {
username: 'brother7',
userId: '1',
tenantId: null,
deptId: null,
authorities: [],
},
});
await waitFor(() => expect(fetchImageWorkspaceMock).toHaveBeenCalledTimes(2));
await waitFor(() => expect(useImageWorkspaceStore.getState().status).toBe('ready'));
});
it('renders the conversation messages edge-to-edge with the unified image/video task list', async () => {
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
expect(await screen.findByTestId('image-workspace-conversation')).toBeInTheDocument();
expect(screen.getByTestId('image-canvas-page')).toHaveClass('font-sans');
expect(screen.queryByTestId('image-canvas-conversation-select')).not.toBeInTheDocument();
expect(screen.getByTestId('image-workspace-conversation'))
.toHaveTextContent('方向已经明确');
expect(screen.queryByText('设计 Agent', { exact: true })).not.toBeInTheDocument();
expect(screen.queryByText('你', { exact: true })).not.toBeInTheDocument();
const messageRows = screen.getAllByTestId('image-workspace-message');
expect(messageRows).toHaveLength(2);
expect(messageRows[0]).toHaveClass('w-full');
expect(messageRows[1]).toHaveClass('w-full');
expect(messageRows[0].firstElementChild).toHaveClass('w-fit', 'max-w-[85%]');
expect(messageRows[0].querySelector('p')).toHaveClass('chat-message-body');
expect(messageRows[1].querySelector('p')).toHaveClass('chat-message-body');
expect(screen.getByTestId('design-quote-quote-one')).toHaveTextContent('1 设计点');
expect(screen.queryByTestId('design-quote-model')).not.toBeInTheDocument();
expect(screen.getByTestId('design-task-list')).toHaveTextContent('生成内容');
expect(screen.queryByText('图片与视频任务统一展示')).not.toBeInTheDocument();
const taskCard = screen.getByTestId('design-task-task-one');
expect(taskCard).toHaveTextContent('生图');
expect(taskCard.querySelector('time')).toHaveAttribute('datetime', taskFixture.createdAt);
expect(taskCard).not.toHaveTextContent('已完成');
expect(screen.queryByRole('button', { name: '下载原图' })).not.toBeInTheDocument();
await waitFor(() => expect(screen.getByRole('img', { name: 'AI 设计生成缩略图' }))
.toHaveAttribute('src', 'http://127.0.0.1:13210/content?token=host'));
expect(screen.queryByText('生成设置')).not.toBeInTheDocument();
expect(screen.queryByText(/当前 Agent/)).not.toBeInTheDocument();
});
it('keeps task cards fixed-size and truncates long descriptions', async () => {
const longDescription = '夜色中的机械城堡角色海报,加入冷暖对比和电影感轮廓光效果';
const compactDescription = `${Array.from(longDescription).slice(0, 24).join('')}`;
fetchImageWorkspaceTasksMock.mockResolvedValueOnce([{
...taskFixture,
taskId: 'task-long-description',
briefSummary: longDescription,
}]);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
const taskCard = await screen.findByTestId('design-task-task-long-description');
expect(taskCard).toHaveClass('h-20', 'w-full');
expect(within(taskCard).getByRole('button')).toHaveClass('h-full');
expect(taskCard).toHaveTextContent(compactDescription);
expect(taskCard).not.toHaveTextContent(longDescription);
});
it('edits the final prompt and server-provided options before re-quoting', async () => {
const requote = deferred<DesignGenerationQuote>();
updateImageWorkspaceGenerationQuoteMock.mockReset();
updateImageWorkspaceGenerationQuoteMock.mockReturnValueOnce(requote.promise);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
const finalPrompt = await screen.findByTestId('design-quote-final-prompt');
fireEvent.change(finalPrompt, { target: { value: '用户最新编辑后的完整提示词' } });
fireEvent.change(screen.getByTestId('design-quote-resolution'), {
target: { value: '2048x2048' },
});
fireEvent.change(screen.getByTestId('design-quote-aspect-ratio'), {
target: { value: '16:9' },
});
await waitFor(() => expect(updateImageWorkspaceGenerationQuoteMock).toHaveBeenCalledWith(
'workspace-cloud',
'quote-one',
'用户最新编辑后的完整提示词',
{
model: 'image-model-one',
resolution: '2048x2048',
aspectRatio: '16:9',
durationSeconds: null,
},
));
expect(screen.getByRole('button', { name: '重新报价中' })).toBeDisabled();
requote.resolve({
...conversationFixture().messages[1].generationQuote!,
finalPrompt: '用户最新编辑后的完整提示词',
generationParameters: {
model: 'image-model-one',
resolution: '2048x2048',
aspectRatio: '16:9',
durationSeconds: null,
},
quotedDesignPoints: 3,
pricing: { schema: 'design-pricing-v2', amount: 3, rounding: 'ceil' },
});
await waitFor(() => expect(screen.getByTestId('design-quote-quote-one'))
.toHaveTextContent('3 设计点'));
expect(screen.getByTestId('design-quote-final-prompt'))
.toHaveValue('用户最新编辑后的完整提示词');
const quoteCard = screen.getByTestId('design-quote-quote-one');
expect(quoteCard).toHaveTextContent('报价 3 设计点');
expect(within(quoteCard).getByTestId('design-quote-points')).toHaveTextContent('报价 3 设计点');
expect(within(quoteCard).queryByTestId('design-quote-model')).not.toBeInTheDocument();
expect(quoteCard).toHaveTextContent('继续调整');
expect(quoteCard).toHaveTextContent('清晰度');
expect(quoteCard).toHaveTextContent('画幅');
expect(quoteCard).not.toHaveTextContent('提示词模式guided');
expect(quoteCard).not.toHaveTextContent('夜色中的机械城堡角色海报');
expect(screen.getByRole('button', { name: '确认并开始生成' })).toBeEnabled();
});
it('offers an actionable retry when re-quoting fails', async () => {
updateImageWorkspaceGenerationQuoteMock.mockRejectedValueOnce(
new ImageWorkspaceApiError(409, 'generation_quote_invalid', '当前生成方案已失效'),
);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
fireEvent.change(await screen.findByTestId('design-quote-final-prompt'), {
target: { value: '需要重新报价的提示词' },
});
const quoteCard = screen.getByTestId('design-quote-quote-one');
await waitFor(() => expect(updateImageWorkspaceGenerationQuoteMock).toHaveBeenCalled());
expect(await within(quoteCard).findByRole('alert')).toHaveTextContent('当前生成方案已失效');
expect(within(quoteCard).getByText(/请点击“重试报价”/)).toBeInTheDocument();
expect(within(quoteCard).getByRole('button', { name: '重试报价' })).toBeEnabled();
expect(within(quoteCard).getByRole('button', { name: '继续调整' })).toBeEnabled();
expect(within(quoteCard).getByRole('button', { name: '等待报价更新' })).toBeDisabled();
fireEvent.click(within(quoteCard).getByRole('button', { name: '重试报价' }));
await waitFor(() => expect(updateImageWorkspaceGenerationQuoteMock).toHaveBeenCalledTimes(2));
await waitFor(() => expect(screen.getByRole('button', { name: '确认并开始生成' })).toBeEnabled());
});
it('uses the compact logo-only empty state for a new design conversation', async () => {
fetchImageWorkspaceConversationMock.mockResolvedValueOnce({
...conversationFixture(),
messages: [],
});
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
const emptyState = await screen.findByTestId('image-workspace-empty-state');
expect(emptyState).toHaveTextContent('今天一起描绘什么?');
expect(within(emptyState).getByRole('img', { name: 'Makelore' })).toBeInTheDocument();
expect(within(emptyState).getAllByRole('img')).toHaveLength(1);
expect(emptyState).not.toHaveTextContent('先聊清楚,再开始创作');
expect(emptyState).not.toHaveTextContent('告诉设计 Agent');
});
it('prefills the exact Prompt Museum text without sending it automatically', async () => {
const prompt = 'Create a quiet editorial poster with generous negative space.';
useImagePromptMuseumStore.getState().setPendingPrompt({
promptId: 'prompt-museum-one',
title: '编辑感产品海报',
prompt,
});
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
const composer = await screen.findByLabelText('设计需求');
await waitFor(() => expect(composer).toHaveValue(prompt));
expect(sendImageWorkspaceMessageMock).not.toHaveBeenCalled();
expect(useImagePromptMuseumStore.getState().pendingPrompt).toBeNull();
});
it('keeps historical choices fixed after a later assistant turn', async () => {
const conversation = conversationFixture();
conversation.messages[1] = {
...conversation.messages[1],
quickReplies: ['暖色调', '冷色调'],
generationQuote: null,
};
conversation.messages.push(
{
id: 'message-follow-up-user',
role: 'user',
kind: 'user',
text: '继续调整色彩',
quickReplies: [],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-07-31T10:01:00Z',
},
{
id: 'message-follow-up-assistant',
role: 'assistant',
kind: 'choice',
text: '请选择下一步方向。',
quickReplies: ['增加对比度', '保持当前方案'],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-07-31T10:01:01Z',
},
);
fetchImageWorkspaceConversationMock.mockResolvedValueOnce(conversation);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('image-workspace-conversation');
expect(screen.queryByRole('button', { name: '暖色调' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '冷色调' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '增加对比度' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '保持当前方案' })).toBeInTheDocument();
});
it.each(['consumed', 'expired', 'superseded'] as const)(
'hides quote quick replies when the quote is %s',
async (status) => {
const conversation = conversationFixture();
conversation.messages[1] = {
...conversation.messages[1],
generationQuote: {
...conversation.messages[1].generationQuote!,
status,
},
};
fetchImageWorkspaceConversationMock.mockResolvedValueOnce(conversation);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('image-workspace-conversation');
expect(screen.queryByRole('button', { name: '确认生成' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '继续调整' })).not.toBeInTheDocument();
expect(screen.queryByTestId('design-quote-quote-one')).not.toBeInTheDocument();
},
);
it('hides quote quick replies when the quote payload is gone', async () => {
const conversation = conversationFixture();
conversation.messages[1] = {
...conversation.messages[1],
generationQuote: null,
};
fetchImageWorkspaceConversationMock.mockResolvedValueOnce(conversation);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('image-workspace-conversation');
expect(screen.queryByRole('button', { name: '确认生成' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '继续调整' })).not.toBeInTheDocument();
});
it('opens a generated image at full size and saves it through Main', async () => {
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
const taskCard = await screen.findByTestId('design-task-task-one');
fireEvent.click(within(taskCard).getByRole('button', { name: '查看生图任务详情' }));
const dialog = await screen.findByRole('dialog', { name: '生成内容详情' });
expect(dialog).toHaveTextContent('1024 × 1024');
expect(within(dialog).getByRole('img', { name: 'AI 设计大图预览' }))
.toHaveAttribute('src', 'http://127.0.0.1:13210/content?token=host');
fireEvent.click(within(dialog).getByRole('button', { name: '下载原图' }));
await waitFor(() => expect(saveImageWorkspaceAssetMock).toHaveBeenCalledWith(
taskFixture.resultAssets[0],
));
});
it('shows video tasks as compact cards and exposes video controls in details', async () => {
const videoAsset = {
...taskFixture.resultAssets[0],
assetId: 'video-asset-one',
mediaType: 'video' as const,
mimeType: 'video/mp4',
durationMilliseconds: 4_000,
};
const videoTask = {
...taskFixture,
taskId: 'task-video-one',
medium: 'video' as const,
resultAssets: [videoAsset],
};
fetchImageWorkspaceTasksMock.mockResolvedValueOnce([videoTask]);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
const taskCard = await screen.findByTestId('design-task-task-video-one');
expect(taskCard).toHaveTextContent('视频');
fireEvent.click(within(taskCard).getByRole('button', { name: '查看视频任务详情' }));
const dialog = await screen.findByRole('dialog', { name: '生成内容详情' });
expect(within(dialog).getByLabelText('AI 设计视频预览')).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('button', { name: '下载视频' }));
await waitFor(() => expect(saveImageWorkspaceAssetMock).toHaveBeenCalledWith(videoAsset));
});
it('announces the image download progress while Main is saving', async () => {
let finishSaving: ((value: { status: 'saved' }) => void) | undefined;
saveImageWorkspaceAssetMock.mockReturnValue(new Promise((resolve) => {
finishSaving = resolve;
}));
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
const taskCard = await screen.findByTestId('design-task-task-one');
fireEvent.click(within(taskCard).getByRole('button', { name: '查看生图任务详情' }));
const dialog = await screen.findByRole('dialog', { name: '生成内容详情' });
fireEvent.click(within(dialog).getByRole('button', { name: '下载原图' }));
const savingButton = within(dialog).getByRole('button', { name: '正在下载生图' });
expect(savingButton).toBeDisabled();
expect(savingButton).toHaveAttribute('aria-busy', 'true');
finishSaving?.({ status: 'saved' });
await waitFor(() => expect(
within(dialog).getByRole('button', { name: '下载原图' }),
).toBeEnabled());
});
it('keeps the task list visible in a 1404px desktop workspace', async () => {
const previousWidth = window.innerWidth;
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1404 });
try {
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('image-workspace-conversation');
expect(screen.getByTestId('design-task-list')).toBeInTheDocument();
expect(screen.getByTestId('design-task-list')).toHaveClass('lg:border-l', 'lg:h-full');
} finally {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: previousWidth,
});
}
});
it('keeps conversation and task scrolling isolated', async () => {
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('image-workspace-conversation');
expect(screen.getByTestId('image-canvas-page'))
.toHaveClass('h-full', 'overflow-hidden');
expect(screen.getByTestId('design-task-list'))
.toHaveClass('lg:h-full', 'lg:flex', 'lg:flex-col', 'overflow-hidden');
expect(screen.getByTestId('image-workspace-conversation'))
.toHaveClass('overflow-y-auto', 'overscroll-contain');
expect(screen.getByTestId('design-task-scroll'))
.toHaveClass('lg:flex-1', 'lg:max-h-none', 'min-h-0', 'overflow-y-auto', 'overscroll-contain');
expect(screen.getByTestId('image-workspace-conversation-fade'))
.toHaveClass('absolute', 'top-0', 'bg-gradient-to-b');
expect(screen.getByTestId('design-task-fade')).toBeInTheDocument();
expect(screen.getByTestId('design-task-list').firstElementChild).not.toHaveClass('border-b');
});
it('explains the project model before the first Workspace is created', async () => {
fetchImageWorkspaceMock.mockResolvedValueOnce({
...bootstrapFixture,
workspaces: [],
});
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
expect(await screen.findByText('创建第一个设计项目')).toBeInTheDocument();
expect(screen.getByText(/持续保留与设计 Agent 的对话/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: '新建设计项目' })).toBeInTheDocument();
});
it('sends text to the Agent with the current Turn revision instead of generating directly', async () => {
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('image-workspace-conversation');
fireEvent.change(screen.getByLabelText('设计需求'), {
target: { value: '把主角换成暖色轮廓光' },
});
fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' }));
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
'conversation-cloud',
1,
'把主角换成暖色轮廓光',
expect.stringMatching(/^turn-/),
));
expect(confirmImageWorkspaceGenerationMock).not.toHaveBeenCalled();
});
it('sends the design message with Enter', async () => {
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('image-workspace-conversation');
const composer = screen.getByLabelText('设计需求');
fireEvent.change(composer, {
target: { value: '让画面增加一层晨雾' },
});
fireEvent.keyDown(composer, { key: 'Enter' });
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
'conversation-cloud',
1,
'让画面增加一层晨雾',
expect.stringMatching(/^turn-/),
));
expect(composer).toHaveValue('');
expect(screen.getByTestId('image-workspace-composer'))
.toHaveClass('w-[calc(100%-2rem)]', 'max-w-[46rem]');
expect(screen.getByTestId('image-workspace-composer-surface'))
.toHaveClass('rounded-lg', 'flex', 'min-w-0');
expect(screen.getByRole('button', { name: '上传参考图' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '语音输入' })).toBeDisabled();
expect(screen.queryByText('Enter 发送 · Shift+Enter 换行')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '上传参考图' }));
expect(screen.queryByRole('dialog', { name: '选择视频首帧' })).not.toBeInTheDocument();
expect(screen.getByLabelText('选择参考图文件')).toBeInTheDocument();
});
it('uploads a reference image inline, lets the user mention it, and sends its Asset id', async () => {
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('image-workspace-conversation');
const file = new File(['image-bytes'], 'ocean-poster.png', { type: 'image/png' });
fireEvent.change(screen.getByLabelText('选择参考图文件'), {
target: { files: [file] },
});
await waitFor(() => expect(uploadImageWorkspaceAssetMock)
.toHaveBeenCalledWith('workspace-cloud', file));
const candidates = await screen.findByTestId('image-workspace-reference-candidates');
expect(candidates).toHaveTextContent('ocean-poster.png');
expect(screen.queryByRole('dialog', { name: '选择视频首帧' })).not.toBeInTheDocument();
fireEvent.click(within(candidates).getByRole('button', { name: '选择参考图 ocean-poster.png' }));
expect(screen.getByLabelText('设计需求')).toHaveValue('@ocean-poster.png ');
fireEvent.change(screen.getByLabelText('设计需求'), {
target: { value: '@ocean-poster.png 请沿用这张图的构图' },
});
fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' }));
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
'conversation-cloud',
1,
'@ocean-poster.png 请沿用这张图的构图',
expect.stringMatching(/^turn-/),
['asset-uploaded'],
));
});
it('opens the inline reference palette when the user types @', async () => {
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('image-workspace-conversation');
const file = new File(['image-bytes'], 'character-sheet.png', { type: 'image/png' });
fireEvent.change(screen.getByLabelText('选择参考图文件'), {
target: { files: [file] },
});
await screen.findByTestId('image-workspace-reference-candidates');
const composer = screen.getByLabelText('设计需求');
fireEvent.change(composer, {
target: { value: '请参考 @', selectionStart: 5, selectionEnd: 5 },
});
const palette = await screen.findByRole('listbox', { name: '选择参考图' });
expect(within(palette).getByRole('option', { name: /character-sheet\.png/ })).toBeInTheDocument();
fireEvent.click(within(palette).getByRole('option', { name: /character-sheet\.png/ }));
expect(composer).toHaveValue('请参考 @character-sheet.png ');
});
it('keeps Shift+Enter for newlines and ignores Enter during IME composition', async () => {
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('image-workspace-conversation');
const composer = screen.getByLabelText('设计需求');
fireEvent.change(composer, {
target: { value: '正在输入中文设计描述' },
});
fireEvent.keyDown(composer, { key: 'Enter', shiftKey: true });
fireEvent.keyDown(composer, { key: 'Enter', isComposing: true });
expect(sendImageWorkspaceMessageMock).not.toHaveBeenCalled();
expect(composer).toHaveValue('正在输入中文设计描述');
});
it('renders the pending user turn and assistant deltas while the command is running', async () => {
const response = deferred<DesignConversation>();
sendImageWorkspaceMessageMock.mockReturnValueOnce(response.promise);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('image-workspace-conversation');
await waitFor(() => expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce());
fireEvent.change(screen.getByLabelText('设计需求'), {
target: { value: '让画面更有海洋呼吸感' },
});
fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' }));
expect(await screen.findByText('让画面更有海洋呼吸感')).toBeInTheDocument();
expect(screen.getByRole('status', { name: '设计 Agent 正在回复' })).toBeInTheDocument();
const pending = useImageWorkspaceStore.getState().pendingTurn!;
act(() => {
taskEventSource.emit('design.assistant.delta', {
id: 'session-one:2',
type: 'design.assistant.delta',
workspaceId: 'workspace-cloud',
conversationId: 'conversation-cloud',
clientTurnId: pending.clientTurnId,
turnRevision: pending.turnRevision,
chunkIndex: 0,
delta: '可以先增加',
} satisfies DesignAssistantDeltaEvent);
});
expect(screen.getByTestId('image-workspace-conversation'))
.toHaveTextContent('可以先增加');
expect(screen.getByTestId('image-workspace-conversation'))
.not.toHaveTextContent('留白和水流节奏');
act(() => {
taskEventSource.emit('design.assistant.delta', {
id: 'session-one:3',
type: 'design.assistant.delta',
workspaceId: 'workspace-cloud',
conversationId: 'conversation-cloud',
clientTurnId: pending.clientTurnId,
turnRevision: pending.turnRevision,
chunkIndex: 1,
delta: '留白和水流节奏。',
} satisfies DesignAssistantDeltaEvent);
});
expect(screen.getByTestId('image-workspace-conversation'))
.toHaveTextContent('可以先增加留白和水流节奏。');
act(() => response.resolve({
...conversationFixture(),
turnRevision: 2,
}));
await waitFor(() => expect(useImageWorkspaceStore.getState().pendingTurn).toBeNull());
});
it('reconciles a running Agent turn after returning to the conversation page', async () => {
const response = deferred<DesignConversation>();
const canonical: DesignConversation = {
...conversationFixture(),
turnRevision: 2,
phase: 'shaping',
messages: [{
id: 'message-latest',
role: 'assistant',
kind: 'reply',
text: '已完成最新方向整理。',
quickReplies: [],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-07-31T10:01:00Z',
}],
};
sendImageWorkspaceMessageMock.mockReturnValueOnce(response.promise);
const firstRender = render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('image-workspace-conversation');
await waitFor(() => expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce());
await waitFor(() => expect(taskEventSource.onopen).not.toBeNull());
act(() => taskEventSource.onopen?.(new Event('open')));
fireEvent.change(screen.getByLabelText('设计需求'), {
target: { value: '继续优化海报' },
});
fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' }));
expect(await screen.findByText('继续优化海报')).toBeInTheDocument();
firstRender.unmount();
fetchImageWorkspaceConversationMock.mockResolvedValueOnce(canonical);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
expect(await screen.findByText('继续优化海报')).toBeInTheDocument();
expect(await screen.findByText('已完成最新方向整理。')).toBeInTheDocument();
expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce();
expect(taskEventSource.close).not.toHaveBeenCalled();
await act(async () => {
response.resolve(canonical);
await response.promise;
});
});
it('does not expose confirmation quick replies when no active Quote exists', async () => {
const workspaceWithoutQuote = conversationFixture();
workspaceWithoutQuote.messages[1] = {
...workspaceWithoutQuote.messages[1],
generationQuote: null,
};
fetchImageWorkspaceMock.mockResolvedValueOnce({
...bootstrapFixture,
capabilities: {
...bootstrapFixture.capabilities,
generation: false,
image: false,
video: false,
},
});
fetchImageWorkspaceConversationMock.mockResolvedValueOnce(workspaceWithoutQuote);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('image-workspace-conversation');
expect(screen.queryByRole('button', { name: '确认生成' })).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('设计需求'), {
target: { value: '确认生成' },
});
fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' }));
expect(await screen.findByRole('alert')).toHaveTextContent('当前没有可确认的生成报价');
expect(screen.getByLabelText('设计需求')).toHaveValue('');
expect(sendImageWorkspaceMessageMock).not.toHaveBeenCalled();
expect(confirmImageWorkspaceGenerationMock).not.toHaveBeenCalled();
expect(screen.getByTestId('image-workspace-composer'))
.toHaveTextContent('当前环境仅支持设计沟通');
});
it('creates a generation task only through explicit Quote confirmation', async () => {
fetchImageWorkspaceTasksMock
.mockResolvedValueOnce([taskFixture])
.mockResolvedValueOnce([{
...taskFixture,
taskId: 'task-two',
quoteId: 'quote-one',
status: 'queued',
resultAssets: [],
}]);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('design-quote-quote-one');
fireEvent.click(screen.getByRole('button', { name: '确认并开始生成' }));
await waitFor(() => expect(confirmImageWorkspaceGenerationMock)
.toHaveBeenCalledWith(
'workspace-cloud',
'conversation-cloud',
1,
'quote-one',
expect.stringMatching(/^turn-/),
'夜色中的机械城堡角色海报,电影感构图。',
{
model: 'image-model-one',
resolution: '1024x1024',
aspectRatio: '1:1',
durationSeconds: null,
},
));
await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2));
expect(await screen.findByTestId('design-task-task-two')).toBeInTheDocument();
});
it('keeps the final confirmation prompt visible in the conversation', async () => {
const confirmedConversation: DesignConversation = {
...conversationFixture(),
turnRevision: 2,
phase: 'shaping',
messages: [{
id: 'message-confirmed',
role: 'assistant',
kind: 'reply',
text: '设计任务已确认并开始执行。',
quickReplies: [],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-07-31T10:01:00Z',
}],
};
confirmImageWorkspaceGenerationMock.mockResolvedValueOnce(confirmedConversation);
fetchImageWorkspaceTasksMock
.mockResolvedValueOnce([taskFixture])
.mockResolvedValueOnce([{
...taskFixture,
taskId: 'task-confirmed-visible',
quoteId: 'quote-one',
status: 'queued',
resultAssets: [],
}]);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('design-quote-quote-one');
fireEvent.click(screen.getByRole('button', { name: '确认并开始生成' }));
expect(await screen.findByText('确认生成')).toBeInTheDocument();
expect(screen.getByText('设计任务已确认并开始执行。')).toBeInTheDocument();
});
it('converts an exact composer confirmation into the structured Quote action', async () => {
fetchImageWorkspaceTasksMock
.mockResolvedValueOnce([taskFixture])
.mockResolvedValueOnce([{
...taskFixture,
taskId: 'task-confirmed-from-composer',
quoteId: 'quote-one',
status: 'queued',
resultAssets: [],
}]);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('design-quote-quote-one');
fireEvent.change(screen.getByLabelText('设计需求'), {
target: { value: '确认生成' },
});
fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' }));
await waitFor(() => expect(confirmImageWorkspaceGenerationMock)
.toHaveBeenCalledWith(
'workspace-cloud',
'conversation-cloud',
1,
'quote-one',
expect.stringMatching(/^turn-/),
'夜色中的机械城堡角色海报,电影感构图。',
{
model: 'image-model-one',
resolution: '1024x1024',
aspectRatio: '1:1',
durationSeconds: null,
},
));
expect(sendImageWorkspaceMessageMock).not.toHaveBeenCalled();
expect(await screen.findByTestId('design-task-task-confirmed-from-composer'))
.toBeInTheDocument();
});
it('keeps longer design questions containing confirmation words as normal chat', async () => {
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('design-quote-quote-one');
const question = '请确认这个生成图的构图是不是还需要调整';
fireEvent.change(screen.getByLabelText('设计需求'), {
target: { value: question },
});
fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' }));
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
'conversation-cloud',
1,
question,
expect.stringMatching(/^turn-/),
));
expect(confirmImageWorkspaceGenerationMock).not.toHaveBeenCalled();
});
it('opens the successful work picker when the Agent asks for a video first frame', async () => {
fetchImageWorkspaceConversationMock.mockResolvedValueOnce({
...conversationFixture(),
phase: 'shaping',
brief: {
version: 2,
status: 'draft',
medium: 'video',
summary: '让海洋公益海报动起来',
ready: false,
missingDecision: '请选择首帧来源',
},
messages: [
...conversationFixture().messages,
{
id: 'message-video-source',
role: 'assistant',
kind: 'choice',
text: '请选择视频首帧来源。',
quickReplies: ['先生成一张首帧图片(推荐)', '从作品列表选择图片'],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-08-06T09:00:00Z',
},
],
} satisfies DesignConversation);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' }));
const picker = screen.getByRole('dialog', { name: '选择视频首帧' });
expect(await within(picker).findByRole('img', { name: '可选作品图片' }))
.toHaveAttribute('src', 'http://127.0.0.1:13210/content?token=host');
expect(within(picker).getByRole('button', { name: '选择此图片' })).toBeEnabled();
expect(within(picker).getByLabelText('上传本地图片')).toBeInTheDocument();
expect(sendImageWorkspaceMessageMock).not.toHaveBeenCalled();
});
it('keeps the historical video picker behavior when the brief medium is not decided yet', async () => {
fetchImageWorkspaceConversationMock.mockResolvedValueOnce({
...conversationFixture(),
phase: 'shaping',
brief: {
version: 2,
status: 'draft',
medium: null,
summary: '',
ready: false,
missingDecision: '请选择首帧来源',
},
messages: [
...conversationFixture().messages,
{
id: 'message-undecided-source',
role: 'assistant',
kind: 'choice',
text: '请选择视频首帧来源。',
quickReplies: ['从作品列表选择图片'],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-08-12T09:00:00Z',
},
],
turnRevision: 2,
} satisfies DesignConversation);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' }));
const picker = screen.getByRole('dialog', { name: '选择视频首帧' });
const selectButton = within(picker).getByRole('button', { name: '选择此图片' });
await waitFor(() => expect(selectButton).toBeEnabled());
fireEvent.click(selectButton);
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
'conversation-cloud',
2,
'使用作品图片作为视频首帧',
expect.stringMatching(/^turn-/),
['asset-one'],
));
expect(screen.queryByRole('dialog', { name: '选择视频首帧' }))
.not.toBeInTheDocument();
});
it('opens the image reference picker and submits a successful work Asset id', async () => {
fetchImageWorkspaceConversationMock.mockResolvedValueOnce({
...conversationFixture(),
phase: 'shaping',
brief: {
version: 2,
status: 'draft',
medium: 'image',
summary: '参考海洋公益海报生成新图',
ready: false,
missingDecision: '请选择参考图来源',
},
messages: [
...conversationFixture().messages,
{
id: 'message-image-source',
role: 'assistant',
kind: 'choice',
text: '请选择图生图参考图。',
quickReplies: ['从作品列表选择图片'],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-08-12T09:00:00Z',
},
],
turnRevision: 2,
} satisfies DesignConversation);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' }));
const picker = screen.getByRole('dialog', { name: '选择图生图参考图' });
expect(within(picker).getByText(/作为图生图参考/)).toBeInTheDocument();
const selectButton = within(picker).getByRole('button', { name: '选择此图片' });
await waitFor(() => expect(selectButton).toBeEnabled());
fireEvent.click(selectButton);
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
'conversation-cloud',
2,
'使用作品图片作为图生图参考图',
expect.stringMatching(/^turn-/),
['asset-one'],
));
expect(screen.queryByRole('dialog', { name: '选择图生图参考图' }))
.not.toBeInTheDocument();
});
it('uploads a local image reference and submits its Asset id to the Agent', async () => {
fetchImageWorkspaceConversationMock.mockResolvedValueOnce({
...conversationFixture(),
messages: [
...conversationFixture().messages,
{
id: 'message-image-source',
role: 'assistant',
kind: 'choice',
text: '请选择图生图参考图。',
quickReplies: ['从作品列表选择图片'],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-08-12T09:00:00Z',
},
],
turnRevision: 2,
} satisfies DesignConversation);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' }));
const picker = screen.getByRole('dialog', { name: '选择图生图参考图' });
const file = new File(['image-bytes'], 'reference.png', { type: 'image/png' });
fireEvent.change(within(picker).getByLabelText('上传本地图片'), {
target: { files: [file] },
});
await waitFor(() => expect(uploadImageWorkspaceAssetMock)
.toHaveBeenCalledWith('workspace-cloud', file));
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
'conversation-cloud',
2,
'使用上传图片作为图生图参考图',
expect.stringMatching(/^turn-/),
['asset-uploaded'],
));
await waitFor(() => expect(
screen.queryByRole('dialog', { name: '选择图生图参考图' }),
).not.toBeInTheDocument());
});
it('submits a successful work Asset id when it is selected as the video first frame', async () => {
const pendingSelection = deferred<DesignConversation>();
sendImageWorkspaceMessageMock.mockReturnValueOnce(pendingSelection.promise);
fetchImageWorkspaceConversationMock.mockResolvedValueOnce({
...conversationFixture(),
brief: {
...conversationFixture().brief,
medium: 'video',
},
turnRevision: 2,
messages: [
...conversationFixture().messages,
{
id: 'message-video-source',
role: 'assistant',
kind: 'choice',
text: '请选择视频首帧来源。',
quickReplies: ['从作品列表选择图片'],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-08-06T09:00:00Z',
},
],
} satisfies DesignConversation);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' }));
const selectButton = within(
screen.getByRole('dialog', { name: '选择视频首帧' }),
).getByRole('button', { name: '选择此图片' });
await waitFor(() => expect(selectButton).toBeEnabled());
fireEvent.click(selectButton);
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
'conversation-cloud',
2,
'使用作品图片作为视频首帧',
expect.stringMatching(/^turn-/),
['asset-one'],
));
expect(screen.queryByRole('dialog', { name: '选择视频首帧' })).not.toBeInTheDocument();
await act(async () => {
pendingSelection.resolve(conversationFixture());
await pendingSelection.promise;
});
});
it('uploads a local first-frame image and submits its Asset id to the Agent', async () => {
fetchImageWorkspaceConversationMock.mockResolvedValueOnce({
...conversationFixture(),
brief: {
...conversationFixture().brief,
medium: 'video',
},
messages: [
...conversationFixture().messages,
{
id: 'message-video-source',
role: 'assistant',
kind: 'choice',
text: '请选择视频首帧来源。',
quickReplies: ['从作品列表选择图片'],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-08-06T09:00:00Z',
},
],
turnRevision: 2,
} satisfies DesignConversation);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' }));
const picker = screen.getByRole('dialog', { name: '选择视频首帧' });
const file = new File(['image-bytes'], 'ocean-poster.png', { type: 'image/png' });
fireEvent.change(within(picker).getByLabelText('上传本地图片'), {
target: { files: [file] },
});
await waitFor(() => expect(uploadImageWorkspaceAssetMock)
.toHaveBeenCalledWith('workspace-cloud', file));
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
'conversation-cloud',
2,
'使用上传的图片作为视频首帧',
expect.stringMatching(/^turn-/),
['asset-uploaded'],
));
await waitFor(() => expect(
screen.queryByRole('dialog', { name: '选择视频首帧' }),
).not.toBeInTheDocument());
});
it('renders policy-blocked generation tasks with a user-facing design message', async () => {
fetchImageWorkspaceTasksMock.mockResolvedValue([{
...taskFixture,
status: 'failed',
failureCode: 'policy_blocked',
resultAssets: [],
}]);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
const taskCard = await screen.findByTestId('design-task-task-one');
expect(within(taskCard).queryByRole('alert')).not.toBeInTheDocument();
fireEvent.click(within(taskCard).getByRole('button', { name: '查看生图任务详情' }));
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent('内容可能涉及版权或安全风险,请调整设计后重试');
expect(alert).not.toHaveTextContent('policy_blocked');
});
it('renders a new task pushed by the design event stream without repeated polling', async () => {
const queuedTask = {
...taskFixture,
taskId: 'task-delayed',
status: 'queued' as const,
resultAssets: [],
};
fetchImageWorkspaceTasksMock.mockResolvedValue([]);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('design-quote-quote-one');
await waitFor(() => expect(openImageWorkspaceTaskEventsMock)
.toHaveBeenCalledWith('workspace-cloud', 'conversation-cloud'));
await waitFor(() => expect(taskEventSource.onopen).not.toBeNull());
act(() => {
taskEventSource.onopen?.(new Event('open'));
taskEventSource.emit('design.generation_task.updated', {
id: 'session-one:2',
type: 'design.generation_task.updated',
workspaceId: 'workspace-cloud',
workspaceViewRevision: 2,
generationTask: queuedTask,
} satisfies DesignGenerationTaskUpdatedEvent);
});
expect(await screen.findByTestId('design-task-task-delayed'))
.toBeInTheDocument();
expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledOnce();
});
});