refactor(chat): split MessageBubble into avatar, content and code block components
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).
This commit is contained in:
1 parent
434cba2a69
commit
2a294f1f87
6 files changed
+465
-260
No files matched your search
@@ -0,0 +1,154 @@
|
||||
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()'));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user