Files
k88936 01e057d047
Project CI / Frontend tests (push) Successful in 3m37s
Project CI / Backend tests (push) Failing after 4m13s
Project CI / Native shell tests (push) Failing after 4m11s
Project CI / Repository checks (push) Successful in 3m24s
Feat/AGC对话渲染markdown (#281)
before:
 ![image.png](/attachments/ca6f46f7-dcbd-4132-bad2-cf5c83c49096)

after:
 ![shotmd-1788500615.jpg](/attachments/719539a1-9dfb-412a-a9b9-298547b1791c)
![shotmd-1788500598.jpg](/attachments/4656815f-caca-4325-bca3-b29fe5742b6e)

考虑到在AGC场景下不是很有理由, 暂不实现链接, 图片, 内嵌html

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: #281
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-09-08 22:03:37 +08:00

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(
'&lt;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) ![示意图](x.png)'
}
/>,
);
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();
});
});