01e057d047
before:  after:   考虑到在AGC场景下不是很有理由, 暂不实现链接, 图片, 内嵌html --------- Co-authored-by: 段舒康 <kdletters@qq.com> Reviewed-on: #281 Co-authored-by: 王德宇 <kvtodev@outlook.com> Co-committed-by: 王德宇 <kvtodev@outlook.com>
231 lines
7.4 KiB
TypeScript
231 lines
7.4 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { render } from '@testing-library/react';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
ChatMarkdownMessage,
|
|
MarkdownErrorBoundary,
|
|
} from '../src/components/ChatMarkdownMessage';
|
|
|
|
function FailingChild({ shouldThrow }: { shouldThrow: boolean }) {
|
|
if (shouldThrow) {
|
|
throw new Error('transient render failure');
|
|
}
|
|
return <span>markdown recovered</span>;
|
|
}
|
|
|
|
describe('ChatMarkdownMessage', () => {
|
|
it('渲染 assistant 的 GFM 内容与代码块', () => {
|
|
const { container } = render(
|
|
<ChatMarkdownMessage
|
|
role="assistant"
|
|
text={'## 标题\n\n- 一项\n- 二项\n\n```ts\nconst answer = 42;\n```'}
|
|
/>,
|
|
);
|
|
|
|
expect(container.querySelector('h2')?.textContent).toBe('标题');
|
|
expect(container.querySelectorAll('li')).toHaveLength(2);
|
|
expect(container.querySelector('pre code')?.textContent).toContain(
|
|
'const answer = 42;',
|
|
);
|
|
});
|
|
|
|
it('为嵌套无序列表保留逐层缩进', () => {
|
|
const { container } = render(
|
|
<ChatMarkdownMessage
|
|
role="assistant"
|
|
text={
|
|
'- 无序号 A\n- 无序号 B\n - 缩进一层的子项\n - 缩进两层的子项\n - 回到一层子项\n- 无序号 C'
|
|
}
|
|
/>,
|
|
);
|
|
|
|
const lists = container.querySelectorAll('ul');
|
|
expect(lists).toHaveLength(3);
|
|
expect(lists[0]?.className).toContain('pl-0');
|
|
expect(lists[1]?.className).toContain('pl-4');
|
|
expect(lists[2]?.className).toContain('pl-4');
|
|
});
|
|
|
|
it('为四到六级标题提供递进的字号与字重', () => {
|
|
const { container } = render(
|
|
<ChatMarkdownMessage
|
|
role="assistant"
|
|
text={'#### H4 四级标题\n\n##### H5 五级标题\n\n###### H6 六级标题'}
|
|
/>,
|
|
);
|
|
|
|
expect(container.querySelector('h4')?.className).toContain('text-sm');
|
|
expect(container.querySelector('h4')?.className).toContain('font-semibold');
|
|
expect(container.querySelector('h5')?.className).toContain('font-medium');
|
|
expect(container.querySelector('h6')?.className).toContain('text-xs');
|
|
expect(container.querySelector('h6')?.className).toContain('tracking-wide');
|
|
});
|
|
|
|
it('有序列表不添加无序列表短横线,列表项段落与文本同行', () => {
|
|
const { container } = render(
|
|
<ChatMarkdownMessage
|
|
role="assistant"
|
|
text={'1. 第一件事\n\n2. 第二件事\n\n 1. 子步骤 2.1'}
|
|
/>,
|
|
);
|
|
|
|
const items = container.querySelectorAll('ol > li');
|
|
expect(items[0]?.textContent?.trim()).toBe('第一件事');
|
|
expect(items[1]?.textContent?.trim()).toContain('第二件事');
|
|
expect(items[0]?.querySelector('p')?.className ?? '').toContain('inline');
|
|
expect(items[0]?.className).toContain('whitespace-normal');
|
|
expect(items[0]?.textContent).not.toContain('-');
|
|
});
|
|
|
|
it('保留 Markdown 有序列表的起始编号', () => {
|
|
const { container } = render(
|
|
<ChatMarkdownMessage role="assistant" text={'3. third\n4. fourth'} />,
|
|
);
|
|
|
|
expect(container.querySelector('ol')?.getAttribute('start')).toBe('3');
|
|
expect(container.querySelectorAll('ol > li')).toHaveLength(2);
|
|
});
|
|
|
|
it('为列表项后续段落保留段落间距', () => {
|
|
const { container } = render(
|
|
<ChatMarkdownMessage role="assistant" text={'- first\n\n second'} />,
|
|
);
|
|
|
|
const paragraphs = container.querySelectorAll('ul > li p');
|
|
expect(paragraphs).toHaveLength(2);
|
|
expect(paragraphs[0]?.className).toContain('inline');
|
|
expect(paragraphs[1]?.className).toContain('mt-2');
|
|
expect(paragraphs[1]?.className).not.toContain('inline');
|
|
});
|
|
|
|
it('不会把 react-markdown 的 node 元数据泄漏到代码节点', () => {
|
|
const { container } = render(
|
|
<ChatMarkdownMessage
|
|
role="assistant"
|
|
text={'```ts\nconst answer = 42;\n```'}
|
|
/>,
|
|
);
|
|
|
|
expect(
|
|
container.querySelector('pre code')?.getAttribute('node'),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('无语言标记的多行围栏代码仍使用代码块样式', () => {
|
|
const { container } = render(
|
|
<ChatMarkdownMessage
|
|
role="assistant"
|
|
text={'```\nconst answer = 42;\nreturn answer;\n```'}
|
|
/>,
|
|
);
|
|
|
|
const code = container.querySelector('pre code');
|
|
expect(code?.className).toContain('whitespace-pre');
|
|
expect(code?.className).not.toContain('rounded');
|
|
});
|
|
|
|
it('保留行内代码中的 HTML 字面量', () => {
|
|
const { container } = render(
|
|
<ChatMarkdownMessage role="assistant" text={'`<span>`'} />,
|
|
);
|
|
|
|
expect(container.querySelector('code')?.textContent).toBe('<span>');
|
|
});
|
|
|
|
it('保留围栏代码中的 HTML 字面量而不双重转义', () => {
|
|
const { container } = render(
|
|
<ChatMarkdownMessage
|
|
role="assistant"
|
|
text={'```html\n<div class="card"></div>\n```'}
|
|
/>,
|
|
);
|
|
|
|
expect(container.querySelector('pre code')?.textContent).toContain(
|
|
'<div class="card"></div>',
|
|
);
|
|
expect(container.querySelector('pre code')?.textContent).not.toContain(
|
|
'<div',
|
|
);
|
|
});
|
|
|
|
it('累计文本变化后可从 Markdown 错误回退中恢复', () => {
|
|
const consoleError = vi
|
|
.spyOn(console, 'error')
|
|
.mockImplementation(() => {});
|
|
try {
|
|
const { container, rerender } = render(
|
|
<MarkdownErrorBoundary fallbackText="partial">
|
|
<FailingChild shouldThrow />
|
|
</MarkdownErrorBoundary>,
|
|
);
|
|
|
|
expect(container.textContent).toBe('partial');
|
|
|
|
rerender(
|
|
<MarkdownErrorBoundary fallbackText="complete">
|
|
<FailingChild shouldThrow={false} />
|
|
</MarkdownErrorBoundary>,
|
|
);
|
|
|
|
expect(container.textContent).toBe('markdown recovered');
|
|
} finally {
|
|
consoleError.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('保留流式 assistant 文本并标记 streaming 状态', () => {
|
|
const { container } = render(
|
|
<ChatMarkdownMessage
|
|
role="assistant"
|
|
streaming
|
|
text={'正在生成 **结果'}
|
|
/>,
|
|
);
|
|
|
|
expect(container.textContent).toContain('正在生成');
|
|
expect(container.textContent).toContain('结果');
|
|
});
|
|
|
|
it('用户消息保持纯文本,不解析 Markdown', () => {
|
|
const { container } = render(
|
|
<ChatMarkdownMessage role="user" text={'**不要解析**\n`/read game`'} />,
|
|
);
|
|
|
|
expect(container.querySelector('strong')).toBeNull();
|
|
expect(container.querySelector('code')).toBeNull();
|
|
expect(container.textContent).toContain('**不要解析**');
|
|
expect(container.textContent).toContain('`/read game`');
|
|
});
|
|
|
|
it('不输出原始 HTML、可点击链接或图片节点', () => {
|
|
const { container } = render(
|
|
<ChatMarkdownMessage
|
|
role="assistant"
|
|
text={
|
|
'<script>alert(1)</script>\n\n[外链](https://example.com) '
|
|
}
|
|
/>,
|
|
);
|
|
|
|
expect(container.querySelector('script')).toBeNull();
|
|
expect(container.querySelector('a')).toBeNull();
|
|
expect(container.querySelector('img')).toBeNull();
|
|
expect(container.textContent).toContain('外链');
|
|
expect(container.textContent).toContain('图片:示意图');
|
|
});
|
|
|
|
it('支持表格并允许窄视口横向滚动', () => {
|
|
const { container } = render(
|
|
<ChatMarkdownMessage
|
|
role="assistant"
|
|
text={'| 名称 | 状态 |\n| --- | --- |\n| demo | ready |'}
|
|
/>,
|
|
);
|
|
|
|
expect(container.querySelectorAll('table th')).toHaveLength(2);
|
|
expect(container.querySelector('div.overflow-x-auto')).toBeTruthy();
|
|
});
|
|
});
|