Files
LocalMind/__tests__/MessageBubble.test.tsx
T
evandro 2a294f1f87 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).
2026-10-10 04:20:59 -03:00

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()'));
});
});