Files
makelore/tests/unit/image-canvas-page.test.tsx
brother7 4980894017 feat(AI设计): 支持输入框回车发送
需求:AI 设计输入框监听 Enter 并发送消息。

实现:复用现有发送链路,保留 Shift+Enter 换行并保护输入法组合状态;补充聚焦回归测试。
2026-08-06 16:46:19 +08:00

735 lines
27 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 { useImageWorkspaceStore } from '@/stores/image-workspace';
import type {
DesignAssistantDeltaEvent,
DesignGenerationTask,
DesignGenerationTaskUpdatedEvent,
DesignWorkspace,
DesignWorkspaceBootstrap,
} from '../../shared/image-workspace';
const fetchImageWorkspaceMock = vi.hoisted(() => vi.fn());
const fetchImageWorkspaceProjectMock = vi.hoisted(() => vi.fn());
const fetchImageWorkspaceTasksMock = vi.hoisted(() => vi.fn());
const sendImageWorkspaceMessageMock = vi.hoisted(() => vi.fn());
const confirmImageWorkspaceGenerationMock = 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),
fetchImageWorkspaceTasks: (...args: unknown[]) => fetchImageWorkspaceTasksMock(...args),
sendImageWorkspaceMessage: (...args: unknown[]) => sendImageWorkspaceMessageMock(...args),
confirmImageWorkspaceGeneration: (...args: unknown[]) => (
confirmImageWorkspaceGenerationMock(...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: '云端角色设定',
turnRevision: 1,
viewRevision: 1,
phase: 'awaiting_confirmation',
brief: {
version: 1,
status: 'ready',
medium: 'image',
summary: '夜色中的机械城堡角色海报',
ready: true,
missingDecision: null,
},
updatedAt: '2026-07-31T10:00:00Z',
}],
};
function workspaceFixture(): DesignWorkspace {
return {
...bootstrapFixture.workspaces[0],
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: '夜色中的机械城堡角色海报',
quotedDesignPoints: 1,
expiresAt: '2026-07-31T11:00:00Z',
},
turnRevision: 1,
createdAt: '2026-07-31T10:00:01Z',
},
],
};
}
const taskFixture: DesignGenerationTask = {
taskId: 'task-one',
workspaceId: 'workspace-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();
fetchImageWorkspaceMock.mockResolvedValue(bootstrapFixture);
fetchImageWorkspaceProjectMock.mockResolvedValue(workspaceFixture());
fetchImageWorkspaceTasksMock.mockResolvedValue([taskFixture]);
sendImageWorkspaceMessageMock.mockResolvedValue(workspaceFixture());
confirmImageWorkspaceGenerationMock.mockResolvedValue({
...workspaceFixture(),
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',
refreshToken: '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,
refreshToken: null,
user: null,
});
expect(useImageWorkspaceStore.getState().status).toBe('auth-required');
useAuthStore.setState({
accessToken: 'fresh-access-token',
refreshToken: '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 one fixed design Agent, a Quote, and the unified image/video task list', async () => {
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
expect(await screen.findByText('云端角色设定')).toBeInTheDocument();
expect(screen.getByTestId('image-workspace-conversation'))
.toHaveTextContent('方向已经明确');
expect(screen.getByText('设计 Agent')).toBeInTheDocument();
expect(screen.getByTestId('design-quote-quote-one')).toHaveTextContent('1 设计点');
expect(screen.getByTestId('design-task-list')).toHaveTextContent('图片与视频任务统一展示');
expect(screen.getByTestId('design-task-task-one')).toHaveTextContent('已完成');
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('opens a generated image at full size and saves it through Main', async () => {
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '放大查看生成图片' }));
const dialog = screen.getByRole('dialog', { name: '图片预览' });
expect(dialog).toHaveTextContent('1024 × 1024');
expect(screen.getByRole('img', { name: 'AI 设计大图预览' }))
.toHaveAttribute('src', 'http://127.0.0.1:13210/content?token=host');
fireEvent.click(screen.getByRole('button', { name: '下载原图' }));
await waitFor(() => expect(saveImageWorkspaceAssetMock).toHaveBeenCalledWith(
taskFixture.resultAssets[0],
));
});
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>);
fireEvent.click(await screen.findByRole('button', { name: '下载生成图片' }));
const savingButton = screen.getByRole('button', { name: '正在保存生成图片' });
expect(savingButton).toBeDisabled();
expect(savingButton).toHaveAttribute('aria-busy', 'true');
finishSaving?.({ status: 'saved' });
await waitFor(() => expect(
screen.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-pane-divider')).toHaveClass('lg:block');
} 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('image-workspace-conversation'))
.toHaveClass('overflow-y-auto', 'overscroll-contain');
expect(screen.getByTestId('design-task-list'))
.toHaveClass('lg:h-full', 'overflow-hidden');
expect(screen.getByTestId('design-task-scroll'))
.toHaveClass('lg:h-[calc(100%-57px)]', 'overflow-y-auto', 'overscroll-contain');
});
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.findByText('云端角色设定');
fireEvent.change(screen.getByLabelText('设计需求'), {
target: { value: '把主角换成暖色轮廓光' },
});
fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' }));
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
1,
'把主角换成暖色轮廓光',
expect.stringMatching(/^turn-/),
));
expect(confirmImageWorkspaceGenerationMock).not.toHaveBeenCalled();
});
it('sends the design message with Enter', async () => {
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByText('云端角色设定');
const composer = screen.getByLabelText('设计需求');
fireEvent.change(composer, {
target: { value: '让画面增加一层晨雾' },
});
fireEvent.keyDown(composer, { key: 'Enter' });
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
1,
'让画面增加一层晨雾',
expect.stringMatching(/^turn-/),
));
expect(composer).toHaveValue('');
expect(screen.getByTestId('image-workspace-composer'))
.toHaveTextContent('Enter 发送 · Shift+Enter 换行');
});
it('keeps Shift+Enter for newlines and ignores Enter during IME composition', async () => {
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByText('云端角色设定');
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<DesignWorkspace>();
sendImageWorkspaceMessageMock.mockReturnValueOnce(response.promise);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByText('云端角色设定');
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',
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',
clientTurnId: pending.clientTurnId,
turnRevision: pending.turnRevision,
chunkIndex: 1,
delta: '留白和水流节奏。',
} satisfies DesignAssistantDeltaEvent);
});
expect(screen.getByTestId('image-workspace-conversation'))
.toHaveTextContent('可以先增加留白和水流节奏。');
act(() => response.resolve({
...workspaceFixture(),
turnRevision: 2,
viewRevision: 2,
}));
await waitFor(() => expect(useImageWorkspaceStore.getState().pendingTurn).toBeNull());
});
it('never downgrades confirmation to normal chat when no active Quote exists', async () => {
const workspaceWithoutQuote = workspaceFixture();
workspaceWithoutQuote.messages[1] = {
...workspaceWithoutQuote.messages[1],
generationQuote: null,
};
fetchImageWorkspaceMock.mockResolvedValueOnce({
...bootstrapFixture,
capabilities: {
...bootstrapFixture.capabilities,
generation: false,
image: false,
video: false,
},
});
fetchImageWorkspaceProjectMock.mockResolvedValueOnce(workspaceWithoutQuote);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
const confirmReply = await screen.findByRole('button', { name: '确认生成' });
fireEvent.click(confirmReply);
expect(screen.getByRole('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',
1,
'quote-one',
expect.stringMatching(/^turn-/),
));
await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2));
expect(await screen.findByTestId('design-task-task-two')).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',
1,
'quote-one',
expect.stringMatching(/^turn-/),
));
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',
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 () => {
fetchImageWorkspaceProjectMock.mockResolvedValueOnce({
...workspaceFixture(),
phase: 'shaping',
brief: {
version: 2,
status: 'draft',
medium: 'video',
summary: '让海洋公益海报动起来',
ready: false,
missingDecision: '请选择首帧来源',
},
messages: [
...workspaceFixture().messages,
{
id: 'message-video-source',
role: 'assistant',
kind: 'choice',
text: '请选择视频首帧来源。',
quickReplies: ['先生成一张首帧图片(推荐)', '从作品列表选择图片'],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-08-06T09:00:00Z',
},
],
} satisfies DesignWorkspace);
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('submits a successful work Asset id when it is selected as the video first frame', async () => {
const pendingSelection = deferred<DesignWorkspace>();
sendImageWorkspaceMessageMock.mockReturnValueOnce(pendingSelection.promise);
fetchImageWorkspaceProjectMock.mockResolvedValueOnce({
...workspaceFixture(),
turnRevision: 2,
messages: [
...workspaceFixture().messages,
{
id: 'message-video-source',
role: 'assistant',
kind: 'choice',
text: '请选择视频首帧来源。',
quickReplies: ['从作品列表选择图片'],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-08-06T09:00:00Z',
},
],
} satisfies DesignWorkspace);
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',
2,
'使用作品图片作为视频首帧',
expect.stringMatching(/^turn-/),
['asset-one'],
));
expect(screen.queryByRole('dialog', { name: '选择视频首帧' })).not.toBeInTheDocument();
await act(async () => {
pendingSelection.resolve(workspaceFixture());
await pendingSelection.promise;
});
});
it('uploads a local first-frame image and submits its Asset id to the Agent', async () => {
fetchImageWorkspaceProjectMock.mockResolvedValueOnce({
...workspaceFixture(),
messages: [
...workspaceFixture().messages,
{
id: 'message-video-source',
role: 'assistant',
kind: 'choice',
text: '请选择视频首帧来源。',
quickReplies: ['从作品列表选择图片'],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-08-06T09:00:00Z',
},
],
turnRevision: 2,
} satisfies DesignWorkspace);
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',
2,
'使用上传的图片作为视频首帧',
expect.stringMatching(/^turn-/),
['asset-uploaded'],
));
});
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 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'));
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();
});
});