import { describe, expect, it } from 'vitest' import { featureAnswer } from '../../../src/codeReader/features/featureResult' import { codeLink } from '../../../src/codeReader/qa/linkRule' import { renderMarkdown, renderMarkdownDiff, renderMarkdownOnce } from './markdown' import { repoImageInner } from './repoImage' import { parseRepoLink } from './repoLink' describe('renderMarkdown', () => { it('一行文字是一个段落;段内换行是软换行,空行分段', () => { expect(renderMarkdown('读配置。')).toBe('

读配置。

\n') expect(renderMarkdown('第一行\n接着写\n\n第二段')).toBe('

第一行\n接着写

\n

第二段

\n') }) it('行内代码(含双反引号)、强调、列表、代码块、标题、引用、表格', () => { expect(renderMarkdown('先过 :func:`normalize_phone`,打印 ``user`` 时 **脱敏**')).toBe( '

先过 :func:normalize_phone,打印 user 时 脱敏

\n', ) expect(renderMarkdown('- 甲\n- 乙')).toBe('\n') expect(renderMarkdown('```ts\nload(\'a\')\n```')).toBe('
load(\'a\')\n
\n') expect(renderMarkdown('例子:\n\n f(1)')).toBe('

例子:

\n
f(1)\n
\n') expect(renderMarkdown('# 标题')).toBe('

标题

\n') expect(renderMarkdown('> 引用')).toBe('
\n

引用

\n
\n') expect(renderMarkdown('| a | b |\n| - | - |\n| 1 | 2 |')).toContain('') }) it('源码里的 HTML 当文字显示', () => { expect(renderMarkdown('用 mask')).toBe( '

用 <b>mask</b> <script>alert(1)</script>

\n', ) expect(renderMarkdown('
块
')).toBe('

<div onclick="x()">块</div>

\n') }) it('带协议的网址、邮箱自动成链接;不带协议的文件名不算', () => { expect(renderMarkdown('见 https://example.com/phone')).toBe('

见 https://example.com/phone

\n') expect(renderMarkdown('[文档](http://example.com)')).toBe('

文档

\n') expect(renderMarkdown('找 dev@example.com')).toContain('') expect(renderMarkdown('改 sample.py 和 README.md')).toBe('

改 sample.py 和 README.md

\n') }) it('危险链接和跑出仓库的路径照原文显示;图片不渲染', () => { expect(renderMarkdown('[点](javascript:alert(1))')).not.toContain(' { const path = 'frontend/src/components/overview/screenshots/OverviewSection.png' const html = renderMarkdown(`![概览卡片](${path})`) expect(html).toContain(`data-path="${path}"`) expect(html).toContain(`>${path}<`) expect(html).not.toContain('概览卡片
`) }) it('http、..、./ 的图片不渲染', () => { expect(renderMarkdown('![图](https://example.com/a.png)')).not.toContain(' { const shown = repoImageInner('frontend/missing.png', '图', 'missing') expect(shown).toBe('找不到图片 frontend/missing.png') expect(shown).not.toContain(' { expect(renderMarkdown('[智能体定义](vendor/agents)')).toBe('

智能体定义

\n') expect(renderMarkdown('[智能体表](backend/src/luffy_agent/agent/models.py#Agent)')).toBe('

智能体表

\n') expect(renderMarkdown('[owner 字段](backend/src/luffy_agent/agent/models.py#Agent.owner_id)')).toBe('

owner 字段

\n') }) it('带几行的仓库链接也能点,注释和问 AI 的回答里一样', () => { expect(renderMarkdown('[service.py:357-364](backend/app/service.py#L357-L364)', 'answer')).toBe('

service.py:357-364

\n') expect(renderMarkdown('[第 3 行](backend/app/service.py#L3)')).toBe('

第 3 行

\n') }) it('链接的标题走悬停提示(data-tooltip),不写原生 title', () => { expect(renderMarkdown('[文档](https://example.com "接口说明")')).toBe('

文档

\n') expect(renderMarkdown('[文档](https://example.com "")')).toBe('

文档

\n') }) }) describe('不带路径的引用', () => { it('注释里的 [Name]、[A.b] 是链接,地址 symbol:名字', () => { expect(renderMarkdown('见 [LocalRuntimeDisabled]。')).toBe('

见 LocalRuntimeDisabled。

\n') expect(renderMarkdown('[Agent.owner_id]')).toBe('

Agent.owner_id

\n') expect(renderMarkdown('转写谁说的话: [USER] / [ASSISTANT]')).toBe('

转写谁说的话: USER / ASSISTANT

\n') expect(renderMarkdown('见[Name]')).toContain('') }) it('反引号里的、下标、中文、后面跟 (…) 的完整链接都不算', () => { expect(renderMarkdown('`[x]`')).toBe('

[x]

\n') expect(renderMarkdown('items[i] 和 f()[k]、a[0][1]')).toBe('

items[i] 和 f()[k]、a[0][1]

\n') expect(renderMarkdown('以 [会话进展] 开头')).toBe('

以 [会话进展] 开头

\n') expect(renderMarkdown('[名字](backend/a.py#Agent)')).toBe('

名字

\n') expect(renderMarkdown('[Agent](backend/a.py#Agent)')).toBe('

Agent

\n') expect(renderMarkdown('[Agent](javascript:x)')).not.toContain(' { expect(renderMarkdown('见 [Agent]', 'answer')).toBe('

见 [Agent]

\n') expect(renderMarkdownDiff([{ text: '见 ' }, { text: '[Agent]', change: 'add' }])).toContain('href="symbol:Agent"') }) it('定义卡片浮窗里别的文件的注释不认(引用按注释所在的文件找);仓库路径链接照样是链接', () => { expect(renderMarkdown('见 [Agent]', 'peek')).toBe('

见 [Agent]

\n') expect(renderMarkdown('见 [Agent](app/models.py#Agent)', 'peek')).toContain('') }) }) describe('renderMarkdownDiff', () => { it('照常排版,新加的字包进 、删掉的包进 ', () => { expect(renderMarkdownDiff([{ text: '放锁' }, { text: '前', change: 'del' }, { text: '都', change: 'add' }, { text: '必须比对 `token`' }])) .toBe('

放锁前都必须比对 token

\n') }) it('跨行的一截逐行包,新加的列表项留住列表记号', () => { expect(renderMarkdownDiff([{ text: '- a\n' }, { text: '- b\n- c', change: 'add' }])) .toBe('
    \n
  • a
  • \n
  • b
  • \n
  • c
  • \n
\n') }) it('行中间的一截不把前面的字当块记号', () => { expect(renderMarkdownDiff([{ text: '共 ' }, { text: '# 3', change: 'add' }])).toBe('

共 # 3

\n') }) }) describe('mermaid 代码块', () => { const source = 'flowchart TD\n A["读 <配置> & 写"] --> B{"成功?"}\n' const escaped = 'flowchart TD\n A["读 <配置> & 写"] --> B{"成功?"}\n' const codeBlock = `
${escaped}
\n` const placeholder = `
${codeBlock}
\n` it('出占位:转义后的源码记在 data-source,里面先放原样的代码块', () => { expect(renderMarkdown(`说明\n\n\`\`\`mermaid\n${source}\`\`\``)).toBe(`

说明

\n${placeholder}`) expect(renderMarkdown(`\`\`\`mermaid\n${source}\`\`\``, 'peek')).toBe(placeholder) expect(renderMarkdown(`\`\`\`mermaid\n${source}\`\`\``, 'answer')).toBe(placeholder) }) it('别的代码块照旧', () => { expect(renderMarkdown('```ts\nload(\'a\')\n```')).toBe('
load(\'a\')\n
\n') expect(renderMarkdown('```\nflowchart TD\n```')).toBe('
flowchart TD\n
\n') expect(renderMarkdown('```mermaidx\nA\n```')).not.toContain('cr-mermaid') }) it('带增删的说明、还在逐字长的回答里仍是普通代码块', () => { expect(renderMarkdownDiff([{ text: `\`\`\`mermaid\n${source}\`\`\`` }])).toBe(codeBlock) expect(renderMarkdownOnce(`\`\`\`mermaid\n${source}\`\`\``)).toBe(codeBlock) }) }) describe('问 AI 的回答里写成行内代码的代码位置', () => { it('整段是代码位置的行内代码包成仓库链接,逐字长着的回答也一样', () => { expect(renderMarkdown('见 `backend/app/routes.py:96-135`。', 'answer')).toBe('

见 backend/app/routes.py:96-135。

\n') expect(renderMarkdown('`web/src/a.ts`', 'answer')).toBe('

web/src/a.ts

\n') expect(renderMarkdownOnce('见 `backend/app/routes.py:96`')).toBe('

见 backend/app/routes.py:96

\n') }) it('不像代码位置的、已经在链接里的不包;注释里的不包', () => { expect(renderMarkdown('`12:30` 和 `a:b`、`token`', 'answer')).toBe('

12:30 和 a:b、token

\n') expect(renderMarkdown('[`backend/app/routes.py:96`](backend/app/routes.py#L96)', 'answer')).toBe('

backend/app/routes.py:96

\n') expect(renderMarkdown('见 `backend/app/routes.py:96-135`')).toBe('

见 backend/app/routes.py:96-135

\n') }) }) describe('「了解一块代码」的回答', () => { /** 回答里各个链接的地址,按页面认仓库链接的办法拆开 */ const links = (html: string): unknown[] => [...html.matchAll(//g)].map(([, href]) => parseRepoLink(href.replaceAll('&', '&'))) it('调用树画成嵌套列表,节点是仓库链接:路径带成对的括号(Next.js 的路由分组)也认成链接,via 是行内代码', () => { const answer = featureAnswer({ paths: [{ name: '首页发出首句', nodes: [ { id: '1', folder: 'web', path: 'src/app/(main)/chat/code/page.tsx', symbol: 'ChatCodePage', role: '会话页' }, { id: '2', parent: '1', folder: 'server', path: 'app/routes.py', symbol: 'create_app', via: 'POST /apps', role: '建应用' }, { id: '3', parent: '2', folder: 'server', path: 'app/models.py', role: '应用表' }, ] }] }, ['web', 'server']) const html = renderMarkdown(answer, 'answer') expect(html).toBe([ '

1. 首页发出首句

', '
', '', ].join('\n')) expect(links(html)).toEqual([ { path: 'web/src/app/(main)/chat/code/page.tsx', symbol: 'ChatCodePage' }, { path: 'server/app/routes.py', symbol: 'create_app' }, { path: 'server/app/models.py' }, ]) }) it('路径里有空白、百分号、不成对的括号:编码后照样认成链接,拆出来是原来的路径', () => { const html = renderMarkdown([codeLink('a', 'docs/a b.md'), codeLink('b', 'docs/100%.md'), codeLink('c', 'docs/a(b.md', 'Doc')].join(' '), 'answer') expect(links(html)).toEqual([{ path: 'docs/a b.md' }, { path: 'docs/100%.md' }, { path: 'docs/a(b.md', symbol: 'Doc' }]) }) })