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
This commit is contained in:
one 2025-06-02 17:36:25 +08:00 committed by GitHub
parent 59cf73f365
commit 611a472b13
15 changed files with 2002 additions and 130 deletions

View File

@ -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",

View File

@ -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;
}
<div
class="c0"
data-testid="scrollbar"
>
内容
</div>
`;
exports[`Scrollbar > rendering > should match default styled snapshot 1`] = `
.c0 {
overflow-y: auto;

View File

@ -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<CitationTooltipProps> = ({ 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 = (
<TooltipContentWrapper>
<TooltipHeader onClick={() => window.open(citation.url, '_blank')}>
<Favicon hostname={hostname} alt={citation.title || hostname} />
<TooltipTitle title={citation.title || hostname}>{citation.title || hostname}</TooltipTitle>
</TooltipHeader>
{citation.content && <TooltipBody>{citation.content}</TooltipBody>}
<TooltipFooter onClick={() => window.open(citation.url, '_blank')}>{hostname}</TooltipFooter>
</TooltipContentWrapper>
const tooltipContent = useMemo(
() => (
<div>
<TooltipHeader role="button" aria-label={`Open ${sourceTitle} in new tab`} onClick={handleClick}>
<Favicon hostname={hostname} alt={sourceTitle} />
<TooltipTitle role="heading" aria-level={3} title={sourceTitle}>
{sourceTitle}
</TooltipTitle>
</TooltipHeader>
{citation.content?.trim() && (
<TooltipBody role="article" aria-label="Citation content">
{citation.content}
</TooltipBody>
)}
<TooltipFooter role="button" aria-label={`Visit ${hostname}`} onClick={handleClick}>
{hostname}
</TooltipFooter>
</div>
),
[citation.content, hostname, handleClick, sourceTitle]
)
return (
<StyledTooltip
title={tooltipContent}
<Tooltip
overlay={tooltipContent}
placement="top"
arrow={false}
overlayInnerStyle={{
backgroundColor: 'var(--color-background-mute)',
border: '1px solid var(--color-border)',
padding: 0,
borderRadius: '8px'
color="var(--color-background-mute)"
styles={{
body: {
border: '1px solid var(--color-border)',
padding: '12px',
borderRadius: '8px'
}
}}>
{children}
</StyledTooltip>
</Tooltip>
)
}
// 使用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)

View File

@ -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) => <div data-testid="mock-favicon" {...props} />
}))
vi.mock('antd', () => ({
Tooltip: ({ children, overlay, title, placement, color, styles, ...props }: any) => (
<div
data-testid="tooltip-wrapper"
data-placement={placement}
data-color={color}
data-styles={JSON.stringify(styles)}
{...props}>
{children}
<div data-testid="tooltip-content">{overlay || title}</div>
</div>
)
}))
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 = <span>Trigger</span>) => {
return render(<CitationTooltip citation={citation}>{children}</CitationTooltip>)
}
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, <span>Click me</span>)
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(
<CitationTooltip citation={citation}>
<span>Test content</span>
</CitationTooltip>
)
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 = (
<a href="https://research.example.com/study" target="_blank" rel="noreferrer">
<sup>1</sup>
</a>
)
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(<CitationTooltip citation={citation}>{null}</CitationTooltip>)
}).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(
<CitationTooltip citation={citation}>
<span>Trigger</span>
</CitationTooltip>
)
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(
<CitationTooltip citation={citation2}>
<span>Trigger</span>
</CitationTooltip>
)
expect(screen.getByText('second.com')).toBeInTheDocument()
expect(screen.queryByText('first.com')).not.toBeInTheDocument()
})
})
})

View File

@ -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) => (
<div data-testid="code-block" data-id={id}>
<code>{children}</code>
<button type="button" onClick={() => onSave(id, 'new content')}>
Save
</button>
</div>
)
}))
vi.mock('../ImagePreview', () => ({
__esModule: true,
default: (props: any) => <img data-testid="image-preview" {...props} />
}))
vi.mock('../Link', () => ({
__esModule: true,
default: ({ citationData, children, ...props }: any) => (
<a data-testid="citation-link" data-citation={citationData} {...props}>
{children}
</a>
)
}))
vi.mock('@renderer/components/MarkdownShadowDOMRenderer', () => ({
__esModule: true,
default: ({ children }: any) => <div data-testid="shadow-dom">{children}</div>
}))
// 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) => (
<div data-testid="markdown-content" className={className}>
{children}
{/* Simulate component rendering */}
{components?.a && <span data-testid="has-link-component">link</span>}
{components?.code && (
<div data-testid="has-code-component">
{components.code({ children: 'test code', node: { position: { start: { line: 1 } } } })}
</div>
)}
{components?.img && <span data-testid="has-img-component">img</span>}
{components?.style && <span data-testid="has-style-component">style</span>}
</div>
)
}))
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> = {}): 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(<Markdown block={block} />)
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(<Markdown block={block} />)).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(<Markdown block={block} />)
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(<Markdown block={block} />)
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(<Markdown block={createMainTextBlock({ content })} />)
expect(escapeBrackets).toHaveBeenCalledWith(content)
expect(removeSvgEmptyLines).toHaveBeenCalledWith(content)
})
it('should match snapshot', () => {
const { container } = render(<Markdown block={createMainTextBlock()} />)
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(<Markdown block={block} />)
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(<Markdown block={createMainTextBlock()} />)
// 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(<Markdown block={createMainTextBlock()} />)
// 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(<Markdown block={createMainTextBlock()} />)
// Component should render successfully without math plugins
expect(screen.getByTestId('markdown-content')).toBeInTheDocument()
})
})
describe('custom components', () => {
it('should integrate Link component for citations', () => {
render(<Markdown block={createMainTextBlock()} />)
expect(screen.getByTestId('has-link-component')).toBeInTheDocument()
})
it('should integrate CodeBlock component with edit functionality', () => {
const block = createMainTextBlock({ id: 'test-block-123' })
render(<Markdown block={block} />)
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(<Markdown block={createMainTextBlock()} />)
expect(screen.getByTestId('has-img-component')).toBeInTheDocument()
})
it('should handle style tags with Shadow DOM', () => {
const block = createMainTextBlock({ content: '<style>body { color: red; }</style>' })
render(<Markdown block={block} />)
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<div>HTML content</div>\n**Bold text**'
})
expect(() => render(<Markdown block={block} />)).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: '<unclosed-tag>content\n# Invalid markdown **unclosed'
})
expect(() => render(<Markdown block={block} />)).not.toThrow()
const markdown = screen.getByTestId('markdown-content')
expect(markdown).toBeInTheDocument()
})
})
describe('component behavior', () => {
it('should re-render when content changes', () => {
const { rerender } = render(<Markdown block={createMainTextBlock({ content: 'Initial' })} />)
expect(screen.getByTestId('markdown-content')).toHaveTextContent('Initial')
rerender(<Markdown block={createMainTextBlock({ content: 'Updated' })} />)
expect(screen.getByTestId('markdown-content')).toHaveTextContent('Updated')
})
it('should re-render when math engine changes', () => {
mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX' })
const { rerender } = render(<Markdown block={createMainTextBlock()} />)
expect(screen.getByTestId('markdown-content')).toBeInTheDocument()
mockUseSettings.mockReturnValue({ mathEngine: 'MathJax' })
rerender(<Markdown block={createMainTextBlock()} />)
// Should still render correctly with new math engine
expect(screen.getByTestId('markdown-content')).toBeInTheDocument()
})
})
})

View File

@ -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;
}
<div
data-color="var(--color-background-mute)"
data-placement="top"
data-styles="{"body":{"border":"1px solid var(--color-border)","padding":"12px","borderRadius":"8px"}}"
data-testid="tooltip-wrapper"
>
<span>
Test content
</span>
<div
data-testid="tooltip-content"
>
<div>
<div
aria-label="Open Example Article in new tab"
class="c0"
role="button"
>
<div
alt="Example Article"
data-testid="mock-favicon"
hostname="example.com"
/>
<div
aria-level="3"
class="c1"
role="heading"
title="Example Article"
>
Example Article
</div>
</div>
<div
aria-label="Citation content"
class="c2"
role="article"
>
This is the article content for testing purposes.
</div>
<div
aria-label="Visit example.com"
class="c3"
role="button"
>
example.com
</div>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,39 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Markdown > rendering > should match snapshot 1`] = `
<div
class="markdown"
data-testid="markdown-content"
>
# Test Markdown
This is **bold** text.
<span
data-testid="has-link-component"
>
link
</span>
<div
data-testid="has-code-component"
>
<div
data-id="code-block-1"
data-testid="code-block"
>
<code>
test code
</code>
<button
type="button"
>
Save
</button>
</div>
</div>
<span
data-testid="has-img-component"
>
img
</span>
</div>
`;

View File

@ -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 } = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&apos;'
}
return str.replace(/[&<>"']/g, (match) => entities[match])
}
interface Props {
block: MainTextMessageBlock
citationBlockId?: string

View File

@ -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, '&quot;'))
}))
// 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) => (
<div data-testid="mock-markdown" data-content={block.content}>
Markdown: {block.content}
</div>
)
}))
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> = {}): 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> = {}): 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(
<Provider store={mockStore}>
<MainTextBlock {...props} />
</Provider>
)
}
// 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_use>tool content</tool_use> after',
expectsFiltering: true
},
{
name: 'multiple tool_use tags',
content: 'Start <tool_use>tool1</tool_use> middle <tool_use>tool2</tool_use> end',
expectsFiltering: true
},
{
name: 'multiline tool_use',
content: `Text before
<tool_use>
multiline
tool content
</tool_use>
text after`,
expectsFiltering: true
},
{
name: 'malformed tool_use',
content: 'Before <tool_use>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()
})
})
})

View File

@ -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) => (
<div
data-testid="collapse-container"
className={className}
data-active-key={activeKey}
data-size={size}
data-expand-icon-position={expandIconPosition}>
{items.map((item: any) => (
<div key={item.key} data-testid={`collapse-item-${item.key}`}>
<div data-testid={`collapse-header-${item.key}`} onClick={() => onChange()}>
{item.label}
</div>
{activeKey === item.key && <div data-testid={`collapse-content-${item.key}`}>{item.children}</div>}
</div>
))}
</div>
),
Tooltip: ({ title, children, mouseEnterDelay }: any) => (
<div data-testid="tooltip" title={title} data-mouse-enter-delay={mouseEnterDelay}>
{children}
</div>
),
message: {
success: vi.fn(),
error: vi.fn()
}
}))
// Mock icons
vi.mock('@ant-design/icons', () => ({
CheckOutlined: ({ style }: any) => (
<span data-testid="check-icon" style={style}>
</span>
)
}))
vi.mock('lucide-react', () => ({
Lightbulb: ({ size }: any) => (
<span data-testid="lightbulb-icon" data-size={size}>
💡
</span>
)
}))
// Mock motion
vi.mock('motion/react', () => ({
motion: {
span: ({ children, variants, animate, initial, style }: any) => (
<span
data-testid="motion-span"
data-variants={JSON.stringify(variants)}
data-animate={animate}
data-initial={initial}
style={style}>
{children}
</span>
)
}
}))
// 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) => (
<div data-testid="mock-markdown" data-block-id={block.id}>
Markdown: {block.content}
</div>
)
}))
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> = {}): 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(<ThinkingBlock block={block} />)
}
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(<ThinkingBlock block={completedBlock} />)
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(<ThinkingBlock block={completedBlock} />)
// 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(<ThinkingBlock block={completedBlock} />)
// 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(<ThinkingBlock block={block2} />)
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(<ThinkingBlock block={createThinkingBlock({ status: MessageBlockStatus.STREAMING })} />)
rerender(<ThinkingBlock block={createThinkingBlock({ status: MessageBlockStatus.SUCCESS })} />)
}
// Should still render correctly
expect(getThinkingContent()).toBeInTheDocument()
expect(getCopyButton()).toBeInTheDocument()
})
})
})

View File

@ -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;
}
<div
class="c0 message-thought-container"
data-active-key="thought"
data-expand-icon-position="end"
data-size="small"
data-testid="collapse-container"
>
<div
data-testid="collapse-item-thought"
>
<div
data-testid="collapse-header-thought"
>
<div
class="c1"
>
<span
data-animate="idle"
data-initial="idle"
data-testid="motion-span"
data-variants="{"active":{"rotate":10,"scale":1.1},"idle":{"rotate":0,"scale":1}}"
style="height: 18px;"
>
<span
data-size="18"
data-testid="lightbulb-icon"
>
💡
</span>
</span>
<span
class="c2"
>
Thought for 5.0s
</span>
<div
data-mouse-enter-delay="0.8"
data-testid="tooltip"
title="Copy"
>
<button
aria-label="Copy"
class="c3 message-action-button"
>
<i
class="iconfont icon-copy"
/>
</button>
</div>
</div>
</div>
<div
data-testid="collapse-content-thought"
>
<div
style="font-family: var(--font-family); font-size: 14px;"
>
<div
data-block-id="test-thinking-block-1"
data-testid="mock-markdown"
>
Markdown:
I need to think about this carefully...
</div>
</div>
</div>
</div>
</div>
`;

View File

@ -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" <dog>`
const result = encodeHTML(input)
expect(result).toBe('Tom &amp; Jerry&apos;s &quot;cat&quot; &lt;dog&gt;')
})
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('&amp;')
expect(encodeHTML('<')).toBe('&lt;')
expect(encodeHTML('>')).toBe('&gt;')
expect(encodeHTML('"')).toBe('&quot;')
expect(encodeHTML("'")).toBe('&apos;')
})
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')

View File

@ -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" <dog>`
const result = encodeHTML(input)
expect(result).toBe('Tom &amp; Jerry&apos;s &quot;cat&quot; &lt;dog&gt;')
})
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('&amp;')
expect(encodeHTML('<')).toBe('&lt;')
expect(encodeHTML('>')).toBe('&gt;')
expect(encodeHTML('"')).toBe('&quot;')
expect(encodeHTML("'")).toBe('&apos;')
})
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

View File

@ -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 } = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&apos;'
}
return entities[match]
})
}
/**
* Markdown内容
* @param text

View File

@ -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 } = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&apos;'
}
return entities[match]
})
}
/**
* Markdown字符串中的代码块内容
*