fix: 优化markdown解析器
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user