994fcd6b20
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
更新消息标题、JSON 文档预览和工具条断言 对齐重构后的对话输入盒结构
277 lines
9.1 KiB
TypeScript
277 lines
9.1 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('代码块带语言标识及高亮节点,文件预览保留空行和 HTML 源码', () => {
|
|
const source =
|
|
'const value = "<script>alert(1)</script>";\n\n\n return value;\n';
|
|
const { container } = render(
|
|
<ChatMarkdownMessage
|
|
role="assistant"
|
|
preserveBlankLines
|
|
text={`\`\`\`\`typescript\n${source}\`\`\`\``}
|
|
/>,
|
|
);
|
|
expect(container.querySelector('pre code')?.textContent).toBe(source);
|
|
expect(
|
|
container.querySelector('code.language-typescript .hljs-keyword'),
|
|
).not.toBeNull();
|
|
expect(container.querySelector('script')).toBeNull();
|
|
});
|
|
|
|
it('未知语言保留代码而不进入错误回退', () => {
|
|
const { container } = render(
|
|
<ChatMarkdownMessage
|
|
role="assistant"
|
|
text={'```unknown-language\n原始内容\n```'}
|
|
/>,
|
|
);
|
|
expect(container.querySelector('pre code')?.textContent).toBe('原始内容\n');
|
|
});
|
|
|
|
it('大文件跳过高亮但保留完整代码', () => {
|
|
const source = 'const value = 42;\n'.repeat(6000);
|
|
const { container } = render(
|
|
<ChatMarkdownMessage
|
|
role="assistant"
|
|
preserveBlankLines
|
|
text={`\`\`\`js\n${source}\`\`\``}
|
|
/>,
|
|
);
|
|
expect(container.querySelector('pre code')?.textContent).toBe(source);
|
|
expect(container.querySelector('.hljs-keyword')).toBeNull();
|
|
});
|
|
|
|
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);
|
|
// 真正的列表标记:靠 `list-disc` 画项目符号、`pl-4` 让换行后的第二行保持悬挂缩进。
|
|
for (const list of Array.from(lists)) {
|
|
expect(list.className).toContain('list-disc');
|
|
expect(list.className).toContain('pl-4');
|
|
expect(list.className).not.toContain('list-none');
|
|
}
|
|
// 列表项文本里不能再出现手写的 `- ` 前缀(那会让换行行顶回最左边,也不符合列表语义)。
|
|
for (const item of Array.from(container.querySelectorAll('ul > li'))) {
|
|
expect(item.textContent ?? '').not.toMatch(/^\s*-\s/);
|
|
}
|
|
expect(container.querySelector('ul > li')?.textContent).toBe('无序号 A');
|
|
});
|
|
|
|
it('为四到六级标题提供递进的字号与字重', () => {
|
|
const { container } = render(
|
|
<ChatMarkdownMessage
|
|
role="assistant"
|
|
text={'#### H4 四级标题\n\n##### H5 五级标题\n\n###### H6 六级标题'}
|
|
/>,
|
|
);
|
|
|
|
expect(container.querySelector('h4')?.className).toContain('font-semibold');
|
|
expect(container.querySelector('h5')?.className).toContain('font-medium');
|
|
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();
|
|
});
|
|
});
|