Files
makelore/tests/unit/image-canvas-page.test.tsx
brother7 90a249db31 feat: 流式展示设计 Agent 对话
需求:设计 Agent 对话与确认生成的回复需要实时展示。

实现:统一通过 Agent Gateway 提交 Turn,接收并去重 assistant delta,以 canonical Workspace 收口,并修复跨项目旧请求回写竞态。
2026-08-02 21:21:50 +08:00

456 lines
16 KiB
TypeScript

import { act, fireEvent, render, screen, waitFor } 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());
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)
),
};
});
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',
);
});
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('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);
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('refreshes tasks after an Agent turn creates a generation task', async () => {
const queuedTask = {
...taskFixture,
taskId: 'task-two',
status: 'queued' as const,
resultAssets: [],
};
fetchImageWorkspaceTasksMock
.mockResolvedValueOnce([])
.mockResolvedValueOnce([queuedTask]);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('design-quote-quote-one');
const confirmationReply = workspaceFixture().messages[1].quickReplies[0];
fireEvent.change(screen.getByLabelText('设计需求'), {
target: { value: confirmationReply },
});
fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' }));
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
1,
confirmationReply,
expect.stringMatching(/^turn-/),
));
await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2));
expect(await screen.findByTestId('design-task-task-two')).toBeInTheDocument();
});
it('creates a generation task only through explicit Quote confirmation', async () => {
fetchImageWorkspaceTasksMock
.mockResolvedValueOnce([taskFixture])
.mockResolvedValueOnce([{
...taskFixture,
taskId: 'task-two',
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));
});
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();
});
});