From 611a472b13534c5c9a5595f981882203e9e35d93 Mon Sep 17 00:00:00 2001 From: one Date: Mon, 2 Jun 2025 17:36:25 +0800 Subject: [PATCH] test: more unit tests for message rendering (#6663) * refactor(encodeHTML): remove duplicate definition * test(Scrollbar): update snapshot * test: add more tests Add tests for - MainTextBlock - ThinkingBlock - Markdown - CitationTooltip --- package.json | 1 + .../__snapshots__/Scrollbar.test.tsx.snap | 22 - .../pages/home/Markdown/CitationTooltip.tsx | 88 ++-- .../__tests__/CitationTooltip.test.tsx | 377 ++++++++++++++ .../home/Markdown/__tests__/Markdown.test.tsx | 368 ++++++++++++++ .../CitationTooltip.test.tsx.snap | 98 ++++ .../__snapshots__/Markdown.test.tsx.snap | 39 ++ .../home/Messages/Blocks/MainTextBlock.tsx | 14 +- .../Blocks/__tests__/MainTextBlock.test.tsx | 477 ++++++++++++++++++ .../Blocks/__tests__/ThinkingBlock.test.tsx | 424 ++++++++++++++++ .../__snapshots__/ThinkingBlock.test.tsx.snap | 116 +++++ .../src/utils/__tests__/formats.test.ts | 36 ++ .../src/utils/__tests__/markdown.test.ts | 36 -- src/renderer/src/utils/formats.ts | 18 + src/renderer/src/utils/markdown.ts | 18 - 15 files changed, 2002 insertions(+), 130 deletions(-) create mode 100644 src/renderer/src/pages/home/Markdown/__tests__/CitationTooltip.test.tsx create mode 100644 src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx create mode 100644 src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/CitationTooltip.test.tsx.snap create mode 100644 src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/Markdown.test.tsx.snap create mode 100644 src/renderer/src/pages/home/Messages/Blocks/__tests__/MainTextBlock.test.tsx create mode 100644 src/renderer/src/pages/home/Messages/Blocks/__tests__/ThinkingBlock.test.tsx create mode 100644 src/renderer/src/pages/home/Messages/Blocks/__tests__/__snapshots__/ThinkingBlock.test.tsx.snap diff --git a/package.json b/package.json index 8ff5d9710c..ce04d03a3b 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "test": "vitest run --silent", "test:main": "vitest run --project main", "test:renderer": "vitest run --project renderer", + "test:update": "yarn test:renderer --update", "test:coverage": "vitest run --coverage --silent", "test:ui": "vitest --ui", "test:watch": "vitest", diff --git a/src/renderer/src/components/__tests__/__snapshots__/Scrollbar.test.tsx.snap b/src/renderer/src/components/__tests__/__snapshots__/Scrollbar.test.tsx.snap index 4bd423378f..335d73c55a 100644 --- a/src/renderer/src/components/__tests__/__snapshots__/Scrollbar.test.tsx.snap +++ b/src/renderer/src/components/__tests__/__snapshots__/Scrollbar.test.tsx.snap @@ -1,27 +1,5 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Scrollbar > props handling > should handle right prop correctly 1`] = ` -.c0 { - overflow-y: auto; -} - -.c0::-webkit-scrollbar-thumb { - transition: background 2s ease; - background: transparent; -} - -.c0::-webkit-scrollbar-thumb:hover { - background: transparent; -} - -
- 内容 -
-`; - exports[`Scrollbar > rendering > should match default styled snapshot 1`] = ` .c0 { overflow-y: auto; diff --git a/src/renderer/src/pages/home/Markdown/CitationTooltip.tsx b/src/renderer/src/pages/home/Markdown/CitationTooltip.tsx index 45e51ed91a..45b804c851 100644 --- a/src/renderer/src/pages/home/Markdown/CitationTooltip.tsx +++ b/src/renderer/src/pages/home/Markdown/CitationTooltip.tsx @@ -1,6 +1,6 @@ import Favicon from '@renderer/components/Icons/FallbackFavicon' import { Tooltip } from 'antd' -import React from 'react' +import React, { memo, useCallback, useMemo } from 'react' import styled from 'styled-components' interface CitationTooltipProps { @@ -13,56 +13,62 @@ interface CitationTooltipProps { } const CitationTooltip: React.FC = ({ children, citation }) => { - let hostname = '' - try { - hostname = new URL(citation.url).hostname - } catch { - hostname = citation.url - } + const hostname = useMemo(() => { + try { + return new URL(citation.url).hostname + } catch { + return citation.url + } + }, [citation.url]) + + const sourceTitle = useMemo(() => { + return citation.title?.trim() || hostname + }, [citation.title, hostname]) + + const handleClick = useCallback(() => { + window.open(citation.url, '_blank', 'noopener,noreferrer') + }, [citation.url]) // 自定义悬浮卡片内容 - const tooltipContent = ( - - window.open(citation.url, '_blank')}> - - {citation.title || hostname} - - {citation.content && {citation.content}} - window.open(citation.url, '_blank')}>{hostname} - + const tooltipContent = useMemo( + () => ( +
+ + + + {sourceTitle} + + + {citation.content?.trim() && ( + + {citation.content} + + )} + + {hostname} + +
+ ), + [citation.content, hostname, handleClick, sourceTitle] ) return ( - {children} - + ) } -// 使用styled-components来自定义Tooltip的样式,包括箭头 -const StyledTooltip = styled(Tooltip)` - .ant-tooltip-arrow { - .ant-tooltip-arrow-content { - background-color: var(--color-background-1); - } - } -` - -const TooltipContentWrapper = styled.div` - padding: 12px; - background-color: var(--color-background-soft); - border-radius: 8px; -` - const TooltipHeader = styled.div` display: flex; align-items: center; @@ -108,4 +114,4 @@ const TooltipFooter = styled.div` } ` -export default CitationTooltip +export default memo(CitationTooltip) diff --git a/src/renderer/src/pages/home/Markdown/__tests__/CitationTooltip.test.tsx b/src/renderer/src/pages/home/Markdown/__tests__/CitationTooltip.test.tsx new file mode 100644 index 0000000000..06a390c06a --- /dev/null +++ b/src/renderer/src/pages/home/Markdown/__tests__/CitationTooltip.test.tsx @@ -0,0 +1,377 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import CitationTooltip from '../CitationTooltip' + +// Mock dependencies +const mockWindowOpen = vi.fn() + +vi.mock('@renderer/components/Icons/FallbackFavicon', () => ({ + __esModule: true, + default: (props: any) =>
+})) + +vi.mock('antd', () => ({ + Tooltip: ({ children, overlay, title, placement, color, styles, ...props }: any) => ( +
+ {children} +
{overlay || title}
+
+ ) +})) + +const originalWindowOpen = window.open + +describe('CitationTooltip', () => { + beforeEach(() => { + vi.clearAllMocks() + Object.defineProperty(window, 'open', { + value: mockWindowOpen, + writable: true + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + window.open = originalWindowOpen + }) + + // Test data factory + const createCitationData = (overrides = {}) => ({ + url: 'https://example.com/article', + title: 'Example Article', + content: 'This is the article content for testing purposes.', + ...overrides + }) + + const renderCitationTooltip = (citation: any, children = Trigger) => { + return render({children}) + } + + const expectWindowOpenCalled = (url: string) => { + expect(mockWindowOpen).toHaveBeenCalledWith(url, '_blank', 'noopener,noreferrer') + } + + const getTooltipContent = () => screen.getByTestId('tooltip-content') + + const getCitationHeaderButton = () => screen.getByRole('button', { name: /open .* in new tab/i }) + const getCitationFooterButton = () => screen.getByRole('button', { name: /visit .*/i }) + const getCitationTitle = () => screen.getByRole('heading', { level: 3 }) + const getCitationContent = () => screen.queryByRole('article', { name: /citation content/i }) + + describe('basic rendering', () => { + it('should render children and basic tooltip structure', () => { + const citation = createCitationData() + renderCitationTooltip(citation, Click me) + + expect(screen.getByText('Click me')).toBeInTheDocument() + expect(screen.getByTestId('tooltip-wrapper')).toBeInTheDocument() + expect(getTooltipContent()).toBeInTheDocument() + }) + + it('should render Favicon with correct props', () => { + const citation = createCitationData({ + url: 'https://example.com', + title: 'Example Title' + }) + renderCitationTooltip(citation) + + const favicon = screen.getByTestId('mock-favicon') + expect(favicon).toHaveAttribute('hostname', 'example.com') + expect(favicon).toHaveAttribute('alt', 'Example Title') + }) + + it('should pass correct props to Tooltip component', () => { + const citation = createCitationData() + renderCitationTooltip(citation) + + const tooltip = screen.getByTestId('tooltip-wrapper') + expect(tooltip).toHaveAttribute('data-placement', 'top') + expect(tooltip).toHaveAttribute('data-color', 'var(--color-background-mute)') + + const styles = JSON.parse(tooltip.getAttribute('data-styles') || '{}') + expect(styles.body).toEqual({ + border: '1px solid var(--color-border)', + padding: '12px', + borderRadius: '8px' + }) + }) + + it('should match snapshot', () => { + const citation = createCitationData() + const { container } = render( + + Test content + + ) + expect(container.firstChild).toMatchSnapshot() + }) + }) + + describe('URL processing and hostname extraction', () => { + it('should extract hostname from valid URLs', () => { + const testCases = [ + { url: 'https://www.example.com/path/to/page?query=1', expected: 'www.example.com' }, + { url: 'http://test.com', expected: 'test.com' }, + { url: 'https://api.v2.example.com/endpoint', expected: 'api.v2.example.com' }, + { url: 'ftp://files.domain.net', expected: 'files.domain.net' } + ] + + testCases.forEach(({ url, expected }) => { + const { unmount } = renderCitationTooltip(createCitationData({ url })) + expect(screen.getByText(expected)).toBeInTheDocument() + unmount() + }) + }) + + it('should handle URLs with ports correctly', () => { + const citation = createCitationData({ url: 'https://localhost:3000/api/data' }) + renderCitationTooltip(citation) + + // URL.hostname strips the port + expect(screen.getByText('localhost')).toBeInTheDocument() + }) + + it('should fallback to original URL when parsing fails', () => { + const testCases = ['not-a-valid-url', '', 'http://'] + + testCases.forEach((invalidUrl) => { + const { unmount } = renderCitationTooltip(createCitationData({ url: invalidUrl })) + const favicon = screen.getByTestId('mock-favicon') + expect(favicon).toHaveAttribute('hostname', invalidUrl) + unmount() + }) + }) + }) + + describe('content display and title logic', () => { + it('should display citation title when provided', () => { + const citation = createCitationData({ title: 'Custom Article Title' }) + renderCitationTooltip(citation) + + expect(screen.getByText('Custom Article Title')).toBeInTheDocument() + expect(screen.getByText('example.com')).toBeInTheDocument() // hostname in footer + }) + + it('should fallback to hostname when title is empty or whitespace', () => { + const testCases = [ + { title: undefined, url: 'https://fallback-test.com' }, + { title: '', url: 'https://empty-title.com' }, + { title: ' ', url: 'https://whitespace-title.com' }, + { title: '\n\t \n', url: 'https://mixed-whitespace.com' } + ] + + testCases.forEach(({ title, url }) => { + const { unmount } = renderCitationTooltip(createCitationData({ title, url })) + const titleElement = getCitationTitle() + const expectedHostname = new URL(url).hostname + expect(titleElement).toHaveTextContent(expectedHostname) + unmount() + }) + }) + + it('should display content when provided and meaningful', () => { + const citation = createCitationData({ content: 'Meaningful article content' }) + renderCitationTooltip(citation) + + expect(screen.getByText('Meaningful article content')).toBeInTheDocument() + }) + + it('should not render content section when content is empty or whitespace', () => { + const testCases = [undefined, null, '', ' ', '\n\t \n'] + + testCases.forEach((content) => { + const { unmount } = renderCitationTooltip(createCitationData({ content })) + expect(getCitationContent()).not.toBeInTheDocument() + unmount() + }) + }) + + it('should handle long content with proper styling', () => { + const longContent = + 'This is a very long content that should be clamped to three lines using CSS line-clamp property for better visual presentation in the tooltip interface.' + const citation = createCitationData({ content: longContent }) + renderCitationTooltip(citation) + + const contentElement = screen.getByText(longContent) + expect(contentElement).toHaveStyle({ + display: '-webkit-box', + overflow: 'hidden' + }) + }) + + it('should handle special characters in title and content', () => { + const citation = createCitationData({ + title: 'Article with Special: <>{}[]()&"\'`', + content: 'Content with chars: <>{}[]()&"\'`' + }) + renderCitationTooltip(citation) + + expect(screen.getByText('Article with Special: <>{}[]()&"\'`')).toBeInTheDocument() + expect(screen.getByText('Content with chars: <>{}[]()&"\'`')).toBeInTheDocument() + }) + }) + + describe('user interactions', () => { + it('should open URL when header is clicked', async () => { + const user = userEvent.setup() + const citation = createCitationData({ url: 'https://header-click.com' }) + renderCitationTooltip(citation) + + const header = getCitationHeaderButton() + await user.click(header) + + expectWindowOpenCalled('https://header-click.com') + }) + + it('should open URL when footer is clicked', async () => { + const user = userEvent.setup() + const citation = createCitationData({ url: 'https://footer-click.com' }) + renderCitationTooltip(citation) + + const footer = getCitationFooterButton() + await user.click(footer) + + expectWindowOpenCalled('https://footer-click.com') + }) + + it('should not trigger click when content area is clicked', async () => { + const user = userEvent.setup() + const citation = createCitationData({ content: 'Non-clickable content' }) + renderCitationTooltip(citation) + + const content = screen.getByText('Non-clickable content') + await user.click(content) + + expect(mockWindowOpen).not.toHaveBeenCalled() + }) + + it('should handle invalid URLs gracefully', async () => { + const user = userEvent.setup() + const citation = createCitationData({ url: 'invalid-url' }) + renderCitationTooltip(citation) + + const footer = getCitationFooterButton() + await user.click(footer) + + expectWindowOpenCalled('invalid-url') + }) + }) + + describe('real-world usage scenarios', () => { + it('should work with actual citation link structure', () => { + const citation = createCitationData({ + url: 'https://research.example.com/study', + title: 'Research Study on AI', + content: + 'This study demonstrates significant improvements in AI capabilities through novel training methodologies and evaluation frameworks.' + }) + + const citationLink = ( + + 1 + + ) + + renderCitationTooltip(citation, citationLink) + + // Should display all citation information + expect(screen.getByText('Research Study on AI')).toBeInTheDocument() + expect(screen.getByText('research.example.com')).toBeInTheDocument() + expect(screen.getByText(/This study demonstrates/)).toBeInTheDocument() + + // Should contain the sup element + expect(screen.getByText('1')).toBeInTheDocument() + }) + + it('should handle truncated content as used in real implementation', () => { + const fullContent = 'A'.repeat(250) // Longer than typical 200 char limit + const citation = createCitationData({ content: fullContent }) + renderCitationTooltip(citation) + + expect(screen.getByText(fullContent)).toBeInTheDocument() + }) + + it('should handle missing title with hostname fallback in real scenario', () => { + const citation = createCitationData({ + url: 'https://docs.python.org/3/library/urllib.html', + title: undefined, // Common case when title extraction fails + content: 'urllib.request module documentation for Python 3' + }) + renderCitationTooltip(citation) + + const titleElement = getCitationTitle() + expect(titleElement).toHaveTextContent('docs.python.org') + }) + }) + + describe('edge cases', () => { + it('should handle malformed URLs', () => { + const malformedUrls = ['http://', 'https://', '://missing-protocol'] + + malformedUrls.forEach((url) => { + expect(() => { + const { unmount } = renderCitationTooltip(createCitationData({ url })) + unmount() + }).not.toThrow() + }) + }) + + it('should handle missing children gracefully', () => { + const citation = createCitationData() + + expect(() => { + render({null}) + }).not.toThrow() + }) + + it('should handle extremely long URLs without breaking', () => { + const longUrl = 'https://extremely-long-domain-name.example.com/' + 'a'.repeat(500) + const citation = createCitationData({ url: longUrl }) + + expect(() => { + renderCitationTooltip(citation) + }).not.toThrow() + }) + }) + + describe('performance', () => { + it('should memoize calculations correctly', () => { + const citation = createCitationData({ url: 'https://memoize-test.com' }) + const { rerender } = renderCitationTooltip(citation) + + expect(screen.getByText('memoize-test.com')).toBeInTheDocument() + + // Re-render with same props should work correctly + rerender( + + Trigger + + ) + expect(screen.getByText('memoize-test.com')).toBeInTheDocument() + }) + + it('should update when citation data changes', () => { + const citation1 = createCitationData({ url: 'https://first.com' }) + const { rerender } = renderCitationTooltip(citation1) + + expect(screen.getByText('first.com')).toBeInTheDocument() + + const citation2 = createCitationData({ url: 'https://second.com' }) + rerender( + + Trigger + + ) + + expect(screen.getByText('second.com')).toBeInTheDocument() + expect(screen.queryByText('first.com')).not.toBeInTheDocument() + }) + }) +}) diff --git a/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx b/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx new file mode 100644 index 0000000000..f5769eb4f8 --- /dev/null +++ b/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx @@ -0,0 +1,368 @@ +import 'katex/dist/katex.min.css' + +import type { MainTextMessageBlock, ThinkingMessageBlock, TranslationMessageBlock } from '@renderer/types/newMessage' +import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage' +import { render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import Markdown from '../Markdown' + +// Mock dependencies +const mockUseSettings = vi.fn() +const mockUseTranslation = vi.fn() + +// Mock hooks +vi.mock('@renderer/hooks/useSettings', () => ({ + useSettings: () => mockUseSettings() +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => mockUseTranslation() +})) + +// Mock services +vi.mock('@renderer/services/EventService', () => ({ + EVENT_NAMES: { + EDIT_CODE_BLOCK: 'EDIT_CODE_BLOCK' + }, + EventEmitter: { + emit: vi.fn() + } +})) + +// Mock utilities +vi.mock('@renderer/utils', () => ({ + parseJSON: vi.fn((str) => { + try { + return JSON.parse(str || '{}') + } catch { + return {} + } + }) +})) + +vi.mock('@renderer/utils/formats', () => ({ + escapeBrackets: vi.fn((str) => str), + removeSvgEmptyLines: vi.fn((str) => str) +})) + +vi.mock('@renderer/utils/markdown', () => ({ + findCitationInChildren: vi.fn(() => '{"id": 1, "url": "https://example.com"}'), + getCodeBlockId: vi.fn(() => 'code-block-1') +})) + +// Mock components with more realistic behavior +vi.mock('../CodeBlock', () => ({ + __esModule: true, + default: ({ id, onSave, children }: any) => ( +
+ {children} + +
+ ) +})) + +vi.mock('../ImagePreview', () => ({ + __esModule: true, + default: (props: any) => +})) + +vi.mock('../Link', () => ({ + __esModule: true, + default: ({ citationData, children, ...props }: any) => ( + + {children} + + ) +})) + +vi.mock('@renderer/components/MarkdownShadowDOMRenderer', () => ({ + __esModule: true, + default: ({ children }: any) =>
{children}
+})) + +// Mock plugins +vi.mock('remark-gfm', () => ({ __esModule: true, default: vi.fn() })) +vi.mock('remark-cjk-friendly', () => ({ __esModule: true, default: vi.fn() })) +vi.mock('remark-math', () => ({ __esModule: true, default: vi.fn() })) +vi.mock('rehype-katex', () => ({ __esModule: true, default: vi.fn() })) +vi.mock('rehype-mathjax', () => ({ __esModule: true, default: vi.fn() })) +vi.mock('rehype-raw', () => ({ __esModule: true, default: vi.fn() })) + +// Mock ReactMarkdown with realistic rendering +vi.mock('react-markdown', () => ({ + __esModule: true, + default: ({ children, components, className }: any) => ( +
+ {children} + {/* Simulate component rendering */} + {components?.a && link} + {components?.code && ( +
+ {components.code({ children: 'test code', node: { position: { start: { line: 1 } } } })} +
+ )} + {components?.img && img} + {components?.style && style} +
+ ) +})) + +describe('Markdown', () => { + let mockEventEmitter: any + + beforeEach(async () => { + vi.clearAllMocks() + + // Default settings + mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX' }) + mockUseTranslation.mockReturnValue({ + t: (key: string) => (key === 'message.chat.completion.paused' ? 'Paused' : key) + }) + + // Get mocked EventEmitter + const { EventEmitter } = await import('@renderer/services/EventService') + mockEventEmitter = EventEmitter + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + // Test data helpers + const createMainTextBlock = (overrides: Partial = {}): MainTextMessageBlock => ({ + id: 'test-block-1', + messageId: 'test-message-1', + type: MessageBlockType.MAIN_TEXT, + status: MessageBlockStatus.SUCCESS, + createdAt: new Date().toISOString(), + content: '# Test Markdown\n\nThis is **bold** text.', + ...overrides + }) + + describe('rendering', () => { + it('should render markdown content with correct structure', () => { + const block = createMainTextBlock({ content: 'Test content' }) + render() + + const markdown = screen.getByTestId('markdown-content') + expect(markdown).toBeInTheDocument() + expect(markdown).toHaveClass('markdown') + expect(markdown).toHaveTextContent('Test content') + }) + + it('should handle empty content gracefully', () => { + const block = createMainTextBlock({ content: '' }) + + expect(() => render()).not.toThrow() + + const markdown = screen.getByTestId('markdown-content') + expect(markdown).toBeInTheDocument() + }) + + it('should show paused message when content is empty and status is paused', () => { + const block = createMainTextBlock({ + content: '', + status: MessageBlockStatus.PAUSED + }) + render() + + const markdown = screen.getByTestId('markdown-content') + expect(markdown).toHaveTextContent('Paused') + }) + + it('should prioritize actual content over paused status', () => { + const block = createMainTextBlock({ + content: 'Real content', + status: MessageBlockStatus.PAUSED + }) + render() + + const markdown = screen.getByTestId('markdown-content') + expect(markdown).toHaveTextContent('Real content') + expect(markdown).not.toHaveTextContent('Paused') + }) + + it('should process content through format utilities', async () => { + const { escapeBrackets, removeSvgEmptyLines } = await import('@renderer/utils/formats') + const content = 'Content with [brackets] and SVG' + + render() + + expect(escapeBrackets).toHaveBeenCalledWith(content) + expect(removeSvgEmptyLines).toHaveBeenCalledWith(content) + }) + + it('should match snapshot', () => { + const { container } = render() + expect(container.firstChild).toMatchSnapshot() + }) + }) + + describe('block type support', () => { + const testCases = [ + { + name: 'MainTextMessageBlock', + block: createMainTextBlock({ content: 'Main text content' }), + expectedContent: 'Main text content' + }, + { + name: 'ThinkingMessageBlock', + block: { + id: 'thinking-1', + messageId: 'msg-1', + type: MessageBlockType.THINKING, + status: MessageBlockStatus.SUCCESS, + createdAt: new Date().toISOString(), + content: 'Thinking content', + thinking_millsec: 5000 + } as ThinkingMessageBlock, + expectedContent: 'Thinking content' + }, + { + name: 'TranslationMessageBlock', + block: { + id: 'translation-1', + messageId: 'msg-1', + type: MessageBlockType.TRANSLATION, + status: MessageBlockStatus.SUCCESS, + createdAt: new Date().toISOString(), + content: 'Translated content', + targetLanguage: 'en' + } as TranslationMessageBlock, + expectedContent: 'Translated content' + } + ] + + testCases.forEach(({ name, block, expectedContent }) => { + it(`should handle ${name} correctly`, () => { + render() + + const markdown = screen.getByTestId('markdown-content') + expect(markdown).toBeInTheDocument() + expect(markdown).toHaveTextContent(expectedContent) + }) + }) + }) + + describe('math engine configuration', () => { + it('should configure KaTeX when mathEngine is KaTeX', () => { + mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX' }) + + render() + + // Component should render successfully with KaTeX configuration + expect(screen.getByTestId('markdown-content')).toBeInTheDocument() + }) + + it('should configure MathJax when mathEngine is MathJax', () => { + mockUseSettings.mockReturnValue({ mathEngine: 'MathJax' }) + + render() + + // Component should render successfully with MathJax configuration + expect(screen.getByTestId('markdown-content')).toBeInTheDocument() + }) + + it('should not load math plugins when mathEngine is none', () => { + mockUseSettings.mockReturnValue({ mathEngine: 'none' }) + + render() + + // Component should render successfully without math plugins + expect(screen.getByTestId('markdown-content')).toBeInTheDocument() + }) + }) + + describe('custom components', () => { + it('should integrate Link component for citations', () => { + render() + + expect(screen.getByTestId('has-link-component')).toBeInTheDocument() + }) + + it('should integrate CodeBlock component with edit functionality', () => { + const block = createMainTextBlock({ id: 'test-block-123' }) + render() + + expect(screen.getByTestId('has-code-component')).toBeInTheDocument() + + // Test code block edit event + const saveButton = screen.getByText('Save') + saveButton.click() + + expect(mockEventEmitter.emit).toHaveBeenCalledWith('EDIT_CODE_BLOCK', { + msgBlockId: 'test-block-123', + codeBlockId: 'code-block-1', + newContent: 'new content' + }) + }) + + it('should integrate ImagePreview component', () => { + render() + + expect(screen.getByTestId('has-img-component')).toBeInTheDocument() + }) + + it('should handle style tags with Shadow DOM', () => { + const block = createMainTextBlock({ content: '' }) + render() + + expect(screen.getByTestId('has-style-component')).toBeInTheDocument() + }) + }) + + describe('HTML content support', () => { + it('should handle mixed markdown and HTML content', () => { + const block = createMainTextBlock({ + content: '# Header\n
HTML content
\n**Bold text**' + }) + + expect(() => render()).not.toThrow() + + const markdown = screen.getByTestId('markdown-content') + expect(markdown).toBeInTheDocument() + expect(markdown).toHaveTextContent('# Header') + expect(markdown).toHaveTextContent('HTML content') + expect(markdown).toHaveTextContent('**Bold text**') + }) + + it('should handle malformed content gracefully', () => { + const block = createMainTextBlock({ + content: 'content\n# Invalid markdown **unclosed' + }) + + expect(() => render()).not.toThrow() + + const markdown = screen.getByTestId('markdown-content') + expect(markdown).toBeInTheDocument() + }) + }) + + describe('component behavior', () => { + it('should re-render when content changes', () => { + const { rerender } = render() + + expect(screen.getByTestId('markdown-content')).toHaveTextContent('Initial') + + rerender() + + expect(screen.getByTestId('markdown-content')).toHaveTextContent('Updated') + }) + + it('should re-render when math engine changes', () => { + mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX' }) + const { rerender } = render() + + expect(screen.getByTestId('markdown-content')).toBeInTheDocument() + + mockUseSettings.mockReturnValue({ mathEngine: 'MathJax' }) + rerender() + + // Should still render correctly with new math engine + expect(screen.getByTestId('markdown-content')).toBeInTheDocument() + }) + }) +}) diff --git a/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/CitationTooltip.test.tsx.snap b/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/CitationTooltip.test.tsx.snap new file mode 100644 index 0000000000..ff5c69767e --- /dev/null +++ b/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/CitationTooltip.test.tsx.snap @@ -0,0 +1,98 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`CitationTooltip > basic rendering > should match snapshot 1`] = ` +.c0 { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + cursor: pointer; +} + +.c0:hover { + opacity: 0.8; +} + +.c1 { + color: var(--color-text-1); + font-size: 14px; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c2 { + font-size: 13px; + line-height: 1.5; + margin-bottom: 8px; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + color: var(--color-text-2); +} + +.c3 { + font-size: 12px; + color: var(--color-link); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} + +.c3:hover { + text-decoration: underline; +} + +
+ + Test content + +
+
+
+
+
+ Example Article +
+
+
+ This is the article content for testing purposes. +
+
+ example.com +
+
+
+
+`; diff --git a/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/Markdown.test.tsx.snap b/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/Markdown.test.tsx.snap new file mode 100644 index 0000000000..e055c83f52 --- /dev/null +++ b/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/Markdown.test.tsx.snap @@ -0,0 +1,39 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Markdown > rendering > should match snapshot 1`] = ` +
+ # Test Markdown + +This is **bold** text. + + link + +
+
+ + test code + + +
+
+ + img + +
+`; diff --git a/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx index 787ffcb08d..14d72dcce8 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx @@ -5,7 +5,7 @@ import type { RootState } from '@renderer/store' import { selectFormattedCitationsByBlockId } from '@renderer/store/messageBlock' import { type Model, WebSearchSource } from '@renderer/types' import type { MainTextMessageBlock, Message } from '@renderer/types/newMessage' -import { cleanMarkdownContent } from '@renderer/utils/formats' +import { cleanMarkdownContent, encodeHTML } from '@renderer/utils/formats' import { Flex } from 'antd' import React, { useMemo } from 'react' import { useSelector } from 'react-redux' @@ -13,18 +13,6 @@ import styled from 'styled-components' import Markdown from '../../Markdown/Markdown' -// HTML实体编码辅助函数 -const encodeHTML = (str: string): string => { - const entities: { [key: string]: string } = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' - } - return str.replace(/[&<>"']/g, (match) => entities[match]) -} - interface Props { block: MainTextMessageBlock citationBlockId?: string diff --git a/src/renderer/src/pages/home/Messages/Blocks/__tests__/MainTextBlock.test.tsx b/src/renderer/src/pages/home/Messages/Blocks/__tests__/MainTextBlock.test.tsx new file mode 100644 index 0000000000..e2badf156c --- /dev/null +++ b/src/renderer/src/pages/home/Messages/Blocks/__tests__/MainTextBlock.test.tsx @@ -0,0 +1,477 @@ +import { configureStore } from '@reduxjs/toolkit' +import type { Model } from '@renderer/types' +import { WebSearchSource } from '@renderer/types' +import type { MainTextMessageBlock } from '@renderer/types/newMessage' +import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage' +import { render, screen } from '@testing-library/react' +import { Provider } from 'react-redux' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import MainTextBlock from '../MainTextBlock' + +// Mock dependencies +const mockUseSettings = vi.fn() +const mockUseSelector = vi.fn() + +// Mock hooks +vi.mock('@renderer/hooks/useSettings', () => ({ + useSettings: () => mockUseSettings() +})) + +vi.mock('react-redux', async () => { + const actual = await import('react-redux') + return { + ...actual, + useSelector: () => mockUseSelector(), + useDispatch: () => vi.fn() + } +}) + +// Mock store to avoid withTypes issues +vi.mock('@renderer/store', () => ({ + useAppSelector: vi.fn(), + useAppDispatch: vi.fn(() => vi.fn()) +})) + +// Mock store selectors +vi.mock('@renderer/store/messageBlock', async () => { + const actual = await import('@renderer/store/messageBlock') + return { + ...actual, + selectFormattedCitationsByBlockId: vi.fn(() => []) + } +}) + +// Mock utilities +vi.mock('@renderer/utils/formats', () => ({ + cleanMarkdownContent: vi.fn((content: string) => content), + encodeHTML: vi.fn((content: string) => content.replace(/"/g, '"')) +})) + +// Mock services +vi.mock('@renderer/services/ModelService', () => ({ + getModelUniqId: vi.fn() +})) + +// Mock Markdown component +vi.mock('@renderer/pages/home/Markdown/Markdown', () => ({ + __esModule: true, + default: ({ block }: any) => ( +
+ Markdown: {block.content} +
+ ) +})) + +describe('MainTextBlock', () => { + // Get references to mocked modules + let mockGetModelUniqId: any + let mockCleanMarkdownContent: any + + // Create a mock store for Provider + const mockStore = configureStore({ + reducer: { + messageBlocks: (state = {}) => state + } + }) + + beforeEach(async () => { + vi.clearAllMocks() + + // Get the mocked functions + const { getModelUniqId } = await import('@renderer/services/ModelService') + const { cleanMarkdownContent } = await import('@renderer/utils/formats') + mockGetModelUniqId = getModelUniqId as any + mockCleanMarkdownContent = cleanMarkdownContent as any + + // Default mock implementations + mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: false }) + mockUseSelector.mockReturnValue([]) // Empty citations by default + mockGetModelUniqId.mockImplementation((model: Model) => `${model.id}-${model.name}`) + }) + + // Test data factory functions + const createMainTextBlock = (overrides: Partial = {}): MainTextMessageBlock => ({ + id: 'test-block-1', + messageId: 'test-message-1', + type: MessageBlockType.MAIN_TEXT, + status: MessageBlockStatus.SUCCESS, + createdAt: new Date().toISOString(), + content: 'Test content', + ...overrides + }) + + const createModel = (overrides: Partial = {}): Model => + ({ + id: 'test-model-1', + name: 'Test Model', + provider: 'test-provider', + ...overrides + }) as Model + + // Helper functions + const renderMainTextBlock = (props: { + block: MainTextMessageBlock + role: 'user' | 'assistant' + mentions?: Model[] + citationBlockId?: string + }) => { + return render( + + + + ) + } + + // User-focused query helpers + const getRenderedMarkdown = () => screen.queryByTestId('mock-markdown') + const getRenderedPlainText = () => screen.queryByRole('paragraph') + const getMentionElements = () => screen.queryAllByText(/@/) + + describe('basic rendering', () => { + it('should render in markdown mode for assistant messages', () => { + const block = createMainTextBlock({ content: 'Assistant response' }) + renderMainTextBlock({ block, role: 'assistant' }) + + // User should see markdown-rendered content + expect(getRenderedMarkdown()).toBeInTheDocument() + expect(screen.getByText('Markdown: Assistant response')).toBeInTheDocument() + expect(getRenderedPlainText()).not.toBeInTheDocument() + }) + + it('should render in plain text mode for user messages when setting disabled', () => { + mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: false }) + const block = createMainTextBlock({ content: 'User message\nWith line breaks' }) + renderMainTextBlock({ block, role: 'user' }) + + // User should see plain text with preserved formatting + expect(getRenderedPlainText()).toBeInTheDocument() + expect(getRenderedPlainText()!.textContent).toBe('User message\nWith line breaks') + expect(getRenderedMarkdown()).not.toBeInTheDocument() + + // Check preserved whitespace + const textElement = getRenderedPlainText()! + expect(textElement).toHaveStyle({ whiteSpace: 'pre-wrap' }) + }) + + it('should render user messages as markdown when setting enabled', () => { + mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: true }) + const block = createMainTextBlock({ content: 'User **bold** content' }) + renderMainTextBlock({ block, role: 'user' }) + + expect(getRenderedMarkdown()).toBeInTheDocument() + expect(screen.getByText('Markdown: User **bold** content')).toBeInTheDocument() + }) + + it('should preserve complex formatting in plain text mode', () => { + mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: false }) + const complexContent = `Line 1 + Indented line +**Bold not parsed** +- List not parsed` + + const block = createMainTextBlock({ content: complexContent }) + renderMainTextBlock({ block, role: 'user' }) + + const textElement = getRenderedPlainText()! + expect(textElement.textContent).toBe(complexContent) + expect(textElement).toHaveClass('markdown') + }) + + it('should handle empty content gracefully', () => { + const block = createMainTextBlock({ content: '' }) + expect(() => { + renderMainTextBlock({ block, role: 'assistant' }) + }).not.toThrow() + + expect(getRenderedMarkdown()).toBeInTheDocument() + }) + }) + + describe('mentions functionality', () => { + it('should display model mentions when provided', () => { + const block = createMainTextBlock({ content: 'Content with mentions' }) + const mentions = [ + createModel({ id: 'model-1', name: 'deepseek-r1' }), + createModel({ id: 'model-2', name: 'claude-sonnet-4' }) + ] + + renderMainTextBlock({ block, role: 'assistant', mentions }) + + // User should see mention tags + expect(screen.getByText('@deepseek-r1')).toBeInTheDocument() + expect(screen.getByText('@claude-sonnet-4')).toBeInTheDocument() + + // Service should be called for model processing + expect(mockGetModelUniqId).toHaveBeenCalledTimes(2) + expect(mockGetModelUniqId).toHaveBeenCalledWith(mentions[0]) + expect(mockGetModelUniqId).toHaveBeenCalledWith(mentions[1]) + }) + + it('should not display mentions when none provided', () => { + const block = createMainTextBlock({ content: 'No mentions content' }) + + renderMainTextBlock({ block, role: 'assistant', mentions: [] }) + expect(getMentionElements()).toHaveLength(0) + + renderMainTextBlock({ block, role: 'assistant', mentions: undefined }) + expect(getMentionElements()).toHaveLength(0) + }) + + it('should style mentions correctly for user visibility', () => { + const block = createMainTextBlock({ content: 'Styled mentions test' }) + const mentions = [createModel({ id: 'model-1', name: 'Test Model' })] + + renderMainTextBlock({ block, role: 'assistant', mentions }) + + const mentionElement = screen.getByText('@Test Model') + expect(mentionElement).toHaveStyle({ color: 'var(--color-link)' }) + + // Check container layout + const container = mentionElement.closest('[style*="gap"]') + expect(container).toHaveStyle({ + gap: '8px', + marginBottom: '10px' + }) + }) + }) + + describe('content processing', () => { + it('should filter tool_use tags from content', () => { + const testCases = [ + { + name: 'single tool_use tag', + content: 'Before tool content after', + expectsFiltering: true + }, + { + name: 'multiple tool_use tags', + content: 'Start tool1 middle tool2 end', + expectsFiltering: true + }, + { + name: 'multiline tool_use', + content: `Text before + + multiline + tool content + +text after`, + expectsFiltering: true + }, + { + name: 'malformed tool_use', + content: 'Before unclosed tag', + expectsFiltering: false // Should preserve malformed tags + } + ] + + testCases.forEach(({ content, expectsFiltering }) => { + const block = createMainTextBlock({ content }) + const { unmount } = renderMainTextBlock({ block, role: 'assistant' }) + + const renderedContent = getRenderedMarkdown() + expect(renderedContent).toBeInTheDocument() + + if (expectsFiltering) { + // Check that tool_use content is not visible to user + expect(screen.queryByText(/tool content|tool1|tool2|multiline/)).not.toBeInTheDocument() + } + + unmount() + }) + }) + + it('should process content through format utilities', () => { + const block = createMainTextBlock({ content: 'Content to process' }) + mockUseSelector.mockReturnValue([{ id: '1', content: 'Citation content', number: 1 }]) + + renderMainTextBlock({ + block, + role: 'assistant', + citationBlockId: 'test-citations' + }) + + // Verify utility functions are called + expect(mockCleanMarkdownContent).toHaveBeenCalled() + }) + }) + + describe('citation integration', () => { + it('should display content normally when no citations are present', () => { + const block = createMainTextBlock({ content: 'Content without citations' }) + mockUseSelector.mockReturnValue([]) + + renderMainTextBlock({ block, role: 'assistant' }) + + expect(screen.getByText('Markdown: Content without citations')).toBeInTheDocument() + expect(mockUseSelector).toHaveBeenCalled() + }) + + it('should integrate with citation system when citations exist', () => { + const block = createMainTextBlock({ + content: 'Content with citation [1]', + citationReferences: [{ citationBlockSource: WebSearchSource.OPENAI }] + }) + + const mockCitations = [ + { + id: '1', + number: 1, + url: 'https://example.com', + title: 'Example Citation', + content: 'Citation content' + } + ] + + mockUseSelector.mockReturnValue(mockCitations) + renderMainTextBlock({ + block, + role: 'assistant', + citationBlockId: 'citation-test' + }) + + // Verify citation integration works + expect(mockUseSelector).toHaveBeenCalled() + expect(getRenderedMarkdown()).toBeInTheDocument() + + // Verify content processing occurred + expect(mockCleanMarkdownContent).toHaveBeenCalledWith('Citation content') + }) + + it('should handle different citation sources correctly', () => { + const testSources = [WebSearchSource.OPENAI, 'DEFAULT' as any, 'CUSTOM' as any] + + testSources.forEach((source) => { + const block = createMainTextBlock({ + content: `Citation test for ${source}`, + citationReferences: [{ citationBlockSource: source }] + }) + + mockUseSelector.mockReturnValue([{ id: '1', number: 1, url: 'https://test.com', title: 'Test' }]) + + const { unmount } = renderMainTextBlock({ + block, + role: 'assistant', + citationBlockId: `test-${source}` + }) + + expect(getRenderedMarkdown()).toBeInTheDocument() + unmount() + }) + }) + + it('should handle multiple citations gracefully', () => { + const block = createMainTextBlock({ + content: 'Multiple citations [1] and [2]', + citationReferences: [{ citationBlockSource: 'DEFAULT' as any }] + }) + + const multipleCitations = [ + { id: '1', number: 1, url: 'https://first.com', title: 'First' }, + { id: '2', number: 2, url: 'https://second.com', title: 'Second' } + ] + + mockUseSelector.mockReturnValue(multipleCitations) + + expect(() => { + renderMainTextBlock({ block, role: 'assistant', citationBlockId: 'multi-test' }) + }).not.toThrow() + + expect(getRenderedMarkdown()).toBeInTheDocument() + }) + }) + + describe('settings integration', () => { + it('should respond to markdown rendering setting changes', () => { + const block = createMainTextBlock({ content: 'Settings test content' }) + + // Test with markdown enabled + mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: true }) + const { unmount } = renderMainTextBlock({ block, role: 'user' }) + expect(getRenderedMarkdown()).toBeInTheDocument() + unmount() + + // Test with markdown disabled + mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: false }) + renderMainTextBlock({ block, role: 'user' }) + expect(getRenderedPlainText()).toBeInTheDocument() + expect(getRenderedMarkdown()).not.toBeInTheDocument() + }) + }) + + describe('edge cases and robustness', () => { + it('should handle large content without performance issues', () => { + const largeContent = 'A'.repeat(1000) + ' with citations [1]' + const block = createMainTextBlock({ content: largeContent }) + + const largeCitations = [ + { + id: '1', + number: 1, + url: 'https://large.com', + title: 'Large', + content: 'B'.repeat(500) + } + ] + + mockUseSelector.mockReturnValue(largeCitations) + + expect(() => { + renderMainTextBlock({ + block, + role: 'assistant', + citationBlockId: 'large-test' + }) + }).not.toThrow() + + expect(getRenderedMarkdown()).toBeInTheDocument() + }) + + it('should handle special characters and Unicode gracefully', () => { + const specialContent = '测试内容 🚀 📝 ✨ <>&"\'` [1]' + const block = createMainTextBlock({ content: specialContent }) + + mockUseSelector.mockReturnValue([{ id: '1', number: 1, title: '特殊字符测试', content: '内容 with 🎉' }]) + + expect(() => { + renderMainTextBlock({ + block, + role: 'assistant', + citationBlockId: 'unicode-test' + }) + }).not.toThrow() + + expect(getRenderedMarkdown()).toBeInTheDocument() + }) + + it('should handle null and undefined values gracefully', () => { + const block = createMainTextBlock({ content: 'Null safety test' }) + + expect(() => { + renderMainTextBlock({ + block, + role: 'assistant', + mentions: undefined, + citationBlockId: undefined + }) + }).not.toThrow() + + expect(getRenderedMarkdown()).toBeInTheDocument() + }) + + it('should integrate properly with Redux store', () => { + const block = createMainTextBlock({ + content: 'Redux integration test', + citationReferences: [{ citationBlockSource: 'DEFAULT' as any }] + }) + + mockUseSelector.mockReturnValue([]) + renderMainTextBlock({ block, role: 'assistant', citationBlockId: 'redux-test' }) + + // Verify Redux integration + expect(mockUseSelector).toHaveBeenCalled() + expect(getRenderedMarkdown()).toBeInTheDocument() + }) + }) +}) diff --git a/src/renderer/src/pages/home/Messages/Blocks/__tests__/ThinkingBlock.test.tsx b/src/renderer/src/pages/home/Messages/Blocks/__tests__/ThinkingBlock.test.tsx new file mode 100644 index 0000000000..6fe5448d5d --- /dev/null +++ b/src/renderer/src/pages/home/Messages/Blocks/__tests__/ThinkingBlock.test.tsx @@ -0,0 +1,424 @@ +import type { ThinkingMessageBlock } from '@renderer/types/newMessage' +import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage' +import { render, screen } from '@testing-library/react' +import { act } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ThinkingBlock from '../ThinkingBlock' + +// Mock dependencies +const mockUseSettings = vi.fn() +const mockUseTranslation = vi.fn() + +// Mock hooks +vi.mock('@renderer/hooks/useSettings', () => ({ + useSettings: () => mockUseSettings() +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => mockUseTranslation() +})) + +// Mock antd components +vi.mock('antd', () => ({ + Collapse: ({ activeKey, onChange, items, className, size, expandIconPosition }: any) => ( +
+ {items.map((item: any) => ( +
+
onChange()}> + {item.label} +
+ {activeKey === item.key &&
{item.children}
} +
+ ))} +
+ ), + Tooltip: ({ title, children, mouseEnterDelay }: any) => ( +
+ {children} +
+ ), + message: { + success: vi.fn(), + error: vi.fn() + } +})) + +// Mock icons +vi.mock('@ant-design/icons', () => ({ + CheckOutlined: ({ style }: any) => ( + + ✓ + + ) +})) + +vi.mock('lucide-react', () => ({ + Lightbulb: ({ size }: any) => ( + + 💡 + + ) +})) + +// Mock motion +vi.mock('motion/react', () => ({ + motion: { + span: ({ children, variants, animate, initial, style }: any) => ( + + {children} + + ) + } +})) + +// Mock motion variants +vi.mock('@renderer/utils/motionVariants', () => ({ + lightbulbVariants: { + active: { rotate: 10, scale: 1.1 }, + idle: { rotate: 0, scale: 1 } + } +})) + +// Mock Markdown component +vi.mock('@renderer/pages/home/Markdown/Markdown', () => ({ + __esModule: true, + default: ({ block }: any) => ( +
+ Markdown: {block.content} +
+ ) +})) + +describe('ThinkingBlock', () => { + beforeEach(async () => { + vi.useFakeTimers() + + // Default mock implementations + mockUseSettings.mockReturnValue({ + messageFont: 'sans-serif', + fontSize: 14, + thoughtAutoCollapse: false + }) + + mockUseTranslation.mockReturnValue({ + t: (key: string, params?: any) => { + if (key === 'chat.thinking' && params?.seconds) { + return `Thinking... ${params.seconds}s` + } + if (key === 'chat.deeply_thought' && params?.seconds) { + return `Thought for ${params.seconds}s` + } + if (key === 'message.copied') return 'Copied!' + if (key === 'message.copy.failed') return 'Copy failed' + if (key === 'common.copy') return 'Copy' + return key + } + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() + vi.clearAllTimers() + vi.useRealTimers() + }) + + // Test data factory functions + const createThinkingBlock = (overrides: Partial = {}): ThinkingMessageBlock => ({ + id: 'test-thinking-block-1', + messageId: 'test-message-1', + type: MessageBlockType.THINKING, + status: MessageBlockStatus.SUCCESS, + createdAt: new Date().toISOString(), + content: 'I need to think about this carefully...', + thinking_millsec: 5000, + ...overrides + }) + + // Helper functions + const renderThinkingBlock = (block: ThinkingMessageBlock) => { + return render() + } + + const getThinkingContent = () => screen.queryByText(/markdown:/i) + const getCopyButton = () => screen.queryByRole('button', { name: /copy/i }) + const getThinkingTimeText = () => screen.getByText(/thinking|thought/i) + + describe('basic rendering', () => { + it('should render thinking content when provided', () => { + const block = createThinkingBlock({ content: 'Deep thoughts about AI' }) + renderThinkingBlock(block) + + // User should see the thinking content + expect(screen.getByText('Markdown: Deep thoughts about AI')).toBeInTheDocument() + expect(screen.getByTestId('lightbulb-icon')).toBeInTheDocument() + }) + + it('should not render when content is empty', () => { + const testCases = ['', undefined] + + testCases.forEach((content) => { + const block = createThinkingBlock({ content: content as any }) + const { container, unmount } = renderThinkingBlock(block) + expect(container.firstChild).toBeNull() + unmount() + }) + }) + + it('should show copy button only when thinking is complete', () => { + // When thinking (streaming) + const thinkingBlock = createThinkingBlock({ status: MessageBlockStatus.STREAMING }) + const { rerender } = renderThinkingBlock(thinkingBlock) + + expect(getCopyButton()).not.toBeInTheDocument() + + // When thinking is complete + const completedBlock = createThinkingBlock({ status: MessageBlockStatus.SUCCESS }) + rerender() + + expect(getCopyButton()).toBeInTheDocument() + }) + + it('should match snapshot', () => { + const block = createThinkingBlock() + const { container } = renderThinkingBlock(block) + expect(container.firstChild).toMatchSnapshot() + }) + }) + + describe('thinking time display', () => { + it('should display appropriate time messages based on status', () => { + // Completed thinking + const completedBlock = createThinkingBlock({ + thinking_millsec: 3500, + status: MessageBlockStatus.SUCCESS + }) + const { unmount } = renderThinkingBlock(completedBlock) + + const timeText = getThinkingTimeText() + expect(timeText).toHaveTextContent('3.5s') + expect(timeText).toHaveTextContent('Thought for') + unmount() + + // Active thinking + const thinkingBlock = createThinkingBlock({ + thinking_millsec: 1000, + status: MessageBlockStatus.STREAMING + }) + renderThinkingBlock(thinkingBlock) + + const activeTimeText = getThinkingTimeText() + expect(activeTimeText).toHaveTextContent('1.0s') + expect(activeTimeText).toHaveTextContent('Thinking...') + }) + + it('should update thinking time in real-time when active', () => { + const block = createThinkingBlock({ + thinking_millsec: 1000, + status: MessageBlockStatus.STREAMING + }) + renderThinkingBlock(block) + + // Initial state + expect(getThinkingTimeText()).toHaveTextContent('1.0s') + + // After time passes + act(() => { + vi.advanceTimersByTime(500) + }) + + expect(getThinkingTimeText()).toHaveTextContent('1.5s') + }) + + it('should handle extreme thinking times correctly', () => { + const testCases = [ + { thinking_millsec: 0, expectedTime: '0.0s' }, + { thinking_millsec: undefined, expectedTime: '0.0s' }, + { thinking_millsec: 86400000, expectedTime: '86400.0s' }, // 1 day + { thinking_millsec: 259200000, expectedTime: '259200.0s' } // 3 days + ] + + testCases.forEach(({ thinking_millsec, expectedTime }) => { + const block = createThinkingBlock({ + thinking_millsec, + status: MessageBlockStatus.SUCCESS + }) + const { unmount } = renderThinkingBlock(block) + expect(getThinkingTimeText()).toHaveTextContent(expectedTime) + unmount() + }) + }) + + it('should stop timer when thinking status changes to completed', () => { + const block = createThinkingBlock({ + thinking_millsec: 1000, + status: MessageBlockStatus.STREAMING + }) + const { rerender } = renderThinkingBlock(block) + + // Advance timer while thinking + act(() => { + vi.advanceTimersByTime(1000) + }) + expect(getThinkingTimeText()).toHaveTextContent('2.0s') + + // Complete thinking + const completedBlock = createThinkingBlock({ + thinking_millsec: 1000, // Original time doesn't matter + status: MessageBlockStatus.SUCCESS + }) + rerender() + + // Timer should stop - text should change from "Thinking..." to "Thought for" + const timeText = getThinkingTimeText() + expect(timeText).toHaveTextContent('Thought for') + expect(timeText).toHaveTextContent('2.0s') + + // Further time advancement shouldn't change the display + act(() => { + vi.advanceTimersByTime(1000) + }) + expect(timeText).toHaveTextContent('2.0s') + }) + }) + + describe('collapse behavior', () => { + it('should respect auto-collapse setting for initial state', () => { + // Test expanded by default (auto-collapse disabled) + mockUseSettings.mockReturnValue({ + messageFont: 'sans-serif', + fontSize: 14, + thoughtAutoCollapse: false + }) + + const block = createThinkingBlock() + const { unmount } = renderThinkingBlock(block) + + // Content should be visible when expanded + expect(getThinkingContent()).toBeInTheDocument() + unmount() + + // Test collapsed by default (auto-collapse enabled) + mockUseSettings.mockReturnValue({ + messageFont: 'sans-serif', + fontSize: 14, + thoughtAutoCollapse: true + }) + + renderThinkingBlock(block) + + // Content should not be visible when collapsed + expect(getThinkingContent()).not.toBeInTheDocument() + }) + + it('should auto-collapse when thinking completes if setting enabled', () => { + mockUseSettings.mockReturnValue({ + messageFont: 'sans-serif', + fontSize: 14, + thoughtAutoCollapse: true + }) + + const streamingBlock = createThinkingBlock({ status: MessageBlockStatus.STREAMING }) + const { rerender } = renderThinkingBlock(streamingBlock) + + // Should be expanded while thinking + expect(getThinkingContent()).toBeInTheDocument() + + // Stop thinking + const completedBlock = createThinkingBlock({ status: MessageBlockStatus.SUCCESS }) + rerender() + + // Should be collapsed after thinking completes + expect(getThinkingContent()).not.toBeInTheDocument() + }) + }) + + describe('font and styling', () => { + it('should apply font settings to thinking content', () => { + const testCases = [ + { + settings: { messageFont: 'serif', fontSize: 16 }, + expectedFont: 'var(--font-family-serif)', + expectedSize: '16px' + }, + { + settings: { messageFont: 'sans-serif', fontSize: 14 }, + expectedFont: 'var(--font-family)', + expectedSize: '14px' + } + ] + + testCases.forEach(({ settings, expectedFont, expectedSize }) => { + mockUseSettings.mockReturnValue({ + ...settings, + thoughtAutoCollapse: false + }) + + const block = createThinkingBlock() + const { unmount } = renderThinkingBlock(block) + + // Find the styled content container + const contentContainer = screen.getByTestId('collapse-content-thought') + const styledDiv = contentContainer.querySelector('div') + + expect(styledDiv).toHaveStyle({ + fontFamily: expectedFont, + fontSize: expectedSize + }) + + unmount() + }) + }) + }) + + describe('integration and edge cases', () => { + it('should handle content updates correctly', () => { + const block1 = createThinkingBlock({ content: 'Original thought' }) + const { rerender } = renderThinkingBlock(block1) + + expect(screen.getByText('Markdown: Original thought')).toBeInTheDocument() + + const block2 = createThinkingBlock({ content: 'Updated thought' }) + rerender() + + expect(screen.getByText('Markdown: Updated thought')).toBeInTheDocument() + expect(screen.queryByText('Markdown: Original thought')).not.toBeInTheDocument() + }) + + it('should clean up timer on unmount', () => { + const block = createThinkingBlock({ status: MessageBlockStatus.STREAMING }) + const { unmount } = renderThinkingBlock(block) + + const clearIntervalSpy = vi.spyOn(global, 'clearInterval') + unmount() + + expect(clearIntervalSpy).toHaveBeenCalled() + }) + + it('should handle rapid status changes gracefully', () => { + const block = createThinkingBlock({ status: MessageBlockStatus.STREAMING }) + const { rerender } = renderThinkingBlock(block) + + // Rapidly toggle between states + for (let i = 0; i < 3; i++) { + rerender() + rerender() + } + + // Should still render correctly + expect(getThinkingContent()).toBeInTheDocument() + expect(getCopyButton()).toBeInTheDocument() + }) + }) +}) diff --git a/src/renderer/src/pages/home/Messages/Blocks/__tests__/__snapshots__/ThinkingBlock.test.tsx.snap b/src/renderer/src/pages/home/Messages/Blocks/__tests__/__snapshots__/ThinkingBlock.test.tsx.snap new file mode 100644 index 0000000000..7f1f866b8b --- /dev/null +++ b/src/renderer/src/pages/home/Messages/Blocks/__tests__/__snapshots__/ThinkingBlock.test.tsx.snap @@ -0,0 +1,116 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ThinkingBlock > basic rendering > should match snapshot 1`] = ` +.c0 { + margin-bottom: 15px; +} + +.c1 { + display: flex; + flex-direction: row; + align-items: center; + height: 22px; + gap: 4px; +} + +.c2 { + color: var(--color-text-2); +} + +.c3 { + background: none; + border: none; + color: var(--color-text-2); + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + margin-left: auto; + opacity: 0.6; + transition: all 0.3s; +} + +.c3:hover { + opacity: 1; + color: var(--color-text); +} + +.c3:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +.c3 .iconfont { + font-size: 14px; +} + +
+
+
+
+ + + 💡 + + + + Thought for 5.0s + +
+ +
+
+
+
+
+
+ Markdown: + I need to think about this carefully... +
+
+
+
+
+`; diff --git a/src/renderer/src/utils/__tests__/formats.test.ts b/src/renderer/src/utils/__tests__/formats.test.ts index 56eddae67b..ed5020913a 100644 --- a/src/renderer/src/utils/__tests__/formats.test.ts +++ b/src/renderer/src/utils/__tests__/formats.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import { addImageFileToContents, + encodeHTML, escapeBrackets, escapeDollarNumber, extractTitle, @@ -121,6 +122,41 @@ function createMessage( // --- Tests --- describe('formats', () => { + describe('encodeHTML', () => { + it('should encode all special HTML characters', () => { + const input = `Tom & Jerry's "cat" ` + const result = encodeHTML(input) + expect(result).toBe('Tom & Jerry's "cat" <dog>') + }) + + it('should return the same string if no special characters', () => { + const input = 'Hello World!' + const result = encodeHTML(input) + expect(result).toBe('Hello World!') + }) + + it('should return empty string if input is empty', () => { + const input = '' + const result = encodeHTML(input) + expect(result).toBe('') + }) + + it('should encode single special character', () => { + expect(encodeHTML('&')).toBe('&') + expect(encodeHTML('<')).toBe('<') + expect(encodeHTML('>')).toBe('>') + expect(encodeHTML('"')).toBe('"') + expect(encodeHTML("'")).toBe(''') + }) + + it('should throw if input is not a string', () => { + // @ts-expect-error purposely pass wrong type to test error branch + expect(() => encodeHTML(null)).toThrow() + // @ts-expect-error purposely pass wrong type to test error branch + expect(() => encodeHTML(undefined)).toThrow() + }) + }) + describe('escapeDollarNumber', () => { it('should escape dollar signs followed by numbers', () => { expect(escapeDollarNumber('The cost is $5')).toBe('The cost is \\$5') diff --git a/src/renderer/src/utils/__tests__/markdown.test.ts b/src/renderer/src/utils/__tests__/markdown.test.ts index 2b6af62cef..9064510b33 100644 --- a/src/renderer/src/utils/__tests__/markdown.test.ts +++ b/src/renderer/src/utils/__tests__/markdown.test.ts @@ -5,7 +5,6 @@ import { describe, expect, it } from 'vitest' import { convertMathFormula, - encodeHTML, findCitationInChildren, getCodeBlockId, removeTrailingDoubleSpaces, @@ -143,41 +142,6 @@ describe('markdown', () => { }) }) - describe('encodeHTML', () => { - it('should encode all special HTML characters', () => { - const input = `Tom & Jerry's "cat" ` - const result = encodeHTML(input) - expect(result).toBe('Tom & Jerry's "cat" <dog>') - }) - - it('should return the same string if no special characters', () => { - const input = 'Hello World!' - const result = encodeHTML(input) - expect(result).toBe('Hello World!') - }) - - it('should return empty string if input is empty', () => { - const input = '' - const result = encodeHTML(input) - expect(result).toBe('') - }) - - it('should encode single special character', () => { - expect(encodeHTML('&')).toBe('&') - expect(encodeHTML('<')).toBe('<') - expect(encodeHTML('>')).toBe('>') - expect(encodeHTML('"')).toBe('"') - expect(encodeHTML("'")).toBe(''') - }) - - it('should throw if input is not a string', () => { - // @ts-expect-error purposely pass wrong type to test error branch - expect(() => encodeHTML(null)).toThrow() - // @ts-expect-error purposely pass wrong type to test error branch - expect(() => encodeHTML(undefined)).toThrow() - }) - }) - describe('getCodeBlockId', () => { it('should generate ID from position information', () => { // 从位置信息生成ID diff --git a/src/renderer/src/utils/formats.ts b/src/renderer/src/utils/formats.ts index 2b67fd2c9a..a99a7c8a14 100644 --- a/src/renderer/src/utils/formats.ts +++ b/src/renderer/src/utils/formats.ts @@ -2,6 +2,24 @@ import type { Message } from '@renderer/types/newMessage' import { findImageBlocks, getMainTextContent } from './messageUtils/find' +/** + * HTML实体编码辅助函数 + * @param str 输入字符串 + * @returns string 编码后的字符串 + */ +export const encodeHTML = (str: string) => { + return str.replace(/[&<>"']/g, (match) => { + const entities: { [key: string]: string } = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + } + return entities[match] + }) +} + /** * 清理Markdown内容 * @param text 要清理的文本 diff --git a/src/renderer/src/utils/markdown.ts b/src/renderer/src/utils/markdown.ts index 05e9c8c39c..3996e0aea2 100644 --- a/src/renderer/src/utils/markdown.ts +++ b/src/renderer/src/utils/markdown.ts @@ -62,24 +62,6 @@ export function getCodeBlockId(start: any): string | null { return start ? `${start.line}:${start.column}:${start.offset}` : null } -/** - * HTML实体编码辅助函数 - * @param str 输入字符串 - * @returns string 编码后的字符串 - */ -export const encodeHTML = (str: string) => { - return str.replace(/[&<>"']/g, (match) => { - const entities: { [key: string]: string } = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' - } - return entities[match] - }) -} - /** * 更新Markdown字符串中的代码块内容。 *