Files
lhk229 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
同步 PR375 的前端界面测试契约
更新消息标题、JSON 文档预览和工具条断言

对齐重构后的对话输入盒结构
2026-09-16 06:03:16 +08:00

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(
'&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();
});
});