Files
makelore/tests/unit/image-canvas-conversation.test.tsx
T

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![预览](https://example.com/unverified.png)'} />);
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');
});
});