完善客户端模块与工作区能力
This commit is contained in:
@@ -17,7 +17,6 @@ import type {
|
||||
const fetchImageWorkspaceMock = vi.hoisted(() => vi.fn());
|
||||
const fetchImageWorkspaceProjectMock = vi.hoisted(() => vi.fn());
|
||||
const fetchImageWorkspaceConversationMock = vi.hoisted(() => vi.fn());
|
||||
const createImageWorkspaceConversationMock = vi.hoisted(() => vi.fn());
|
||||
const fetchImageWorkspaceTasksMock = vi.hoisted(() => vi.fn());
|
||||
const sendImageWorkspaceMessageMock = vi.hoisted(() => vi.fn());
|
||||
const confirmImageWorkspaceGenerationMock = vi.hoisted(() => vi.fn());
|
||||
@@ -63,9 +62,6 @@ vi.mock('@/lib/image-workspace', async (importOriginal) => {
|
||||
fetchImageWorkspaceConversation: (...args: unknown[]) => (
|
||||
fetchImageWorkspaceConversationMock(...args)
|
||||
),
|
||||
createImageWorkspaceConversation: (...args: unknown[]) => (
|
||||
createImageWorkspaceConversationMock(...args)
|
||||
),
|
||||
fetchImageWorkspaceTasks: (...args: unknown[]) => fetchImageWorkspaceTasksMock(...args),
|
||||
sendImageWorkspaceMessage: (...args: unknown[]) => sendImageWorkspaceMessageMock(...args),
|
||||
confirmImageWorkspaceGeneration: (...args: unknown[]) => (
|
||||
@@ -196,7 +192,6 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
fetchImageWorkspaceMock.mockResolvedValue(bootstrapFixture);
|
||||
fetchImageWorkspaceProjectMock.mockResolvedValue(workspaceFixture());
|
||||
fetchImageWorkspaceConversationMock.mockResolvedValue(conversationFixture());
|
||||
createImageWorkspaceConversationMock.mockResolvedValue(conversationFixture());
|
||||
fetchImageWorkspaceTasksMock.mockResolvedValue([taskFixture]);
|
||||
sendImageWorkspaceMessageMock.mockResolvedValue(conversationFixture());
|
||||
confirmImageWorkspaceGenerationMock.mockResolvedValue({
|
||||
@@ -286,15 +281,26 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
await waitFor(() => expect(useImageWorkspaceStore.getState().status).toBe('ready'));
|
||||
});
|
||||
|
||||
it('renders one fixed design Agent, a Quote, and the unified image/video task list', async () => {
|
||||
it('renders the conversation messages edge-to-edge with the unified image/video task list', async () => {
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
|
||||
expect(await screen.findByText('云端角色设定')).toBeInTheDocument();
|
||||
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.getByText('设计 Agent')).toBeInTheDocument();
|
||||
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.getByTestId('design-task-list')).toHaveTextContent('图片与视频任务统一展示');
|
||||
expect(screen.getByTestId('design-task-list')).toHaveTextContent('生成内容');
|
||||
expect(screen.queryByText('图片与视频任务统一展示')).not.toBeInTheDocument();
|
||||
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'));
|
||||
@@ -302,75 +308,57 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
expect(screen.queryByText(/当前 Agent/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('creates and switches Conversations without hiding project generation tasks', async () => {
|
||||
const firstConversation = conversationFixture();
|
||||
const secondConversation: DesignConversation = {
|
||||
it('uses the compact logo-only empty state for a new design conversation', async () => {
|
||||
fetchImageWorkspaceConversationMock.mockResolvedValueOnce({
|
||||
...conversationFixture(),
|
||||
conversationId: 'conversation-two',
|
||||
title: '第二会话',
|
||||
turnRevision: 0,
|
||||
phase: 'shaping',
|
||||
brief: {
|
||||
version: 0,
|
||||
status: 'draft',
|
||||
medium: null,
|
||||
summary: '',
|
||||
ready: false,
|
||||
missingDecision: '请描述新的设计方向',
|
||||
},
|
||||
messages: [{
|
||||
id: 'message-two',
|
||||
role: 'assistant',
|
||||
kind: 'reply',
|
||||
text: '这是独立的第二会话',
|
||||
quickReplies: [],
|
||||
generationQuote: null,
|
||||
turnRevision: 0,
|
||||
createdAt: '2026-07-31T10:05:00Z',
|
||||
}],
|
||||
createdAt: '2026-07-31T10:05:00Z',
|
||||
updatedAt: '2026-07-31T10:05:00Z',
|
||||
};
|
||||
const { messages: _firstMessages, ...firstSummary } = firstConversation;
|
||||
const { messages: _secondMessages, ...secondSummary } = secondConversation;
|
||||
fetchImageWorkspaceProjectMock
|
||||
.mockResolvedValueOnce(workspaceFixture())
|
||||
.mockResolvedValue({
|
||||
...workspaceFixture(),
|
||||
conversationCount: 2,
|
||||
conversations: [secondSummary, firstSummary],
|
||||
});
|
||||
createImageWorkspaceConversationMock.mockResolvedValue(secondConversation);
|
||||
fetchImageWorkspaceConversationMock.mockImplementation(
|
||||
async (_workspaceId: string, conversationId: string) => (
|
||||
conversationId === secondConversation.conversationId
|
||||
? secondConversation
|
||||
: firstConversation
|
||||
),
|
||||
);
|
||||
messages: [],
|
||||
});
|
||||
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
|
||||
expect(await screen.findByTestId('design-task-task-one')).toHaveTextContent('已完成');
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建设计会话' }));
|
||||
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');
|
||||
});
|
||||
|
||||
await waitFor(() => expect(createImageWorkspaceConversationMock)
|
||||
.toHaveBeenCalledWith('workspace-cloud', '新会话'));
|
||||
await waitFor(() => expect(screen.getByLabelText('切换设计会话'))
|
||||
.toHaveValue('conversation-two'));
|
||||
expect(screen.getByTestId('image-workspace-conversation'))
|
||||
.toHaveTextContent('这是独立的第二会话');
|
||||
expect(screen.getByTestId('design-task-task-one')).toHaveTextContent('已完成');
|
||||
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);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('切换设计会话'), {
|
||||
target: { value: 'conversation-cloud' },
|
||||
});
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
|
||||
await waitFor(() => expect(fetchImageWorkspaceConversationMock)
|
||||
.toHaveBeenLastCalledWith('workspace-cloud', 'conversation-cloud'));
|
||||
expect(screen.getByTestId('image-workspace-conversation'))
|
||||
.toHaveTextContent('方向已经明确');
|
||||
expect(screen.getByTestId('design-task-task-one')).toHaveTextContent('已完成');
|
||||
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 () => {
|
||||
@@ -418,7 +406,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
await screen.findByTestId('image-workspace-conversation');
|
||||
|
||||
expect(screen.getByTestId('design-task-list')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('design-pane-divider')).toHaveClass('lg:block');
|
||||
expect(screen.getByTestId('design-task-list')).toHaveClass('lg:border-l', 'lg:h-full');
|
||||
} finally {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
@@ -433,12 +421,16 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
|
||||
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-list'))
|
||||
.toHaveClass('lg:h-full', 'overflow-hidden');
|
||||
expect(screen.getByTestId('design-task-scroll'))
|
||||
.toHaveClass('lg:h-[calc(100%-57px)]', 'overflow-y-auto', 'overscroll-contain');
|
||||
.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 () => {
|
||||
@@ -456,7 +448,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
|
||||
it('sends text to the Agent with the current Turn revision instead of generating directly', async () => {
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
await screen.findByText('云端角色设定');
|
||||
await screen.findByTestId('image-workspace-conversation');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('设计需求'), {
|
||||
target: { value: '把主角换成暖色轮廓光' },
|
||||
@@ -475,7 +467,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
|
||||
it('sends the design message with Enter', async () => {
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
await screen.findByText('云端角色设定');
|
||||
await screen.findByTestId('image-workspace-conversation');
|
||||
const composer = screen.getByLabelText('设计需求');
|
||||
|
||||
fireEvent.change(composer, {
|
||||
@@ -492,12 +484,75 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
));
|
||||
expect(composer).toHaveValue('');
|
||||
expect(screen.getByTestId('image-workspace-composer'))
|
||||
.toHaveTextContent('Enter 发送 · Shift+Enter 换行');
|
||||
.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.findByText('云端角色设定');
|
||||
await screen.findByTestId('image-workspace-conversation');
|
||||
const composer = screen.getByLabelText('设计需求');
|
||||
|
||||
fireEvent.change(composer, {
|
||||
@@ -514,7 +569,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
const response = deferred<DesignConversation>();
|
||||
sendImageWorkspaceMessageMock.mockReturnValueOnce(response.promise);
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
await screen.findByText('云端角色设定');
|
||||
await screen.findByTestId('image-workspace-conversation');
|
||||
await waitFor(() => expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce());
|
||||
|
||||
fireEvent.change(screen.getByLabelText('设计需求'), {
|
||||
@@ -565,7 +620,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
await waitFor(() => expect(useImageWorkspaceStore.getState().pendingTurn).toBeNull());
|
||||
});
|
||||
|
||||
it('never downgrades confirmation to normal chat when no active Quote exists', async () => {
|
||||
it('does not expose confirmation quick replies when no active Quote exists', async () => {
|
||||
const workspaceWithoutQuote = conversationFixture();
|
||||
workspaceWithoutQuote.messages[1] = {
|
||||
...workspaceWithoutQuote.messages[1],
|
||||
@@ -583,11 +638,15 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
fetchImageWorkspaceConversationMock.mockResolvedValueOnce(workspaceWithoutQuote);
|
||||
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
const confirmReply = await screen.findByRole('button', { name: '确认生成' });
|
||||
await screen.findByTestId('image-workspace-conversation');
|
||||
expect(screen.queryByRole('button', { name: '确认生成' })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(confirmReply);
|
||||
fireEvent.change(screen.getByLabelText('设计需求'), {
|
||||
target: { value: '确认生成' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' }));
|
||||
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('当前没有可确认的生成报价');
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('当前没有可确认的生成报价');
|
||||
expect(screen.getByLabelText('设计需求')).toHaveValue('');
|
||||
expect(sendImageWorkspaceMessageMock).not.toHaveBeenCalled();
|
||||
expect(confirmImageWorkspaceGenerationMock).not.toHaveBeenCalled();
|
||||
|
||||
Reference in New Issue
Block a user