MessageBubble CC 19 -> below 10. The fence and indented code block rules were identical and are now one CodeBlock component; the Markdown style map lives in markdownStyles.ts. Adds characterization tests for the bubble (roles, actions visibility, edited badge, copy feedback).
155 lines
6.1 KiB
TypeScript
155 lines
6.1 KiB
TypeScript
import { act, fireEvent, render, screen } from '@testing-library/react-native';
|
|
import * as Clipboard from 'expo-clipboard';
|
|
import React from 'react';
|
|
import { MessageBubble } from '../components/chat/MessageBubble';
|
|
import { ChatMessage } from '../store/useAppStore';
|
|
|
|
jest.mock('expo-clipboard', () => ({ setStringAsync: jest.fn(() => Promise.resolve(true)) }));
|
|
|
|
let actionsProps: any;
|
|
jest.mock('../components/chat/MessageActions', () => ({
|
|
MessageActions: (props: any) => {
|
|
actionsProps = props;
|
|
const { Text } = require('react-native');
|
|
return <Text>actions</Text>;
|
|
},
|
|
}));
|
|
|
|
const message = (overrides: Partial<ChatMessage> = {}): ChatMessage => ({
|
|
id: '1',
|
|
role: 'assistant',
|
|
content: 'hello **world**',
|
|
timestamp: new Date(2026, 0, 1, 13, 5).getTime(),
|
|
...overrides,
|
|
});
|
|
|
|
beforeEach(() => {
|
|
actionsProps = undefined;
|
|
(Clipboard.setStringAsync as jest.Mock).mockClear();
|
|
});
|
|
|
|
describe('MessageBubble user message', () => {
|
|
it('shows the plain text and keeps the actions hidden until touched', () => {
|
|
render(<MessageBubble message={message({ role: 'user', content: 'hi there' })} />);
|
|
expect(screen.getByText('hi there')).toBeTruthy();
|
|
expect(screen.queryByText('actions')).toBeNull();
|
|
});
|
|
|
|
it('reveals edit and delete (but not regenerate) after a touch', () => {
|
|
const onEdit = jest.fn();
|
|
const onDelete = jest.fn();
|
|
const onRegenerate = jest.fn();
|
|
render(
|
|
<MessageBubble
|
|
message={message({ role: 'user', content: 'hi' })}
|
|
onEdit={onEdit}
|
|
onDelete={onDelete}
|
|
onRegenerate={onRegenerate}
|
|
isLastAssistant
|
|
/>,
|
|
);
|
|
fireEvent(screen.getByText('hi').parent!.parent!.parent!.parent!, 'touchStart');
|
|
|
|
expect(screen.getByText('actions')).toBeTruthy();
|
|
expect(actionsProps.role).toBe('user');
|
|
expect(actionsProps.onEdit).toBe(onEdit);
|
|
expect(actionsProps.onDelete).toBe(onDelete);
|
|
expect(actionsProps.onRegenerate).toBeUndefined();
|
|
});
|
|
|
|
it('never renders actions for an empty message', () => {
|
|
render(<MessageBubble message={message({ role: 'user', content: '' })} />);
|
|
fireEvent(screen.getByText(/\d/).parent!.parent!.parent!, 'touchStart');
|
|
expect(screen.queryByText('actions')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('MessageBubble assistant message', () => {
|
|
it('renders markdown and always shows actions', () => {
|
|
render(<MessageBubble message={message()} />);
|
|
expect(screen.getByText('world')).toBeTruthy();
|
|
expect(screen.getByText('actions')).toBeTruthy();
|
|
});
|
|
|
|
it('offers regenerate only on the last assistant message and never edit', () => {
|
|
const onEdit = jest.fn();
|
|
const onRegenerate = jest.fn();
|
|
const { rerender } = render(<MessageBubble message={message()} onEdit={onEdit} onRegenerate={onRegenerate} />);
|
|
expect(actionsProps.onRegenerate).toBeUndefined();
|
|
expect(actionsProps.onEdit).toBeUndefined();
|
|
|
|
rerender(<MessageBubble message={message()} onEdit={onEdit} onRegenerate={onRegenerate} isLastAssistant />);
|
|
expect(actionsProps.onRegenerate).toBe(onRegenerate);
|
|
expect(actionsProps.onEdit).toBeUndefined();
|
|
});
|
|
|
|
it('flags actions as streaming only for the last assistant message', () => {
|
|
const { rerender } = render(<MessageBubble message={message()} isStreaming />);
|
|
expect(actionsProps.isStreaming).toBeFalsy();
|
|
|
|
rerender(<MessageBubble message={message()} isStreaming isLastAssistant />);
|
|
expect(actionsProps.isStreaming).toBe(true);
|
|
});
|
|
|
|
it('renders nothing inside the bubble or actions while an empty answer is streaming', () => {
|
|
render(<MessageBubble message={message({ content: '' })} isStreaming isLastAssistant />);
|
|
expect(screen.queryByText('actions')).toBeNull();
|
|
});
|
|
|
|
it('renders an empty answer as markdown when not streaming', () => {
|
|
render(<MessageBubble message={message({ content: '' })} />);
|
|
expect(screen.queryByText('actions')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('MessageBubble metadata', () => {
|
|
it('shows the time and the edited badge only for edited messages', () => {
|
|
const { rerender } = render(<MessageBubble message={message({ role: 'user' })} />);
|
|
expect(screen.queryByText(/edited/)).toBeNull();
|
|
|
|
rerender(<MessageBubble message={message({ role: 'user', isEdited: true })} />);
|
|
expect(screen.getByText(/· edited/)).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe('MessageBubble code blocks', () => {
|
|
const fenced = 'text\n\n```ts\nconst a = 1;\n```\n';
|
|
|
|
it('shows the language, the code and copies it to the clipboard', async () => {
|
|
jest.useFakeTimers();
|
|
try {
|
|
render(<MessageBubble message={message({ content: fenced })} />);
|
|
expect(screen.getByText('ts')).toBeTruthy();
|
|
expect(screen.getByText(/const a = 1;/)).toBeTruthy();
|
|
expect(screen.getByText('Copy')).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
fireEvent.press(screen.getByText('Copy'));
|
|
});
|
|
expect(Clipboard.setStringAsync).toHaveBeenCalledWith('const a = 1;\n');
|
|
expect(screen.getByText('Copied!')).toBeTruthy();
|
|
|
|
act(() => {
|
|
jest.advanceTimersByTime(2000);
|
|
});
|
|
expect(screen.getByText('Copy')).toBeTruthy();
|
|
} finally {
|
|
jest.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('labels a fence without a language as "code"', () => {
|
|
render(<MessageBubble message={message({ content: '```\nplain\n```\n' })} />);
|
|
expect(screen.getByText('code')).toBeTruthy();
|
|
});
|
|
|
|
it('renders indented code blocks with a "code" header and copy button', async () => {
|
|
render(<MessageBubble message={message({ content: 'intro\n\n indented()\n' })} />);
|
|
expect(screen.getByText('code')).toBeTruthy();
|
|
await act(async () => {
|
|
fireEvent.press(screen.getByText('Copy'));
|
|
});
|
|
expect(Clipboard.setStringAsync).toHaveBeenCalledWith(expect.stringContaining('indented()'));
|
|
});
|
|
});
|