import { describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen } from '@testing-library/react'; import { ChatMessage } from '@/pages/Chat/ChatMessage'; import { getToolVisibilityId } from '@/pages/Chat/chat-transcript'; import { formatTimestamp } from '@/pages/Chat/message-utils'; import type { RawMessage } from '@/types/chat'; vi.mock('@/lib/api-client', () => ({ invokeIpc: vi.fn(), statFile: vi.fn(async (path: string) => { if (path.includes('missing')) { return { ok: false, error: 'notFound' }; } const isFile = /\.[A-Za-z0-9]+$/.test(path); return { ok: true, isFile, isDir: !isFile, size: isFile ? 1024 : 0, }; }), })); describe('ChatMessage attachment rendering', () => { it('keeps attachment-only assistant replies visible when process attachments are suppressed', () => { const message: RawMessage = { role: 'assistant', content: [], _attachedFiles: [ { fileName: 'artifact.png', mimeType: 'image/png', fileSize: 0, preview: '/tmp/artifact.png', filePath: '/tmp/artifact.png', source: 'tool-result', }, ], }; render(); expect(screen.getByAltText('artifact.png')).toBeInTheDocument(); }); it('keeps image artifacts visible alongside reply text', () => { const message: RawMessage = { role: 'assistant', content: 'Screenshot captured.', _attachedFiles: [ { fileName: 'desktop_screenshot.jpg', mimeType: 'image/jpeg', fileSize: 837_000, preview: 'data:image/jpeg;base64,xyz', filePath: '/tmp/desktop_screenshot.jpg', source: 'message-ref', }, ], }; render(); expect(screen.getByAltText('desktop_screenshot.jpg')).toBeInTheDocument(); }); it('hides metadata-only image attachments instead of rendering blank file tiles', () => { const message: RawMessage = { role: 'assistant', content: 'Screenshot captured.', _attachedFiles: [ { fileName: 'desktop_screenshot.jpg', mimeType: 'image/jpeg', fileSize: 0, preview: null, filePath: '/tmp/desktop_screenshot.jpg', source: 'tool-result', }, ], }; render(); expect(screen.queryByAltText('desktop_screenshot.jpg')).not.toBeInTheDocument(); expect(screen.queryByText('desktop_screenshot.jpg')).not.toBeInTheDocument(); }); it('deduplicates image attachments and keeps the copy with a preview', () => { const message: RawMessage = { role: 'user', content: 'Please inspect this screenshot.', _attachedFiles: [ { fileName: 'farm.png', mimeType: 'image/png', fileSize: 1024, preview: null, filePath: '/tmp/farm.png', source: 'runtime-media', }, { fileName: 'farm.png', mimeType: 'image/png', fileSize: 1024, preview: 'data:image/png;base64,xyz', filePath: '/tmp/farm.png', source: 'user-upload', }, ], }; render(); expect(screen.getAllByAltText('farm.png')).toHaveLength(1); }); it('derives preview cards from assistant text paths under NianCode data', async () => { const message: RawMessage = { role: 'assistant', content: 'Generated file: `/Users/me/.niancode/workspace/report.pdf`', }; render(); expect(await screen.findByText('report.pdf')).toBeInTheDocument(); }); it('derives skill directory cards from .niancode skill paths', async () => { const message: RawMessage = { role: 'assistant', content: 'Skill location: ~/.niancode/skills/open-eastmoney', }; render(); expect(await screen.findByText('open-eastmoney')).toBeInTheDocument(); expect(screen.getByText('Folder')).toBeInTheDocument(); }); it('does not show cards for missing text paths', async () => { const message: RawMessage = { role: 'assistant', content: 'Missing file: ~/.niancode/skills/missing-skill/SKILL.md', }; render(); await new Promise((resolve) => setTimeout(resolve, 0)); expect(screen.queryByText('SKILL.md')).not.toBeInTheDocument(); }); it('continues hiding non-preview process attachments when process attachments are suppressed', () => { const message: RawMessage = { role: 'assistant', content: 'I also used a temporary file.', _attachedFiles: [ { fileName: 'debug.log', mimeType: 'text/plain', fileSize: 1024, preview: null, filePath: '/tmp/debug.log', source: 'message-ref', }, ], }; render(); expect(screen.queryByText('debug.log')).not.toBeInTheDocument(); }); }); describe('ChatMessage delivery status rendering', () => { it('shows queued status for a pending user prompt', () => { const message = { role: 'user', content: [{ type: 'text', text: 'Run the next task' }], _deliveryStatus: 'queued', } as RawMessage; render(); expect(screen.getByText('Queued')).toBeInTheDocument(); }); it('shows sending status for an active user prompt', () => { const message = { role: 'user', content: [{ type: 'text', text: 'Run this now' }], _deliveryStatus: 'sending', } as RawMessage; render(); expect(screen.getByText('Sending')).toBeInTheDocument(); }); }); describe('ChatMessage current-view controls', () => { it('hides both user and assistant timestamps when showTimestamps is false', () => { const timestamp = Date.UTC(2026, 6, 18, 8, 0, 0); const { rerender } = render( , ); expect(screen.queryByText(formatTimestamp(timestamp))).not.toBeInTheDocument(); rerender( , ); expect(screen.queryByText(formatTimestamp(timestamp))).not.toBeInTheDocument(); }); it('renders tool details from the parent-controlled expanded ID set', () => { const message: RawMessage = { id: 'assistant-tools', role: 'assistant', content: [{ type: 'tool_use', id: 'tool-read', name: 'read', input: { file: 'README.md' }, }], }; const onToolExpandedChange = vi.fn(); const toolId = getToolVisibilityId('assistant-tools', 'tool-read', 0); const { rerender } = render( , ); expect(screen.queryByText(/README\.md/)).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /read/i })); expect(onToolExpandedChange).toHaveBeenCalledWith(toolId, true); rerender( , ); expect(screen.getByText(/README\.md/)).toBeInTheDocument(); }); }); describe('ChatMessage bubble surface styling', () => { it('uses the NITU light paper palette for user and assistant message backgrounds', () => { const userMessage: RawMessage = { role: 'user', content: [{ type: 'text', text: 'Please inspect this prototype.' }], }; const assistantMessage: RawMessage = { role: 'assistant', content: 'I will review it against the project style.', }; const { rerender } = render(); expect(screen.getByTestId('chat-message-bubble-user')).toHaveClass( 'bg-[#DCE6EF]', 'text-[#26384D]', 'shadow-[3px_3px_0_#26384D]', ); expect(screen.getByTestId('chat-message-bubble-user')).not.toHaveClass('bg-[#168fff]', 'text-white'); rerender(); expect(screen.getByTestId('chat-message-bubble-assistant')).toHaveClass( 'bg-[#FFFFFF]', 'text-[#26384D]', 'shadow-[3px_3px_0_#26384D]', ); }); it('does not render a user avatar or an extra row gap', () => { const message: RawMessage = { role: 'user', content: 'Please inspect this prototype.', }; render(); expect(screen.queryByTestId('chat-message-avatar-user')).not.toBeInTheDocument(); expect(screen.getByTestId('chat-message-bubble-user').parentElement?.parentElement).toHaveClass('gap-0'); }); }); describe('ChatMessage LaTeX rendering', () => { it('renders the configured project Agent avatar for assistant messages', () => { const message: RawMessage = { role: 'assistant', content: '已完成。' }; render(); expect(screen.getByRole('img', { name: '小码头像' })).toHaveAttribute('src', '/avatar-16.png'); }); it('renders inline `$...$` math with KaTeX', () => { const message: RawMessage = { role: 'assistant', content: 'Mass-energy equivalence: $E=mc^2$ is famous.', }; const { container } = render(); expect(container.querySelector('.katex')).not.toBeNull(); }); it('renders display `$$...$$` math as a block', () => { const message: RawMessage = { role: 'assistant', content: 'Definite integral:\n\n$$\n\\int_0^1 x\\,dx = \\frac{1}{2}\n$$\n', }; const { container } = render(); expect(container.querySelector('.katex-display')).not.toBeNull(); }); it('renders `\\(...\\)` inline math', () => { const message: RawMessage = { role: 'assistant', content: 'Quadratic formula: \\(x = \\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}\\).', }; const { container } = render(); expect(container.querySelector('.katex')).not.toBeNull(); expect(container.querySelector('.katex-display')).toBeNull(); }); it('does not rewrite `\\(` inside code fences', () => { const message: RawMessage = { role: 'assistant', content: 'Code sample:\n\n```\nprintf("\\(hello\\)")\n```\n', }; const { container } = render(); expect(container.textContent).toContain('\\(hello\\)'); expect(container.querySelector('.katex')).toBeNull(); }); });