Files
ligent/tests/test_markdown.c
T
2026-10-03 14:56:56 +08:00

523 lines
19 KiB
C

/* test_markdown.c - 流式 Markdown 渲染器测试
*
* 最关键的不变量: 把同一段文本任意切分后逐片喂入, 输出必须与一次性喂入
* 完全一致. SSE 的分片位置是随机的, 这条不成立就会漏出"半个星号".
*/
#include "markdown.h"
#include "util.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
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("<ESC>");
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;
}