需求:生成图片需要支持大图查看和本地下载;制作视频时需要从当前项目作品选择首帧,或上传本地图片。 实现:新增首帧选择弹窗、JPEG/PNG/WebP 有界上传、附件 Asset ID 透传、Main 到服务端 multipart 转发,并保留上传失败重试与私有图片下载链路。 验证:相关 57 项单测、TypeScript 类型检查、目标 ESLint 和 Vite 生产构建全部通过。
691 lines
25 KiB
TypeScript
691 lines
25 KiB
TypeScript
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('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 () => {
|
||
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'],
|
||
));
|
||
});
|
||
|
||
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();
|
||
});
|
||
});
|