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 actions; }, })); const message = (overrides: Partial = {}): 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(); 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( , ); 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(); 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(); 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(); expect(actionsProps.onRegenerate).toBeUndefined(); expect(actionsProps.onEdit).toBeUndefined(); rerender(); expect(actionsProps.onRegenerate).toBe(onRegenerate); expect(actionsProps.onEdit).toBeUndefined(); }); it('flags actions as streaming only for the last assistant message', () => { const { rerender } = render(); expect(actionsProps.isStreaming).toBeFalsy(); rerender(); expect(actionsProps.isStreaming).toBe(true); }); it('renders nothing inside the bubble or actions while an empty answer is streaming', () => { render(); expect(screen.queryByText('actions')).toBeNull(); }); it('renders an empty answer as markdown when not streaming', () => { render(); expect(screen.queryByText('actions')).toBeNull(); }); }); describe('MessageBubble metadata', () => { it('shows the time and the edited badge only for edited messages', () => { const { rerender } = render(); expect(screen.queryByText(/edited/)).toBeNull(); rerender(); 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(); 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(); expect(screen.getByText('code')).toBeTruthy(); }); it('renders indented code blocks with a "code" header and copy button', async () => { render(); expect(screen.getByText('code')).toBeTruthy(); await act(async () => { fireEvent.press(screen.getByText('Copy')); }); expect(Clipboard.setStringAsync).toHaveBeenCalledWith(expect.stringContaining('indented()')); }); });