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\nHTML 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字符串中的代码块内容。
*