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

523 lines
19 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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;
}