mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-30 15:59:09 +08:00
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:
parent
59cf73f365
commit
611a472b13
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
368
src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx
Normal file
368
src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
`;
|
||||
@ -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>
|
||||
`;
|
||||
@ -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
|
||||
|
||||
@ -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) => (
|
||||
<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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
`;
|
||||
@ -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 & 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')
|
||||
|
||||
@ -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 & 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
|
||||
|
||||
@ -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 要清理的文本
|
||||
|
||||
@ -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字符串中的代码块内容。
|
||||
*
|
||||
|
||||
Loading…
Reference in New Issue
Block a user