问题:生成任务未及时出现在右侧栏,任务区与会话区还会共享外层滚动。 实现:补齐 Agent 回合与 Quote 确认后的任务同步,锁定左右滚动容器并增加桌面布局和异步任务回归测试。
357 lines
13 KiB
TypeScript
357 lines
13 KiB
TypeScript
import { 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 {
|
|
DesignGenerationTask,
|
|
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 resolveImageWorkspaceAssetUrlMock = vi.hoisted(() => vi.fn());
|
|
|
|
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)
|
|
),
|
|
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', () => {
|
|
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',
|
|
});
|
|
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(confirmImageWorkspaceGenerationMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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,
|
|
));
|
|
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'));
|
|
await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2));
|
|
});
|
|
|
|
it('keeps checking after Quote confirmation until the new task becomes visible', async () => {
|
|
const queuedTask = {
|
|
...taskFixture,
|
|
taskId: 'task-delayed',
|
|
status: 'queued' as const,
|
|
resultAssets: [],
|
|
};
|
|
fetchImageWorkspaceTasksMock
|
|
.mockResolvedValueOnce([])
|
|
.mockResolvedValueOnce([])
|
|
.mockResolvedValueOnce([queuedTask]);
|
|
|
|
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'));
|
|
await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(3));
|
|
expect(await screen.findByTestId('design-task-task-delayed'))
|
|
.toBeInTheDocument();
|
|
});
|
|
});
|