Files
makelore/tests/unit/chat-message.test.tsx
2026-07-29 17:22:35 +08:00

340 lines
11 KiB
TypeScript

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(<ChatMessage message={message} suppressProcessAttachments />);
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(<ChatMessage message={message} suppressProcessAttachments />);
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(<ChatMessage message={message} />);
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(<ChatMessage message={message} />);
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(<ChatMessage message={message} suppressProcessAttachments />);
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(<ChatMessage message={message} suppressProcessAttachments />);
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(<ChatMessage message={message} suppressProcessAttachments />);
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(<ChatMessage message={message} suppressProcessAttachments />);
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(<ChatMessage message={message} />);
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(<ChatMessage message={message} />);
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(
<ChatMessage message={{ role: 'user', content: 'hello', timestamp }} showTimestamps={false} />,
);
expect(screen.queryByText(formatTimestamp(timestamp))).not.toBeInTheDocument();
rerender(
<ChatMessage message={{ role: 'assistant', content: 'world', timestamp }} showTimestamps={false} />,
);
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(
<ChatMessage
message={message}
messageKey="assistant-tools"
expandedToolIds={new Set()}
onToolExpandedChange={onToolExpandedChange}
/>,
);
expect(screen.queryByText(/README\.md/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /read/i }));
expect(onToolExpandedChange).toHaveBeenCalledWith(toolId, true);
rerender(
<ChatMessage
message={message}
messageKey="assistant-tools"
expandedToolIds={new Set([toolId])}
onToolExpandedChange={onToolExpandedChange}
/>,
);
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(<ChatMessage message={userMessage} />);
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(<ChatMessage message={assistantMessage} />);
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(<ChatMessage message={message} />);
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(<ChatMessage message={message} assistantAvatarSrc="/avatar-16.png" assistantAvatarAlt="小码头像" />);
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(<ChatMessage message={message} />);
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(<ChatMessage message={message} />);
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(<ChatMessage message={message} />);
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(<ChatMessage message={message} />);
expect(container.textContent).toContain('\\(hello\\)');
expect(container.querySelector('.katex')).toBeNull();
});
});