/**
* optimizeMarkdownForFeishu 单元测试
*
* 覆盖:
* - H1~H3 降级 (workaround Feishu tag:'markdown' H1~H3 渲染异常)
* - 代码块保护(代码块内的 # 不能被降级)
* - 空行压缩
* - 边界场景: 无标题不动、全代码块、H4+ 不触发降级、混合内容
*/
import { describe, it, expect } from 'bun:test'
import { optimizeMarkdownForFeishu } from '../markdown-style.js'
describe('optimizeMarkdownForFeishu: 标题降级', () => {
it('H1 → H4', () => {
const out = optimizeMarkdownForFeishu('# Title')
expect(out).toBe('#### Title')
})
it('H2 → H5', () => {
const out = optimizeMarkdownForFeishu('## Title')
expect(out).toBe('##### Title')
})
it('H3 → H5', () => {
const out = optimizeMarkdownForFeishu('### Title')
expect(out).toBe('##### Title')
})
it('混合 H1+H2+H3 全部降级', () => {
const input = '# H1\n## H2\n### H3'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe('#### H1\n##### H2\n##### H3')
})
it('已存在的 H4 在没有 H1~H3 时原样保留(不触发降级管道)', () => {
// 触发条件是原文必须有 H1~H3。纯 H4 文档原样返回。
const input = '#### Already H4'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe('#### Already H4')
})
it('H5 在没有 H1~H3 时保留', () => {
const input = '##### Already H5'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe('##### Already H5')
})
it('同时存在 H1 和 H4: H1→H4,原 H4 被规则降级为 H5', () => {
// H4~H6 也走 #{2,6} → H5 的管道(因为原文有 H1)
const input = '# Top\n#### Sub'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe('#### Top\n##### Sub')
})
it('标题文本前必须有空格才算标题(#xxx 不是标题)', () => {
const input = '#notaheading'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe('#notaheading')
})
it('降级顺序: 不会把 # 先降成 ####,然后再被 #{2,6} 吃成 #####', () => {
// 这是 openclaw-lark 源码里的关键注释 —— 顺序不能颠倒
const out = optimizeMarkdownForFeishu('# Top')
expect(out).toBe('#### Top') // 必须恰好是 4 个 #,不是 5 个
})
it('无标题文本原样返回', () => {
const input = 'just some plain text\nwith a newline'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe(input)
})
})
describe('optimizeMarkdownForFeishu: 代码块保护', () => {
it('代码块内的 # 不会被降级', () => {
const input = '```\n# not a heading\n## also not\n```'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe(input)
})
it('有外部标题时,代码块内的 # 仍受保护', () => {
const input = '# Real heading\n\n```\n# inside code\n```'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe('#### Real heading\n\n```\n# inside code\n```')
})
it('语言标记的 fenced 代码块也受保护', () => {
const input = '## Section\n\n```python\n# python comment\n### not a heading\n```'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe('##### Section\n\n```python\n# python comment\n### not a heading\n```')
})
it('多个代码块按顺序保护与还原', () => {
const input = '# A\n```\n# b1\n```\n## C\n```\n### b2\n```'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe('#### A\n```\n# b1\n```\n##### C\n```\n### b2\n```')
})
it('全文只有代码块时原样返回', () => {
const input = '```\n# in code\n## also in code\n```'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe(input)
})
})
describe('optimizeMarkdownForFeishu: 空行压缩', () => {
it('3 个换行 → 2 个', () => {
const input = 'line1\n\n\nline2'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe('line1\n\nline2')
})
it('5 个换行 → 2 个', () => {
const input = 'line1\n\n\n\n\nline2'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe('line1\n\nline2')
})
it('2 个换行保留', () => {
const input = 'line1\n\nline2'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe('line1\n\nline2')
})
it('空行压缩不影响代码块内的连续换行', () => {
// 代码块内的换行应当完全保留
const input = '# Title\n\n```\nline1\n\n\nline2\n```'
const out = optimizeMarkdownForFeishu(input)
expect(out).toBe('#### Title\n\n```\nline1\n\n\nline2\n```')
})
})
describe('optimizeMarkdownForFeishu: 真实场景', () => {
it('复现 screenshot 里的项目结构报告场景', () => {
// 用户 screenshot 里的真实 markdown 片段
const input = `## OpenCutSkill 项目架构概览
### 1. 项目定位
Screen Studio 视频自动剪辑工具。
### 2. 模块结构
\`\`\`
opencutskill/
├── cli/
├── core/
└── tests/
\`\`\``
const out = optimizeMarkdownForFeishu(input)
// 所有 H2~H3 应该被降级为 H5
expect(out).toContain('##### OpenCutSkill 项目架构概览')
expect(out).toContain('##### 1. 项目定位')
expect(out).toContain('##### 2. 模块结构')
// 代码块内容原封不动
expect(out).toContain('opencutskill/')
expect(out).toContain('├── cli/')
// 代码块围栏也原样
expect(out).toContain('```\nopencutskill/')
// 原始的 ## 字面量不应残留
expect(out).not.toMatch(/^## OpenCutSkill/m)
expect(out).not.toMatch(/^### 1\./m)
})
it('错误输入 fallback 到原文不抛错', () => {
// 即使输入是奇怪的字符串也不应抛错
const weird = '\u0000\uFFFF```unclosed'
expect(() => optimizeMarkdownForFeishu(weird)).not.toThrow()
})
it('空字符串返回空字符串', () => {
expect(optimizeMarkdownForFeishu('')).toBe('')
})
})
describe('optimizeMarkdownForFeishu: cardVersion 参数', () => {
it('cardVersion=1 (默认) 不加段落间距
', () => {
const input = '# A\n## B'
const out = optimizeMarkdownForFeishu(input)
expect(out).not.toContain('
')
})
it('cardVersion=2 在连续降级标题之间加
', () => {
const input = '# A\n# B'
const out = optimizeMarkdownForFeishu(input, 2)
// 两个 H1 都降级为 H4,之间插入
expect(out).toContain('#### A\n
\n#### B')
})
})