348 lines
10 KiB
TypeScript
348 lines
10 KiB
TypeScript
import React from 'react';
|
|
import { render, screen, waitFor } from '@testing-library/react';
|
|
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { EnhancedMarkdownRenderer } from '../../components/EnhancedMarkdownRenderer';
|
|
import { markdownService } from '../../services/markdownService';
|
|
import {
|
|
MarkdownParseResult,
|
|
MarkdownNode,
|
|
MarkdownNodeType,
|
|
ValidationResult,
|
|
} from '../../types/markdown';
|
|
|
|
// Mock the markdown service
|
|
vi.mock('../../services/markdownService', () => ({
|
|
markdownService: {
|
|
parseMarkdown: vi.fn(),
|
|
validateMarkdown: vi.fn(),
|
|
extractTextContent: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const mockMarkdownService = markdownService as any;
|
|
|
|
describe('EnhancedMarkdownRenderer', () => {
|
|
const mockParseResult: MarkdownParseResult = {
|
|
root: {
|
|
node_type: MarkdownNodeType.Document,
|
|
content: '# Test\n\nHello **world**',
|
|
range: {
|
|
start: { line: 0, column: 0, offset: 0 },
|
|
end: { line: 2, column: 12, offset: 19 },
|
|
},
|
|
children: [
|
|
{
|
|
node_type: MarkdownNodeType.Heading,
|
|
content: '# Test',
|
|
range: {
|
|
start: { line: 0, column: 0, offset: 0 },
|
|
end: { line: 0, column: 6, offset: 6 },
|
|
},
|
|
children: [
|
|
{
|
|
node_type: MarkdownNodeType.Text,
|
|
content: 'Test',
|
|
range: {
|
|
start: { line: 0, column: 2, offset: 2 },
|
|
end: { line: 0, column: 6, offset: 6 },
|
|
},
|
|
children: [],
|
|
attributes: {},
|
|
},
|
|
],
|
|
attributes: { level: '1' },
|
|
},
|
|
{
|
|
node_type: MarkdownNodeType.Paragraph,
|
|
content: 'Hello **world**',
|
|
range: {
|
|
start: { line: 2, column: 0, offset: 8 },
|
|
end: { line: 2, column: 15, offset: 23 },
|
|
},
|
|
children: [
|
|
{
|
|
node_type: MarkdownNodeType.Text,
|
|
content: 'Hello ',
|
|
range: {
|
|
start: { line: 2, column: 0, offset: 8 },
|
|
end: { line: 2, column: 6, offset: 14 },
|
|
},
|
|
children: [],
|
|
attributes: {},
|
|
},
|
|
{
|
|
node_type: MarkdownNodeType.Strong,
|
|
content: '**world**',
|
|
range: {
|
|
start: { line: 2, column: 6, offset: 14 },
|
|
end: { line: 2, column: 15, offset: 23 },
|
|
},
|
|
children: [
|
|
{
|
|
node_type: MarkdownNodeType.Text,
|
|
content: 'world',
|
|
range: {
|
|
start: { line: 2, column: 8, offset: 16 },
|
|
end: { line: 2, column: 13, offset: 21 },
|
|
},
|
|
children: [],
|
|
attributes: {},
|
|
},
|
|
],
|
|
attributes: {},
|
|
},
|
|
],
|
|
attributes: {},
|
|
},
|
|
],
|
|
attributes: {},
|
|
},
|
|
statistics: {
|
|
total_nodes: 5,
|
|
error_nodes: 0,
|
|
parse_time_ms: 3,
|
|
document_length: 19,
|
|
max_depth: 3,
|
|
},
|
|
source_text: '# Test\n\nHello **world**',
|
|
};
|
|
|
|
const mockValidation: ValidationResult = {
|
|
is_valid: true,
|
|
issues: [],
|
|
statistics: {
|
|
total_nodes: 5,
|
|
error_nodes: 0,
|
|
parse_time_ms: 3,
|
|
document_length: 19,
|
|
max_depth: 3,
|
|
},
|
|
};
|
|
|
|
beforeEach(() => {
|
|
mockMarkdownService.parseMarkdown.mockResolvedValue(mockParseResult);
|
|
mockMarkdownService.validateMarkdown.mockResolvedValue(mockValidation);
|
|
mockMarkdownService.extractTextContent.mockImplementation((node: MarkdownNode) => {
|
|
if (node.node_type === MarkdownNodeType.Text) {
|
|
return node.content;
|
|
}
|
|
return node.children.map(child => mockMarkdownService.extractTextContent(child)).join('');
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('renders markdown content using MarkdownService', async () => {
|
|
render(
|
|
<EnhancedMarkdownRenderer
|
|
content="# Test\n\nHello **world**"
|
|
enableMarkdown={true}
|
|
/>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(mockMarkdownService.parseMarkdown).toHaveBeenCalledWith(expect.stringContaining('# Test'));
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Test')).toBeInTheDocument();
|
|
expect(screen.getByText('Hello')).toBeInTheDocument();
|
|
expect(screen.getByText('world')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('renders plain text when markdown is disabled', () => {
|
|
render(
|
|
<EnhancedMarkdownRenderer
|
|
content="# Test\n\nHello **world**"
|
|
enableMarkdown={false}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText(/# Test.*Hello \*\*world\*\*/s)).toBeInTheDocument();
|
|
expect(mockMarkdownService.parseMarkdown).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('shows loading state during parsing', () => {
|
|
// Make parseMarkdown return a pending promise
|
|
mockMarkdownService.parseMarkdown.mockReturnValue(new Promise(() => {}));
|
|
|
|
render(
|
|
<EnhancedMarkdownRenderer
|
|
content="# Test"
|
|
enableMarkdown={true}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('解析中...')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows error state when parsing fails', async () => {
|
|
mockMarkdownService.parseMarkdown.mockRejectedValue(new Error('Parse error'));
|
|
|
|
render(
|
|
<EnhancedMarkdownRenderer
|
|
content="# Test"
|
|
enableMarkdown={true}
|
|
/>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('解析错误')).toBeInTheDocument();
|
|
expect(screen.getByText('Parse error')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('shows statistics when enabled', async () => {
|
|
render(
|
|
<EnhancedMarkdownRenderer
|
|
content="# Test"
|
|
enableMarkdown={true}
|
|
showStatistics={true}
|
|
/>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('解析统计')).toBeInTheDocument();
|
|
expect(screen.getByText('节点数: 5')).toBeInTheDocument();
|
|
expect(screen.getByText('解析时间: 3ms')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('shows validation warnings when document is invalid', async () => {
|
|
const invalidValidation: ValidationResult = {
|
|
is_valid: false,
|
|
issues: [
|
|
{
|
|
issue_type: 'SkippedHeadingLevel' as any,
|
|
message: 'Heading level jumps from 1 to 3',
|
|
range: {
|
|
start: { line: 2, column: 0, offset: 10 },
|
|
end: { line: 2, column: 10, offset: 20 },
|
|
},
|
|
severity: 'Warning' as any,
|
|
},
|
|
],
|
|
statistics: mockValidation.statistics,
|
|
};
|
|
|
|
mockMarkdownService.validateMarkdown.mockResolvedValue(invalidValidation);
|
|
|
|
render(
|
|
<EnhancedMarkdownRenderer
|
|
content="# Test\n\n### Skipped"
|
|
enableMarkdown={true}
|
|
/>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('文档验证警告')).toBeInTheDocument();
|
|
expect(screen.getByText('• Heading level jumps from 1 to 3')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('renders grounding metadata when provided', async () => {
|
|
const groundingMetadata = {
|
|
sources: [
|
|
{ title: 'Source 1', url: 'https://example.com/1' },
|
|
{ title: 'Source 2', url: 'https://example.com/2' },
|
|
],
|
|
};
|
|
|
|
render(
|
|
<EnhancedMarkdownRenderer
|
|
content="# Test"
|
|
enableMarkdown={true}
|
|
enableReferences={true}
|
|
groundingMetadata={groundingMetadata}
|
|
/>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('基于 2 个来源的信息')).toBeInTheDocument();
|
|
expect(screen.getByText('1')).toBeInTheDocument();
|
|
expect(screen.getByText('2')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('handles real-time parsing when enabled', async () => {
|
|
const { rerender } = render(
|
|
<EnhancedMarkdownRenderer
|
|
content="# Test"
|
|
enableMarkdown={true}
|
|
enableRealTimeParsing={true}
|
|
/>
|
|
);
|
|
|
|
// Change content
|
|
rerender(
|
|
<EnhancedMarkdownRenderer
|
|
content="# Updated Test"
|
|
enableMarkdown={true}
|
|
enableRealTimeParsing={true}
|
|
/>
|
|
);
|
|
|
|
// Wait for debounced parsing
|
|
await waitFor(() => {
|
|
expect(mockMarkdownService.parseMarkdown).toHaveBeenCalledWith('# Updated Test');
|
|
}, { timeout: 1000 });
|
|
});
|
|
|
|
it('applies custom className', () => {
|
|
const { container } = render(
|
|
<EnhancedMarkdownRenderer
|
|
content="# Test"
|
|
enableMarkdown={true}
|
|
className="custom-class"
|
|
/>
|
|
);
|
|
|
|
expect(container.firstChild).toHaveClass('custom-class');
|
|
});
|
|
|
|
it('renders different heading levels correctly', async () => {
|
|
const headingResult: MarkdownParseResult = {
|
|
...mockParseResult,
|
|
root: {
|
|
...mockParseResult.root,
|
|
children: [
|
|
{
|
|
node_type: MarkdownNodeType.Heading,
|
|
content: '# H1',
|
|
range: { start: { line: 0, column: 0, offset: 0 }, end: { line: 0, column: 4, offset: 4 } },
|
|
children: [{ node_type: MarkdownNodeType.Text, content: 'H1', range: { start: { line: 0, column: 2, offset: 2 }, end: { line: 0, column: 4, offset: 4 } }, children: [], attributes: {} }],
|
|
attributes: { level: '1' },
|
|
},
|
|
{
|
|
node_type: MarkdownNodeType.Heading,
|
|
content: '## H2',
|
|
range: { start: { line: 1, column: 0, offset: 5 }, end: { line: 1, column: 5, offset: 10 } },
|
|
children: [{ node_type: MarkdownNodeType.Text, content: 'H2', range: { start: { line: 1, column: 3, offset: 8 }, end: { line: 1, column: 5, offset: 10 } }, children: [], attributes: {} }],
|
|
attributes: { level: '2' },
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
mockMarkdownService.parseMarkdown.mockResolvedValue(headingResult);
|
|
|
|
render(
|
|
<EnhancedMarkdownRenderer
|
|
content="# H1\n## H2"
|
|
enableMarkdown={true}
|
|
/>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
const h1 = screen.getByRole('heading', { level: 1 });
|
|
const h2 = screen.getByRole('heading', { level: 2 });
|
|
|
|
expect(h1.textContent).toBe('H1');
|
|
expect(h2.textContent).toBe('H2');
|
|
});
|
|
});
|
|
});
|