/* test_markdown.c - 流式 Markdown 渲染器测试 * * 最关键的不变量: 把同一段文本任意切分后逐片喂入, 输出必须与一次性喂入 * 完全一致. SSE 的分片位置是随机的, 这条不成立就会漏出"半个星号". */ #include "markdown.h" #include "util.h" #include #include #include static int failures = 0; static int checks = 0; #define CHECK(cond, ...) \ do { \ checks++; \ if (!(cond)) { \ failures++; \ printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ printf(__VA_ARGS__); \ printf("\n"); \ } \ } while (0) /* 去掉 ANSI 转义, 得到"肉眼可见"的文本 */ static char *strip_ansi(const char *s) { StrBuf out; sb_init(&out); for (const char *p = s; *p; ) { if (*p == 0x1b && p[1] == '[') { p += 2; while (*p && !(*p >= '@' && *p <= '~')) p++; if (*p) p++; continue; } sb_putc(&out, *p++); } return sb_detach(&out); } static void show_escaped(const char *s) { for (const unsigned char *p = (const unsigned char *)s; *p; p++) { if (*p == 0x1b) printf(""); else if (*p == '\n') printf("\\n"); else putchar(*p); } } /* 渲染到内存流; chunk == 0 表示一次性喂入 */ static char *do_render(const char *input, int enabled, size_t chunk) { char *buf = NULL; size_t size = 0; FILE *f = open_memstream(&buf, &size); if (!f) return NULL; MdRenderer *r = md_new(enabled); md_set_out(r, f); if (chunk == 0) { md_feed(r, input); } else { size_t len = strlen(input); for (size_t i = 0; i < len; i += chunk) { size_t n = len - i < chunk ? len - i : chunk; char tmp[64]; if (n >= sizeof tmp) n = sizeof tmp - 1; memcpy(tmp, input + i, n); tmp[n] = '\0'; md_feed(r, tmp); } } md_finish(r); md_free(r); fclose(f); return buf; } static char *render(const char *in, int enabled) { return do_render(in, enabled, 0); } /* ------------------------------------------------------------------ */ static void test_split_invariance(void) { puts("分片不变性 (一次性 / 3 字节 / 逐字节 必须一致)"); static const char *samples[] = { "**粗体**\n", "普通 *斜体* 文本\n", "这是 `code` 片段\n", "# 一级标题\n## 二级标题\n", "```c\nint main(void) { return 0; }\n```\n", "- 项目一\n- 项目二\n", "> 引用内容\n", "2 * 3 * 4 = 24\n", "**a** 和 *b* 和 `c`\n", "---\n", "----\n", "-x\n", "--x\n", "混合 **粗** 与 `代` 与 *斜* 一行\n", "# 标题里有 **粗体**\n", "```\n**这不是粗体**\n```\n", "```\n没有闭合的代码块\n", "未闭合 **粗体\n下一行\n", "*a\nb*\n", "**\n", "***\n", "\n\n**\n\n", "行内 `代码` 跨分片被切断\n", "| a | b |\n|---|---|\n| 1 | 22 |\n", "| 名称 | 值 |\n|---|---|\n| 苹果 | 3 |\n| 香蕉 | 12 |\n", "| 左 | 中 | 右 |\n|:---|:---:| ---:|\n| a | b | c |\n", "见 [文档](https://example.com) 说明\n", "这是 _斜体_ 与 foo_bar_baz\n", "```\n# 不是标题\n[x](y)\n```\n", "ls -la | grep foo\n", "a | b\nc | d\n", /* 以下三个是模糊测试找到并最小化出来的历史缺陷, * 每个都曾让"一次性喂"与"分片喂"结果不一致, 留作回归见证 */ "1|`", /* 表格探测挂起整行 -> 行内代码被漏渲染 */ "- |", /* try_block 提前 return 跳过了列表规则 */ "s|\n-", /* 表格判断依赖分片边界 */ "| a |\n|---|\n", /* 表头+分隔行后紧跟 EOF */ "| a |\n|---|", /* 末尾无换行的表格 */ "| a | b |\n|---|---|\n| 1 | 2 |\n- 列表\n", /* 表格后紧跟列表 */ "| a |\n| b |\n| c |\n", /* 有续行但缺分隔行 */ }; for (size_t k = 0; k < sizeof samples / sizeof samples[0]; k++) { char *whole = render(samples[k], 1); char *by1 = do_render(samples[k], 1, 1); char *by3 = do_render(samples[k], 1, 3); int ok = whole && by1 && by3 && strcmp(whole, by1) == 0 && strcmp(whole, by3) == 0; checks++; if (!ok) { failures++; printf(" ✗ 分片不一致: [%s]\n", samples[k]); printf(" 一次性: "); show_escaped(whole ? whole : "(null)"); printf("\n"); printf(" 逐字节: "); show_escaped(by1 ? by1 : "(null)"); printf("\n"); printf(" 3 字节: "); show_escaped(by3 ? by3 : "(null)"); printf("\n"); } free(whole); free(by1); free(by3); } } static void test_headings(void) { puts("标题"); char *o = render("# 一\n## 二\n### 三\n#### 四\n", 1); CHECK(strstr(o, "\033[1;36m# 一\033[0m") != NULL, "H1 应为粗体青"); CHECK(strstr(o, "\033[1;35m## 二\033[0m") != NULL, "H2 应为粗体品红"); CHECK(strstr(o, "\033[1;34m### 三") != NULL, "H3 应为粗体蓝"); CHECK(strstr(o, "\033[1m#### 四") != NULL, "H4 应为粗体"); char *vis = strip_ansi(o); CHECK(strcmp(vis, "# 一\n## 二\n### 三\n#### 四\n") == 0, "可见文本应保留 # 标记: [%s]", vis); free(vis); free(o); /* 不是标题的情况必须原样保留 */ o = render("#没有空格\n", 1); CHECK(strcmp(o, "#没有空格\n") == 0, "无空格不应识别为标题: [%s]", o); free(o); o = render("####### 七个\n", 1); CHECK(strstr(o, "####### 七个") != NULL, "7 个 # 不应识别为标题: [%s]", o); free(o); o = render("a # b\n", 1); CHECK(strcmp(o, "a # b\n") == 0, "行中 # 应原样: [%s]", o); free(o); } static void test_bold_italic(void) { puts("粗体 / 斜体"); char *o = render("**粗**\n", 1); CHECK(strstr(o, "\033[1m粗\033[0m") != NULL, "粗体样式: [%s]", o); char *vis = strip_ansi(o); CHECK(strcmp(vis, "粗\n") == 0, "粗体标记应被吃掉: [%s]", vis); free(vis); free(o); o = render("*斜*\n", 1); CHECK(strstr(o, "\033[3m斜\033[0m") != NULL, "斜体样式: [%s]", o); vis = strip_ansi(o); CHECK(strcmp(vis, "斜\n") == 0, "斜体标记应被吃掉: [%s]", vis); free(vis); free(o); /* 字面星号: 乘法 */ o = render("2 * 3 * 4\n", 1); CHECK(strcmp(o, "2 * 3 * 4\n") == 0, "乘法星号应原样输出: [%s]", o); free(o); /* snake_case 不能被误伤 (这是不实现 _斜体_ 的原因) */ o = render("foo_bar_baz\n", 1); CHECK(strcmp(o, "foo_bar_baz\n") == 0, "下划线标识符不应被改动: [%s]", o); free(o); /* 样式不应跨行泄漏 */ o = render("*a\nb\n", 1); CHECK(strstr(o, "\033[0m\n") != NULL, "行尾应复位样式: [%s]", o); free(o); /* 嵌套: 粗体里的斜体 */ o = render("**粗 *斜* 粗**\n", 1); vis = strip_ansi(o); CHECK(strcmp(vis, "粗 斜 粗\n") == 0, "嵌套可见文本: [%s]", vis); free(vis); free(o); } static void test_code(void) { puts("代码"); /* 行内代码: 保留反引号 (便于复制), 内容着色 */ char *o = render("用 `ls -la` 看看\n", 1); char *vis = strip_ansi(o); CHECK(strcmp(vis, "用 `ls -la` 看看\n") == 0, "行内代码应保留反引号: [%s]", vis); CHECK(strstr(o, "\033[36m") != NULL, "内容应着色: [%s]", o); free(vis); free(o); /* 围栏代码块内部不做行内解析 */ o = render("```\n**原样**\n```\n", 1); vis = strip_ansi(o); CHECK(strcmp(vis, "```\n**原样**\n```\n") == 0, "代码块内不应渲染粗体: [%s]", vis); free(vis); free(o); /* 带语言标识 */ o = render("```c\nx;\n```\n", 1); vis = strip_ansi(o); CHECK(strcmp(vis, "```c\nx;\n```\n") == 0, "语言标识与内容应完整保留: [%s]", vis); free(vis); free(o); /* 未闭合的代码块: 不能吞掉后面的内容 */ o = render("```\ncode\n", 1); vis = strip_ansi(o); CHECK(strcmp(vis, "```\ncode\n") == 0, "未闭合代码块: [%s]", vis); free(vis); free(o); } static void test_blocks(void) { puts("列表 / 引用 / 分隔线"); /* 列表标记必须保留模型输出的原始字符, 只上色, 不换成 • */ char *o = render("- 甲\n- 乙\n", 1); char *vis = strip_ansi(o); CHECK(strcmp(vis, "- 甲\n- 乙\n") == 0, "列表应保留原始 - : [%s]", vis); CHECK(strstr(o, "\033[36m-\033[0m") != NULL, "列表标记 - 应着色: [%s]", o); CHECK(strstr(o, "•") == NULL, "不应出现非 ASCII 圆点: [%s]", o); free(vis); free(o); o = render("* 甲\n+ 乙\n", 1); vis = strip_ansi(o); CHECK(strcmp(vis, "* 甲\n+ 乙\n") == 0, "列表应保留原始 * 和 + : [%s]", vis); CHECK(strstr(o, "\033[36m*\033[0m") != NULL, "列表标记 * 应着色: [%s]", o); CHECK(strstr(o, "\033[36m+\033[0m") != NULL, "列表标记 + 应着色: [%s]", o); free(vis); free(o); /* 表格不受影响: 仍然是框线字符 */ o = render("| a |\n|---|\n| 1 |\n", 1); vis = strip_ansi(o); CHECK(strstr(vis, "┌") && strstr(vis, "│") && strstr(vis, "└"), "表格仍用框线字符: [%s]", vis); free(vis); free(o); o = render("> 引用\n", 1); vis = strip_ansi(o); CHECK(strcmp(vis, "│ 引用\n") == 0, "引用: [%s]", vis); free(vis); free(o); o = render("---\n", 1); vis = strip_ansi(o); CHECK(strcmp(vis, "---\n") == 0, "分隔线可见文本: [%s]", vis); CHECK(strstr(o, "\033[2m") != NULL, "分隔线应暗色: [%s]", o); free(vis); free(o); /* 非分隔线、非列表应原样 */ o = render("--x\n", 1); CHECK(strcmp(o, "--x\n") == 0, "非分隔线应原样: [%s]", o); free(o); } static void test_passthrough(void) { puts("关闭渲染时逐字节透传"); static const char *samples[] = { "# 标题 **粗体** `code`\n", "- 列表\n```\ncode\n```\n", "\033 转义 中文 😀\n", "没有换行的最后一行", "", }; for (size_t k = 0; k < sizeof samples / sizeof samples[0]; k++) { char *whole = render(samples[k], 0); char *by1 = do_render(samples[k], 0, 1); CHECK(strcmp(whole, samples[k]) == 0, "应完全透传: 期望 [%s] 实际 [%s]", samples[k], whole); CHECK(strcmp(by1, samples[k]) == 0, "分片透传也应一致"); free(whole); free(by1); } } static void test_fence_verbatim(void) { puts("围栏代码块: 全部不渲染"); /* 纯代码块: 输出里不应出现任何 ANSI 转义 */ char *o = render("```c\nint x = 1; // **不是粗体**\n```\n", 1); CHECK(strstr(o, "\033") == NULL, "代码块内不应有任何 ANSI 转义: [%s]", o); CHECK(strcmp(o, "```c\nint x = 1; // **不是粗体**\n```\n") == 0, "代码块应逐字节原样: [%s]", o); free(o); /* 代码块里的 # - > [ 都不能被当成语法 */ o = render("```\n# 不是标题\n- 不是列表\n> 不是引用\n[x](y)\n```\n", 1); CHECK(strstr(o, "\033") == NULL, "代码块内不应有转义: [%s]", o); CHECK(strstr(o, "# 不是标题") && strstr(o, "[x](y)") && strstr(o, "- 不是列表") && strstr(o, "> 不是引用"), "代码块内容应完整: [%s]", o); free(o); /* 代码块结束之后, 普通文本要恢复渲染 */ o = render("```\nx\n```\n**加粗**\n", 1); CHECK(strstr(o, "\033[1m加粗\033[0m") != NULL, "代码块之后应恢复渲染: [%s]", o); free(o); } static void test_inline_code_protected(void) { puts("行内代码内容不受行内语法影响"); char *o = render("形如 `a**b**c` 的写法\n", 1); CHECK(strstr(o, "\033[1m") == NULL, "行内代码内不应渲染粗体: [%s]", o); char *vis = strip_ansi(o); CHECK(strcmp(vis, "形如 `a**b**c` 的写法\n") == 0, "行内代码可见文本应原样: [%s]", vis); free(vis); free(o); o = render("`[不是链接](x)` 和 `_不是斜体_`\n", 1); CHECK(strstr(o, "\033[94m") == NULL, "行内代码内不应高亮 []: [%s]", o); CHECK(strstr(o, "\033[3m") == NULL, "行内代码内不应渲染斜体: [%s]", o); vis = strip_ansi(o); CHECK(strcmp(vis, "`[不是链接](x)` 和 `_不是斜体_`\n") == 0, "可见文本应原样: [%s]", vis); free(vis); free(o); } static void test_underscore_italic(void) { puts("_斜体_ 与词内下划线保护"); char *o = render("这是 _斜体_ 文字\n", 1); CHECK(strstr(o, "\033[3m斜体\033[0m") != NULL, "_斜体_ 应渲染: [%s]", o); char *vis = strip_ansi(o); CHECK(strcmp(vis, "这是 斜体 文字\n") == 0, "可见文本: [%s]", vis); free(vis); free(o); /* 中文紧邻下划线也应生效 (非 ASCII 不算词字符) */ o = render("中文_斜体_中文\n", 1); CHECK(strstr(o, "\033[3m斜体") != NULL, "中文紧邻应生效: [%s]", o); free(o); /* 关键保护: 下划线标识符不能被当成斜体 */ o = render("foo_bar_baz 和 __init__ 和 a_b_c\n", 1); CHECK(strstr(o, "\033[3m") == NULL, "下划线标识符不应被渲染为斜体: [%s]", o); CHECK(strcmp(o, "foo_bar_baz 和 __init__ 和 a_b_c\n") == 0, "标识符应逐字节保留: [%s]", o); free(o); } static void test_link_bracket(void) { puts("链接: [] 下划线+青色高亮, 其余原样"); char *o = render("见 [文档](https://example.com/a) 说明\n", 1); /* 这里是下划线 + 青 (SGR 4;36), 不是亮蓝. 整个项目只用 16 色范围内的 * 码, 理由见 src/markdown.c 顶部和 tools/check-colors.sh. */ CHECK(strstr(o, "\033[4;36m[文档]") != NULL, "[链接文字] 应下划线+青色高亮: [%s]", o); char *vis = strip_ansi(o); CHECK(strcmp(vis, "见 [文档](https://example.com/a) 说明\n") == 0, "可见文本应逐字节保留 (含 URL): [%s]", vis); free(vis); free(o); /* 图片也是 [...], 同样只高亮方括号部分, 整段保留 */ o = render("![图](a.png)\n", 1); vis = strip_ansi(o); CHECK(strcmp(vis, "![图](a.png)\n") == 0, "图片应保留原文: [%s]", vis); free(vis); free(o); /* 下标写法不应被当成链接 */ o = render("arr[i] = br[j]\n", 1); CHECK(strstr(o, "\033[94m") == NULL, "下标不应高亮: [%s]", o); CHECK(strcmp(o, "arr[i] = br[j]\n") == 0, "下标应原样: [%s]", o); free(o); } static void test_tables(void) { puts("表格"); /* ASCII 表格: 逐字节比对完整边框输出 */ char *o = render("| a | b |\n|---|---|\n| 1 | 22 |\n", 1); char *vis = strip_ansi(o); const char *want = "┌───┬────┐\n" "│ a │ b │\n" "├───┼────┤\n" "│ 1 │ 22 │\n" "└───┴────┘\n"; CHECK(strcmp(vis, want) == 0, "ASCII 表格:\n期望:\n%s\n实际:\n%s", want, vis); CHECK(strstr(o, "\033[1m") != NULL, "表头应加粗: [%s]", o); free(vis); free(o); /* 中文列宽按 2 列计算, 边框必须对齐 */ o = render("| 名称 | 值 |\n|---|---|\n| 苹果 | 3 |\n", 1); vis = strip_ansi(o); const char *want2 = "┌──────┬────┐\n" "│ 名称 │ 值 │\n" "├──────┼────┤\n" "│ 苹果 │ 3 │\n" "└──────┴────┘\n"; CHECK(strcmp(vis, want2) == 0, "中文表格:\n期望:\n%s\n实际:\n%s", want2, vis); free(vis); free(o); /* 对齐标记 */ o = render("| 左 | 中 | 右 |\n|:---|:---:|---:|\n| a | b | c |\n", 1); vis = strip_ansi(o); const char *want3 = "┌────┬────┬────┐\n" "│ 左 │ 中 │ 右 │\n" "├────┼────┼────┤\n" "│ a │ b │ c │\n" "└────┴────┴────┘\n"; CHECK(strcmp(vis, want3) == 0, "对齐表格:\n期望:\n%s\n实际:\n%s", want3, vis); free(vis); free(o); /* 顶格必须写 '|'. 不带首竖线的写法按普通文本处理 —— * 这是为保证"输出与分片方式无关"而做的取舍: * 判断必须在行的第 0 个字节就能做出, 否则分片落在竖线之前时, * 行的前半段已被当作普通文本输出, 结果会随分片方式而变. */ o = render("a | b\n--- | ---\n1 | 2\n", 1); vis = strip_ansi(o); CHECK(strcmp(vis, "a | b\n--- | ---\n1 | 2\n") == 0, "无首竖线应原样输出: [%s]", vis); free(vis); free(o); /* 首竖线前有缩进同理: 不认 */ o = render(" | a |\n |---|\n", 1); vis = strip_ansi(o); CHECK(strstr(vis, "┌") == NULL, "缩进的表格不识别: [%s]", vis); free(vis); free(o); /* 误判防护: 普通文本里出现竖线 + 下一行是短横线, 不能当成表格 */ o = render("运行 ls |\n-\n", 1); vis = strip_ansi(o); CHECK(strstr(vis, "┌") == NULL, "竖线+短横线不应误判成表格: [%s]", vis); free(vis); free(o); /* 单列极简表 */ o = render("| x |\n|---|\n| 1 |\n", 1); vis = strip_ansi(o); CHECK(strstr(vis, "┌───┐") != NULL, "单列表格: [%s]", vis); free(vis); free(o); /* 没有分隔行就不是表格 */ o = render("a | b\nc | d\n", 1); CHECK(strcmp(o, "a | b\nc | d\n") == 0, "无分隔行不应成表: [%s]", o); free(o); /* 单竖线的普通命令行不能被误判为表格 */ o = render("ls -la | grep foo\n", 1); CHECK(strcmp(o, "ls -la | grep foo\n") == 0, "管道命令不应成表: [%s]", o); free(o); /* 表格后面紧跟普通文本, 两者都要正常 */ o = render("| a | b |\n|---|---|\n| 1 | 2 |\n后续段落 **粗体**\n", 1); vis = strip_ansi(o); CHECK(strstr(vis, "└───┴───┘\n后续段落 粗体\n") != NULL, "表格后应恢复普通渲染: [%s]", vis); free(vis); free(o); } static void test_long_line_bounded(void) { puts("超长待处理缓冲应有界"); /* 一个没有换行的超长 fence 信息行: 渲染器不能无限缓存 */ StrBuf sb; sb_init(&sb); sb_puts(&sb, "```"); for (int i = 0; i < 200000; i++) sb_putc(&sb, 'x'); char *o = render(sb.data, 1); size_t n = strlen(o); CHECK(n > 100000, "应该已经把内容吐出, 而不是无限缓存 (实际 %zu 字节)", n); free(o); sb_free(&sb); } int main(void) { test_split_invariance(); test_headings(); test_bold_italic(); test_code(); test_blocks(); test_fence_verbatim(); test_inline_code_protected(); test_underscore_italic(); test_link_bracket(); test_tables(); test_passthrough(); test_long_line_bounded(); printf("\n%d 项检查, %d 项失败\n", checks, failures); return failures ? 1 : 0; }