191 lines
9.1 KiB
TypeScript
191 lines
9.1 KiB
TypeScript
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { DesignConversationPane } from '@/pages/ImageCanvas/DesignConversationPane';
|
|
import { DesignMessageMarkdown } from '@/pages/ImageCanvas/DesignMessageMarkdown';
|
|
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
|
import { designWorkspaceFixture } from '../fixtures/design-workspace-v2';
|
|
|
|
const { openExternalMock, toastErrorMock } = vi.hoisted(() => ({
|
|
openExternalMock: vi.fn().mockResolvedValue(undefined),
|
|
toastErrorMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('sonner', () => ({ toast: { error: toastErrorMock } }));
|
|
vi.mock('@/lib/conversation-links', async (importOriginal) => ({
|
|
...await importOriginal<typeof import('@/lib/conversation-links')>(),
|
|
openConversationExternalUrl: openExternalMock,
|
|
}));
|
|
vi.mock('@/pages/ImageCanvas/DesignProductionPanel', () => ({
|
|
DesignPendingOperationsPanel: () => <div data-testid="pending-operations-panel" />,
|
|
}));
|
|
vi.mock('@/pages/ImageCanvas/DesignPlanHistory', () => ({
|
|
DesignPlanHistory: () => <div data-testid="plan-history" />,
|
|
}));
|
|
vi.mock('@/pages/ImageCanvas/YouthCreationCard', () => ({
|
|
YouthCreationCard: () => <div data-testid="youth-creation-card" />,
|
|
}));
|
|
|
|
function prepare(workspace = designWorkspaceFixture()) {
|
|
const sendChat = vi.fn().mockResolvedValue(workspace);
|
|
useImageWorkspaceStore.setState({
|
|
workspace,
|
|
activeWorkspaceId: workspace.workspace.workspaceId,
|
|
chatDraft: '',
|
|
assistantStreams: {},
|
|
assistantActivities: {},
|
|
pendingOperations: {},
|
|
sendChat,
|
|
});
|
|
return { workspace, sendChat };
|
|
}
|
|
|
|
function pane(workspace = designWorkspaceFixture(), showCreationCard = true) {
|
|
return <DesignConversationPane workspace={workspace} quoteBlockers={[]} generationAvailable showCreationCard={showCreationCard} />;
|
|
}
|
|
|
|
function pendingChat(workspaceId = 'workspace-1') {
|
|
return {
|
|
id: 'chat-1', label: '发送消息', status: 'submitting' as const,
|
|
command: {
|
|
kind: 'apply_input' as const, workspaceId, sessionId: 'session-1',
|
|
expectedDirectionRevision: 4, clientOperationId: 'chat-1',
|
|
input: { kind: 'chat' as const, message: '画一只小熊' },
|
|
},
|
|
error: null, clearDraftPaths: [], clearChatDraft: true, baseRawTurnSequence: 1,
|
|
};
|
|
}
|
|
|
|
describe('Canvas conversation presentation', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
useImageWorkspaceStore.getState().reset();
|
|
});
|
|
|
|
it('formats assistant Markdown identically during streaming and after confirmation', () => {
|
|
const text = '**画面方向**\n\n- 黄色的小熊\n- 蓝色的夜空\n\n| 颜色 | 用途 |\n| --- | --- |\n| 黄色 | 小熊 |';
|
|
const { workspace } = prepare(designWorkspaceFixture({ turns: [] }));
|
|
useImageWorkspaceStore.setState({
|
|
pendingOperations: { 'chat-1': pendingChat() },
|
|
assistantStreams: { 'chat-1': text },
|
|
});
|
|
const { rerender } = render(pane(workspace));
|
|
const streaming = screen.getByTestId('streaming-design-assistant-chat-1');
|
|
const streamedBody = streaming.querySelector('.canvas-message-markdown')?.innerHTML;
|
|
expect(within(streaming).getByText('画面方向').tagName).toBe('STRONG');
|
|
expect(within(streaming).getAllByRole('listitem')).toHaveLength(2);
|
|
expect(within(streaming).getByRole('table')).toBeInTheDocument();
|
|
expect(streaming).not.toHaveTextContent('**');
|
|
|
|
const finalWorkspace = {
|
|
...workspace,
|
|
turns: [{ turnId: 'turn-2', rawTurnSequence: 2, userMessage: '画一只小熊', assistantMessage: text }],
|
|
};
|
|
act(() => { useImageWorkspaceStore.setState({ pendingOperations: {}, workspace: finalWorkspace }); });
|
|
rerender(pane(finalWorkspace));
|
|
expect(screen.queryByTestId('streaming-design-assistant-chat-1')).not.toBeInTheDocument();
|
|
expect(screen.getByLabelText('麦洛的回复').querySelector('.canvas-message-markdown')?.innerHTML).toBe(streamedBody);
|
|
});
|
|
|
|
it('leaves user messages literal and can move only the creation card out of the conversation', () => {
|
|
const { workspace } = prepare(designWorkspaceFixture({ turns: [{
|
|
turnId: 'turn-1', rawTurnSequence: 1, userMessage: '**我的原话**', assistantMessage: '收到',
|
|
}] }));
|
|
const { rerender } = render(pane(workspace));
|
|
expect(screen.getByText('**我的原话**').querySelector('strong')).toBeNull();
|
|
expect(screen.getByTestId('youth-creation-card')).toBeInTheDocument();
|
|
rerender(pane(workspace, false));
|
|
expect(screen.queryByTestId('youth-creation-card')).not.toBeInTheDocument();
|
|
expect(screen.getByTestId('pending-operations-panel')).toBeInTheDocument();
|
|
expect(screen.getByTestId('plan-history')).toBeInTheDocument();
|
|
});
|
|
|
|
it('fills the starter draft without sending it', () => {
|
|
const { workspace, sendChat } = prepare(designWorkspaceFixture({ turns: [] }));
|
|
render(pane(workspace));
|
|
fireEvent.click(screen.getByRole('button', { name: '我脑中有一只在月球踢球的熊猫' }));
|
|
expect(screen.getByRole('textbox', { name: '告诉 AI 你想创作什么' })).toHaveValue('我脑中有一只在月球踢球的熊猫');
|
|
expect(sendChat).not.toHaveBeenCalled();
|
|
expect(screen.getByRole('button', { name: '发送' })).toHaveClass('h-10', 'w-10');
|
|
});
|
|
|
|
it('preserves IME and Shift+Enter, blocks repeat submit, and keeps the draft on failure', async () => {
|
|
const { workspace, sendChat } = prepare();
|
|
let rejectSend!: (reason: Error) => void;
|
|
sendChat.mockImplementation(() => {
|
|
useImageWorkspaceStore.setState({ pendingOperations: { 'chat-1': pendingChat() } });
|
|
return new Promise((_, reject) => { rejectSend = reject; });
|
|
});
|
|
useImageWorkspaceStore.setState({ chatDraft: '第一行\n第二行' });
|
|
render(pane(workspace));
|
|
const input = screen.getByRole('textbox', { name: '告诉 AI 你想创作什么' });
|
|
expect(input).toHaveAttribute('rows', '2');
|
|
fireEvent.keyDown(input, { key: 'Enter', isComposing: true });
|
|
fireEvent.keyDown(input, { key: 'Enter', keyCode: 229 });
|
|
fireEvent.keyDown(input, { key: 'Enter', shiftKey: true });
|
|
expect(sendChat).not.toHaveBeenCalled();
|
|
fireEvent.keyDown(input, { key: 'Enter' });
|
|
fireEvent.submit(screen.getByTestId('design-message-composer'));
|
|
expect(sendChat).toHaveBeenCalledOnce();
|
|
expect(input).toBeDisabled();
|
|
expect(screen.getByText('麦洛正在整理你的想法…')).toBeInTheDocument();
|
|
await act(async () => {
|
|
useImageWorkspaceStore.setState({ pendingOperations: {} });
|
|
rejectSend(new Error('offline'));
|
|
});
|
|
await waitFor(() => expect(input).toBeEnabled());
|
|
expect(input).toHaveValue('第一行\n第二行');
|
|
expect(toastErrorMock).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('keeps another Workspace pending command outside this composer and reply', () => {
|
|
const { workspace } = prepare();
|
|
useImageWorkspaceStore.setState({
|
|
chatDraft: '本项目的想法',
|
|
pendingOperations: { 'chat-1': pendingChat('workspace-other') },
|
|
assistantStreams: { 'chat-1': '另一个项目的回复' },
|
|
});
|
|
render(pane(workspace));
|
|
expect(screen.getByRole('button', { name: '发送' })).toBeEnabled();
|
|
expect(screen.queryByText('另一个项目的回复')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('lets canonical completion restore the composer even before the HTTP response arrives', () => {
|
|
const { workspace, sendChat } = prepare();
|
|
sendChat.mockImplementation(() => {
|
|
useImageWorkspaceStore.setState({ pendingOperations: { 'chat-1': pendingChat() } });
|
|
return new Promise(() => undefined);
|
|
});
|
|
useImageWorkspaceStore.setState({ chatDraft: '画一只小熊' });
|
|
render(pane(workspace));
|
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
|
const input = screen.getByRole('textbox', { name: '告诉 AI 你想创作什么' });
|
|
expect(input).toBeDisabled();
|
|
act(() => {
|
|
useImageWorkspaceStore.setState({ pendingOperations: {}, chatDraft: '' });
|
|
});
|
|
expect(input).toBeEnabled();
|
|
fireEvent.change(input, { target: { value: '让天空更蓝一点' } });
|
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
|
expect(sendChat).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe('Canvas reply safety', () => {
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
it('blocks executable HTML, unsafe links, local file access and unverified image requests', () => {
|
|
const { container } = render(<DesignMessageMarkdown text={'<script>alert(1)</script>\n\n[危险](javascript:alert(1)) [本地](file:///private/art.png) [相对](../art.png)\n\n'} />);
|
|
expect(container.querySelector('script, iframe, img, a')).toBeNull();
|
|
expect(screen.getByText('[图片:预览]')).toBeInTheDocument();
|
|
expect(openExternalMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('opens a safe explicit HTTP(S) link through the existing host boundary', () => {
|
|
render(<DesignMessageMarkdown text="[查看参考](https://example.com/reference)" />);
|
|
const link = screen.getByRole('link', { name: '查看参考' });
|
|
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
|
fireEvent.click(link);
|
|
expect(openExternalMock).toHaveBeenCalledWith('https://example.com/reference');
|
|
});
|
|
});
|