style: 应用 clang-format

This commit is contained in:
pluv committed 2026-10-04 13:50:33 +08:00
1 parent 016a45bc6e
commit d1612f1d31
65 files changed
+7288 -5983

No files matched your search

+122 -72
View File
@@ -1,9 +1,9 @@
/* fuzz.c - 对抗性模糊测试: JSON 解析器 + Markdown 渲染器
*
* 这两个模块是"解析不可信输入"的地方 (服务端响应、模型输出),
* 也是最可能出崩溃/越界/泄漏的地方. 所以用随机输入去砸它们.
* 这两个模块是"解析不可信输入"的地方 (服务端响应、模型输出),
* 也是最可能出崩溃/越界/泄漏的地方. 所以用随机输入去砸它们.
*
* 检查的不变量:
* 检查的不变量:
* JSON
* 1. 任何输入都不能崩溃、不能泄漏 (靠 ASan/UBSan/LeakSanitizer)
* 2. 解析成功 => dump 再 parse 再 dump, 两次 dump 必须一致 (幂等)
@@ -19,15 +19,14 @@
#include "markdown.h"
#include "util.h"
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
static uint64_t RNG;
static uint32_t rnd(void)
{
static uint32_t rnd(void) {
RNG ^= RNG << 13;
RNG ^= RNG >> 7;
RNG ^= RNG << 17;
@@ -41,13 +40,24 @@ static uint32_t rnd_below(uint32_t n) { return n ? rnd() % n : 0; }
/* ------------------------------------------------------------------ */
static const char *STR_POOL[] = {
"", "a", "hello", "中文", "😀", "line\nbreak", "tab\there", "quote\"x",
"back\\slash", "ctrl\x01\x1f", "surrogate\u00e9", "aaaaaaaaaaaaaaaaaaaa",
"/", "\x7f", "\xc3\xa9\xe4\xb8\xad",
"",
"a",
"hello",
"中文",
"😀",
"line\nbreak",
"tab\there",
"quote\"x",
"back\\slash",
"ctrl\x01\x1f",
"surrogate\u00e9",
"aaaaaaaaaaaaaaaaaaaa",
"/",
"\x7f",
"\xc3\xa9\xe4\xb8\xad",
};
static void gen_json_string(StrBuf *sb)
{
static void gen_json_string(StrBuf *sb) {
const char *s = STR_POOL[rnd_below(sizeof STR_POOL / sizeof STR_POOL[0])];
char *q = json_quote_string(s);
sb_puts(sb, q);
@@ -55,27 +65,37 @@ static void gen_json_string(StrBuf *sb)
}
/* 递归生成合法 JSON */
static void gen_json(StrBuf *sb, int depth)
{
static void gen_json(StrBuf *sb, int depth) {
uint32_t kind = rnd_below(depth > 3 ? 4 : 6);
switch (kind) {
case 0: sb_puts(sb, "null"); break;
case 1: sb_puts(sb, rnd() & 1 ? "true" : "false"); break;
case 0:
sb_puts(sb, "null");
break;
case 1:
sb_puts(sb, rnd() & 1 ? "true" : "false");
break;
case 2: {
char buf[64];
int mode = (int)rnd_below(3);
if (mode == 0) snprintf(buf, sizeof buf, "%d", (int)rnd() - 1000);
else if (mode == 1) snprintf(buf, sizeof buf, "%.6g", (double)rnd() / 7.0);
else snprintf(buf, sizeof buf, "%de%d", (int)rnd_below(1000), (int)rnd_below(20) - 10);
if (mode == 0)
snprintf(buf, sizeof buf, "%d", (int)rnd() - 1000);
else if (mode == 1)
snprintf(buf, sizeof buf, "%.6g", (double)rnd() / 7.0);
else
snprintf(buf, sizeof buf, "%de%d", (int)rnd_below(1000),
(int)rnd_below(20) - 10);
sb_puts(sb, buf);
break;
}
case 3: gen_json_string(sb); break;
case 3:
gen_json_string(sb);
break;
case 4: {
sb_putc(sb, '[');
uint32_t n = rnd_below(5);
for (uint32_t i = 0; i < n; i++) {
if (i) sb_putc(sb, ',');
if (i)
sb_putc(sb, ',');
gen_json(sb, depth + 1);
}
sb_putc(sb, ']');
@@ -85,7 +105,8 @@ static void gen_json(StrBuf *sb, int depth)
sb_putc(sb, '{');
uint32_t n = rnd_below(5);
for (uint32_t i = 0; i < n; i++) {
if (i) sb_putc(sb, ',');
if (i)
sb_putc(sb, ',');
gen_json_string(sb);
sb_putc(sb, ':');
gen_json(sb, depth + 1);
@@ -97,8 +118,7 @@ static void gen_json(StrBuf *sb, int depth)
}
/* 返回 1 表示这一轮失败 */
static int fuzz_json_once(char *err, size_t errlen)
{
static int fuzz_json_once(char *err, size_t errlen) {
StrBuf sb;
sb_init(&sb);
@@ -107,36 +127,43 @@ static int fuzz_json_once(char *err, size_t errlen)
/* 变异模式: 先造合法 JSON, 再随机破坏 */
gen_json(&sb, 0);
size_t n = sb.len;
if (n == 0) { sb_free(&sb); return 0; }
/* 每次突变都必须基于"当前"长度. 早期版本用了突变前记下的旧长度,
* 截断之后再按下标写就会覆盖掉结尾的 '\0', 后续 strlen 冲出分配区,
* 让 ASan 在 json_parse 里报出假阳性 —— 模糊测试器自己不可信比没有更糟. */
if (n == 0) {
sb_free(&sb);
return 0;
}
/* 每次突变都必须基于"当前"长度. 早期版本用了突变前记下的旧长度,
* 截断之后再按下标写就会覆盖掉结尾的 '\0', 后续 strlen 冲出分配区,
* 让 ASan 在 json_parse 里报出假阳性 —— 模糊测试器自己不可信比没有更糟.
*/
uint32_t muts = 1 + rnd_below(4);
for (uint32_t i = 0; i < muts && sb.len > 0; i++) {
uint32_t op = rnd_below(3);
if (op == 0) { /* 翻一个字节 (下标恒 < len) */
if (op == 0) { /* 翻一个字节 (下标恒 < len) */
sb.data[rnd_below((uint32_t)sb.len)] = (char)(rnd() & 0xFF);
} else if (op == 1) { /* 截断 */
} else if (op == 1) { /* 截断 */
sb.len = rnd_below((uint32_t)sb.len);
} else { /* 插入一个字节 */
} else { /* 插入一个字节 */
if (sb.len < 4096) {
size_t pos = rnd_below((uint32_t)sb.len + 1);
sb_reserve(&sb, 1);
memmove(sb.data + pos + 1, sb.data + pos, sb.len - pos + 1);
sb.data[pos] = (char)"{}[]\",:0123456789.eE+-truefalsn"[rnd_below(30)];
sb.data[pos] =
(char)"{}[]\",:0123456789.eE+-truefalsn"[rnd_below(30)];
sb.len++;
}
}
}
/* 无论怎么突变, 结尾符必须是好的 */
if (sb.data) sb.data[sb.len] = '\0';
if (sb.data)
sb.data[sb.len] = '\0';
n = sb.len;
} else {
/* 纯随机字节 */
uint32_t n = rnd_below(256);
for (uint32_t i = 0; i < n; i++) {
/* 偏向 JSON 元字符, 提高触发率 */
static const char meta[] = "{}[]\",:0123456789.eE+-truefalsn \t\n\\/u";
static const char meta[] =
"{}[]\",:0123456789.eE+-truefalsn \t\n\\/u";
char c = (rnd() & 3) ? meta[rnd_below(sizeof meta - 1)]
: (char)(rnd() & 0xFF);
sb_putc(&sb, c);
@@ -175,14 +202,19 @@ static int fuzz_json_once(char *err, size_t errlen)
}
d2 = json_dump(v2);
if (strcmp(d1, d2) != 0) {
snprintf(err, errlen, "dump 不幂等: \n 输入 %.200s\n 一次 %.200s\n 二次 %.200s",
input, d1, d2);
free(d1); free(d2);
json_free(v); json_free(v2);
snprintf(
err, errlen,
"dump 不幂等: \n 输入 %.200s\n 一次 %.200s\n 二次 %.200s",
input, d1, d2);
free(d1);
free(d2);
json_free(v);
json_free(v2);
sb_free(&sb);
return 1;
}
free(d1); free(d2);
free(d1);
free(d2);
json_free(v2);
json_free(v);
}
@@ -197,12 +229,12 @@ static int fuzz_json_once(char *err, size_t errlen)
#define MD_RENDER_LIMIT (1u << 20)
/* 渲染到内存; chunk==0 表示一次性 */
static char *md_render(const char *src, size_t len, size_t chunk)
{
static char *md_render(const char *src, size_t len, size_t chunk) {
char *buf = NULL;
size_t size = 0;
FILE *f = open_memstream(&buf, &size);
if (!f) return NULL;
if (!f)
return NULL;
MdRenderer *r = md_new(1);
md_set_out(r, f);
@@ -222,16 +254,14 @@ static char *md_render(const char *src, size_t len, size_t chunk)
return buf;
}
static int markdown_metachar(int c)
{
static int markdown_metachar(int c) {
/* 这些字符会触发渲染器改写输出; 其余字符必须原样通过 */
return c == '*' || c == '_' || c == '`' || c == '[' || c == ']' ||
c == '#' || c == '>' || c == '-' || c == '+' || c == '|' ||
c == '\n' || c == '\r';
}
static int fuzz_md_once(char *err, size_t errlen)
{
static int fuzz_md_once(char *err, size_t errlen) {
StrBuf sb;
sb_init(&sb);
@@ -240,7 +270,7 @@ static int fuzz_md_once(char *err, size_t errlen)
size_t alen = sizeof alpha - 1;
uint32_t n = rnd_below(400);
int pure = (int)rnd_below(4) == 0; /* 1/4 概率生成"无元字符"的干净文本 */
int pure = (int)rnd_below(4) == 0; /* 1/4 概率生成"无元字符"的干净文本 */
for (uint32_t i = 0; i < n; i++) {
char c;
if (pure) {
@@ -249,41 +279,57 @@ static int fuzz_md_once(char *err, size_t errlen)
} while (markdown_metachar((unsigned char)c) || c == '\0');
} else {
uint32_t mode = rnd_below(10);
if (mode < 7) c = alpha[rnd_below((uint32_t)alen)];
else if (mode < 9) c = (char)(0x20 + rnd_below(0x5F));
else c = (char)(rnd() & 0xFF); /* 偶尔来点非法 UTF-8 */
if (mode < 7)
c = alpha[rnd_below((uint32_t)alen)];
else if (mode < 9)
c = (char)(0x20 + rnd_below(0x5F));
else
c = (char)(rnd() & 0xFF); /* 偶尔来点非法 UTF-8 */
}
if (c == '\0') c = 'x';
if (c == '\0')
c = 'x';
sb_putc(&sb, c);
}
const char *src = sb.data ? sb.data : "";
size_t len = sb.len;
if (len == 0) { sb_free(&sb); return 0; }
if (len == 0) {
sb_free(&sb);
return 0;
}
char *whole = md_render(src, len, 0);
if (!whole) { sb_free(&sb); return 0; }
if (!whole) {
sb_free(&sb);
return 0;
}
if (strlen(whole) > MD_RENDER_LIMIT * 4) {
snprintf(err, errlen, "输出异常膨胀: 输入 %zu 字节 => %zu 字节",
len, strlen(whole));
free(whole); sb_free(&sb);
snprintf(err, errlen, "输出异常膨胀: 输入 %zu 字节 => %zu 字节", len,
strlen(whole));
free(whole);
sb_free(&sb);
return 1;
}
/* 分片不变性 */
size_t chunks[4];
for (int k = 0; k < 4; k++) chunks[k] = 1 + rnd_below(8);
for (int k = 0; k < 4; k++)
chunks[k] = 1 + rnd_below(8);
for (int k = 0; k < 4; k++) {
char *part = md_render(src, len, chunks[k]);
if (!part) continue;
if (!part)
continue;
if (strcmp(whole, part) != 0) {
snprintf(err, errlen,
"分片不变性被破坏 (%zu 字节/片)",
chunks[k]);
snprintf(err, errlen, "分片不变性被破坏 (%zu 字节/片)", chunks[k]);
/* 落盘, 方便用 build/reduce 做最小化 */
FILE *fp = fopen("build/md_fail.bin", "wb");
if (fp) { fwrite(src, 1, len, fp); fclose(fp); }
free(part); free(whole); sb_free(&sb);
if (fp) {
fwrite(src, 1, len, fp);
fclose(fp);
}
free(part);
free(whole);
sb_free(&sb);
return 1;
}
free(part);
@@ -292,9 +338,11 @@ static int fuzz_md_once(char *err, size_t errlen)
/* 无元字符 => 必须逐字节原样 */
if (pure) {
if (strcmp(whole, src) != 0) {
snprintf(err, errlen, "纯净文本被改写了: \n 输入 %.300s\n 输出 %.300s",
src, whole);
free(whole); sb_free(&sb);
snprintf(err, errlen,
"纯净文本被改写了: \n 输入 %.300s\n 输出 %.300s", src,
whole);
free(whole);
sb_free(&sb);
return 1;
}
}
@@ -306,23 +354,24 @@ static int fuzz_md_once(char *err, size_t errlen)
/* ------------------------------------------------------------------ */
int main(int argc, char **argv)
{
int main(int argc, char **argv) {
long iters = (argc > 1) ? strtol(argv[1], NULL, 10) : 3000;
RNG = (argc > 2) ? strtoull(argv[2], NULL, 10) : 0x9E3779B97F4A7C15ULL;
if (!RNG) RNG = 1;
if (!RNG)
RNG = 1;
char err[512];
long json_fail = 0, md_fail = 0;
printf("模糊测试: JSON %ld 轮 + Markdown %ld 轮 (种子 %llu)\n",
iters, iters, (unsigned long long)RNG);
printf("模糊测试: JSON %ld 轮 + Markdown %ld 轮 (种子 %llu)\n", iters,
iters, (unsigned long long)RNG);
for (long i = 0; i < iters; i++) {
err[0] = '\0';
if (fuzz_json_once(err, sizeof err)) {
printf(" ✗ JSON 第 %ld 轮失败: %s\n", i, err);
if (++json_fail >= 3) break;
if (++json_fail >= 3)
break;
}
}
printf(" JSON %ld 轮, %ld 次失败\n", iters, json_fail);
@@ -331,7 +380,8 @@ int main(int argc, char **argv)
err[0] = '\0';
if (fuzz_md_once(err, sizeof err)) {
printf(" ✗ Markdown 第 %ld 轮失败: %s\n", i, err);
if (++md_fail >= 3) break;
if (++md_fail >= 3)
break;
}
}
printf(" Markdown %ld 轮, %ld 次失败\n", iters, md_fail);
+47 -25
View File
@@ -1,6 +1,6 @@
/* reduce.c - 把 fuzz 找到的失败输入最小化到最短复现样例
*
* 判定 (oracle): 同一段输入, "一次性喂" 与 "逐字节喂" 的渲染结果不一致.
* 判定 (oracle): 同一段输入, "一次性喂" 与 "逐字节喂" 的渲染结果不一致.
* 用法: build/reduce build/md_fail.bin
*/
#include "markdown.h"
@@ -11,8 +11,7 @@
/* 注意: md_feed 只接受 NUL 结尾的字符串, 而最小化过程中的缓冲区
* 是"长度受限"的, 所以必须先做一份带结尾符的副本. */
static char *render(const char *src, size_t len, size_t chunk)
{
static char *render(const char *src, size_t len, size_t chunk) {
char *text = xmalloc(len + 1);
memcpy(text, src, len);
text[len] = '\0';
@@ -20,7 +19,10 @@ static char *render(const char *src, size_t len, size_t chunk)
char *buf = NULL;
size_t size = 0;
FILE *f = open_memstream(&buf, &size);
if (!f) { free(text); return NULL; }
if (!f) {
free(text);
return NULL;
}
MdRenderer *r = md_new(1);
md_set_out(r, f);
if (chunk == 0) {
@@ -41,43 +43,60 @@ static char *render(const char *src, size_t len, size_t chunk)
}
/* 只要"一次性"与"逐字节"不一致就算失败 */
static int fails(const char *src, size_t len)
{
if (len == 0) return 0;
static int fails(const char *src, size_t len) {
if (len == 0)
return 0;
/* md_feed 以 NUL 结尾, 输入里不能有 NUL */
for (size_t i = 0; i < len; i++) if (src[i] == '\0') return 0;
for (size_t i = 0; i < len; i++)
if (src[i] == '\0')
return 0;
char *a = render(src, len, 0);
char *b = render(src, len, 1);
int bad = (a && b && strcmp(a, b) != 0);
if (bad) {
/* 确认真的不是"空输出 vs 空输出"这种假阳性 */
if (strcmp(a, b) == 0) bad = 0;
if (strcmp(a, b) == 0)
bad = 0;
}
free(a); free(b);
free(a);
free(b);
return bad;
}
static void show(const char *label, const char *buf, size_t len)
{
static void show(const char *label, const char *buf, size_t len) {
printf("%s (%zu 字节): ", label, len);
for (size_t i = 0; i < len; i++) {
unsigned char c = (unsigned char)buf[i];
if (c == '\n') printf("\\n");
else if (c == '\t') printf("\\t");
else if (c == 0x1b) printf("<ESC>");
else if (c >= 0x20 && c < 0x7f) putchar(c);
else printf("\\x%02x", c);
if (c == '\n')
printf("\\n");
else if (c == '\t')
printf("\\t");
else if (c == 0x1b)
printf("<ESC>");
else if (c >= 0x20 && c < 0x7f)
putchar(c);
else
printf("\\x%02x", c);
}
putchar('\n');
}
int main(int argc, char **argv)
{
if (argc < 2) { fputs("用法: reduce <失败输入文件>\n", stderr); return 2; }
int main(int argc, char **argv) {
if (argc < 2) {
fputs("用法: reduce <失败输入文件>\n", stderr);
return 2;
}
size_t len = 0;
char *buf = read_entire_file(argv[1], &len);
if (!buf) { perror("读取失败"); return 2; }
if (!fails(buf, len)) { puts("该输入当前不复现失败"); free(buf); return 1; }
if (!buf) {
perror("读取失败");
return 2;
}
if (!fails(buf, len)) {
puts("该输入当前不复现失败");
free(buf);
return 1;
}
puts("== 原始失败输入 ==");
show(" ", buf, len);
@@ -85,7 +104,8 @@ int main(int argc, char **argv)
char *a = render(buf, len, 0), *b = render(buf, len, 1);
show(" 一次性", a, strlen(a));
show(" 逐字节", b, strlen(b));
free(a); free(b);
free(a);
free(b);
}
/* 贪心块删除 */
@@ -105,7 +125,8 @@ int main(int argc, char **argv)
}
free(save);
}
if (gran == 1) break;
if (gran == 1)
break;
gran /= 2;
}
@@ -115,7 +136,8 @@ int main(int argc, char **argv)
char *a = render(buf, len, 0), *b = render(buf, len, 1);
show(" 一次性", a, strlen(a));
show(" 逐字节", b, strlen(b));
free(a); free(b);
free(a);
free(b);
}
free(buf);
return 0;
+64 -66
View File
@@ -10,23 +10,23 @@
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"); \
} \
#define CHECK(cond, ...) \
do { \
checks++; \
if (!(cond)) { \
failures++; \
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
printf(__VA_ARGS__); \
printf("\n"); \
} \
} while (0)
static size_t count_role(const Agent *a, const char *role)
{
static size_t count_role(const Agent *a, const char *role) {
size_t n = 0;
for (size_t i = 0; i < json_array_size(a->messages); i++) {
const char *r = json_get_string(json_array_get(a->messages, i), "role");
if (r && strcmp(r, role) == 0) n++;
if (r && strcmp(r, role) == 0)
n++;
}
return n;
}
@@ -35,8 +35,7 @@ static size_t count_role(const Agent *a, const char *role)
/* 上下文超限的自动恢复 */
/* ------------------------------------------------------------------ */
static void add_msg(Agent *a, const char *role, const char *content)
{
static void add_msg(Agent *a, const char *role, const char *content) {
JsonValue *m = json_new_object();
json_object_set_string(m, "role", role);
json_object_set_string(m, "content", content);
@@ -44,8 +43,8 @@ static void add_msg(Agent *a, const char *role, const char *content)
}
/* 造一条 assistant 带 tool_calls 的消息 */
static void add_assistant_tool_call(Agent *a, const char *id, const char *name)
{
static void add_assistant_tool_call(Agent *a, const char *id,
const char *name) {
JsonValue *m = json_new_object();
json_object_set_string(m, "role", "assistant");
json_object_set_null(m, "content");
@@ -62,8 +61,7 @@ static void add_assistant_tool_call(Agent *a, const char *id, const char *name)
json_array_push(a->messages, m);
}
static void add_tool_result(Agent *a, const char *id, const char *content)
{
static void add_tool_result(Agent *a, const char *id, const char *content) {
JsonValue *m = json_new_object();
json_object_set_string(m, "role", "tool");
json_object_set_string(m, "tool_call_id", id);
@@ -72,20 +70,17 @@ static void add_tool_result(Agent *a, const char *id, const char *content)
}
/* 序列化历史, 便于断言 */
static char *dump_history(const Agent *a)
{
return json_dump(a->messages);
}
static char *dump_history(const Agent *a) { return json_dump(a->messages); }
/* 配对不变量: 任何 assistant(tool_calls) 后面必须**紧跟**同 id 的 tool 消息.
/* 配对不变量: 任何 assistant(tool_calls) 后面必须**紧跟**同 id 的 tool 消息.
* 服务端就是这么要求的, 违反了下一轮直接 400 —— 所以每次动过历史之后都要查. */
static void check_pairing(Agent *a)
{
static void check_pairing(Agent *a) {
size_t n = json_array_size(a->messages);
for (size_t i = 0; i < n; i++) {
JsonValue *m = json_array_get(a->messages, i);
JsonValue *tcs = json_object_get(m, "tool_calls");
if (!tcs) continue;
if (!tcs)
continue;
const char *id = json_get_string(json_array_get(tcs, 0), "id");
CHECK(i + 1 < n, "tool_calls 之后必须有消息");
if (i + 1 < n) {
@@ -97,15 +92,14 @@ static void check_pairing(Agent *a)
}
}
/* 一轮一轮摘下去 (/back 走的就是这条路), 历史在每一步都必须仍然自洽.
/* 一轮一轮摘下去 (/back 走的就是这条路), 历史在每一步都必须仍然自洽.
*
* 这里原来测的是"上下文超限自动裁剪" (agent_drop_oldest_round)—— 那套机制
* 已经**整个删掉**: 它的判据只是错误文案里的关键词, 却会不可逆地删掉最老的
* 几轮对话. 现在超限就是如实报错、历史一个字节都不动, 怎么处理由用户决定
* (/back 摘一轮、/reset 重来). 但"摘掉一整轮之后 tool 配对不能散"这条
* 不变量与由谁触发无关, 所以留着继续测. */
static void test_take_rounds_keeps_pairing(void)
{
static void test_take_rounds_keeps_pairing(void) {
puts("逐轮摘掉之后历史仍然自洽");
Agent a;
agent_init(&a);
@@ -122,10 +116,11 @@ static void test_take_rounds_keeps_pairing(void)
int rounds = 0;
for (;;) {
JsonValue *r = agent_take_last_round(&a);
if (!r) break;
if (!r)
break;
json_free(r);
rounds++;
check_pairing(&a); /* 每摘一轮都查一遍配对 */
check_pairing(&a); /* 每摘一轮都查一遍配对 */
}
CHECK(rounds == 4, "共应摘掉 4 轮, 实际 %d", rounds);
CHECK(agent_history_len(&a) == 1, "最后应只剩 system, 实际 %zu",
@@ -133,8 +128,7 @@ static void test_take_rounds_keeps_pairing(void)
agent_free(&a);
}
static void test_take_last_round(void)
{
static void test_take_last_round(void) {
puts("摘掉最近一轮 (/back 用)");
Agent a;
agent_init(&a);
@@ -146,7 +140,8 @@ static void test_take_last_round(void)
add_msg(&a, "user", "U2");
add_msg(&a, "assistant", "A2");
CHECK(agent_history_len(&a) == 7, "初始 7 条, 实际 %zu", agent_history_len(&a));
CHECK(agent_history_len(&a) == 7, "初始 7 条, 实际 %zu",
agent_history_len(&a));
JsonValue *r = agent_take_last_round(&a);
CHECK(r != NULL, "应摘到一轮");
@@ -192,8 +187,7 @@ static void test_take_last_round(void)
/* ------------------------------------------------------------------ */
/* 造一条 assistant, 带 count 个工具调用 (id 依次是 c0, c1, ...) */
static JsonValue *mk_assistant(size_t count)
{
static JsonValue *mk_assistant(size_t count) {
JsonValue *m = json_new_object();
json_object_set_string(m, "role", "assistant");
JsonValue *tcs = json_new_array();
@@ -213,8 +207,7 @@ static JsonValue *mk_assistant(size_t count)
return m;
}
static JsonValue *mk_tool(const char *id)
{
static JsonValue *mk_tool(const char *id) {
JsonValue *m = json_new_object();
json_object_set_string(m, "role", "tool");
json_object_set_string(m, "tool_call_id", id);
@@ -222,8 +215,7 @@ static JsonValue *mk_tool(const char *id)
return m;
}
static JsonValue *mk_plain(const char *role, const char *text)
{
static JsonValue *mk_plain(const char *role, const char *text) {
JsonValue *m = json_new_object();
json_object_set_string(m, "role", role);
json_object_set_string(m, "content", text);
@@ -231,35 +223,37 @@ static JsonValue *mk_plain(const char *role, const char *text)
}
/* 数一数某个 assistant 之后紧跟的 tool 消息覆盖了几个 id */
static const char *nth_role(const JsonValue *msgs, size_t i)
{
static const char *nth_role(const JsonValue *msgs, size_t i) {
return json_get_string(json_array_get((JsonValue *)msgs, i), "role");
}
static int pairing_ok(const JsonValue *msgs)
{
static int pairing_ok(const JsonValue *msgs) {
size_t n = json_array_size((JsonValue *)msgs);
for (size_t i = 0; i < n; i++) {
JsonValue *m = json_array_get((JsonValue *)msgs, i);
const char *r = json_get_string(m, "role");
if (!r || strcmp(r, "assistant") != 0) continue;
if (!r || strcmp(r, "assistant") != 0)
continue;
JsonValue *tcs = json_object_get(m, "tool_calls");
if (!tcs || json_array_size(tcs) == 0) continue;
if (!tcs || json_array_size(tcs) == 0)
continue;
for (size_t k = 0; k < json_array_size(tcs); k++) {
const char *id = json_get_string(json_array_get(tcs, k), "id");
size_t j = i + 1;
int found = 0;
for (; j < n && strcmp(nth_role(msgs, j), "tool") == 0; j++)
if (strcmp(json_get_string(json_array_get((JsonValue *)msgs, j),
"tool_call_id"), id) == 0) found = 1;
if (!found) return 0;
"tool_call_id"),
id) == 0)
found = 1;
if (!found)
return 0;
}
}
return 1;
}
static void test_repair_pairing(void)
{
static void test_repair_pairing(void) {
int fixed = 0;
puts("结构体检: 本来没问题就不该动它");
@@ -290,18 +284,19 @@ static void test_repair_pairing(void)
if (r) {
CHECK(pairing_ok(r), "修完之后配对必须成立");
/* 两处: 给 c1 补一条占位 (它没被回应), 再把错位的那条 c1 结果丢掉
* (它被 user 消息隔开, 已经不在 tool 段里了 —— 留着就是孤儿).
* (它被 user 消息隔开, 已经不在 tool 段里了 —— 留着就是孤儿).
* 丢掉真实结果听着可惜, 但结构坏了只能二选一: 要么丢它, 要么留一段
* 服务端必然拒绝的历史. 模型会从占位消息里知道该重跑. */
CHECK(fixed == 2, "应补 1 处、丢 1 处孤儿, 实际 %d", fixed);
CHECK(json_array_size(r) == 5,
"补一条、丢一条之后仍是 5 条, 实际 %zu", json_array_size(r));
CHECK(json_array_size(r) == 5, "补一条、丢一条之后仍是 5 条, 实际 %zu",
json_array_size(r));
/* 占位消息必须紧跟在 assistant 的 tool 段里, 而不是被塞到最后 */
CHECK(strcmp(nth_role(r, 2), "tool") == 0 &&
strcmp(nth_role(r, 3), "tool") == 0,
strcmp(nth_role(r, 3), "tool") == 0,
"两条 tool 消息应紧跟在 assistant 之后");
const char *c = json_get_string(json_array_get(r, 3), "content");
CHECK(c && strstr(c, "没有执行") == NULL && strstr(c, "没有得到结果") != NULL,
CHECK(c && strstr(c, "没有执行") == NULL &&
strstr(c, "没有得到结果") != NULL,
"占位消息要说明它没拿到结果, 实际 %s", c ? c : "(null)");
json_free(r);
}
@@ -313,7 +308,8 @@ static void test_repair_pairing(void)
json_array_push(tail, mk_assistant(1));
fixed = 0;
r = agent_repair_tool_pairing(tail, &fixed);
CHECK(r && pairing_ok(r) && fixed == 1, "结尾缺的也要补上 (fixed=%d)", fixed);
CHECK(r && pairing_ok(r) && fixed == 1, "结尾缺的也要补上 (fixed=%d)",
fixed);
json_free(r);
json_free(tail);
@@ -329,7 +325,8 @@ static void test_repair_pairing(void)
puts("空输入与边界");
fixed = 7;
CHECK(agent_repair_tool_pairing(NULL, &fixed) == NULL && fixed == 0, "NULL 输入");
CHECK(agent_repair_tool_pairing(NULL, &fixed) == NULL && fixed == 0,
"NULL 输入");
JsonValue *empty = json_new_array();
CHECK(agent_repair_tool_pairing(empty, &fixed) == NULL, "空历史没什么可修");
json_free(empty);
@@ -346,8 +343,7 @@ static void test_repair_pairing(void)
/* 面向用户的历史口径: 不含 system */
/* ------------------------------------------------------------------ */
static void test_conversation_len(void)
{
static void test_conversation_len(void) {
puts("历史条数: system 是配置, 不该算进对话");
Agent a;
agent_init(&a);
@@ -382,8 +378,7 @@ static void test_conversation_len(void)
agent_free(&b);
}
int main(void)
{
int main(void) {
Agent a;
agent_init(&a);
CHECK(a.messages && a.messages->type == JSON_ARRAY, "messages 应为数组");
@@ -397,11 +392,13 @@ int main(void)
/* 关键不变量: 重复设置 system 应替换而不是追加 */
agent_add_system(&a, "S2");
CHECK(count_role(&a, "system") == 1, "重复设置 system 不应叠加, 实际 %zu 条",
count_role(&a, "system"));
CHECK(strcmp(json_get_string(json_array_get(a.messages, 0), "content"), "S2") == 0,
CHECK(count_role(&a, "system") == 1,
"重复设置 system 不应叠加, 实际 %zu 条", count_role(&a, "system"));
CHECK(strcmp(json_get_string(json_array_get(a.messages, 0), "content"),
"S2") == 0,
"新 system 应在第 0 条");
CHECK(strcmp(json_get_string(json_array_get(a.messages, 1), "content"), "U1") == 0,
CHECK(strcmp(json_get_string(json_array_get(a.messages, 1), "content"),
"U1") == 0,
"原有对话应保留");
agent_add_user(&a, "U2");
@@ -411,7 +408,8 @@ int main(void)
CHECK(agent_history_len(&a) == 1, "reset 后只保留 system, 实际 %zu",
agent_history_len(&a));
CHECK(count_role(&a, "system") == 1, "reset 后 system 仍在");
CHECK(strcmp(json_get_string(json_array_get(a.messages, 0), "content"), "S2") == 0,
CHECK(strcmp(json_get_string(json_array_get(a.messages, 0), "content"),
"S2") == 0,
"reset 保留的是最新 system");
/* 历史里没有 system 时 reset 应得到空历史 */
+73 -46
View File
@@ -5,49 +5,53 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/stat.h>
#include <unistd.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"); \
} \
#define CHECK(cond, ...) \
do { \
checks++; \
if (!(cond)) { \
failures++; \
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
printf(__VA_ARGS__); \
printf("\n"); \
} \
} while (0)
static int is_alnum_lower(const char *s, size_t n)
{
static int is_alnum_lower(const char *s, size_t n) {
for (size_t i = 0; i < n; i++)
if (!((s[i] >= 'a' && s[i] <= 'z') || (s[i] >= '0' && s[i] <= '9')))
return 0;
return 1;
}
int main(void)
{
int main(void) {
char tmpl[] = "/tmp/ligent_arch_XXXXXX";
char *dir = mkdtemp(tmpl);
if (!dir) { perror("mkdtemp"); return 2; }
if (!dir) {
perror("mkdtemp");
return 2;
}
char err[256];
puts("根名: 8 位小写字母数字");
char *n1 = NULL;
CHECK(archive_next_name(dir, NULL, NULL, 0, &n1, err, sizeof err) == 0, "生成失败: %s", err);
CHECK(archive_next_name(dir, NULL, NULL, 0, &n1, err, sizeof err) == 0,
"生成失败: %s", err);
CHECK(n1 && strlen(n1) == ARCHIVE_ROOT_LEN, "根名应为 %d 位, 实际 %s",
ARCHIVE_ROOT_LEN, n1 ? n1 : "(null)");
CHECK(n1 && is_alnum_lower(n1, strlen(n1)), "根名字符集应为 [a-z0-9]: %s", n1);
CHECK(n1 && is_alnum_lower(n1, strlen(n1)), "根名字符集应为 [a-z0-9]: %s",
n1);
free(n1);
/* 空字符串与 NULL 等价: 都表示"没有来源" */
char *n2 = NULL;
CHECK(archive_next_name(dir, "", NULL, 0, &n2, err, sizeof err) == 0, "空 lineage 应可用");
CHECK(archive_next_name(dir, "", NULL, 0, &n2, err, sizeof err) == 0,
"空 lineage 应可用");
CHECK(n2 && strlen(n2) == ARCHIVE_ROOT_LEN, "空 lineage 也应给出根名");
free(n2);
@@ -57,7 +61,8 @@ int main(void)
"分支生成失败: %s", err);
CHECK(b && strncmp(b, "a1b2c3d4_", 9) == 0, "应以来源加下划线开头: %s", b);
CHECK(b && strlen(b) == 8 + 1 + ARCHIVE_BRANCH_LEN, "分支长度不对: %s", b);
CHECK(b && is_alnum_lower(b + 9, ARCHIVE_BRANCH_LEN), "后缀字符集不对: %s", b);
CHECK(b && is_alnum_lower(b + 9, ARCHIVE_BRANCH_LEN), "后缀字符集不对: %s",
b);
free(b);
/* 多次生成应当不同 (随机性) */
@@ -65,17 +70,20 @@ int main(void)
archive_next_name(dir, "a1b2c3d4", NULL, 0, &x1, err, sizeof err);
archive_next_name(dir, "a1b2c3d4", NULL, 0, &x2, err, sizeof err);
CHECK(x1 && x2 && strcmp(x1, x2) != 0, "两次生成不应相同: %s / %s", x1, x2);
free(x1); free(x2);
free(x1);
free(x2);
puts("长度上限: 接不下就重新起根");
/* 边界: 来源长度 + 1 + 3 + ".json" <= 240 */
size_t maxlineage = ARCHIVE_NAME_MAX - 1 - ARCHIVE_BRANCH_LEN - strlen(".json");
size_t maxlineage =
ARCHIVE_NAME_MAX - 1 - ARCHIVE_BRANCH_LEN - strlen(".json");
char *ok = xmalloc(maxlineage + 1);
memset(ok, 'a', maxlineage);
ok[maxlineage] = '\0';
char *edge = NULL;
CHECK(archive_next_name(dir, ok, NULL, 0, &edge, err, sizeof err) == 0, "边界生成失败");
CHECK(archive_next_name(dir, ok, NULL, 0, &edge, err, sizeof err) == 0,
"边界生成失败");
CHECK(edge && strncmp(edge, "aaa", 3) == 0, "刚好装得下时应继续接分支");
CHECK(edge && strlen(edge) + strlen(".json") <= ARCHIVE_NAME_MAX,
"完整文件名不应超上限: %zu", edge ? strlen(edge) + 5 : 0);
@@ -86,35 +94,40 @@ int main(void)
memset(too, 'a', maxlineage + 1);
too[maxlineage + 1] = '\0';
char *fresh = NULL;
CHECK(archive_next_name(dir, too, NULL, 0, &fresh, err, sizeof err) == 0, "超限生成失败");
CHECK(archive_next_name(dir, too, NULL, 0, &fresh, err, sizeof err) == 0,
"超限生成失败");
CHECK(fresh && strlen(fresh) == ARCHIVE_ROOT_LEN,
"超限时应退回 %d 位根名, 实际 %s (%zu 位)",
ARCHIVE_ROOT_LEN, fresh ? fresh : "(null)", fresh ? strlen(fresh) : 0);
"超限时应退回 %d 位根名, 实际 %s (%zu 位)", ARCHIVE_ROOT_LEN,
fresh ? fresh : "(null)", fresh ? strlen(fresh) : 0);
free(fresh);
puts("前缀: 临时存档");
char *tp = NULL;
CHECK(archive_next_name(dir, "a1b2c3d4", "tmp_", 0, &tp, err, sizeof err) == 0,
CHECK(archive_next_name(dir, "a1b2c3d4", "tmp_", 0, &tp, err, sizeof err) ==
0,
"带前缀生成失败");
CHECK(tp && strncmp(tp, "tmp_a1b2c3d4_", 13) == 0,
"前缀应在最前面、其后才是分支名: %s", tp ? tp : "(null)");
free(tp);
char *tr = NULL;
CHECK(archive_next_name(dir, NULL, "tmp_", 0, &tr, err, sizeof err) == 0, "带前缀建根失败");
CHECK(archive_next_name(dir, NULL, "tmp_", 0, &tr, err, sizeof err) == 0,
"带前缀建根失败");
CHECK(tr && strncmp(tr, "tmp_", 4) == 0 &&
strlen(tr) == 4 + ARCHIVE_ROOT_LEN,
strlen(tr) == 4 + ARCHIVE_ROOT_LEN,
"新根也应带前缀: %s", tr ? tr : "(null)");
free(tr);
/* 前缀要算进长度上限: 原本刚好装得下的, 加了 tmp_ 就该退回新根 */
char *edgep = NULL;
CHECK(archive_next_name(dir, ok, "tmp_", 0, &edgep, err, sizeof err) == 0, "边界失败");
CHECK(archive_next_name(dir, ok, "tmp_", 0, &edgep, err, sizeof err) == 0,
"边界失败");
CHECK(edgep && strlen(edgep) == 4 + ARCHIVE_ROOT_LEN,
"加了前缀装不下时应退回根名, 实际 %s (%zu 位)",
edgep ? edgep : "(null)", edgep ? strlen(edgep) : 0);
free(edgep);
free(ok); free(too);
free(ok);
free(too);
puts("导出与载入往返");
JsonValue *msgs = json_new_array();
@@ -126,30 +139,38 @@ int main(void)
char *path = NULL, *name = NULL;
time_t t = 1700000000;
CHECK(archive_export(msgs, dir, "parentname", NULL, 0, t, t + 5,
"test-model", "http://example/v1",
&path, &name, err, sizeof err) == 0,
"test-model", "http://example/v1", &path, &name, err,
sizeof err) == 0,
"导出失败: %s", err);
CHECK(name && strncmp(name, "parentname_", 11) == 0,
"导出名应挂在来源下: %s", name ? name : "(null)");
CHECK(path && access(path, R_OK) == 0, "文件应存在: %s", path ? path : "");
char *created = NULL, *saved = NULL, *model = NULL, *iname = NULL, *iparent = NULL;
JsonValue *back = archive_load(path, &created, &saved, &model, &iname, &iparent,
err, sizeof err);
char *created = NULL, *saved = NULL, *model = NULL, *iname = NULL,
*iparent = NULL;
JsonValue *back = archive_load(path, &created, &saved, &model, &iname,
&iparent, err, sizeof err);
CHECK(back != NULL, "载入失败: %s", err);
CHECK(back && json_array_size(back) == 1, "消息条数应为 1");
CHECK(iname && name && strcmp(iname, name) == 0, "内容里的 name 应与文件名一致");
CHECK(iname && name && strcmp(iname, name) == 0,
"内容里的 name 应与文件名一致");
CHECK(iparent && strcmp(iparent, "parentname") == 0,
"内容里的 parent 应记下来源: %s", iparent ? iparent : "(null)");
CHECK(model && strcmp(model, "test-model") == 0, "model 应往返");
CHECK(created && strstr(created, "2023-") != NULL,
"created 应可读: %s", created ? created : "(null)");
/* saved 是"这份存档写出时的时间", 与 created (这段对话的开始时间)是两回事 */
CHECK(saved && strstr(saved, "2023-") != NULL,
"saved 应可读: %s", saved ? saved : "(null)");
CHECK(created && strstr(created, "2023-") != NULL, "created 应可读: %s",
created ? created : "(null)");
/* saved 是"这份存档写出时的时间", 与 created (这段对话的开始时间)是两回事
*/
CHECK(saved && strstr(saved, "2023-") != NULL, "saved 应可读: %s",
saved ? saved : "(null)");
json_free(back); json_free(msgs);
free(created); free(saved); free(model); free(iname); free(iparent);
json_free(back);
json_free(msgs);
free(created);
free(saved);
free(model);
free(iname);
free(iparent);
puts("列举");
ArchiveEntry *list = NULL;
@@ -165,13 +186,19 @@ int main(void)
/* 非 ligent 存档 (没有 format 标记)应被忽略 */
char *jd = xasprintf("%s/junk.json", dir);
FILE *j2 = fopen(jd, "w");
if (j2) { fputs("{\"hello\":1}", j2); fclose(j2); }
if (j2) {
fputs("{\"hello\":1}", j2);
fclose(j2);
}
n = archive_list(dir, &list);
CHECK(n == 1, "非 ligent 存档应被忽略, 实际 %zu", n);
archive_list_free(list, n);
unlink(jd); free(jd);
unlink(jd);
free(jd);
unlink(path); free(path); free(name);
unlink(path);
free(path);
free(name);
rmdir(dir);
printf("\n%d 项检查, %d 项失败\n", checks, failures);
return failures ? 1 : 0;
+56 -35
View File
@@ -1,7 +1,7 @@
/* test_attach.c - /include 的暂存、glob 展开与体积/类型判断
*
* 交互式挑选 (attach_pick)要真终端才能测, 由 e2e 第 55 段覆盖;
* 这里测的是不需要人操作的那一半.
* 交互式挑选 (attach_pick)要真终端才能测, 由 e2e 第 55 段覆盖;
* 这里测的是不需要人操作的那一半.
*/
#include "attach.h"
#include "image.h"
@@ -17,57 +17,69 @@
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"); \
} \
#define CHECK(cond, ...) \
do { \
checks++; \
if (!(cond)) { \
failures++; \
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
printf(__VA_ARGS__); \
printf("\n"); \
} \
} while (0)
static void write_file(const char *path, const void *data, size_t n)
{
static void write_file(const char *path, const void *data, size_t n) {
FILE *f = fopen(path, "wb");
if (!f) { perror(path); exit(2); }
if (n && fwrite(data, 1, n, f) != n) { perror("fwrite"); exit(2); }
if (!f) {
perror(path);
exit(2);
}
if (n && fwrite(data, 1, n, f) != n) {
perror("fwrite");
exit(2);
}
fclose(f);
}
static const unsigned char PNG[] = {
0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, 'p', 'a', 'y', 'l', 'o', 'a', 'd'
};
static const unsigned char PNG[] = {0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A,
'p', 'a', 'y', 'l', 'o', 'a', 'd'};
/* 数一数暂存里某类块有几个 */
static size_t count_type(const JsonValue *parts, const char *type)
{
if (!parts) return 0;
static size_t count_type(const JsonValue *parts, const char *type) {
if (!parts)
return 0;
size_t n = 0;
for (size_t i = 0; i < json_array_size((JsonValue *)parts); i++) {
const char *t = json_get_string(json_array_get((JsonValue *)parts, i), "type");
if (t && strcmp(t, type) == 0) n++;
const char *t =
json_get_string(json_array_get((JsonValue *)parts, i), "type");
if (t && strcmp(t, type) == 0)
n++;
}
return n;
}
/* 记一下回调被问了几次 —— 它只在遇到图片时该被问到.
/* 记一下回调被问了几次 —— 它只在遇到图片时该被问到.
* 答案和计数放在一起, 以免"计数版只实现了一种答案"这种测试自己的错. */
static int ask_count;
static int ask_answer;
static int counting_ask(void *ud) { (void)ud; ask_count++; return ask_answer; }
static int counting_ask(void *ud) {
(void)ud;
ask_count++;
return ask_answer;
}
int main(void)
{
int main(void) {
char tmpl[] = "/tmp/ligent_attach_XXXXXX";
char *dir = mkdtemp(tmpl);
if (!dir) { perror("mkdtemp"); return 2; }
if (!dir) {
perror("mkdtemp");
return 2;
}
/* 备份目录也指到临时目录, 别污染仓库 */
image_set_bindir(dir);
char *f_c = xasprintf("%s/a.c", dir);
char *f_c = xasprintf("%s/a.c", dir);
char *f_txt = xasprintf("%s/b.txt", dir);
char *f_bin = xasprintf("%s/bin.dat", dir);
char *f_png = xasprintf("%s/pic.png", dir);
@@ -133,24 +145,28 @@ int main(void)
CHECK(added == 1, "png 被收下");
CHECK(attach_has_image(), "暂存里有图片");
parts = attach_take_parts();
CHECK(count_type(parts, "ligent_image") == 1, "应当是一个 ligent_image 引用块");
CHECK(count_type(parts, "ligent_image") == 1,
"应当是一个 ligent_image 引用块");
json_free(parts);
puts("模型不收图时跳过图片 (回调只在遇到图片时被问)");
attach_clear();
ask_count = 0; ask_answer = 1;
ask_count = 0;
ask_answer = 1;
added = attach_glob(f_txt, counting_ask, NULL, &skipped, err, sizeof err);
CHECK(added == 1, "纯文本照带");
CHECK(ask_count == 0, "纯文本不该去问'能不能收图' (问了一次就是白探测)");
attach_clear();
ask_count = 0; ask_answer = 0;
ask_count = 0;
ask_answer = 0;
added = attach_glob(f_png, counting_ask, NULL, &skipped, err, sizeof err);
CHECK(added == 0 && skipped == 1, "回调说不行就跳过");
CHECK(ask_count == 1, "遇到图片时问了一次, 实际 %d", ask_count);
puts("匹配不到 / 参数非法");
attach_clear();
added = attach_glob("/tmp/绝对不存在的模式*.zzz", NULL, NULL, &skipped, err, sizeof err);
added = attach_glob("/tmp/绝对不存在的模式*.zzz", NULL, NULL, &skipped, err,
sizeof err);
CHECK(added == -1 && err[0], "匹配不到返回 -1 并给出原因: %s", err);
added = attach_glob(NULL, NULL, NULL, &skipped, err, sizeof err);
CHECK(added == -1, "NULL 模式返回 -1");
@@ -166,10 +182,15 @@ int main(void)
free(pat);
attach_clear();
free(f_c); free(f_txt); free(f_bin); free(f_png); free(f_big);
free(f_c);
free(f_txt);
free(f_bin);
free(f_png);
free(f_big);
image_set_bindir(".");
char *cmd = xasprintf("rm -rf %s", tmpl);
if (system(cmd) != 0) { }
if (system(cmd) != 0) {
}
free(cmd);
printf("\n%d 项检查, %d 项失败\n", checks, failures);
+17 -18
View File
@@ -1,13 +1,13 @@
/* test_capability.c - 能力探测的纯逻辑部分
*
* 探测序列本身 (基线 -> 合并 -> 拆开)要发 HTTP, 属于端到端范畴,
* 探测序列本身 (基线 -> 合并 -> 拆开)要发 HTTP, 属于端到端范畴,
* 由 e2e 第 51 段对着 mock 覆盖: 正常模型 / 不收图片 / 不收 tools /
* 探测失败 (401)/ --no-probe 五种走法都在那儿验.
* 探测失败 (401)/ --no-probe 五种走法都在那儿验.
*
* 这里只测不需要网络的: 名字映射、关闭探测时的语义、缓存查询.
* 其中"关闭探测必须给未测而不是不支持"那条是回归测试 —— 那句话看着像废话,
* 这里只测不需要网络的: 名字映射、关闭探测时的语义、缓存查询.
* 其中"关闭探测必须给未测而不是不支持"那条是回归测试 —— 那句话看着像废话,
* 但 CAP_NO 恰好是 0, 而 Agent 是 memset 出来的, 忘了显式初始化就会一声不响地
* 把所有工具摘掉. 这个 bug 真的发生过一次.
* 把所有工具摘掉. 这个 bug 真的发生过一次.
*/
#include "capability.h"
#include "llm.h"
@@ -18,19 +18,18 @@
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"); \
} \
#define CHECK(cond, ...) \
do { \
checks++; \
if (!(cond)) { \
failures++; \
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
printf(__VA_ARGS__); \
printf("\n"); \
} \
} while (0)
int main(void)
{
int main(void) {
/* CAP_NO 必须是 0 —— Agent 是 memset 出来的, 整个 capability 模块的
* 设计都建立在"零值不能是某个真实结论"之上. 这条断言是那个 bug 的护栏. */
CHECK(CAP_NO == 0, "CAP_NO 应为 0 (零值即'不支持'是事故的根源)");
@@ -45,7 +44,7 @@ int main(void)
CHECK(strcmp(cap_name(42), "未测") == 0, "越界值应落到未测");
puts("关掉探测: 一律未测, 绝不冒充结论");
/* 用可变数组而不是字面量: LlmConfig 的这些字段是 char * (真要释放的),
/* 用可变数组而不是字面量: LlmConfig 的这些字段是 char * (真要释放的),
* 而 -Wwrite-strings 下 "字面量" 是 const char * —— 直接赋值会被警告. */
char model[] = "some-model";
char base_url[] = "http://127.0.0.1:1/v1";
@@ -58,7 +57,7 @@ int main(void)
cap_cache_clear();
Capabilities c;
memset(&c, 0xAB, sizeof c); /* 故意填脏, 验证会被完整覆盖 */
memset(&c, 0xAB, sizeof c); /* 故意填脏, 验证会被完整覆盖 */
int probed = cap_get(&cfg, 0, &c, 0, NULL, 0);
CHECK(probed == 0, "被禁用时不该声称探测过");
CHECK(c.probed == 0, "probed 应为 0");
+120 -95
View File
@@ -1,44 +1,42 @@
/* test_config.c - 配置默认值 / 环境变量 / 配置文件覆盖 */
#include "config.h"
#include "util.h"
#include "i18n.h"
#include "util.h"
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <limits.h>
#include <sys/stat.h>
#include <unistd.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"); \
} \
#define CHECK(cond, ...) \
do { \
checks++; \
if (!(cond)) { \
failures++; \
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
printf(__VA_ARGS__); \
printf("\n"); \
} \
} while (0)
static void unset_all(void)
{
static void unset_all(void) {
const char *vars[] = {
"LIGENT_API_KEY", "DEEPSEEK_API_KEY", "OPENAI_API_KEY",
"LIGENT_BASE_URL", "LIGENT_MODEL", "LIGENT_SYSTEM",
"LIGENT_API_KEY", "DEEPSEEK_API_KEY", "OPENAI_API_KEY",
"LIGENT_BASE_URL", "LIGENT_MODEL", "LIGENT_SYSTEM",
"LIGENT_TEMPERATURE", "LIGENT_MAX_TOKENS", "LIGENT_TIMEOUT",
"LIGENT_MAX_STEPS", "LIGENT_STREAM", "LIGENT_TOOLS",
"OPENAI_BASE_URL", "LIGENT_CONFIG",
"LIGENT_MAX_STEPS", "LIGENT_STREAM", "LIGENT_TOOLS",
"OPENAI_BASE_URL", "LIGENT_CONFIG",
};
for (size_t i = 0; i < sizeof vars / sizeof vars[0]; i++)
unsetenv(vars[i]);
}
static void test_defaults(void)
{
static void test_defaults(void) {
puts("默认值");
unset_all();
LigentConfig c;
@@ -55,8 +53,7 @@ static void test_defaults(void)
config_free(&c);
}
static void test_env_key(void)
{
static void test_env_key(void) {
puts("环境变量取 key");
unset_all();
LigentConfig c;
@@ -98,8 +95,7 @@ static void test_env_key(void)
config_free(&c);
}
static void test_env_others(void)
{
static void test_env_others(void) {
puts("环境变量其他项");
unset_all();
LigentConfig c;
@@ -132,24 +128,26 @@ static void test_env_others(void)
config_free(&c);
}
static void test_thinking_parse(void)
{
static void test_thinking_parse(void) {
puts("思考模式 / 级别解析");
int t = 123;
CHECK(config_parse_thinking("auto", &t) == 0 && t == THINKING_AUTO, "auto");
CHECK(config_parse_thinking("", &t) == 0 && t == THINKING_AUTO, "空串 = auto");
CHECK(config_parse_thinking("", &t) == 0 && t == THINKING_AUTO,
"空串 = auto");
CHECK(config_parse_thinking("on", &t) == 0 && t == THINKING_ON, "on");
CHECK(config_parse_thinking("true", &t) == 0 && t == THINKING_ON, "true");
CHECK(config_parse_thinking("enabled", &t) == 0 && t == THINKING_ON, "enabled");
CHECK(config_parse_thinking("enabled", &t) == 0 && t == THINKING_ON,
"enabled");
CHECK(config_parse_thinking("off", &t) == 0 && t == THINKING_OFF, "off");
CHECK(config_parse_thinking("false", &t) == 0 && t == THINKING_OFF, "false");
CHECK(config_parse_thinking("disabled", &t) == 0 && t == THINKING_OFF, "disabled");
CHECK(config_parse_thinking("false", &t) == 0 && t == THINKING_OFF,
"false");
CHECK(config_parse_thinking("disabled", &t) == 0 && t == THINKING_OFF,
"disabled");
CHECK(config_parse_thinking("maybe", &t) != 0, "无法识别的值应报错");
CHECK(config_parse_thinking(NULL, &t) != 0, "NULL 应报错");
static const char *const good[] = {
"minimal", "low", "medium", "high", "xhigh", "max", "ultra", NULL
};
static const char *const good[] = {"minimal", "low", "medium", "high",
"xhigh", "max", "ultra", NULL};
for (size_t i = 0; good[i]; i++)
CHECK(config_effort_valid(good[i]), "%s 应是合法级别", good[i]);
CHECK(!config_effort_valid("huge"), "huge 不是合法级别");
@@ -161,8 +159,7 @@ static void test_thinking_parse(void)
CHECK(config_thinking_name(THINKING_OFF) != NULL, "THINKING_OFF 有说明");
}
static void test_file_thinking(void)
{
static void test_file_thinking(void) {
puts("配置文件里的 thinking / reasoning_effort");
char err[256];
LigentConfig c;
@@ -170,7 +167,10 @@ static void test_file_thinking(void)
/* 布尔写法 */
char t1[] = "/tmp/ligent_think_XXXXXX";
int fd = mkstemp(t1);
if (fd < 0) { CHECK(0, "mkstemp 失败"); return; }
if (fd < 0) {
CHECK(0, "mkstemp 失败");
return;
}
const char *j1 = "{\"thinking\":false,\"reasoning_effort\":\"medium\"}";
ssize_t w = write(fd, j1, strlen(j1));
close(fd);
@@ -192,7 +192,8 @@ static void test_file_thinking(void)
close(fd);
CHECK(w == (ssize_t)strlen(j2), "写临时配置失败");
config_defaults(&c);
CHECK(config_load_file(&c, t2, err, sizeof err) == 0, "加载失败: %s", err);
CHECK(config_load_file(&c, t2, err, sizeof err) == 0, "加载失败: %s",
err);
CHECK(c.thinking == THINKING_ON, "thinking:\"on\" 应为开启");
config_free(&c);
unlink(t2);
@@ -228,8 +229,7 @@ static void test_file_thinking(void)
}
}
static void test_file_override(void)
{
static void test_file_override(void) {
puts("配置文件覆盖 + ${VAR} 展开");
unset_all();
setenv("MY_SECRET", "sk-from-env", 1);
@@ -237,16 +237,16 @@ static void test_file_override(void)
char tmpl[] = "/tmp/ligent_cfg_XXXXXX";
int fd = mkstemp(tmpl);
CHECK(fd >= 0, "mkstemp 失败");
if (fd < 0) return;
const char *json =
"{\"base_url\":\"http://localhost:11434/v1\","
"\"api_key\":\"${MY_SECRET}\","
"\"model\":\"qwen2.5:14b\","
"\"temperature\":0.3,"
"\"max_tokens\":4096,"
"\"max_steps\":9,"
"\"stream\":false,"
"\"tools\":false}";
if (fd < 0)
return;
const char *json = "{\"base_url\":\"http://localhost:11434/v1\","
"\"api_key\":\"${MY_SECRET}\","
"\"model\":\"qwen2.5:14b\","
"\"temperature\":0.3,"
"\"max_tokens\":4096,"
"\"max_steps\":9,"
"\"stream\":false,"
"\"tools\":false}";
ssize_t w = write(fd, json, strlen(json));
close(fd);
CHECK(w == (ssize_t)strlen(json), "写临时配置失败");
@@ -256,7 +256,8 @@ static void test_file_override(void)
char err[256];
int rc = config_load_file(&c, tmpl, err, sizeof err);
CHECK(rc == 0, "加载配置失败: %s", err);
CHECK(strcmp(c.base_url, "http://localhost:11434/v1") == 0, "base_url 覆盖");
CHECK(strcmp(c.base_url, "http://localhost:11434/v1") == 0,
"base_url 覆盖");
CHECK(strcmp(c.model, "qwen2.5:14b") == 0, "model 覆盖");
CHECK(strcmp(c.api_key, "sk-from-env") == 0,
"${MY_SECRET} 应被展开, 实际 [%s]", c.api_key);
@@ -265,9 +266,10 @@ static void test_file_override(void)
CHECK(c.max_steps == 9, "max_steps 覆盖");
CHECK(c.stream == 0, "stream 覆盖");
CHECK(c.use_tools == 0, "tools 覆盖");
CHECK(c.config_path && strcmp(c.config_path, tmpl) == 0, "记录配置文件路径");
CHECK(c.config_path && strcmp(c.config_path, tmpl) == 0,
"记录配置文件路径");
/* 坏配置应报错而不是静默通过.
/* 坏配置应报错而不是静默通过.
*
* 解析器的报错跟着界面语言走 (json_parse_ui), 所以下面这段断语先把语言
* 钉住, 再顺手把英文那份也验一遍 —— 位置信息两种语言都得有. */
@@ -293,7 +295,7 @@ static void test_file_override(void)
i18n_set_lang(LIGENT_LANG_EN);
config_defaults(&c3);
CHECK(config_load_file(&c3, tmpl2, err, sizeof err) != 0 &&
strstr(err, "line") != NULL,
strstr(err, "line") != NULL,
"英文界面下报错也带位置 (且是英文): %s", err);
config_free(&c3);
i18n_set_lang(LIGENT_LANG_ZH);
@@ -306,8 +308,7 @@ static void test_file_override(void)
}
/* 配置只认二进制所在目录: 不做用户级、系统级, 也不做当前目录查找 */
static void test_binary_dir_lookup(void)
{
static void test_binary_dir_lookup(void) {
puts("配置位置 = 二进制所在目录");
char err[256];
@@ -322,11 +323,18 @@ static void test_binary_dir_lookup(void)
/* 当前目录放一份 ligent.json, 它绝不能被加载 */
char tmpl[] = "/tmp/ligent_cwd_XXXXXX";
char *tmp = mkdtemp(tmpl);
if (!tmp) { free(dir); free(canon); return; }
if (!tmp) {
free(dir);
free(canon);
return;
}
char *fake = xasprintf("%s/ligent.json", tmp);
FILE *f = fopen(fake, "w");
if (f) { fputs("{\"model\":\"FROM-CWD-ONLY\"}", f); fclose(f); }
if (f) {
fputs("{\"model\":\"FROM-CWD-ONLY\"}", f);
fclose(f);
}
char *saved = getcwd(NULL, 0);
int loaded_from_cwd = 0;
@@ -338,7 +346,8 @@ static void test_binary_dir_lookup(void)
loaded_from_cwd = (c.model && strcmp(c.model, "FROM-CWD-ONLY") == 0);
got_dir = c.config_dir ? xstrdup(c.config_dir) : NULL;
config_free(&c);
if (saved) chdir(saved);
if (saved)
chdir(saved);
}
CHECK(!loaded_from_cwd, "不应加载当前工作目录下的 ligent.json");
@@ -347,7 +356,7 @@ static void test_binary_dir_lookup(void)
free(got_dir);
free(saved);
unlink(fake); /* 先删文件再释放路径字符串 */
unlink(fake); /* 先删文件再释放路径字符串 */
free(fake);
rmdir(tmp);
free(dir);
@@ -355,25 +364,27 @@ static void test_binary_dir_lookup(void)
}
/* 写一个临时配置文件并加载 */
static int load_temp_config(LigentConfig *c, const char *json)
{
static int load_temp_config(LigentConfig *c, const char *json) {
char tmpl[] = "/tmp/ligent_prec_XXXXXX";
int fd = mkstemp(tmpl);
if (fd < 0) return -1;
if (fd < 0)
return -1;
ssize_t w = write(fd, json, strlen(json));
close(fd);
if (w != (ssize_t)strlen(json)) { unlink(tmpl); return -1; }
if (w != (ssize_t)strlen(json)) {
unlink(tmpl);
return -1;
}
char err[256];
int rc = config_load_file(c, tmpl, err, sizeof err);
unlink(tmpl);
return rc;
}
/* 厂商专属的环境变量不能越权覆盖配置文件里写好的密钥.
* 这正是用户踩到的坑: 配置写了 OpenRouter 的 key,
/* 厂商专属的环境变量不能越权覆盖配置文件里写好的密钥.
* 这正是用户踩到的坑: 配置写了 OpenRouter 的 key,
* shell 里残留的 DEEPSEEK_API_KEY 把它顶掉, 于是 A 家的密钥发给了 B 家. */
static void test_env_key_precedence(void)
{
static void test_env_key_precedence(void) {
puts("api_key 优先级: 厂商环境变量不得越权");
unset_all();
setenv("DEEPSEEK_API_KEY", "sk-leftover-in-shell", 1);
@@ -381,9 +392,9 @@ static void test_env_key_precedence(void)
/* 1) 配置文件写了 key => 它就是最终值, 环境变量顶不掉 */
LigentConfig c;
config_defaults(&c);
CHECK(load_temp_config(&c,
"{\"base_url\":\"https://openrouter.ai/api/v1\","
"\"api_key\":\"sk-or-v1-from-file\"}") == 0, "加载配置失败");
CHECK(load_temp_config(&c, "{\"base_url\":\"https://openrouter.ai/api/v1\","
"\"api_key\":\"sk-or-v1-from-file\"}") == 0,
"加载配置失败");
config_apply_env(&c);
CHECK(strcmp(c.api_key, "sk-or-v1-from-file") == 0,
"配置文件里的 key 不应被残留环境变量顶掉, 实际 %s", c.api_key);
@@ -391,7 +402,8 @@ static void test_env_key_precedence(void)
/* 2) 配置文件没写 key + 端点就是 DeepSeek => 用 DEEPSEEK_API_KEY 兜底 */
config_defaults(&c);
CHECK(load_temp_config(&c, "{\"base_url\":\"https://api.deepseek.com/v1\"}") == 0,
CHECK(load_temp_config(
&c, "{\"base_url\":\"https://api.deepseek.com/v1\"}") == 0,
"加载配置失败");
config_apply_env(&c);
CHECK(strcmp(c.api_key, "sk-leftover-in-shell") == 0,
@@ -400,7 +412,8 @@ static void test_env_key_precedence(void)
/* 3) 配置文件没写 key + 端点是别家 => 不猜, 宁可空着让用户看到明确提示 */
config_defaults(&c);
CHECK(load_temp_config(&c, "{\"base_url\":\"https://openrouter.ai/api/v1\"}") == 0,
CHECK(load_temp_config(
&c, "{\"base_url\":\"https://openrouter.ai/api/v1\"}") == 0,
"加载配置失败");
config_apply_env(&c);
CHECK(c.api_key[0] == '\0',
@@ -410,9 +423,9 @@ static void test_env_key_precedence(void)
/* 4) LIGENT_API_KEY 是显式覆盖, 任何时候都最高 */
setenv("LIGENT_API_KEY", "sk-explicit", 1);
config_defaults(&c);
CHECK(load_temp_config(&c,
"{\"base_url\":\"https://openrouter.ai/api/v1\","
"\"api_key\":\"sk-or-v1-from-file\"}") == 0, "加载配置失败");
CHECK(load_temp_config(&c, "{\"base_url\":\"https://openrouter.ai/api/v1\","
"\"api_key\":\"sk-or-v1-from-file\"}") == 0,
"加载配置失败");
config_apply_env(&c);
CHECK(strcmp(c.api_key, "sk-explicit") == 0,
"LIGENT_API_KEY 应最高优先, 实际 %s", c.api_key);
@@ -421,41 +434,48 @@ static void test_env_key_precedence(void)
unset_all();
}
/* 软链接: 配置目录要跟着"用户实际调用那个文件"走.
* 否则把 ligent 软链到 ~/bin 之后, 配置会被悄悄挪到源文件所在的目录,
/* 软链接: 配置目录要跟着"用户实际调用那个文件"走.
* 否则把 ligent 软链到 ~/bin 之后, 配置会被悄悄挪到源文件所在的目录,
* 用户放在 ~/bin 旁边的 ligent.json 就永远读不到. */
static void test_symlink_dir(void)
{
static void test_symlink_dir(void) {
puts("软链接: 配置目录跟着软链接走");
char self[PATH_MAX];
ssize_t n = readlink("/proc/self/exe", self, sizeof self - 1);
if (n <= 0) { puts(" (没有 /proc/self/exe, 跳过)"); return; }
if (n <= 0) {
puts(" (没有 /proc/self/exe, 跳过)");
return;
}
self[n] = '\0';
char tmpl[] = "/tmp/ligent_link_XXXXXX";
char *dir = mkdtemp(tmpl);
if (!dir) { puts(" (mkdtemp 失败, 跳过)"); return; }
if (!dir) {
puts(" (mkdtemp 失败, 跳过)");
return;
}
char *link = xasprintf("%s/ligent", dir);
if (symlink(self, link) != 0) {
puts(" (symlink 失败, 跳过)");
free(link); rmdir(dir);
free(link);
rmdir(dir);
return;
}
/* 核心: 指向软链接时取软链接所在目录, 而不是源文件所在目录 */
char *got = config_binary_dir(link);
CHECK(got && strcmp(got, dir) == 0,
"应取软链接所在目录 %s, 实际 %s", dir, got ? got : "(null)");
CHECK(got && strcmp(got, dir) == 0, "应取软链接所在目录 %s, 实际 %s", dir,
got ? got : "(null)");
free(got);
/* 路径里的 . 应被规范化, 但最后一段仍不解析 */
char *dotted = xasprintf("%s/./ligent", dir);
got = config_binary_dir(dotted);
CHECK(got && strcmp(got, dir) == 0,
"带 . 的路径应规范化, 实际 %s", got ? got : "(null)");
free(got); free(dotted);
CHECK(got && strcmp(got, dir) == 0, "带 . 的路径应规范化, 实际 %s",
got ? got : "(null)");
free(got);
free(dotted);
/* argv[0] 只是个名字时经 $PATH 找到的仍应是软链接本身 */
char *saved = getenv("PATH") ? xstrdup(getenv("PATH")) : NULL;
@@ -465,25 +485,30 @@ static void test_symlink_dir(void)
CHECK(got && strcmp(got, dir) == 0,
"$PATH 找到软链接时应取它所在目录, 实际 %s", got ? got : "(null)");
free(got);
if (saved) setenv("PATH", saved, 1); else unsetenv("PATH");
free(saved); free(newpath);
if (saved)
setenv("PATH", saved, 1);
else
unsetenv("PATH");
free(saved);
free(newpath);
/* 直接用真实文件时, 取的还是真实目录 */
char *selfdir = xstrdup(self);
char *slash = strrchr(selfdir, '/');
if (slash) *slash = '\0';
if (slash)
*slash = '\0';
got = config_binary_dir(self);
CHECK(got && strcmp(got, selfdir) == 0,
"真实文件应取真实目录 %s, 实际 %s", selfdir, got ? got : "(null)");
free(got); free(selfdir);
CHECK(got && strcmp(got, selfdir) == 0, "真实文件应取真实目录 %s, 实际 %s",
selfdir, got ? got : "(null)");
free(got);
free(selfdir);
unlink(link);
free(link);
rmdir(dir);
}
int main(void)
{
int main(void) {
test_defaults();
test_env_key();
test_env_others();
+30 -28
View File
@@ -9,33 +9,32 @@
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"); \
} \
#define CHECK(cond, ...) \
do { \
checks++; \
if (!(cond)) { \
failures++; \
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
printf(__VA_ARGS__); \
printf("\n"); \
} \
} while (0)
int main(void)
{
int main(void) {
puts("重试判定: 只重试服务端明确报错的情况");
/* 应当重试 */
long retryable[] = { 408, 409, 429, 500, 502, 503, 504 };
long retryable[] = {408, 409, 429, 500, 502, 503, 504};
for (size_t i = 0; i < sizeof retryable / sizeof retryable[0]; i++)
CHECK(http_status_retryable(retryable[i]) == 1,
"HTTP %ld 应当可重试", retryable[i]);
CHECK(http_status_retryable(retryable[i]) == 1, "HTTP %ld 应当可重试",
retryable[i]);
/* 绝不应当重试: 这些是确定性错误, 重试只是浪费时间和钱 */
long permanent[] = { 400, 401, 403, 404, 405, 413, 415, 422, 501,
200, 201, 204, 301, 0, -1 };
long permanent[] = {400, 401, 403, 404, 405, 413, 415, 422,
501, 200, 201, 204, 301, 0, -1};
for (size_t i = 0; i < sizeof permanent / sizeof permanent[0]; i++)
CHECK(http_status_retryable(permanent[i]) == 0,
"HTTP %ld 不应重试", permanent[i]);
CHECK(http_status_retryable(permanent[i]) == 0, "HTTP %ld 不应重试",
permanent[i]);
/* 默认策略与上限的合理性 */
HttpRetry def = HTTP_RETRY_DEFAULT;
@@ -53,19 +52,22 @@ int main(void)
size_t sz = 0;
FILE *f = open_memstream(&buf, &sz);
/* 这条正是曾经漏出去的: libcurl 的 HTTP/2 调试行走的是 CURLINFO_TEXT,
/* 这条正是曾经漏出去的: libcurl 的 HTTP/2 调试行走的是 CURLINFO_TEXT,
* 不是 HEADER_OUT, 所以只处理 HEADER_OUT 的做法根本挡不住 */
const char *h2 = "== Info: [HTTP/2] [1] [authorization: Bearer sk-0f114e3dac8249f0a36fd8fd5c517360]\n";
const char *h2 = "== Info: [HTTP/2] [1] [authorization: Bearer "
"sk-0f114e3dac8249f0a36fd8fd5c517360]\n";
http_write_redacted(f, h2, strlen(h2), key);
/* 整块请求头 (HEADER_OUT 是整块传来的, 不是逐行) */
const char *hdrs = "POST /v1/chat/completions HTTP/2\r\n"
"Authorization: Bearer sk-0f114e3dac8249f0a36fd8fd5c517360\r\n"
"Content-Type: application/json\r\n";
const char *hdrs =
"POST /v1/chat/completions HTTP/2\r\n"
"Authorization: Bearer sk-0f114e3dac8249f0a36fd8fd5c517360\r\n"
"Content-Type: application/json\r\n";
http_write_redacted(f, hdrs, strlen(hdrs), key);
/* 其它内容不能被改动 */
const char *plain = "== Info: TLSv1.3 (IN), TLS handshake, Finished (20):\n";
const char *plain =
"== Info: TLSv1.3 (IN), TLS handshake, Finished (20):\n";
http_write_redacted(f, plain, strlen(plain), key);
/* 没有密钥时原样输出 */
@@ -87,9 +89,9 @@ int main(void)
puts("\n空请求体: POST 分支也要扛得住 body == NULL");
{
/* body 允许为 NULL (http_probe 就是这么调的), 但 POST 分支以前写的是
* 无条件的 strlen(body) —— 传 NULL 会在 curl_easy_setopt 期间就段错误.
* 无条件的 strlen(body) —— 传 NULL 会在 curl_easy_setopt 期间就段错误.
*
* 这里故意用一个格式非法的 URL: libcurl 会在真正联网之前就拒绝它,
* 这里故意用一个格式非法的 URL: libcurl 会在真正联网之前就拒绝它,
* 所以本测试仍然不碰网络. 而 strlen(NULL) 发生在设置 POSTFIELDS 期间、
* curl_easy_perform 之前, 也就是说旧代码在这里必崩 —— 这个断言对旧
* 代码是有效的回归守卫, 不是走过场. */
@@ -99,8 +101,8 @@ int main(void)
long status = 0;
http_global_init();
int rc = http_post(":::", "sk-test", NULL, 5, 0, &no_retry,
NULL, &status, &attempts, errbuf, sizeof errbuf);
int rc = http_post(":::", "sk-test", NULL, 5, 0, &no_retry, NULL,
&status, &attempts, errbuf, sizeof errbuf);
http_global_cleanup();
CHECK(rc == -1, "非法 URL 应当返回 -1 (实际返回 %d)", rc);
+71 -54
View File
@@ -1,10 +1,10 @@
/* test_i18n.c - 界面文案表
*
* 这张表是"每种语言一条". 它最容易出的错不是逻辑, 而是漏: 加了键忘了翻,
* 或者译文里少了个占位符 ({path} 写成 {paths})—— 用户看到的就是一句缺字的话.
* 所以这里的检查全部是机械的: 遍历表, 逐条比对.
* 这张表是"每种语言一条". 它最容易出的错不是逻辑, 而是漏: 加了键忘了翻,
* 或者译文里少了个占位符 ({path} 写成 {paths})—— 用户看到的就是一句缺字的话.
* 所以这里的检查全部是机械的: 遍历表, 逐条比对.
*
* 文案本身好不好不在这里管; "英文界面是不是每句都有着落"才是.
* 文案本身好不好不在这里管; "英文界面是不是每句都有着落"才是.
*/
#include "i18n.h"
@@ -15,27 +15,29 @@
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"); \
} \
#define CHECK(cond, ...) \
do { \
checks++; \
if (!(cond)) { \
failures++; \
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
printf(__VA_ARGS__); \
printf("\n"); \
} \
} while (0)
/* 收一份占位符名单 ({name} 里的 name), 按出现顺序放进 out */
static int placeholders(const char *s, char (*out)[32], int max)
{
static int placeholders(const char *s, char (*out)[32], int max) {
int n = 0;
for (const char *p = s; *p; p++) {
if (*p != '{') continue;
if (*p != '{')
continue;
const char *e = strchr(p + 1, '}');
if (!e) break;
if (!e)
break;
size_t len = (size_t)(e - p - 1);
if (len == 0 || len >= 32 || n >= max) break;
if (len == 0 || len >= 32 || n >= max)
break;
memcpy(out[n], p + 1, len);
out[n][len] = '\0';
n++;
@@ -44,20 +46,27 @@ static int placeholders(const char *s, char (*out)[32], int max)
return n;
}
/* 收一份 printf 转换符名单 (%zu、%c、%.20s …), 原样带走标志/宽度/长度.
/* 收一份 printf 转换符名单 (%zu、%c、%.20s …), 原样带走标志/宽度/长度.
* json.* 那几条是 printf 模板而不是 {name}, 两种语言只要转换符顺序或类型
* 对不上, snprintf 就会读错参数 —— 比少字更糟, 得由机器盯住. */
static int printf_specs(const char *s, char (*out)[16], int max)
{
static int printf_specs(const char *s, char (*out)[16], int max) {
int n = 0;
for (const char *p = s; p && *p; p++) {
if (*p != '%' || n >= max) continue;
if (*p != '%' || n >= max)
continue;
const char *q = p + 1;
while (*q && strchr("-+ #0", *q)) q++; /* 标志 */
while (*q >= '0' && *q <= '9') q++; /* 宽度 */
if (*q == '.') { q++; while (*q >= '0' && *q <= '9') q++; }
while (*q == 'h' || *q == 'l' || *q == 'L' ||
*q == 'z' || *q == 'j' || *q == 't') q++; /* 长度修饰 */
while (*q && strchr("-+ #0", *q))
q++; /* 标志 */
while (*q >= '0' && *q <= '9')
q++; /* 宽度 */
if (*q == '.') {
q++;
while (*q >= '0' && *q <= '9')
q++;
}
while (*q == 'h' || *q == 'l' || *q == 'L' || *q == 'z' || *q == 'j' ||
*q == 't')
q++; /* 长度修饰 */
if (strchr("diouxXcspfgeE", *q)) {
size_t len = (size_t)(q + 1 - p);
if (len < 16) {
@@ -66,7 +75,8 @@ static int printf_specs(const char *s, char (*out)[16], int max)
n++;
}
}
if (*q) p = q;
if (*q)
p = q;
}
return n;
}
@@ -74,24 +84,22 @@ static int printf_specs(const char *s, char (*out)[16], int max)
/* 有没有中日韩字符 (含全角标点: 〔〕 ()). 英文那一列里出现它, 基本就是
* "照抄了中文"或者"美化用的框线忘了换" —— 用户看到的是英文句子中间卡着一对
* 中文括号. 顺带把"英文栏里混进日文假名"这种复制粘贴也一并挡住. */
static int has_cjk(const char *s)
{
static int has_cjk(const char *s) {
for (const unsigned char *p = (const unsigned char *)s; p && *p; p++) {
if (p[0] >= 0xE3 && p[0] <= 0xEF && p[1] >= 0x80 && p[2] >= 0x80) {
unsigned cp = (unsigned)((p[0] & 0x0Fu) << 12)
| (unsigned)((p[1] & 0x3Fu) << 6)
| (unsigned)(p[2] & 0x3Fu);
if ((cp >= 0x3000 && cp <= 0x30FF) || /* 标点 / 假名 */
(cp >= 0x4E00 && cp <= 0x9FFF) || /* 汉字 */
(cp >= 0xFF00 && cp <= 0xFFEF)) /* 全角 */
unsigned cp = (unsigned)((p[0] & 0x0Fu) << 12) |
(unsigned)((p[1] & 0x3Fu) << 6) |
(unsigned)(p[2] & 0x3Fu);
if ((cp >= 0x3000 && cp <= 0x30FF) || /* 标点 / 假名 */
(cp >= 0x4E00 && cp <= 0x9FFF) || /* 汉字 */
(cp >= 0xFF00 && cp <= 0xFFEF)) /* 全角 */
return 1;
}
}
return 0;
}
int main(void)
{
int main(void) {
size_t n = 0;
const LigentMsg *tab = i18n_table(&n);
CHECK(tab != NULL && n > 0, "文案表不能是空的");
@@ -100,7 +108,8 @@ int main(void)
for (size_t i = 0; i < n; i++) {
const LigentMsg *m = &tab[i];
CHECK(m->key && *m->key, "第 %zu 条没有 key", i);
if (!m->key || !*m->key) continue;
if (!m->key || !*m->key)
continue;
CHECK(m->en && *m->en, "%s 缺英文", m->key);
CHECK(m->zh && *m->zh, "%s 缺中文", m->key);
CHECK(!has_cjk(m->en ? m->en : ""), "%s 的英文里混进了中日韩字符",
@@ -110,32 +119,35 @@ int main(void)
* CHECK 的话, check-docs 数出来的"单元项数"就没意义了. */
int dup = 0;
for (size_t j = i + 1; j < n; j++)
if (strcmp(tab[j].key, m->key) == 0) dup = 1;
if (strcmp(tab[j].key, m->key) == 0)
dup = 1;
CHECK(!dup, "key 重复: %s", m->key);
char pe[16][32], pz[16][32];
int ne = placeholders(m->en ? m->en : "", pe, 16);
int nz = placeholders(m->zh ? m->zh : "", pz, 16);
CHECK(ne == nz, "%s 两种语言的占位符数量不同 (en %d / zh %d)",
m->key, ne, nz);
CHECK(ne == nz, "%s 两种语言的占位符数量不同 (en %d / zh %d)", m->key,
ne, nz);
for (int a = 0; a < ne && a < nz; a++) {
int found = 0;
for (int b = 0; b < nz; b++)
if (strcmp(pe[a], pz[b]) == 0) found = 1;
if (strcmp(pe[a], pz[b]) == 0)
found = 1;
CHECK(found, "%s: 英文的 {%s} 在中文里没有对应", m->key, pe[a]);
}
/* 译文里多出一个原文没有的占位符, 同样会在运行时漏字 */
for (int a = 0; a < nz && a < ne; a++) {
int found = 0;
for (int b = 0; b < ne; b++)
if (strcmp(pz[a], pe[b]) == 0) found = 1;
if (strcmp(pz[a], pe[b]) == 0)
found = 1;
CHECK(found, "%s: 中文的 {%s} 在英文里没有对应", m->key, pz[a]);
}
}
/* 表里有一部分键是**当 printf 模板用的** (repl.usage.*、repl.model.row、
* json.* …): 调用方直接把 t(key) 交给 printf/snprintf. 这些条目的
* {name} 检查管不到, 得单独比转换符 —— 少一个 %s 会读到别的参数,
* {name} 检查管不到, 得单独比转换符 —— 少一个 %s 会读到别的参数,
* 类型写错 (%d 对 %zu)更是直接踩内存. */
puts("printf 模板: 两种语言的转换符必须一模一样");
for (size_t i = 0; i < n; i++) {
@@ -143,12 +155,13 @@ int main(void)
char se[16][16], sz[16][16];
int ce = printf_specs(m->en ? m->en : "", se, 16);
int cz = printf_specs(m->zh ? m->zh : "", sz, 16);
if (ce == 0 && cz == 0) continue; /* 不是模板, 跳过 */
if (ce == 0 && cz == 0)
continue; /* 不是模板, 跳过 */
CHECK(ce == cz, "%s: 转换符个数不同 (en %d / zh %d)", m->key, ce, cz);
for (int a = 0; a < ce && a < cz; a++)
CHECK(strcmp(se[a], sz[a]) == 0,
"%s: 第 %d 个转换符不同 (en %s / zh %s)",
m->key, a + 1, se[a], sz[a]);
"%s: 第 %d 个转换符不同 (en %s / zh %s)", m->key, a + 1,
se[a], sz[a]);
}
puts("查表、切换语言、漏键可见");
@@ -169,15 +182,17 @@ int main(void)
const char *r = tf("cli.err.unknown_option", "option", "--nope", NULL);
CHECK(strstr(r, "--nope") != NULL, "值要填进去");
CHECK(strstr(r, "{option}") == NULL, "填过的占位符不该留下");
CHECK(strstr(tf("cli.err.unknown_option", "wrong", "x", NULL), "{option}") != NULL,
CHECK(strstr(tf("cli.err.unknown_option", "wrong", "x", NULL),
"{option}") != NULL,
"名字对不上时原样保留, 别静默吞掉");
i18n_set_lang(LIGENT_LANG_ZH);
CHECK(strstr(tf("cli.err.unknown_option", "option", "选项X", NULL), "选项X") != NULL,
CHECK(strstr(tf("cli.err.unknown_option", "option", "选项X", NULL),
"选项X") != NULL,
"中文模板同样能填");
/* 同一句里多个占位符, 且顺序与调用顺序相反 */
i18n_set_lang(LIGENT_LANG_EN);
const char *two = tf("doctor.val.match_bad", "guess", "OpenAI",
"owner", "DeepSeek", NULL);
const char *two = tf("doctor.val.match_bad", "guess", "OpenAI", "owner",
"DeepSeek", NULL);
CHECK(strstr(two, "DeepSeek") && strstr(two, "OpenAI"), "两个值都要在");
CHECK(strstr(two, "{") == NULL, "不该有没填上的占位符: %s", two);
@@ -192,7 +207,8 @@ int main(void)
CHECK(i18n_parse_lang("") < 0, "空串返回 -1");
CHECK(i18n_parse_lang(NULL) < 0, "NULL 返回 -1");
puts("环境探测: LIGENT_LANG > LC_ALL > LC_MESSAGES > LANG, 认不出/没设 -> 英文");
puts("环境探测: LIGENT_LANG > LC_ALL > LC_MESSAGES > LANG, 认不出/没设 -> "
"英文");
unsetenv("LIGENT_LANG");
unsetenv("LC_ALL");
unsetenv("LC_MESSAGES");
@@ -207,7 +223,8 @@ int main(void)
setenv("LIGENT_LANG", "en", 1);
CHECK(i18n_lang_from_env() == LIGENT_LANG_EN, "LIGENT_LANG 优先级最高");
setenv("LIGENT_LANG", "fr", 1);
CHECK(i18n_lang_from_env() == LIGENT_LANG_EN, "LIGENT_LANG 认不出时回退英文");
CHECK(i18n_lang_from_env() == LIGENT_LANG_EN,
"LIGENT_LANG 认不出时回退英文");
unsetenv("LIGENT_LANG");
unsetenv("LC_ALL");
unsetenv("LC_MESSAGES");
+76 -60
View File
@@ -5,69 +5,77 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/stat.h>
#include <unistd.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"); \
} \
#define CHECK(cond, ...) \
do { \
checks++; \
if (!(cond)) { \
failures++; \
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
printf(__VA_ARGS__); \
printf("\n"); \
} \
} while (0)
static const char PNG[] = "\x89PNG\r\n\x1a\n rest";
static const char PNG[] = "\x89PNG\r\n\x1a\n rest";
static const char JPEG[] = "\xff\xd8\xff\xe0 rest";
static const char GIF87[] = "GIF87a rest";
static const char GIF89[] = "GIF89a rest";
static const char WEBP[] = "RIFF\x00\x00\x00\x00WEBPVP8 ";
static void test_sniff(void)
{
static void test_sniff(void) {
puts("按文件内容认格式 (不看扩展名)");
const char *ext = NULL, *mime = NULL;
CHECK(image_sniff((const unsigned char *)PNG, 14, &ext, &mime) == 0 &&
strcmp(ext, "png") == 0 && strcmp(mime, "image/png") == 0, "PNG");
strcmp(ext, "png") == 0 && strcmp(mime, "image/png") == 0,
"PNG");
CHECK(image_sniff((const unsigned char *)JPEG, 7, &ext, &mime) == 0 &&
strcmp(ext, "jpg") == 0 && strcmp(mime, "image/jpeg") == 0, "JPEG");
strcmp(ext, "jpg") == 0 && strcmp(mime, "image/jpeg") == 0,
"JPEG");
CHECK(image_sniff((const unsigned char *)GIF87, 10, &ext, &mime) == 0 &&
strcmp(ext, "gif") == 0, "GIF87a");
strcmp(ext, "gif") == 0,
"GIF87a");
CHECK(image_sniff((const unsigned char *)GIF89, 10, &ext, &mime) == 0 &&
strcmp(ext, "gif") == 0, "GIF89a");
strcmp(ext, "gif") == 0,
"GIF89a");
CHECK(image_sniff((const unsigned char *)WEBP, 16, &ext, &mime) == 0 &&
strcmp(ext, "webp") == 0 && strcmp(mime, "image/webp") == 0, "WebP");
strcmp(ext, "webp") == 0 && strcmp(mime, "image/webp") == 0,
"WebP");
/* 认不出来要明确失败, 别猜一个格式报上去 */
CHECK(image_sniff((const unsigned char *)"not an image", 12, &ext, &mime) != 0,
CHECK(image_sniff((const unsigned char *)"not an image", 12, &ext, &mime) !=
0,
"杂文本不该被认成图片");
CHECK(image_sniff((const unsigned char *)"\x89PNG", 4, &ext, &mime) != 0,
"截断的 PNG 头不该被认出来");
CHECK(image_sniff((const unsigned char *)"RIFFxxxxNOPE", 12, &ext, &mime) != 0,
CHECK(image_sniff((const unsigned char *)"RIFFxxxxNOPE", 12, &ext, &mime) !=
0,
"RIFF 但不是 WEBP 不该被认成 WebP");
CHECK(image_sniff(NULL, 0, &ext, &mime) != 0, "NULL 不该崩");
}
static void test_backup(void)
{
static void test_backup(void) {
puts("本地备份: 命名、内容、不重复");
char tmpl[] = "/tmp/ligent_img_XXXXXX";
char *dir = mkdtemp(tmpl);
CHECK(dir != NULL, "建临时目录失败");
if (!dir) return;
if (!dir)
return;
const unsigned char data[] = "\x89PNG\r\n\x1a\n payload";
size_t n = sizeof data - 1;
char *b1 = image_backup_save(dir, data, n, "png");
CHECK(b1 != NULL, "备份应成功");
if (!b1) { return; }
if (!b1) {
return;
}
/* 名字形如 <上传时间戳>_<4位随机>.<ext> */
CHECK(strlen(b1) > 5, "名字不该太短: %s", b1);
@@ -79,34 +87,39 @@ static void test_backup(void)
char *p1 = image_backup_path(dir, b1);
size_t len = 0;
char *back = read_entire_file(p1, &len);
CHECK(back && len == n && memcmp(back, data, n) == 0, "备份内容应逐字节一致");
free(back); free(p1);
CHECK(back && len == n && memcmp(back, data, n) == 0,
"备份内容应逐字节一致");
free(back);
free(p1);
/* 再存一次应该拿到不同的名字 (随机后缀), 而不是互相覆盖 */
char *b2 = image_backup_save(dir, data, n, "png");
CHECK(b2 && strcmp(b1, b2) != 0, "两次备份不该同号: %s / %s",
b1, b2 ? b2 : "(null)");
CHECK(b2 && strcmp(b1, b2) != 0, "两次备份不该同号: %s / %s", b1,
b2 ? b2 : "(null)");
free(b2);
free(b1);
/* 清场 */
char *cmd = xasprintf("rm -rf %s", dir);
if (system(cmd) != 0) { /* 清理失败不影响断言 */ }
if (system(cmd) != 0) { /* 清理失败不影响断言 */
}
free(cmd);
}
static void test_base64(void)
{
static void test_base64(void) {
puts("base64: 长度、补位、往返");
/* 用几组长度覆盖三种补位情况: 3n、3n+1、3n+2 */
struct { const char *in; const char *want; } cases[] = {
{ "", "" },
{ "f", "Zg==" },
{ "fo", "Zm8=" },
{ "foo", "Zm9v" },
{ "foob", "Zm9vYg==" },
{ "fooba", "Zm9vYmE=" },
{ "foobar", "Zm9vYmFy" },
struct {
const char *in;
const char *want;
} cases[] = {
{"", ""},
{"f", "Zg=="},
{"fo", "Zm8="},
{"foo", "Zm9v"},
{"foob", "Zm9vYg=="},
{"fooba", "Zm9vYmE="},
{"foobar", "Zm9vYmFy"},
};
for (size_t i = 0; i < sizeof cases / sizeof cases[0]; i++) {
size_t n = strlen(cases[i].in);
@@ -119,7 +132,7 @@ static void test_base64(void)
free(got);
}
/* 二进制字节 (0x00 / 0xFF 这类不能被当字符串处理) */
const unsigned char bin[] = { 0x00, 0xFF, 0x10, 0x80 };
const unsigned char bin[] = {0x00, 0xFF, 0x10, 0x80};
char *b = base64_encode(bin, sizeof bin);
CHECK(b && strcmp(b, "AP8QgA==") == 0, "二进制应正确编码, 实际 %s",
b ? b : "(null)");
@@ -127,21 +140,21 @@ static void test_base64(void)
}
/* 造一张内容合法 (靠文件头就能嗅探出来)的假图, 返回字节数 */
static size_t fake_png(unsigned char *out, size_t n)
{
static size_t fake_png(unsigned char *out, size_t n) {
static const char magic[] = "\x89PNG\r\n\x1a\n";
size_t m = sizeof magic - 1;
if (n < m) return 0;
if (n < m)
return 0;
memcpy(out, magic, m);
for (size_t i = m; i < n; i++) out[i] = (unsigned char)(i & 0xFF);
for (size_t i = m; i < n; i++)
out[i] = (unsigned char)(i & 0xFF);
return n;
}
/* 把一段备份引用塞进一条 user 消息, 模拟 read_image 之后的历史 */
static const char *ref_message_type = "ligent_image";
static JsonValue *history_with_ref(const char *backup)
{
static JsonValue *history_with_ref(const char *backup) {
JsonValue *msgs = json_new_array();
JsonValue *sys = json_new_object();
@@ -162,20 +175,23 @@ static JsonValue *history_with_ref(const char *backup)
return msgs;
}
static void test_expand(void)
{
static void test_expand(void) {
puts("请求前展开: 引用 -> image_url + base64");
char tmpl[] = "/tmp/ligent_imgx_XXXXXX";
char *dir = mkdtemp(tmpl);
CHECK(dir != NULL, "建临时目录失败");
if (!dir) return;
if (!dir)
return;
image_set_bindir(dir);
unsigned char png[256];
size_t n = fake_png(png, sizeof png);
char *backup = image_backup_save(dir, png, n, "png");
CHECK(backup != NULL, "备份应成功");
if (!backup) { image_set_bindir("."); return; }
if (!backup) {
image_set_bindir(".");
return;
}
/* --- 正常展开 --- */
JsonValue *msgs = history_with_ref(backup);
@@ -191,14 +207,14 @@ static void test_expand(void)
"附件消息应有一个 content 块");
JsonValue *wp = json_array_get(wc, 0);
CHECK(strcmp(json_get_string(wp, "type"), "image_url") == 0,
"发送形态的类型应是 image_url, 实际 %s",
json_get_string(wp, "type"));
"发送形态的类型应是 image_url, 实际 %s", json_get_string(wp, "type"));
JsonValue *iu = json_object_get(wp, "image_url");
const char *url = iu ? json_get_string(iu, "url") : NULL;
CHECK(url && strncmp(url, "data:image/png;base64,", 22) == 0,
"应是 data: URL, 实际 %.40s", url ? url : "(null)");
/* base64 段应能解回原字节 —— 这里只验长度与首尾, 避免在测试里再写一个解码器 */
/* base64 段应能解回原字节 —— 这里只验长度与首尾, 避免在测试里再写一个解码器
*/
CHECK(url && strlen(url) == 22 + base64_encoded_len(n),
"data URL 长度应等于前缀 + base64 长度");
@@ -274,14 +290,14 @@ static void test_expand(void)
free(backup);
image_set_bindir(".");
char *cmd = xasprintf("rm -rf %s", tmpl);
if (system(cmd) != 0) { }
if (system(cmd) != 0) {
}
free(cmd);
}
static void test_pending(void)
{
static void test_pending(void) {
puts("待发附件: 取走即清空");
image_pending_take(); /* 先清干净 */
image_pending_take(); /* 先清干净 */
image_pending_add("1700000000_aaaa.png");
image_pending_add("1700000001_bbbb.png");
@@ -296,7 +312,8 @@ static void test_pending(void)
CHECK(json_get_string(p0, "file_id") == NULL,
"不该再有 file_id 这种协议字段");
CHECK(strcmp(json_get_string(json_array_get(arr, 1), "backup"),
"1700000001_bbbb.png") == 0, "第二个备份名");
"1700000001_bbbb.png") == 0,
"第二个备份名");
}
json_free(arr);
@@ -306,8 +323,7 @@ static void test_pending(void)
CHECK(image_pending_take() == NULL, "空备份名不该被记下");
}
int main(void)
{
int main(void) {
test_sniff();
test_backup();
test_base64();
+92 -68
View File
@@ -9,25 +9,26 @@
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"); \
} \
#define CHECK(cond, ...) \
do { \
checks++; \
if (!(cond)) { \
failures++; \
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
printf(__VA_ARGS__); \
printf("\n"); \
} \
} while (0)
static void test_basic(void)
{
static void test_basic(void) {
puts("test_basic");
char err[256];
JsonValue *v = json_parse("{\"a\": 1, \"b\": [true, false, null], \"c\": \"hi\"}",
err, sizeof err);
JsonValue *v =
json_parse("{\"a\": 1, \"b\": [true, false, null], \"c\": \"hi\"}", err,
sizeof err);
CHECK(v != NULL, "解析失败: %s", err);
if (!v) return;
if (!v)
return;
CHECK(json_get_number(v, "a", -1) == 1, "a 应为 1");
CHECK(json_array_size(json_object_get(v, "b")) == 3, "b 应有 3 个元素");
CHECK(json_bool_value(json_array_get(json_object_get(v, "b"), 0), 0) == 1,
@@ -41,18 +42,20 @@ static void test_basic(void)
CHECK(v2 != NULL, "往返解析失败: %s", err);
char *dump2 = json_dump(v2);
CHECK(strcmp(dump, dump2) == 0, "往返不一致:\n %s\n %s", dump, dump2);
free(dump); free(dump2);
json_free(v); json_free(v2);
free(dump);
free(dump2);
json_free(v);
json_free(v2);
}
static void test_escapes(void)
{
static void test_escapes(void) {
puts("test_escapes");
char err[256];
JsonValue *v = json_parse("\"a\\nb\\t\\\"c\\\\d\\u4e2d\\u6587\\uD83D\\uDE00\"",
err, sizeof err);
JsonValue *v = json_parse(
"\"a\\nb\\t\\\"c\\\\d\\u4e2d\\u6587\\uD83D\\uDE00\"", err, sizeof err);
CHECK(v != NULL, "解析失败: %s", err);
if (!v) return;
if (!v)
return;
const char *s = json_string_value(v);
CHECK(strcmp(s, "a\nb\t\"c\\d中文😀") == 0, "转义解码错误: [%s]", s);
@@ -61,16 +64,18 @@ static void test_escapes(void)
CHECK(v2 != NULL, "转义往返失败: %s / %s", q, err);
CHECK(v2 && strcmp(json_string_value(v2), s) == 0, "转义往返内容不一致");
free(q);
json_free(v); json_free(v2);
json_free(v);
json_free(v2);
}
static void test_numbers(void)
{
static void test_numbers(void) {
puts("test_numbers");
char err[256];
JsonValue *v = json_parse("[0, -1, 3.5, 1e3, -2.5e-2, 123456789012]", err, sizeof err);
JsonValue *v =
json_parse("[0, -1, 3.5, 1e3, -2.5e-2, 123456789012]", err, sizeof err);
CHECK(v != NULL, "解析失败: %s", err);
if (!v) return;
if (!v)
return;
CHECK(json_number_value(json_array_get(v, 0), -1) == 0, "[0]");
CHECK(json_number_value(json_array_get(v, 1), 0) == -1, "[1]");
CHECK(json_number_value(json_array_get(v, 2), 0) == 3.5, "[2]");
@@ -92,24 +97,34 @@ static void test_numbers(void)
}
/* 边界上合法的写法不能被误伤 */
const char *ok_nums[] = {
"0", "-0", "0.0", "10", "-1.5", "1e3", "1E3", "1e+3", "1e-3", "0.1e10",
"123456789012345678901234567890", "1e308",
"0",
"-0",
"0.0",
"10",
"-1.5",
"1e3",
"1E3",
"1e+3",
"1e-3",
"0.1e10",
"123456789012345678901234567890",
"1e308",
};
for (size_t i = 0; i < sizeof ok_nums / sizeof ok_nums[0]; i++) {
JsonValue *g = json_parse(ok_nums[i], err, sizeof err);
CHECK(g != NULL && g->type == JSON_NUMBER,
"合法数字被拒: [%s] (%s)", ok_nums[i], err);
CHECK(g != NULL && g->type == JSON_NUMBER, "合法数字被拒: [%s] (%s)",
ok_nums[i], err);
json_free(g);
}
}
static void test_invalid(void)
{
static void test_invalid(void) {
puts("test_invalid");
char err[256];
const char *bad[] = {
"{", "}", "[1,]", "{\"a\":}", "\"unterminated", "tru", "{\"a\" 1}",
"[1 2]", "01abc", "", " ", "{\"a\":1}}", "[1,2] extra",
"{", "}", "[1,]", "{\"a\":}", "\"unterminated",
"tru", "{\"a\" 1}", "[1 2]", "01abc", "",
" ", "{\"a\":1}}", "[1,2] extra",
};
for (size_t i = 0; i < sizeof bad / sizeof bad[0]; i++) {
JsonValue *v = json_parse(bad[i], err, sizeof err);
@@ -118,12 +133,12 @@ static void test_invalid(void)
}
/* 空字符串是合法 JSON 字符串 */
JsonValue *e = json_parse("\"\"", err, sizeof err);
CHECK(e != NULL && strcmp(json_string_value(e), "") == 0, "空字符串应为合法");
CHECK(e != NULL && strcmp(json_string_value(e), "") == 0,
"空字符串应为合法");
json_free(e);
}
static void test_builders(void)
{
static void test_builders(void) {
puts("test_builders");
JsonValue *o = json_new_object();
json_object_set_string(o, "model", "gpt-x");
@@ -133,30 +148,34 @@ static void test_builders(void)
json_array_push(arr, json_new_string("a"));
json_array_push(arr, json_new_string("b"));
json_object_set(o, "items", arr);
json_object_set_string(o, "model", "gpt-y"); /* 覆盖 */
json_object_set_string(o, "model", "gpt-y"); /* 覆盖 */
char *s = json_dump(o);
CHECK(strcmp(s, "{\"model\":\"gpt-y\",\"n\":3,\"ok\":true,\"items\":[\"a\",\"b\"]}") == 0,
CHECK(strcmp(s, "{\"model\":\"gpt-y\",\"n\":3,\"ok\":true,\"items\":[\"a\","
"\"b\"]}") == 0,
"构造结果不对: %s", s);
free(s);
/* clone 独立性 */
JsonValue *c = json_clone(o);
json_object_set_string(o, "model", "changed");
CHECK(strcmp(json_get_string(c, "model"), "gpt-y") == 0, "clone 应相互独立");
CHECK(strcmp(json_get_string(c, "model"), "gpt-y") == 0,
"clone 应相互独立");
json_object_remove(o, "model");
CHECK(json_object_get(o, "model") == NULL, "remove 应删除键");
json_free(o); json_free(c);
json_free(o);
json_free(c);
}
static void test_nested_depth(void)
{
static void test_nested_depth(void) {
puts("test_nested_depth");
char err[256];
size_t n = 300;
char *deep = xmalloc(n * 2 + 1);
for (size_t i = 0; i < n; i++) deep[i] = '[';
for (size_t i = 0; i < n; i++) deep[n + i] = ']';
for (size_t i = 0; i < n; i++)
deep[i] = '[';
for (size_t i = 0; i < n; i++)
deep[n + i] = ']';
deep[n * 2] = '\0';
JsonValue *v = json_parse(deep, err, sizeof err);
CHECK(v == NULL, "超深嵌套应被拒绝");
@@ -168,8 +187,7 @@ static void test_nested_depth(void)
}
/* 报错必须能定位: 只有"语法错了"而不知道错在哪, 等于没有报错 */
static void test_error_location(void)
{
static void test_error_location(void) {
puts("test_error_location");
char err[256];
JsonValue *v;
@@ -192,7 +210,8 @@ static void test_error_location(void)
/* 缺冒号 */
v = json_parse("{\"a\" 1}", err, sizeof err);
CHECK(v == NULL, "缺冒号应报错");
CHECK(strstr(err, "第 1 行") && strstr(err, "':'") != NULL, "缺冒号: %s", err);
CHECK(strstr(err, "第 1 行") && strstr(err, "':'") != NULL, "缺冒号: %s",
err);
json_free(v);
/* 尾部多余内容 */
@@ -220,30 +239,29 @@ static void test_error_location(void)
json_free(v);
}
/* 序列化出去的东西必须是**合法 UTF-8**.
/* 序列化出去的东西必须是**合法 UTF-8**.
*
* 服务端会因为请求体里有非法 UTF-8 直接回
* 400 ... invalid unicode code point
* 而那段坏数据一旦进了对话历史, 之后每次请求都会失败 —— 除非丢掉整轮对话.
* 而那段坏数据一旦进了对话历史, 之后每次请求都会失败 —— 除非丢掉整轮对话.
* 坏字节的来源很日常: 某条命令在非 UTF-8 locale 下打出来的路径、被截断的多
* 字节字符、二进制垃圾. 所以这条不能只靠"在入口清理", 必须在序列化器这层兜住:
* 出口是唯一能保证"发出去的永远合法"的地方.
* 字节字符、二进制垃圾. 所以这条不能只靠"在入口清理", 必须在序列化器这层兜住:
* 出口是唯一能保证"发出去的永远合法"的地方.
*
* 这个校验函数故意用 utf8_seq_len 自己走一遍, 而不是信任 dump 的实现 ——
* 断言的是**产物**的性质, 不是实现的自述. */
static int utf8_all_valid(const char *s)
{
static int utf8_all_valid(const char *s) {
size_t n = strlen(s), i = 0;
while (i < n) {
size_t adv = utf8_seq_len(s + i, n - i);
if (adv == 0) return 0;
if (adv == 0)
return 0;
i += adv;
}
return 1;
}
static void test_dump_utf8_safety(void)
{
static void test_dump_utf8_safety(void) {
puts("test_dump_utf8_safety");
/* utf8_seq_len: 合法序列返回长度 */
@@ -262,19 +280,25 @@ static void test_dump_utf8_safety(void)
/* 各种坏字节经 json_dump 之后, 整份输出必须是合法 UTF-8 */
static const char *bad[] = {
"a\x80" "b", /* 孤立续字节 */
"a\xe4\xb8" "b", /* 截断的 3 字节序列 */
"a\xc0\xaf" "b", /* 过长编码 (历史上被用来夹带 '/') */
"a\xed\xa0\x80" "b", /* UTF-16 代理区 */
"a\xf5\x80\x80\x80" "b", /* 超出 U+10FFFF */
"\xff\xfe", /* 二进制垃圾 */
"\xe4\xb8\xad\x80\xe6", /* 合法中文里夹一个坏字节 */
"a\x80"
"b", /* 孤立续字节 */
"a\xe4\xb8"
"b", /* 截断的 3 字节序列 */
"a\xc0\xaf"
"b", /* 过长编码 (历史上被用来夹带 '/') */
"a\xed\xa0\x80"
"b", /* UTF-16 代理区 */
"a\xf5\x80\x80\x80"
"b", /* 超出 U+10FFFF */
"\xff\xfe", /* 二进制垃圾 */
"\xe4\xb8\xad\x80\xe6", /* 合法中文里夹一个坏字节 */
};
for (size_t k = 0; k < sizeof bad / sizeof bad[0]; k++) {
JsonValue *o = json_new_object();
json_object_set_string(o, "content", bad[k]);
char *d = json_dump(o);
CHECK(utf8_all_valid(d), "第 %zu 个坏输入序列化后仍含非法 UTF-8: %s", k, d);
CHECK(utf8_all_valid(d), "第 %zu 个坏输入序列化后仍含非法 UTF-8: %s", k,
d);
/* 而且我们的解析器 (和服务端一样严格)必须收得下 */
char err[256];
JsonValue *back = json_parse(d, err, sizeof err);
@@ -294,14 +318,14 @@ static void test_dump_utf8_safety(void)
json_object_set_string(o, "content", "中文 😀 tail");
char *d = json_dump(o);
CHECK(strstr(d, "中文") != NULL, "合法中文不该被改动: %s", d);
CHECK(strstr(d, "\xf0\x9f\x98\x80") != NULL, "合法 emoji 不该被改动: %s", d);
CHECK(strstr(d, "\xf0\x9f\x98\x80") != NULL, "合法 emoji 不该被改动: %s",
d);
CHECK(strstr(d, "\xef\xbf\xbd") == NULL, "合法输入不该出现替换字符: %s", d);
free(d);
json_free(o);
}
int main(void)
{
int main(void) {
test_basic();
test_escapes();
test_numbers();
+51 -42
View File
@@ -10,41 +10,41 @@
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"); \
} \
#define CHECK(cond, ...) \
do { \
checks++; \
if (!(cond)) { \
failures++; \
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
printf(__VA_ARGS__); \
printf("\n"); \
} \
} while (0)
#define BUFEQ(lb, want) \
CHECK(strcmp((lb).buf, (want)) == 0 && (lb).len == strlen(want), \
#define BUFEQ(lb, want) \
CHECK(strcmp((lb).buf, (want)) == 0 && (lb).len == strlen(want), \
"内容应为 [%s], 实际 [%s]", want, (lb).buf)
/* 读一个文本文件的前 n 字节到 buf (用于检查落盘格式) */
static void slurp(const char *path, char *buf, size_t buflen)
{
static void slurp(const char *path, char *buf, size_t buflen) {
buf[0] = '\0';
FILE *f = fopen(path, "r");
if (!f) return;
if (!f)
return;
size_t n = fread(buf, 1, buflen - 1, f);
buf[n] = '\0';
fclose(f);
}
static void test_history_file(void)
{
static void test_history_file(void) {
puts("输入历史的持久化");
char path[] = "/tmp/ligent_hist_XXXXXX";
int fd = mkstemp(path);
CHECK(fd >= 0, "mkstemp 失败");
if (fd < 0) return;
if (fd < 0)
return;
close(fd);
unlink(path); /* 故意从"文件不存在"开始 */
unlink(path); /* 故意从"文件不存在"开始 */
line_history_clear();
CHECK(line_history_load(path, 100) == 0, "文件不存在不算错误");
@@ -75,7 +75,7 @@ static void test_history_file(void)
unlink(path);
line_history_add("echo C:\\Users\\me");
line_history_append(path, "echo C:\\Users\\me");
line_history_add("第一行\n第二行"); /* 反斜杠续行拼出来的多行输入 */
line_history_add("第一行\n第二行"); /* 反斜杠续行拼出来的多行输入 */
line_history_append(path, "第一行\n第二行");
line_history_add("中文历史");
line_history_append(path, "中文历史");
@@ -84,7 +84,9 @@ static void test_history_file(void)
CHECK(strchr(raw, '\n') != NULL && strstr(raw, "\n\n") == NULL,
"每条只占一行, 实际 %s", raw);
int newlines = 0;
for (const char *p = raw; *p; p++) if (*p == '\n') newlines++;
for (const char *p = raw; *p; p++)
if (*p == '\n')
newlines++;
CHECK(newlines == 3, "3 条历史应占 3 行, 实际 %d 行", newlines);
line_history_clear();
@@ -92,8 +94,10 @@ static void test_history_file(void)
CHECK(line_history_len() == 3, "应读回 3 条, 实际 %zu", line_history_len());
CHECK(strcmp(line_history_get(0), "echo C:\\Users\\me") == 0,
"反斜杠应原样还原, 实际 [%s]", line_history_get(0));
CHECK(line_history_get(1) && strcmp(line_history_get(1), "第一行\n第二行") == 0,
"多行条目应整条还原, 实际 [%s]", line_history_get(1) ? line_history_get(1) : "(null)");
CHECK(line_history_get(1) &&
strcmp(line_history_get(1), "第一行\n第二行") == 0,
"多行条目应整条还原, 实际 [%s]",
line_history_get(1) ? line_history_get(1) : "(null)");
CHECK(strcmp(line_history_get(2), "中文历史") == 0, "中文应原样还原");
line_history_clear();
@@ -107,12 +111,15 @@ static void test_history_file(void)
}
int lines = 0;
slurp(path, raw, sizeof raw);
for (const char *p = raw; *p; p++) if (*p == '\n') lines++;
for (const char *p = raw; *p; p++)
if (*p == '\n')
lines++;
CHECK(lines == 10, "截短前应有 10 行, 实际 %d", lines);
line_history_clear();
line_history_load(path, 4);
CHECK(line_history_len() == 4, "keep=4 时应只剩 4 条, 实际 %zu", line_history_len());
CHECK(line_history_len() == 4, "keep=4 时应只剩 4 条, 实际 %zu",
line_history_len());
CHECK(line_history_get(0) && strcmp(line_history_get(0), "cmd-6") == 0,
"应保留最新的 4 条, 第一条应是 cmd-6, 实际 [%s]",
line_history_get(0) ? line_history_get(0) : "(null)");
@@ -122,20 +129,22 @@ static void test_history_file(void)
slurp(path, raw, sizeof raw);
lines = 0;
for (const char *p = raw; *p; p++) if (*p == '\n') lines++;
for (const char *p = raw; *p; p++)
if (*p == '\n')
lines++;
CHECK(lines == 4, "文件应被就地截短成 4 行, 实际 %d", lines);
/* 截短后的文件读回来还是那 4 条 */
line_history_clear();
line_history_load(path, 100);
CHECK(line_history_len() == 4, "截短后应只读到 4 条, 实际 %zu", line_history_len());
CHECK(line_history_len() == 4, "截短后应只读到 4 条, 实际 %zu",
line_history_len());
line_history_clear();
unlink(path);
}
int main(void)
{
int main(void) {
puts("插入与删除");
LineBuf lb;
linebuf_init(&lb);
@@ -164,13 +173,13 @@ int main(void)
linebuf_delete(&lb);
BUFEQ(lb, "eXllo");
linebuf_end(&lb);
linebuf_delete(&lb); /* 末尾 Delete 应无动作 */
linebuf_delete(&lb); /* 末尾 Delete 应无动作 */
BUFEQ(lb, "eXllo");
linebuf_free(&lb);
puts("UTF-8: 按字符而非字节移动");
linebuf_init(&lb);
linebuf_set(&lb, "中文abc"); /* 中(3) 文(3) a b c = 9 字节 */
linebuf_set(&lb, "中文abc"); /* 中(3) 文(3) a b c = 9 字节 */
CHECK(lb.len == 9, "应为 9 字节, 实际 %zu", lb.len);
linebuf_left(&lb);
CHECK(lb.pos == 8, "左移一格应为 8, 实际 %zu", lb.pos);
@@ -202,8 +211,8 @@ int main(void)
/* 中间删除多字节字符 */
linebuf_set(&lb, "a中b");
linebuf_home(&lb);
linebuf_right(&lb); /* 光标到 'a' 之后 */
linebuf_delete(&lb); /* 删掉 '中' */
linebuf_right(&lb); /* 光标到 'a' 之后 */
linebuf_delete(&lb); /* 删掉 '中' */
BUFEQ(lb, "ab");
CHECK(lb.pos == 1, "删除后光标不动, 实际 %zu", lb.pos);
linebuf_free(&lb);
@@ -217,41 +226,41 @@ int main(void)
CHECK(lb.pos == lb.len, "单行时 End 到行尾");
/* 多行 (粘贴进来的那种): Home/End 只在本行内走 */
linebuf_set(&lb, "aaa\nbbb\nccc"); /* 长度 11 */
linebuf_set(&lb, "aaa\nbbb\nccc"); /* 长度 11 */
CHECK(lb.len == 11, "应为 11 字节, 实际 %zu", lb.len);
linebuf_home(&lb);
CHECK(lb.pos == 8, "末尾按 Home 应到最后一行的行首 (8), 实际 %zu", lb.pos);
linebuf_end(&lb);
CHECK(lb.pos == 11, "End 应到本行行尾 (11), 实际 %zu", lb.pos);
lb.pos = 5; /* 落在 "bbb" 中间 */
lb.pos = 5; /* 落在 "bbb" 中间 */
linebuf_home(&lb);
CHECK(lb.pos == 4, "中间行按 Home 应到该行行首 (4), 实际 %zu", lb.pos);
linebuf_end(&lb);
CHECK(lb.pos == 7, "中间行按 End 应到该行行尾 (7), 实际 %zu", lb.pos);
lb.pos = 4; /* 正好在行首 */
lb.pos = 4; /* 正好在行首 */
linebuf_home(&lb);
CHECK(lb.pos == 4, "已在行首时 Home 不应跳到整个缓冲区开头");
lb.pos = 7; /* 正好在行尾 (换行符前) */
lb.pos = 7; /* 正好在行尾 (换行符前) */
linebuf_end(&lb);
CHECK(lb.pos == 7, "已在行尾时 End 不应跳过换行符");
linebuf_set(&lb, "one two three");
lb.pos = 7; /* "one two" 之后 */
lb.pos = 7; /* "one two" 之后 */
linebuf_kill_to_end(&lb);
BUFEQ(lb, "one two");
linebuf_set(&lb, "one two three");
lb.pos = 8; /* "one two " 之后 */
lb.pos = 8; /* "one two " 之后 */
linebuf_kill_to_start(&lb);
BUFEQ(lb, "three");
CHECK(lb.pos == 0, "删到行首后光标应在行首");
linebuf_set(&lb, "one two three");
linebuf_kill_word(&lb); /* 从末尾回删一个词 */
linebuf_kill_word(&lb); /* 从末尾回删一个词 */
BUFEQ(lb, "one two ");
linebuf_kill_word(&lb); /* 先跳过空格再删词 */
linebuf_kill_word(&lb); /* 先跳过空格再删词 */
BUFEQ(lb, "one ");
linebuf_free(&lb);
@@ -260,8 +269,8 @@ int main(void)
CHECK(line_history_len() == 0, "初始应为空");
line_history_add("first");
line_history_add("second");
line_history_add("second"); /* 与上一条重复, 应忽略 */
line_history_add(""); /* 空行不入历史 */
line_history_add("second"); /* 与上一条重复, 应忽略 */
line_history_add(""); /* 空行不入历史 */
CHECK(line_history_len() == 2, "应有 2 条, 实际 %zu", line_history_len());
CHECK(strcmp(line_history_get(0), "first") == 0, "第 0 条");
CHECK(strcmp(line_history_get(1), "second") == 0, "第 1 条");
+158 -133
View File
@@ -1,7 +1,7 @@
/* test_markdown.c - 流式 Markdown 渲染器测试
*
* 最关键的不变量: 把同一段文本任意切分后逐片喂入, 输出必须与一次性喂入
* 完全一致. SSE 的分片位置是随机的, 这条不成立就会漏出"半个星号".
* 完全一致. SSE 的分片位置是随机的, 这条不成立就会漏出"半个星号".
*/
#include "markdown.h"
#include "util.h"
@@ -13,27 +13,28 @@
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"); \
} \
#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)
{
static char *strip_ansi(const char *s) {
StrBuf out;
sb_init(&out);
for (const char *p = s; *p; ) {
for (const char *p = s; *p;) {
if (*p == 0x1b && p[1] == '[') {
p += 2;
while (*p && !(*p >= '@' && *p <= '~')) p++;
if (*p) p++;
while (*p && !(*p >= '@' && *p <= '~'))
p++;
if (*p)
p++;
continue;
}
sb_putc(&out, *p++);
@@ -41,22 +42,24 @@ static char *strip_ansi(const char *s)
return sb_detach(&out);
}
static void show_escaped(const char *s)
{
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);
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)
{
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;
if (!f)
return NULL;
MdRenderer *r = md_new(enabled);
md_set_out(r, f);
@@ -67,7 +70,8 @@ static char *do_render(const char *input, int enabled, size_t chunk)
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;
if (n >= sizeof tmp)
n = sizeof tmp - 1;
memcpy(tmp, input + i, n);
tmp[n] = '\0';
md_feed(r, tmp);
@@ -79,15 +83,13 @@ static char *do_render(const char *input, int enabled, size_t chunk)
return buf;
}
static char *render(const char *in, int enabled)
{
static char *render(const char *in, int enabled) {
return do_render(in, enabled, 0);
}
/* ------------------------------------------------------------------ */
static void test_split_invariance(void)
{
static void test_split_invariance(void) {
puts("分片不变性 (一次性 / 3 字节 / 逐字节 必须一致)");
static const char *samples[] = {
"**粗体**\n",
@@ -121,36 +123,43 @@ static void test_split_invariance(void)
"```\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", /* 有续行但缺分隔行 */
"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;
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");
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);
free(whole);
free(by1);
free(by3);
}
}
static void test_headings(void)
{
static void test_headings(void) {
puts("标题");
char *o = render("# 一\n## 二\n### 三\n#### 四\n", 1);
CHECK(strstr(o, "\033[1;36m# 一\033[0m") != NULL, "H1 应为粗体青");
@@ -160,7 +169,8 @@ static void test_headings(void)
char *vis = strip_ansi(o);
CHECK(strcmp(vis, "# 一\n## 二\n### 三\n#### 四\n") == 0,
"可见文本应保留 # 标记: [%s]", vis);
free(vis); free(o);
free(vis);
free(o);
/* 不是标题的情况必须原样保留 */
o = render("#没有空格\n", 1);
@@ -176,20 +186,21 @@ static void test_headings(void)
free(o);
}
static void test_bold_italic(void)
{
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);
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);
free(vis);
free(o);
/* 字面星号: 乘法 */
o = render("2 * 3 * 4\n", 1);
@@ -210,43 +221,46 @@ static void test_bold_italic(void)
o = render("**粗 *斜* 粗**\n", 1);
vis = strip_ansi(o);
CHECK(strcmp(vis, "粗 斜 粗\n") == 0, "嵌套可见文本: [%s]", vis);
free(vis); free(o);
free(vis);
free(o);
}
static void test_code(void)
{
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(strcmp(vis, "用 `ls -la` 看看\n") == 0, "行内代码应保留反引号: [%s]",
vis);
CHECK(strstr(o, "\033[36m") != NULL, "内容应着色: [%s]", o);
free(vis); free(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);
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);
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);
free(vis);
free(o);
}
static void test_blocks(void)
{
static void test_blocks(void) {
puts("列表 / 引用 / 分隔线");
/* 列表标记必须保留模型输出的原始字符, 只上色, 不换成 • */
char *o = render("- 甲\n- 乙\n", 1);
@@ -254,32 +268,38 @@ static void test_blocks(void)
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);
free(vis);
free(o);
o = render("* 甲\n+ 乙\n", 1);
vis = strip_ansi(o);
CHECK(strcmp(vis, "* 甲\n+ 乙\n") == 0, "列表应保留原始 * 和 + : [%s]", vis);
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);
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);
free(vis);
free(o);
o = render("> 引用\n", 1);
vis = strip_ansi(o);
CHECK(strcmp(vis, "│ 引用\n") == 0, "引用: [%s]", vis);
free(vis); free(o);
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);
free(vis);
free(o);
/* 非分隔线、非列表应原样 */
o = render("--x\n", 1);
@@ -287,8 +307,7 @@ static void test_blocks(void)
free(o);
}
static void test_passthrough(void)
{
static void test_passthrough(void) {
puts("关闭渲染时逐字节透传");
static const char *samples[] = {
"# 标题 **粗体** `code`\n",
@@ -300,20 +319,19 @@ static void test_passthrough(void)
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(whole, samples[k]) == 0, "应完全透传: 期望 [%s] 实际 [%s]",
samples[k], whole);
CHECK(strcmp(by1, samples[k]) == 0, "分片透传也应一致");
free(whole); free(by1);
free(whole);
free(by1);
}
}
static void test_fence_verbatim(void)
{
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(strstr(o, "\033") == NULL, "代码块内不应有任何 ANSI 转义: [%s]", o);
CHECK(strcmp(o, "```c\nint x = 1; // **不是粗体**\n```\n") == 0,
"代码块应逐字节原样: [%s]", o);
free(o);
@@ -322,27 +340,26 @@ static void test_fence_verbatim(void)
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, "> 不是引用"),
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);
CHECK(strstr(o, "\033[1m加粗\033[0m") != NULL, "代码块之后应恢复渲染: [%s]",
o);
free(o);
}
static void test_inline_code_protected(void)
{
static void test_inline_code_protected(void) {
puts("行内代码内容不受行内语法影响");
char *o = render("形如 `a**b**c` 的写法\n", 1);
CHECK(strstr(o, "\033[1m") == NULL,
"行内代码内不应渲染粗体: [%s]", o);
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);
free(vis);
free(o);
o = render("`[不是链接](x)` 和 `_不是斜体_`\n", 1);
CHECK(strstr(o, "\033[94m") == NULL, "行内代码内不应高亮 []: [%s]", o);
@@ -350,17 +367,18 @@ static void test_inline_code_protected(void)
vis = strip_ansi(o);
CHECK(strcmp(vis, "`[不是链接](x)` 和 `_不是斜体_`\n") == 0,
"可见文本应原样: [%s]", vis);
free(vis); free(o);
free(vis);
free(o);
}
static void test_underscore_italic(void)
{
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);
free(vis);
free(o);
/* 中文紧邻下划线也应生效 (非 ASCII 不算词字符) */
o = render("中文_斜体_中文\n", 1);
@@ -369,15 +387,14 @@ static void test_underscore_italic(void)
/* 关键保护: 下划线标识符不能被当成斜体 */
o = render("foo_bar_baz 和 __init__ 和 a_b_c\n", 1);
CHECK(strstr(o, "\033[3m") == NULL,
"下划线标识符不应被渲染为斜体: [%s]", o);
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)
{
static void test_link_bracket(void) {
puts("链接: [] 下划线+青色高亮, 其余原样");
char *o = render("见 [文档](https://example.com/a) 说明\n", 1);
/* 这里是下划线 + 青 (SGR 4;36), 不是亮蓝. 整个项目只用 16 色范围内的
@@ -387,13 +404,15 @@ static void test_link_bracket(void)
char *vis = strip_ansi(o);
CHECK(strcmp(vis, "见 [文档](https://example.com/a) 说明\n") == 0,
"可见文本应逐字节保留 (含 URL): [%s]", vis);
free(vis); free(o);
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);
free(vis);
free(o);
/* 下标写法不应被当成链接 */
o = render("arr[i] = br[j]\n", 1);
@@ -402,73 +421,79 @@ static void test_link_bracket(void)
free(o);
}
static void test_tables(void)
{
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);
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);
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);
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);
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 个字节就能做出, 否则分片落在竖线之前时,
* 这是为保证"输出与分片方式无关"而做的取舍:
* 判断必须在行的第 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);
free(vis);
free(o);
/* 首竖线前有缩进同理: 不认 */
o = render(" | a |\n |---|\n", 1);
vis = strip_ansi(o);
CHECK(strstr(vis, "┌") == NULL, "缩进的表格不识别: [%s]", vis);
free(vis); free(o);
free(vis);
free(o);
/* 误判防护: 普通文本里出现竖线 + 下一行是短横线, 不能当成表格 */
o = render("运行 ls |\n-\n", 1);
vis = strip_ansi(o);
CHECK(strstr(vis, "┌") == NULL, "竖线+短横线不应误判成表格: [%s]", vis);
free(vis); free(o);
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);
free(vis);
free(o);
/* 没有分隔行就不是表格 */
o = render("a | b\nc | d\n", 1);
@@ -485,17 +510,18 @@ static void test_tables(void)
vis = strip_ansi(o);
CHECK(strstr(vis, "└───┴───┘\n后续段落 粗体\n") != NULL,
"表格后应恢复普通渲染: [%s]", vis);
free(vis); free(o);
free(vis);
free(o);
}
static void test_long_line_bounded(void)
{
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');
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);
@@ -503,8 +529,7 @@ static void test_long_line_bounded(void)
sb_free(&sb);
}
int main(void)
{
int main(void) {
test_split_invariance();
test_headings();
test_bold_italic();
+111 -84
View File
@@ -1,8 +1,8 @@
/* test_meta.c - meta 历史编辑: 轮次切分、结构安全、各工具的行为 */
#include "meta.h"
#include "json.h"
#include "util.h"
#include "agent.h"
#include "json.h"
#include "meta.h"
#include "util.h"
#include <stdio.h>
#include <stdlib.h>
@@ -11,20 +11,19 @@
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"); \
} \
#define CHECK(cond, ...) \
do { \
checks++; \
if (!(cond)) { \
failures++; \
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
printf(__VA_ARGS__); \
printf("\n"); \
} \
} while (0)
/* 造一条消息 */
static JsonValue *msg(const char *role, const char *content)
{
static JsonValue *msg(const char *role, const char *content) {
JsonValue *m = json_new_object();
json_object_set_string(m, "role", role);
json_object_set_string(m, "content", content);
@@ -32,8 +31,8 @@ static JsonValue *msg(const char *role, const char *content)
}
/* assistant 带一个 tool_call */
static JsonValue *assistant_call(const char *id, const char *name, const char *args)
{
static JsonValue *assistant_call(const char *id, const char *name,
const char *args) {
JsonValue *m = json_new_object();
json_object_set_string(m, "role", "assistant");
json_object_set_string(m, "content", "");
@@ -50,7 +49,7 @@ static JsonValue *assistant_call(const char *id, const char *name, const char *a
return m;
}
/* 造一段典型历史:
/* 造一段典型历史:
* #0 system
* #1 user A ← 轮 1
* #2 assistant(tool_calls)
@@ -61,12 +60,12 @@ static JsonValue *assistant_call(const char *id, const char *name, const char *a
* #7 user C ← 轮 3
* #8 assistant 回答
*/
static JsonValue *sample(void)
{
static JsonValue *sample(void) {
JsonValue *a = json_new_array();
json_array_push(a, msg("system", "你是助手"));
json_array_push(a, msg("user", "帮我用 Python 写"));
json_array_push(a, assistant_call("call_1", "run_command", "{\"command\":\"ls\"}"));
json_array_push(
a, assistant_call("call_1", "run_command", "{\"command\":\"ls\"}"));
json_array_push(a, msg("tool", "file1\nfile2"));
json_array_push(a, msg("assistant", "写好了"));
json_array_push(a, msg("user", "改用 C"));
@@ -76,13 +75,13 @@ static JsonValue *sample(void)
return a;
}
static void test_rounds(void)
{
static void test_rounds(void) {
puts("轮次切分");
JsonValue *m = sample();
MetaRounds r;
CHECK(meta_rounds_build(m, &r) == 1, "应该能切出轮次");
CHECK(r.head == 1, "system 之后的 #1 才是第一条可编辑消息, 实际 %zu", r.head);
CHECK(r.head == 1, "system 之后的 #1 才是第一条可编辑消息, 实际 %zu",
r.head);
CHECK(r.count == 3, "应切出 3 轮, 实际 %zu", r.count);
CHECK(r.starts[0] == 1, "轮 1 从 #1 开始, 实际 %zu", r.starts[0]);
CHECK(r.starts[1] == 5, "轮 2 从 #5 开始, 实际 %zu", r.starts[1]);
@@ -105,14 +104,17 @@ static void test_rounds(void)
meta_rounds_free(&r);
json_free(only);
/* read_image 补的那条"只有图片附件、没有文字"的 user 消息**不算新的一轮** ——
* 判据必须与主循环 (agent_opens_round)一致, 否则 /meta 报的轮号会和主循环的
* 口径对不上, 模型照轮号删下去, 删掉的不是它以为的那一轮.
* 结构: system / user(text) / assistant(tool_calls) / tool / user(只有图) / assistant */
/* read_image 补的那条"只有图片附件、没有文字"的 user 消息**不算新的一轮**
* —— 判据必须与主循环 (agent_opens_round)一致, 否则 /meta
* 报的轮号会和主循环的 口径对不上, 模型照轮号删下去,
* 删掉的不是它以为的那一轮. 结构: system / user(text) /
* assistant(tool_calls) / tool / user(只有图) / assistant */
JsonValue *withimg = json_new_array();
json_array_push(withimg, msg("system", "s"));
json_array_push(withimg, msg("user", "看图"));
json_array_push(withimg, msg("assistant", "ok")); /* 占位, 模拟带 tool_calls 的轮内消息 */
json_array_push(
withimg,
msg("assistant", "ok")); /* 占位, 模拟带 tool_calls 的轮内消息 */
json_array_push(withimg, msg("tool", "结果"));
{
JsonValue *att = json_new_object();
@@ -126,15 +128,15 @@ static void test_rounds(void)
}
json_array_push(withimg, msg("assistant", "看到图了"));
CHECK(meta_rounds_build(withimg, &r) == 1, "带附件的历史也应该能切");
CHECK(r.count == 1, "只有附件的那条 user 不该算新的一轮, 实际 %zu 轮", r.count);
CHECK(r.count == 1, "只有附件的那条 user 不该算新的一轮, 实际 %zu 轮",
r.count);
meta_rounds_free(&r);
json_free(withimg);
json_free(m);
}
static void test_deletable(void)
{
static void test_deletable(void) {
puts("结构安全: 哪些消息能单独删");
JsonValue *m = sample();
CHECK(meta_deletable(m, 0) == 0, "system 不可删");
@@ -148,11 +150,13 @@ static void test_deletable(void)
/* ---- 工具驱动 ---- */
static char *call(Agent *a, const char *name, const char *json)
{
static char *call(Agent *a, const char *name, const char *json) {
char err[256];
JsonValue *args = json_parse(json, err, sizeof err);
if (!args) { printf(" (测试自身出错: %s)\n", err); return xstrdup(""); }
if (!args) {
printf(" (测试自身出错: %s)\n", err);
return xstrdup("");
}
int is_err = 0;
char *r = meta_tools_call(name, args, &is_err);
json_free(args);
@@ -160,37 +164,43 @@ static char *call(Agent *a, const char *name, const char *json)
}
/* 把一段历史挂上, 跑 body, 再摘下来 */
#define WITH_TOOLS(a) \
do { \
CHECK(meta_tools_attach(a) == 0, "attach 应成功"); \
CHECK(meta_tools_attach(a) != 0, "重复 attach 应被拒绝"); \
#define WITH_TOOLS(a) \
do { \
CHECK(meta_tools_attach(a) == 0, "attach 应成功"); \
CHECK(meta_tools_attach(a) != 0, "重复 attach 应被拒绝"); \
} while (0)
static void test_replace(void)
{
static void test_replace(void) {
puts("replace_message");
Agent a; agent_init(&a);
json_free(a.messages); a.messages = sample();
Agent a;
agent_init(&a);
json_free(a.messages);
a.messages = sample();
meta_tools_attach(&a);
int err = 0;
char *r = call(&a, "replace_message", "{\"index\":1,\"content\":\"帮我用 C 写\"}");
CHECK(err == 0 && r && strstr(r, "已改写") != NULL, "应改写成功: %s", r ? r : "");
char *r = call(&a, "replace_message",
"{\"index\":1,\"content\":\"帮我用 C 写\"}");
CHECK(err == 0 && r && strstr(r, "已改写") != NULL, "应改写成功: %s",
r ? r : "");
JsonValue *m1 = json_array_get(a.messages, 1);
CHECK(strcmp(json_get_string(m1, "content"), "帮我用 C 写") == 0, "内容应被替换");
CHECK(strcmp(json_get_string(m1, "content"), "帮我用 C 写") == 0,
"内容应被替换");
CHECK(strcmp(json_get_string(m1, "role"), "user") == 0, "角色不该变");
free(r);
/* 改 assistant 说的话 —— 这是 meta 存在的理由 */
r = call(&a, "replace_message", "{\"index\":4,\"content\":\"改好了\"}");
CHECK(r && strstr(r, "已改写") != NULL, "应能改写 assistant 消息");
CHECK(strcmp(json_get_string(json_array_get(a.messages, 4), "content"), "改好了") == 0,
CHECK(strcmp(json_get_string(json_array_get(a.messages, 4), "content"),
"改好了") == 0,
"assistant 内容应被替换");
free(r);
/* tool 结果和 system 都不该被动 */
r = call(&a, "replace_message", "{\"index\":3,\"content\":\"x\"}");
CHECK(r && strstr(r, "tool 结果") != NULL, "改 tool 结果应被拒绝: %s", r ? r : "");
CHECK(r && strstr(r, "tool 结果") != NULL, "改 tool 结果应被拒绝: %s",
r ? r : "");
free(r);
r = call(&a, "replace_message", "{\"index\":0,\"content\":\"x\"}");
CHECK(r && strstr(r, "system") != NULL, "改 system 应被拒绝");
@@ -205,16 +215,18 @@ static void test_replace(void)
agent_free(&a);
}
static void test_delete_safety(void)
{
static void test_delete_safety(void) {
puts("delete_messages 的结构安全");
Agent a; agent_init(&a);
json_free(a.messages); a.messages = sample();
Agent a;
agent_init(&a);
json_free(a.messages);
a.messages = sample();
meta_tools_attach(&a);
size_t before = json_array_size(a.messages);
char *r = call(&a, "delete_messages", "{\"indices\":[2]}");
CHECK(r && strstr(r, "不可拆") != NULL, "带 tool_calls 的 assistant 应被拒绝");
CHECK(r && strstr(r, "不可拆") != NULL,
"带 tool_calls 的 assistant 应被拒绝");
CHECK(json_array_size(a.messages) == before, "被拒绝时不该改动任何东西");
free(r);
@@ -231,8 +243,10 @@ static void test_delete_safety(void)
/* 合法删除: 删掉第 1 轮里那句 user, 并给上下文提示 */
r = call(&a, "delete_messages", "{\"indices\":[1]}");
CHECK(r && strstr(r, "已删除 1 条") != NULL, "合法删除应成功: %s", r ? r : "");
CHECK(strstr(r, "没有对应的提问") != NULL, "删掉提问后应提示后续回答失去上下文");
CHECK(r && strstr(r, "已删除 1 条") != NULL, "合法删除应成功: %s",
r ? r : "");
CHECK(strstr(r, "没有对应的提问") != NULL,
"删掉提问后应提示后续回答失去上下文");
CHECK(json_array_size(a.messages) == before - 1, "条数应减一");
free(r);
@@ -245,20 +259,23 @@ static void test_delete_safety(void)
agent_free(&a);
}
static void test_delete_rounds(void)
{
static void test_delete_rounds(void) {
puts("delete_rounds");
Agent a; agent_init(&a);
json_free(a.messages); a.messages = sample();
Agent a;
agent_init(&a);
json_free(a.messages);
a.messages = sample();
meta_tools_attach(&a);
size_t before = json_array_size(a.messages);
char *r = call(&a, "delete_rounds", "{\"from\":2,\"to\":2}");
CHECK(r && strstr(r, "已删除轮 2..2") != NULL, "应删掉第 2 轮: %s", r ? r : "");
CHECK(r && strstr(r, "已删除轮 2..2") != NULL, "应删掉第 2 轮: %s",
r ? r : "");
CHECK(json_array_size(a.messages) == before - 2, "第 2 轮是 2 条消息");
/* 剩下的结构必须还是自洽的: #2 的 tool_calls 后面仍跟着 tool */
CHECK(meta_deletable(a.messages, 2) == 0, "#2 仍应是结构消息");
CHECK(strcmp(json_get_string(json_array_get(a.messages, 3), "role"), "tool") == 0,
CHECK(strcmp(json_get_string(json_array_get(a.messages, 3), "role"),
"tool") == 0,
"tool 结果应仍紧跟它的调用");
free(r);
@@ -276,24 +293,29 @@ static void test_delete_rounds(void)
agent_free(&a);
}
static void test_insert_and_undo(void)
{
static void test_insert_and_undo(void) {
puts("insert_message 与 undo");
Agent a; agent_init(&a);
json_free(a.messages); a.messages = sample();
Agent a;
agent_init(&a);
json_free(a.messages);
a.messages = sample();
meta_tools_attach(&a);
size_t before = json_array_size(a.messages);
char *r = call(&a, "insert_message",
"{\"after\":8,\"role\":\"user\",\"content\":\"这个项目必须用 C99\"}");
CHECK(r && strstr(r, "插入一条 user") != NULL, "应插入成功: %s", r ? r : "");
char *r = call(
&a, "insert_message",
"{\"after\":8,\"role\":\"user\",\"content\":\"这个项目必须用 C99\"}");
CHECK(r && strstr(r, "插入一条 user") != NULL, "应插入成功: %s",
r ? r : "");
CHECK(json_array_size(a.messages) == before + 1, "条数应加一");
CHECK(strcmp(json_get_string(json_array_get(a.messages, 9), "content"),
"这个项目必须用 C99") == 0, "应插在 #8 之后");
"这个项目必须用 C99") == 0,
"应插在 #8 之后");
free(r);
/* tool 角色不能凭空插 —— 没有配对的 tool_calls */
r = call(&a, "insert_message", "{\"after\":0,\"role\":\"tool\",\"content\":\"x\"}");
r = call(&a, "insert_message",
"{\"after\":0,\"role\":\"tool\",\"content\":\"x\"}");
CHECK(r && strstr(r, "role 只能是") != NULL, "插 tool 消息应被拒绝");
free(r);
@@ -313,21 +335,24 @@ static void test_insert_and_undo(void)
agent_free(&a);
}
static void test_read_tools(void)
{
static void test_read_tools(void) {
puts("list_rounds / read_messages (只读)");
Agent a; agent_init(&a);
json_free(a.messages); a.messages = sample();
Agent a;
agent_init(&a);
json_free(a.messages);
a.messages = sample();
meta_tools_attach(&a);
char *r = call(&a, "list_rounds", "{}");
CHECK(r && strstr(r, "共 3 轮") != NULL, "应报告 3 轮: %s", r ? r : "");
CHECK(r && strstr(r, "轮 1") != NULL && strstr(r, "轮 3") != NULL, "应列出每轮");
CHECK(r && strstr(r, "轮 1") != NULL && strstr(r, "轮 3") != NULL,
"应列出每轮");
CHECK(r && strstr(r, "Python") != NULL, "应带开头预览");
free(r);
r = call(&a, "list_rounds", "{\"from\":2,\"to\":2}");
CHECK(r && strstr(r, "轮 2") != NULL && strstr(r, "轮 3") == NULL, "应只列第 2 轮");
CHECK(r && strstr(r, "轮 2") != NULL && strstr(r, "轮 3") == NULL,
"应只列第 2 轮");
free(r);
r = call(&a, "read_messages", "{\"from\":2,\"to\":3}");
@@ -337,9 +362,11 @@ static void test_read_tools(void)
free(r);
CHECK(meta_tools_mutating("list_rounds") == 0, "list_rounds 不是写操作");
CHECK(meta_tools_mutating("read_messages") == 0, "read_messages 不是写操作");
CHECK(meta_tools_mutating("read_messages") == 0,
"read_messages 不是写操作");
CHECK(meta_tools_mutating("delete_rounds") == 1, "delete_rounds 是写操作");
CHECK(meta_tools_mutating("replace_message") == 1, "replace_message 是写操作");
CHECK(meta_tools_mutating("replace_message") == 1,
"replace_message 是写操作");
CHECK(meta_tools_mutating("不存在的工具") == 0, "未知工具不该被当成写操作");
r = call(&a, "delete_rounds_but_typo", "{}");
@@ -350,16 +377,17 @@ static void test_read_tools(void)
agent_free(&a);
}
static void test_reroot_flag(void)
{
static void test_reroot_flag(void) {
puts("重新存根标记");
Agent a; agent_init(&a);
json_free(a.messages); a.messages = sample();
Agent a;
agent_init(&a);
json_free(a.messages);
a.messages = sample();
meta_reroot_clear();
CHECK(meta_reroot_pending() == 0, "初始不该有标记");
meta_tools_attach(&a);
char *r = call(&a, "list_rounds", "{}"); /* 只读, 不该打标记 */
char *r = call(&a, "list_rounds", "{}"); /* 只读, 不该打标记 */
free(r);
CHECK(meta_reroot_pending() == 0, "只读操作不该打标记");
@@ -372,8 +400,7 @@ static void test_reroot_flag(void)
meta_reroot_clear();
}
int main(void)
{
int main(void) {
test_rounds();
test_deletable();
test_read_tools();
+25 -29
View File
@@ -1,12 +1,12 @@
/* test_models.c - /models 响应体的解析
*
* 只测纯解析. 拉取 (GET {base_url}/models)要走网络, 由 e2e 第 52 段对着
* mock 覆盖, 那里还顺带验了交互式选择本身 (方向键、确认、取消).
* mock 覆盖, 那里还顺带验了交互式选择本身 (方向键、确认、取消).
*
* 为什么要容忍这么多种形状: OpenAI 兼容是个很大的家庭, /models 的返回花样
* 比文档里写的多 —— 见过裸数组、见过重复列同一模型、见过元素里没有 id.
* 比文档里写的多 —— 见过裸数组、见过重复列同一模型、见过元素里没有 id.
* 解析器要么跳过, 要么去重, 但**不能**因此整个失败: 列表里少一个模型远好过
* 一个都选不了.
* 一个都选不了.
*/
#include "models.h"
@@ -16,19 +16,18 @@
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"); \
} \
#define CHECK(cond, ...) \
do { \
checks++; \
if (!(cond)) { \
failures++; \
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
printf(__VA_ARGS__); \
printf("\n"); \
} \
} while (0)
static void test_standard(void)
{
static void test_standard(void) {
puts("标准形状: {\"data\":[{\"id\":...}]}");
const char *json =
"{\"object\":\"list\",\"data\":["
@@ -39,20 +38,19 @@ static void test_standard(void)
char **l = models_parse(json, &n);
CHECK(l && n == 2, "应解析出 2 个, 实际 %zu", n);
if (l && n == 2) {
CHECK(strcmp(l[0], "alpha") == 0, "顺序应保持服务端给的 (第一个 alpha)");
CHECK(strcmp(l[0], "alpha") == 0,
"顺序应保持服务端给的 (第一个 alpha)");
CHECK(strcmp(l[1], "beta") == 0, "第二个 beta");
}
models_free(l, n);
}
static void test_dedupe_and_skip(void)
{
static void test_dedupe_and_skip(void) {
puts("重复项要去重, 缺 id 的要跳过");
const char *json =
"{\"data\":["
"{\"id\":\"a\"},{\"id\":\"a\"},{\"object\":\"model\"},"
"{\"id\":\"\"},{\"id\":\"b\"},{\"id\":42}"
"]}";
const char *json = "{\"data\":["
"{\"id\":\"a\"},{\"id\":\"a\"},{\"object\":\"model\"},"
"{\"id\":\"\"},{\"id\":\"b\"},{\"id\":42}"
"]}";
size_t n = 0;
char **l = models_parse(json, &n);
CHECK(l && n == 2, "去重 + 跳过后应剩 2 个, 实际 %zu", n);
@@ -63,8 +61,7 @@ static void test_dedupe_and_skip(void)
models_free(l, n);
}
static void test_bare_array(void)
{
static void test_bare_array(void) {
puts("兼容裸数组 (字符串 / 对象都认)");
size_t n = 0;
char **l = models_parse("[\"alpha\",\"beta\"]", &n);
@@ -77,8 +74,7 @@ static void test_bare_array(void)
models_free(l, n);
}
static void test_bad_input(void)
{
static void test_bad_input(void) {
puts("拿不到模型时返回 NULL, 而不是空数组或崩溃");
size_t n = 123;
CHECK(models_parse(NULL, &n) == NULL && n == 0, "NULL 输入");
@@ -96,7 +92,8 @@ static void test_bad_input(void)
CHECK(models_parse("{\"data\":\"nope\"}", &n) == NULL && n == 0,
"data 不是数组");
n = 123;
CHECK(models_parse("{\"data\":[{\"object\":\"model\"}]}", &n) == NULL && n == 0,
CHECK(models_parse("{\"data\":[{\"object\":\"model\"}]}", &n) == NULL &&
n == 0,
"全是坏条目");
/* out_n 也可为 NULL (调用方只关心数组) */
@@ -122,8 +119,7 @@ static void test_bad_input(void)
CHECK(1, "释放 NULL 没崩");
}
int main(void)
{
int main(void) {
test_standard();
test_dedupe_and_skip();
test_bare_array();
+42 -31
View File
@@ -1,12 +1,12 @@
/* test_priv.c - 提权模块里那几条"不能搞错"的不变量
*
* 这个模块摔过一跤: 上一版把认证路径也 setsid + 接管道, 于是 sudo 拿不到
* 控制终端, 真机上认证 100% 失败 (而替身 sudo 无条件成功, 给了个假绿).
* 所以这里把两条硬不变量钉住:
* 控制终端, 真机上认证 100% 失败 (而替身 sudo 无条件成功, 给了个假绿).
* 所以这里把两条硬不变量钉住:
*
* 1. **提权调用里不能有 -n**. 带 -n 就等于"不许提问", 需要密码时直接失败,
* 而用户要的恰恰是"提权要有身份验证".
* 2. **没有控制终端时能识别出来**. 识别不出来就会起一个等不到输入的进程.
* 1. **提权调用里不能有 -n**. 带 -n 就等于"不许提问", 需要密码时直接失败,
* 而用户要的恰恰是"提权要有身份验证".
* 2. **没有控制终端时能识别出来**. 识别不出来就会起一个等不到输入的进程.
*/
#include "priv.h"
#include "util.h"
@@ -20,31 +20,32 @@
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"); \
} \
#define CHECK(cond, ...) \
do { \
checks++; \
if (!(cond)) { \
failures++; \
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
printf(__VA_ARGS__); \
printf("\n"); \
} \
} while (0)
static int argv_has(char *const *a, const char *want)
{
static int argv_has(char *const *a, const char *want) {
for (size_t i = 0; a[i]; i++)
if (strcmp(a[i], want) == 0) return 1;
if (strcmp(a[i], want) == 0)
return 1;
return 0;
}
int main(void)
{
int main(void) {
puts("包装成 sudo -u <用户> -- <argv>");
/* 显式转换, 和 priv.c 里 w[0] = (char *)"sudo" 是同一个理由:
/* 显式转换, 和 priv.c 里 w[0] = (char *)"sudo" 是同一个理由:
* priv_sudo_argv 的签名要 char *const[] (execvp 的要求), 而这里放进去的
* 字面量没有任何人会改写. 把整条链路改成非 const 才是真的丢 const 正确性. */
char *plain[] = { (char *)"/bin/sh", (char *)"-c", (char *)"rm -rf /tmp/x", NULL };
* 字面量没有任何人会改写. 把整条链路改成非 const 才是真的丢 const 正确性.
*/
char *plain[] = {(char *)"/bin/sh", (char *)"-c", (char *)"rm -rf /tmp/x",
NULL};
char **w = priv_sudo_argv("root", plain);
CHECK(w != NULL, "应当返回数组");
CHECK(strcmp(w[0], "sudo") == 0, "第一个是 sudo, 实际 %s", w[0]);
@@ -53,7 +54,7 @@ int main(void)
CHECK(strcmp(w[3], "--") == 0,
"必须有 -- : 否则命令里的选项会被 sudo 当成自己的");
CHECK(strcmp(w[4], "/bin/sh") == 0 && strcmp(w[5], "-c") == 0 &&
strcmp(w[6], "rm -rf /tmp/x") == 0,
strcmp(w[6], "rm -rf /tmp/x") == 0,
"原 argv 原样接在后面, 不被拆开");
CHECK(w[7] == NULL, "数组要 NULL 结尾");
/* 命门外的那条不变量 */
@@ -75,7 +76,8 @@ int main(void)
puts("sudo 的失败原话要换成能照做的中文");
char err[512];
memset(err, 0, sizeof err);
priv_explain(1, "sudo: no tty present and no askpass program specified", err, sizeof err);
priv_explain(1, "sudo: no tty present and no askpass program specified",
err, sizeof err);
CHECK(strstr(err, "终端") != NULL, "缺终端要说终端, 实际 %s", err);
priv_explain(1, "Sorry, try again.", err, sizeof err);
CHECK(strstr(err, "认证") != NULL, "认证失败要说认证, 实际 %s", err);
@@ -86,19 +88,27 @@ int main(void)
priv_explain(1, "some brand new message", err, sizeof err);
CHECK(strstr(err, "some brand new message") != NULL,
"认不出来要透传原文, 实际 %s", err);
priv_explain(127, "无法执行 sudo: No such file or directory", err, sizeof err);
CHECK(strstr(err, "没有 sudo") != NULL, "没有 sudo 时要说清楚, 实际 %s", err);
priv_explain(127, "无法执行 sudo: No such file or directory", err,
sizeof err);
CHECK(strstr(err, "没有 sudo") != NULL, "没有 sudo 时要说清楚, 实际 %s",
err);
puts("没有控制终端时能识别出来");
int fds[2];
if (pipe(fds) != 0) { perror("pipe"); return 2; }
if (pipe(fds) != 0) {
perror("pipe");
return 2;
}
pid_t pid = fork();
if (pid < 0) { perror("fork"); return 2; }
if (pid < 0) {
perror("fork");
return 2;
}
if (pid == 0) {
close(fds[0]);
int rc = -1;
if (setsid() >= 0) { /* 摘掉控制终端 */
rc = priv_have_tty(); /* 必须是 0 */
if (setsid() >= 0) { /* 摘掉控制终端 */
rc = priv_have_tty(); /* 必须是 0 */
}
ssize_t wr = write(fds[1], &rc, sizeof rc);
(void)wr;
@@ -109,7 +119,8 @@ int main(void)
int got = -1;
ssize_t rd = read(fds[0], &got, sizeof got);
close(fds[0]);
while (waitpid(pid, NULL, 0) < 0) { }
while (waitpid(pid, NULL, 0) < 0) {
}
CHECK(rd == (ssize_t)sizeof got && got == 0,
"setsid() 之后不该认为有终端 (识别不出来就会卡住)");
+48 -44
View File
@@ -1,45 +1,47 @@
/* test_spinner.c - 等待动画的两段式标签
*
* 动画写的是 stderr, 所以这里把 stderr 挪到一个临时文件, 驱动几轮之后
* 再把内容读回来断言. spinner_init(1) 是显式开关, 测试里不依赖 isatty.
* 再把内容读回来断言. spinner_init(1) 是显式开关, 测试里不依赖 isatty.
*/
#include "spinner.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <time.h>
#include <unistd.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"); \
} \
#define CHECK(cond, ...) \
do { \
checks++; \
if (!(cond)) { \
failures++; \
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
printf(__VA_ARGS__); \
printf("\n"); \
} \
} while (0)
/* 把 stderr 接到一个临时文件上, 返回文件描述符; *saved 收原来的 stderr.
/* 把 stderr 接到一个临时文件上, 返回文件描述符; *saved 收原来的 stderr.
* 用完必须调用 capture_end 还原, 否则后面的失败信息也会被吞掉. */
static int capture_begin(char *path, size_t pathlen, int *saved)
{
static int capture_begin(char *path, size_t pathlen, int *saved) {
snprintf(path, pathlen, "/tmp/ligent_spin_XXXXXX");
int fd = mkstemp(path);
if (fd < 0) return -1;
if (fd < 0)
return -1;
*saved = dup(STDERR_FILENO);
if (*saved < 0) { close(fd); return -1; }
if (*saved < 0) {
close(fd);
return -1;
}
dup2(fd, STDERR_FILENO);
return fd;
}
static void capture_end(int fd, int saved, char *buf, size_t buflen)
{
static void capture_end(int fd, int saved, char *buf, size_t buflen) {
fflush(stderr);
dup2(saved, STDERR_FILENO);
close(saved);
@@ -49,14 +51,14 @@ static void capture_end(int fd, int saved, char *buf, size_t buflen)
close(fd);
}
static void test_two_phase(void)
{
static void test_two_phase(void) {
puts("两段式标签: 等待响应 → 思考中");
char path[64];
int saved = -1;
int fd = capture_begin(path, sizeof path, &saved);
CHECK(fd >= 0, "无法重定向 stderr");
if (fd < 0) return;
if (fd < 0)
return;
spinner_init(1);
spinner_start("等待响应");
@@ -81,17 +83,18 @@ static void test_two_phase(void)
CHECK(a && b && a < b, "先\"等待响应\"后\"思考中\", 顺序反了");
CHECK(strstr(buf, "\r\033[K") != NULL, "每帧应先回车清行再打印");
/* 停在"思考中"之后就不该再有"等待响应"冒出来 */
if (b) CHECK(strstr(b, "等待响应") == NULL, "换标签后不应回退到\"等待响应\"");
if (b)
CHECK(strstr(b, "等待响应") == NULL, "换标签后不应回退到\"等待响应\"");
}
static void test_idempotent(void)
{
static void test_idempotent(void) {
puts("未开始/未启用时的行为");
char path[64];
int saved = -1;
int fd = capture_begin(path, sizeof path, &saved);
CHECK(fd >= 0, "无法重定向 stderr");
if (fd < 0) return;
if (fd < 0)
return;
/* 没 start 就 set: 只记标签, 不该打印任何东西, 也不该崩 */
spinner_init(1);
@@ -121,24 +124,23 @@ static void test_idempotent(void)
CHECK(1, "关闭状态下调用不崩溃");
}
static double now_sec(void)
{
static double now_sec(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9;
}
/* spinner_stop 要 pthread_join worker. 如果 worker 是 nanosleep 一整帧,
/* spinner_stop 要 pthread_join worker. 如果 worker 是 nanosleep 一整帧,
* 这里就得干等最多 250ms —— 而这笔账会加在每一轮模型往返、每一次工具
* 来回的用户感知延迟上. */
static void test_stop_is_prompt(void)
{
static void test_stop_is_prompt(void) {
puts("stop 不该等满一帧 (250ms)");
char path[64];
int saved = -1;
int fd = capture_begin(path, sizeof path, &saved);
CHECK(fd >= 0, "无法重定向 stderr");
if (fd < 0) return;
if (fd < 0)
return;
spinner_init(1);
double worst = 0.0;
@@ -147,31 +149,33 @@ static void test_stop_is_prompt(void)
spinner_start("等待响应");
spinner_stop();
double dt = now_sec() - t0;
if (dt > worst) worst = dt;
if (dt > worst)
worst = dt;
}
char buf[4096];
capture_end(fd, saved, buf, sizeof buf);
unlink(path);
CHECK(worst < 0.10, "stop 应立即返回, 最慢一次 %.3fs (帧间隔是 0.25s)", worst);
CHECK(worst < 0.10, "stop 应立即返回, 最慢一次 %.3fs (帧间隔是 0.25s)",
worst);
}
/* 帧率必须受 250ms 限制. 曾经踩过一次: pthread_cond_timedwait 默认按
* CLOCK_REALTIME 解释超时, 而传进去的是 CLOCK_MONOTONIC 的绝对时间
* (开机时长永远小于 epoch 时间), 于是超时永远"已经过了" —— worker
* 变成疯狂重绘的忙循环, 把 pty 刷屏. */
static void test_frame_rate_is_bounded(void)
{
static void test_frame_rate_is_bounded(void) {
puts("帧率受 250ms 限制, 不能狂刷");
char path[64];
int saved = -1;
int fd = capture_begin(path, sizeof path, &saved);
CHECK(fd >= 0, "无法重定向 stderr");
if (fd < 0) return;
if (fd < 0)
return;
spinner_init(1);
spinner_start("等待响应");
usleep(600 * 1000); /* 0.6 秒 ≈ 2~3 帧 */
usleep(600 * 1000); /* 0.6 秒 ≈ 2~3 帧 */
spinner_stop();
char buf[65536];
@@ -179,7 +183,8 @@ static void test_frame_rate_is_bounded(void)
unlink(path);
int frames = 0;
for (const char *p = buf; (p = strstr(p, "等待响应")); p++) frames++;
for (const char *p = buf; (p = strstr(p, "等待响应")); p++)
frames++;
CHECK(frames >= 1, "至少该画一帧, 实际 %d", frames);
CHECK(frames <= 5, "0.6 秒内不该超过 5 帧, 实际 %d (忙循环?)", frames);
}
@@ -188,18 +193,18 @@ static void test_frame_rate_is_bounded(void)
* 自己 —— 而 spinner_start 曾经先执行 g_label[0] = '\0' 再读那个 label, 于是
* 标签被自己清空、退化成默认的"思考中". 症状: 每次 HTTP 重试退避之后, 屏幕上
* 的"等待响应"都会变成"思考中", 而这两段等待的含义恰恰是它要区分的. */
static void test_notice_keeps_label(void)
{
static void test_notice_keeps_label(void) {
puts("notice 之后标签保持不变");
char path[64];
int saved = -1;
int fd = capture_begin(path, sizeof path, &saved);
CHECK(fd >= 0, "无法重定向 stderr");
if (fd < 0) return;
if (fd < 0)
return;
spinner_init(1);
spinner_start("自定义标签A");
usleep(300 * 1000); /* 让它画至少一帧 */
usleep(300 * 1000); /* 让它画至少一帧 */
spinner_notice("一条插进来的提示");
usleep(300 * 1000);
spinner_stop();
@@ -217,8 +222,7 @@ static void test_notice_keeps_label(void)
noticed);
}
int main(void)
{
int main(void) {
test_two_phase();
test_idempotent();
test_stop_is_prompt();
+187 -102
View File
@@ -1,31 +1,30 @@
/* test_tools.c - 工具层测试 */
#include "tools.h"
#include "image.h"
#include "json.h"
#include "tools.h"
#include "util.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/stat.h>
#include <unistd.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"); \
} \
#define CHECK(cond, ...) \
do { \
checks++; \
if (!(cond)) { \
failures++; \
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
printf(__VA_ARGS__); \
printf("\n"); \
} \
} while (0)
static char *call(const char *name, const char *args_json, int *is_error)
{
static char *call(const char *name, const char *args_json, int *is_error) {
char err[256];
JsonValue *args = json_parse(args_json, err, sizeof err);
if (!args) {
@@ -37,12 +36,17 @@ static char *call(const char *name, const char *args_json, int *is_error)
return r;
}
int main(void)
{
int main(void) {
char tmpl[] = "/tmp/ligent_test_XXXXXX";
char *dir = mkdtemp(tmpl);
if (!dir) { perror("mkdtemp"); return 2; }
if (chdir(dir) != 0) { perror("chdir"); return 2; }
if (!dir) {
perror("mkdtemp");
return 2;
}
if (chdir(dir) != 0) {
perror("chdir");
return 2;
}
/* ---- 工具 schema 合法性 ---- */
puts("tools_schema");
@@ -62,19 +66,22 @@ int main(void)
{
int has_image = 0;
for (size_t i = 0; i < json_array_size(schema); i++) {
JsonValue *fn = json_object_get(json_array_get(schema, i), "function");
JsonValue *fn =
json_object_get(json_array_get(schema, i), "function");
const char *nm = json_get_string(fn, "name");
if (nm && strcmp(nm, "read_image") == 0) has_image = 1;
if (nm && strcmp(nm, "read_image") == 0)
has_image = 1;
}
CHECK(has_image, "schema 里应有 read_image");
}
{
/* line 是集合型参数: schema 里必须是 array (元素 integer),
/* line 是集合型参数: schema 里必须是 array (元素 integer),
* 否则模型根本不会想到可以给数组 —— 参数表的 "integer[]" 写法
* 只有 schema 展开对了才作数 */
JsonValue *props = NULL, *reqd = NULL;
for (size_t i = 0; i < json_array_size(schema); i++) {
JsonValue *fn = json_object_get(json_array_get(schema, i), "function");
JsonValue *fn =
json_object_get(json_array_get(schema, i), "function");
const char *nm = json_get_string(fn, "name");
if (nm && strcmp(nm, "edit_file") == 0) {
JsonValue *par = json_object_get(fn, "parameters");
@@ -86,18 +93,19 @@ int main(void)
int line_required = 0;
for (size_t i = 0; i < json_array_size(reqd); i++) {
const char *nm = json_string_value(json_array_get(reqd, i));
if (nm && strcmp(nm, "line") == 0) line_required = 1;
if (nm && strcmp(nm, "line") == 0)
line_required = 1;
}
CHECK(!line_required, "line 可以省略 (= 全文件替换), 不该是 required");
JsonValue *line = props ? json_object_get(props, "line") : NULL;
const char *lt = line ? json_get_string(line, "type") : NULL;
CHECK(lt && strcmp(lt, "array") == 0, "edit_file 的 line 应是 array, 实际 %s",
lt ? lt : "(缺)");
CHECK(lt && strcmp(lt, "array") == 0,
"edit_file 的 line 应是 array, 实际 %s", lt ? lt : "(缺)");
JsonValue *items = line ? json_object_get(line, "items") : NULL;
const char *it = items ? json_get_string(items, "type") : NULL;
CHECK(it && strcmp(it, "integer") == 0, "line 的元素应是 integer, 实际 %s",
it ? it : "(缺)");
CHECK(it && strcmp(it, "integer") == 0,
"line 的元素应是 integer, 实际 %s", it ? it : "(缺)");
}
/* 序列化一遍确保可发给服务端 */
@@ -137,8 +145,10 @@ int main(void)
/* ---- write_file / read_file ---- */
puts("write_file / read_file");
r = call("write_file",
"{\"path\":\"sub/dir/a.txt\",\"content\":\"line1\\nline2\\nline3\\n\"}", &err);
r = call(
"write_file",
"{\"path\":\"sub/dir/a.txt\",\"content\":\"line1\\nline2\\nline3\\n\"}",
&err);
CHECK(err == 0, "写入应成功: %s", r);
CHECK(access("sub/dir/a.txt", R_OK) == 0, "父目录应被自动创建");
free(r);
@@ -149,7 +159,8 @@ int main(void)
CHECK(strstr(r, "1\t") != NULL, "应带行号: %s", r);
free(r);
r = call("read_file", "{\"path\":\"sub/dir/a.txt\",\"offset\":2,\"limit\":1}", &err);
r = call("read_file",
"{\"path\":\"sub/dir/a.txt\",\"offset\":2,\"limit\":1}", &err);
CHECK(strstr(r, "line2") && !strstr(r, "line1") && !strstr(r, "line3"),
"offset/limit 应生效: %s", r);
free(r);
@@ -162,7 +173,8 @@ int main(void)
puts("edit_file");
r = call("edit_file",
"{\"path\":\"sub/dir/a.txt\",\"line\":2,\"old_string\":\"line2\","
"\"new_string\":\"LINE-TWO\"}", &err);
"\"new_string\":\"LINE-TWO\"}",
&err);
CHECK(err == 0, "按行号替换应成功: %s", r);
CHECK(strstr(r, "第 2 行") != NULL, "回执里应带上行号: %s", r);
free(r);
@@ -173,9 +185,11 @@ int main(void)
/* 省略 line 不再是错误: 那是"全文件替换"这条逃生门 (见下面那组用例) */
/* 行号对不上: 必须报出它**真正在哪一行**, 否则模型只能反复猜 */
r = call("edit_file",
"{\"path\":\"sub/dir/a.txt\",\"line\":3,\"old_string\":\"LINE-TWO\","
"\"new_string\":\"x\"}", &err);
r = call(
"edit_file",
"{\"path\":\"sub/dir/a.txt\",\"line\":3,\"old_string\":\"LINE-TWO\","
"\"new_string\":\"x\"}",
&err);
CHECK(err == 1, "行号不对应报错: %s", r);
CHECK(strstr(r, "第 2 行") != NULL, "应指出它真正的行号: %s", r);
free(r);
@@ -183,7 +197,8 @@ int main(void)
/* 越界行号: 要连文件总行数一起说清楚 */
r = call("edit_file",
"{\"path\":\"sub/dir/a.txt\",\"line\":99,\"old_string\":\"x\","
"\"new_string\":\"y\"}", &err);
"\"new_string\":\"y\"}",
&err);
CHECK(err == 1 && strstr(r, "只有 3 行") != NULL,
"越界行号应报错并给出总行数: %s", r);
free(r);
@@ -192,16 +207,19 @@ int main(void)
* (这正是"全文唯一"最烦人的地方——文件一长, 随便一个短串都不唯一) */
r = call("edit_file",
"{\"path\":\"sub/dir/a.txt\",\"line\":1,\"old_string\":\"line\","
"\"new_string\":\"LINE\"}", &err);
"\"new_string\":\"LINE\"}",
&err);
CHECK(err == 0, "同一行里唯一即可, 别的行的重复不该拦: %s", r);
free(r);
/* 同一行里出现两处 → 仍然拒绝 (行内也要定得下来) */
r = call("write_file", "{\"path\":\"dup.txt\",\"content\":\"foo foo\\n\"}", &err);
r = call("write_file", "{\"path\":\"dup.txt\",\"content\":\"foo foo\\n\"}",
&err);
CHECK(err == 0, "写入 dup.txt: %s", r);
free(r);
r = call("edit_file",
"{\"path\":\"dup.txt\",\"line\":1,\"old_string\":\"foo\",\"new_string\":\"bar\"}",
"{\"path\":\"dup.txt\",\"line\":1,\"old_string\":\"foo\",\"new_"
"string\":\"bar\"}",
&err);
CHECK(err == 1 && strstr(r, "第 1 行里有 2 处") != NULL,
"同一行里多匹配应拒绝: %s", r);
@@ -209,8 +227,10 @@ int main(void)
/* replace_all 仍然换全文件, 但 line 必须指向其中真实的一处 */
r = call("edit_file",
"{\"path\":\"dup.txt\",\"line\":1,\"old_string\":\"foo\",\"new_string\":\"bar\","
"\"replace_all\":true}", &err);
"{\"path\":\"dup.txt\",\"line\":1,\"old_string\":\"foo\",\"new_"
"string\":\"bar\","
"\"replace_all\":true}",
&err);
CHECK(err == 0, "replace_all 应成功: %s", r);
free(r);
r = call("read_file", "{\"path\":\"dup.txt\"}", &err);
@@ -218,8 +238,10 @@ int main(void)
"replace_all 应换掉文件里全部匹配: %s", r);
free(r);
r = call("edit_file",
"{\"path\":\"dup.txt\",\"line\":7,\"old_string\":\"bar\",\"new_string\":\"x\","
"\"replace_all\":true}", &err);
"{\"path\":\"dup.txt\",\"line\":7,\"old_string\":\"bar\",\"new_"
"string\":\"x\","
"\"replace_all\":true}",
&err);
CHECK(err == 1, "replace_all 也必须锚定在真实的一行上: %s", r);
free(r);
@@ -228,14 +250,16 @@ int main(void)
* "每行都改一次": 真改动的仍是唯一那一处. */
puts("edit_file: line 集合");
r = call("write_file",
"{\"path\":\"set.txt\",\"content\":\"aaa\\nbbb\\nccc\\nddd\\n\"}", &err);
"{\"path\":\"set.txt\",\"content\":\"aaa\\nbbb\\nccc\\nddd\\n\"}",
&err);
CHECK(err == 0, "写入 set.txt: %s", r);
free(r);
/* 数组写法: 起点落在集合里的哪一行都算命中; 回执要带上整个集合 */
r = call("edit_file",
"{\"path\":\"set.txt\",\"line\":[1,2],\"old_string\":\"aaa\","
"\"new_string\":\"AAA\"}", &err);
"\"new_string\":\"AAA\"}",
&err);
CHECK(err == 0, "集合圈住的替换应成功: %s", r);
CHECK(strstr(r, "第 1、2 行") != NULL, "回执里应带上整个集合: %s", r);
free(r);
@@ -244,40 +268,48 @@ int main(void)
free(r);
/* old_string 跨行时, 把覆盖到的几行都填进集合——不必赌起点是第几行 */
r = call("edit_file",
"{\"path\":\"set.txt\",\"line\":[2,3],\"old_string\":\"bbb\\nccc\","
"\"new_string\":\"B\\nC\"}", &err);
r = call(
"edit_file",
"{\"path\":\"set.txt\",\"line\":[2,3],\"old_string\":\"bbb\\nccc\","
"\"new_string\":\"B\\nC\"}",
&err);
CHECK(err == 0, "跨行替换 (集合给两行)应成功: %s", r);
free(r);
r = call("read_file", "{\"path\":\"set.txt\"}", &err);
CHECK(strstr(r, "B") && strstr(r, "C") && !strstr(r, "bbb"), "跨行替换应落盘: %s", r);
CHECK(strstr(r, "B") && strstr(r, "C") && !strstr(r, "bbb"),
"跨行替换应落盘: %s", r);
free(r);
/* 集合里有多处匹配 → 仍然拒绝 (集合不是"每行改一次"的授权) */
r = call("write_file", "{\"path\":\"two.txt\",\"content\":\"foo\\nx\\nfoo\\n\"}", &err);
r = call("write_file",
"{\"path\":\"two.txt\",\"content\":\"foo\\nx\\nfoo\\n\"}", &err);
CHECK(err == 0, "写入 two.txt: %s", r);
free(r);
r = call("edit_file",
"{\"path\":\"two.txt\",\"line\":[1,3],\"old_string\":\"foo\","
"\"new_string\":\"bar\"}", &err);
"\"new_string\":\"bar\"}",
&err);
CHECK(err == 1 && strstr(r, "第 1、3 行里有 2 处") != NULL,
"集合里多处匹配应拒绝: %s", r);
free(r);
/* 重复的行号要去重: 不然同一处匹配会被数成两处 */
r = call("write_file", "{\"path\":\"dup3.txt\",\"content\":\"foo\\n\"}", &err);
r = call("write_file", "{\"path\":\"dup3.txt\",\"content\":\"foo\\n\"}",
&err);
CHECK(err == 0, "写入 dup3.txt: %s", r);
free(r);
r = call("edit_file",
"{\"path\":\"dup3.txt\",\"line\":[1,1],\"old_string\":\"foo\","
"\"new_string\":\"bar\"}", &err);
"\"new_string\":\"bar\"}",
&err);
CHECK(err == 0, "重复行号应去重, 而不是当成两处: %s", r);
free(r);
/* 集合里有一行越界 → 和单个行号时一样, 报出文件总行数 */
r = call("edit_file",
"{\"path\":\"set.txt\",\"line\":[1,99],\"old_string\":\"x\","
"\"new_string\":\"y\"}", &err);
"\"new_string\":\"y\"}",
&err);
CHECK(err == 1 && strstr(r, "只有 4 行") != NULL,
"集合里的越界行号应报错并给出总行数: %s", r);
free(r);
@@ -285,25 +317,30 @@ int main(void)
/* 写法本身不对的三种: 空数组、非整数元素、一次给太多 */
r = call("edit_file",
"{\"path\":\"set.txt\",\"line\":[],\"old_string\":\"x\","
"\"new_string\":\"y\"}", &err);
"\"new_string\":\"y\"}",
&err);
CHECK(err == 1 && strstr(r, "空数组") != NULL, "空数组应报错: %s", r);
CHECK(strstr(r, "省略") != NULL, "空数组的报错要提示可以整个省略 line: %s", r);
CHECK(strstr(r, "省略") != NULL, "空数组的报错要提示可以整个省略 line: %s",
r);
free(r);
r = call("edit_file",
"{\"path\":\"set.txt\",\"line\":[1,1.5],\"old_string\":\"x\","
"\"new_string\":\"y\"}", &err);
"\"new_string\":\"y\"}",
&err);
CHECK(err == 1 && strstr(r, "第 2 个") != NULL,
"非整数元素应报错并指出是第几个: %s", r);
free(r);
/* ---- 省略 line = 全文件替换所有匹配 ----
* 这是 range 那样的集合参数该有的默认值: 不给范围就是不设范围. */
r = call("write_file", "{\"path\":\"all.txt\",\"content\":\"k\\nx\\nk\\n\"}", &err);
r = call("write_file",
"{\"path\":\"all.txt\",\"content\":\"k\\nx\\nk\\n\"}", &err);
CHECK(err == 0, "写入 all.txt: %s", r);
free(r);
r = call("edit_file",
"{\"path\":\"all.txt\",\"old_string\":\"k\",\"new_string\":\"K\"}", &err);
"{\"path\":\"all.txt\",\"old_string\":\"k\",\"new_string\":\"K\"}",
&err);
CHECK(err == 0, "省略 line 应做全文件替换: %s", r);
CHECK(strstr(r, "全文件替换") != NULL && strstr(r, "2 处") != NULL,
"回执要说清是全文件替换、换了几处: %s", r);
@@ -312,29 +349,39 @@ int main(void)
CHECK(strstr(r, "K") != NULL && !strstr(r, "k"), "两处都该被换掉: %s", r);
free(r);
/* 省略 line 时文件里没有 old_string: 只说整个文件里没有 (没有"哪几行"可说) */
r = call("edit_file",
"{\"path\":\"all.txt\",\"old_string\":\"zzz\",\"new_string\":\"y\"}", &err);
CHECK(err == 1 && strstr(r, "整个文件里没有") != NULL, "该报整个文件里没有: %s", r);
/* 省略 line 时文件里没有 old_string: 只说整个文件里没有 (没有"哪几行"可说)
*/
r = call(
"edit_file",
"{\"path\":\"all.txt\",\"old_string\":\"zzz\",\"new_string\":\"y\"}",
&err);
CHECK(err == 1 && strstr(r, "整个文件里没有") != NULL,
"该报整个文件里没有: %s", r);
free(r);
/* line 给错了类型 (不是"想全局"): 仍然当场报错, 并告诉它可以省略 */
r = call("edit_file",
"{\"path\":\"all.txt\",\"line\":\"1\",\"old_string\":\"K\","
"\"new_string\":\"z\"}", &err);
CHECK(err == 1 && strstr(r, "整数或整数数组") != NULL, "line 类型错应报错: %s", r);
"\"new_string\":\"z\"}",
&err);
CHECK(err == 1 && strstr(r, "整数或整数数组") != NULL,
"line 类型错应报错: %s", r);
CHECK(strstr(r, "整个省略") != NULL, "报错要指出可以省略 line: %s", r);
free(r);
{
/* 65 个行号: 超过一次能圈住的范围, 要明说 */
char big[600];
size_t o = (size_t)snprintf(big, sizeof big, "{\"path\":\"set.txt\",\"line\":[");
size_t o = (size_t)snprintf(big, sizeof big,
"{\"path\":\"set.txt\",\"line\":[");
for (int i = 1; i <= 65; i++)
o += (size_t)snprintf(big + o, sizeof big - o, "%s%d", i > 1 ? "," : "", i);
snprintf(big + o, sizeof big - o, "],\"old_string\":\"x\",\"new_string\":\"y\"}");
o += (size_t)snprintf(big + o, sizeof big - o, "%s%d",
i > 1 ? "," : "", i);
snprintf(big + o, sizeof big - o,
"],\"old_string\":\"x\",\"new_string\":\"y\"}");
r = call("edit_file", big, &err);
CHECK(err == 1 && strstr(r, "一次最多") != NULL, "行号过多应报错: %s", r);
CHECK(err == 1 && strstr(r, "一次最多") != NULL, "行号过多应报错: %s",
r);
free(r);
}
@@ -348,21 +395,23 @@ int main(void)
CHECK(err == 1, "不存在的目录应报错: %s", r);
free(r);
/* 这两个工具都已经删掉: 搜索交给模型自己用 run_command 里的真 grep,
/* 这两个工具都已经删掉: 搜索交给模型自己用 run_command 里的真 grep,
* 子代理 (spawn_subagent)整个功能也去掉了. 留着断言是为了钉住
* "不要再冒出一个半吊子的替代品". */
r = call("grep", "{\"pattern\":\"x\",\"path\":\".\"}", &err);
CHECK(err == 1 && strstr(r, "未知工具") != NULL, "不该再有 grep 工具: %s", r);
CHECK(err == 1 && strstr(r, "未知工具") != NULL, "不该再有 grep 工具: %s",
r);
free(r);
r = call("spawn_subagent", "{}", &err);
CHECK(err == 1 && strstr(r, "未知工具") != NULL, "不该再有子代理工具: %s", r);
CHECK(err == 1 && strstr(r, "未知工具") != NULL, "不该再有子代理工具: %s",
r);
free(r);
/* ---- 超大文件不能把内存吃干 ---- */
puts("超大文件防护");
{
/* 稀疏文件, 创建几乎不耗时; 关键是客户端要在读之前就发现它太大.
* 开头 16 KB 写成 'x': 这样它是"文本" (探测的前 8 KB 里没有 NUL),
/* 稀疏文件, 创建几乎不耗时; 关键是客户端要在读之前就发现它太大.
* 开头 16 KB 写成 'x': 这样它是"文本" (探测的前 8 KB 里没有 NUL),
* 否则连 read_file 都会先被二进制检测拦掉 —— 这个用例就测不到
* "分段读大文件"了. */
FILE *big = fopen("huge.txt", "wb");
@@ -376,7 +425,8 @@ int main(void)
fclose(big);
}
r = call("edit_file",
"{\"path\":\"huge.txt\",\"line\":1,\"old_string\":\"x\",\"new_string\":\"y\"}",
"{\"path\":\"huge.txt\",\"line\":1,\"old_string\":\"x\",\"new_"
"string\":\"y\"}",
&err);
CHECK(err == 1, "超大文件的 edit_file 应被拒绝: %.120s", r);
CHECK(strstr(r, "上限") != NULL, "应说明是超上限: %.120s", r);
@@ -389,12 +439,16 @@ int main(void)
unlink("huge.txt");
}
/* ---- 二进制文件: 所有处理文本的工具都必须拒绝 (而且要说明该用什么) ---- */
/* ---- 二进制文件: 所有处理文本的工具都必须拒绝 (而且要说明该用什么) ----
*/
puts("二进制拒绝");
{
FILE *bf = fopen("bin.dat", "wb");
CHECK(bf != NULL, "创建二进制测试文件失败");
if (bf) { fwrite("PNG\0\1\2\3", 1, 8, bf); fclose(bf); }
if (bf) {
fwrite("PNG\0\1\2\3", 1, 8, bf);
fclose(bf);
}
}
r = call("read_file", "{\"path\":\"bin.dat\"}", &err);
@@ -404,9 +458,11 @@ int main(void)
free(r);
r = call("edit_file",
"{\"path\":\"bin.dat\",\"line\":1,\"old_string\":\"PNG\",\"new_string\":\"JPG\"}",
"{\"path\":\"bin.dat\",\"line\":1,\"old_string\":\"PNG\",\"new_"
"string\":\"JPG\"}",
&err);
CHECK(err == 1 && strstr(r, "二进制") != NULL, "edit_file 改二进制应被拒绝: %s", r);
CHECK(err == 1 && strstr(r, "二进制") != NULL,
"edit_file 改二进制应被拒绝: %s", r);
free(r);
r = call("write_file", "{\"path\":\"bin.dat\",\"content\":\"text\"}", &err);
@@ -420,30 +476,35 @@ int main(void)
char keep[16];
memset(keep, 0, sizeof keep);
size_t kn = bf ? fread(keep, 1, sizeof keep, bf) : 0;
if (bf) fclose(bf);
if (bf)
fclose(bf);
CHECK(kn == 8 && memcmp(keep, "PNG\0\1\2\3", 8) == 0,
"被拒绝的读/写不能改动二进制文件的字节");
}
/* 确认提示里要能看出改的是哪一行 */
r = call("write_file", "{\"path\":\"confirm.txt\",\"content\":\"aaa\\nbbb\\n\"}", &err);
r = call("write_file",
"{\"path\":\"confirm.txt\",\"content\":\"aaa\\nbbb\\n\"}", &err);
free(r);
r = call("edit_file",
"{\"path\":\"confirm.txt\",\"line\":2,\"old_string\":\"bbb\",\"new_string\":\"ccc\"}",
"{\"path\":\"confirm.txt\",\"line\":2,\"old_string\":\"bbb\","
"\"new_string\":\"ccc\"}",
&err);
CHECK(err == 0 && strstr(r, "第 2 行") != NULL, "回执带行号: %s", r);
free(r);
/* ---- 哪些工具会改动磁盘 (决定是否弹确认) ---- */
puts("写操作分类");
CHECK(tool_mutating("run_command") == 1, "run_command 会执行命令, 必须确认");
CHECK(tool_mutating("run_command") == 1,
"run_command 会执行命令, 必须确认");
CHECK(tool_mutating("write_file") == 1, "write_file 会写盘, 必须确认");
CHECK(tool_mutating("edit_file") == 1, "edit_file 会改盘, 必须确认");
CHECK(tool_mutating("read_file") == 0, "read_file 只读, 不该打扰");
CHECK(tool_mutating("list_dir") == 0, "list_dir 只读, 不该打扰");
/* read_image 会在 ligent 自己的目录里留备份 (副作用),
/* read_image 会在 ligent 自己的目录里留备份 (副作用),
* 但不改用户的任何东西 —— 它是"读", 不该每次都拦一道 */
CHECK(tool_mutating("read_image") == 0, "read_image 不改用户的文件, 不该打扰");
CHECK(tool_mutating("read_image") == 0,
"read_image 不改用户的文件, 不该打扰");
CHECK(tool_mutating("没有这个工具") == 0, "未知工具按只读处理");
CHECK(tool_mutating(NULL) == 0, "空名字不应崩");
@@ -458,18 +519,21 @@ int main(void)
CHECK(bdir != NULL, "建备份临时目录失败");
if (bdir) {
image_set_bindir(bdir);
image_pending_take(); /* 清干净, 以免上一段残留 */
image_pending_take(); /* 清干净, 以免上一段残留 */
/* 1) 内容是文本、扩展名骗人 */
FILE *f = fopen("notanimage.png", "wb");
if (f) { fputs("这不是图片", f); fclose(f); }
if (f) {
fputs("这不是图片", f);
fclose(f);
}
r = call("read_image", "{\"path\":\"notanimage.png\"}", &err);
CHECK(err == 1 && strstr(r, "不认识") != NULL,
"非图片内容应被拒: %s", r);
CHECK(strstr(r, "按文件内容判断") != NULL, "应说明是看内容判断的");
free(r);
/* 2) 体积超限: base64 后超过 IMAGE_MAX_B64.
/* 2) 体积超限: base64 后超过 IMAGE_MAX_B64.
* 4/3 * 原始大小 = 编码后大小, 所以原始要 > 3/4 * 上限. */
size_t raw_needed = (size_t)(IMAGE_MAX_B64 / 4 * 3) + 4096;
f = fopen("huge.png", "wb");
@@ -479,7 +543,8 @@ int main(void)
size_t left = raw_needed - 8;
while (left > 0) {
size_t chunk = left > 65536 ? 65536 : left;
if (fwrite(pad, 1, chunk, f) != chunk) break;
if (fwrite(pad, 1, chunk, f) != chunk)
break;
left -= chunk;
}
free(pad);
@@ -493,7 +558,11 @@ int main(void)
/* 3) 合法的小图: 成功, 并且挂上待发附件 */
f = fopen("ok.png", "wb");
if (f) { fwrite("\x89PNG\r\n\x1a\n", 1, 8, f); fwrite("payload", 1, 7, f); fclose(f); }
if (f) {
fwrite("\x89PNG\r\n\x1a\n", 1, 8, f);
fwrite("payload", 1, 7, f);
fclose(f);
}
r = call("read_image", "{\"path\":\"ok.png\"}", &err);
CHECK(err == 0 && strstr(r, "已读入图片") != NULL,
"合法图片应成功: %s", r);
@@ -504,14 +573,20 @@ int main(void)
CHECK(att && json_array_size(att) == 1, "应挂上 1 个待发附件");
if (att && json_array_size(att) == 1)
CHECK(strcmp(json_get_string(json_array_get(att, 0), "type"),
"ligent_image") == 0, "附件应是内部引用块");
"ligent_image") == 0,
"附件应是内部引用块");
json_free(att);
/* 只有成功那一张留了备份 —— 两条拒绝路径都不该留下东西 */
char *ls = xasprintf("ls %s/ligent.archive/images/*.png | wc -l", bdir);
char *ls =
xasprintf("ls %s/ligent.archive/images/*.png | wc -l", bdir);
FILE *p = popen(ls, "r");
int cnt = -1;
if (p) { if (fscanf(p, "%d", &cnt) != 1) cnt = -1; pclose(p); }
if (p) {
if (fscanf(p, "%d", &cnt) != 1)
cnt = -1;
pclose(p);
}
free(ls);
CHECK(cnt == 1, "只该留 1 份备份 (成功那张), 实际 %d", cnt);
@@ -522,13 +597,15 @@ int main(void)
/* ---- 超长单行: read_file 报出的行号必须与 edit_file 的口径一致 ----
*
* read_file 以前用 fgets + 固定 64 KB 缓冲, 一行比缓冲长时会被切成"多行"、
* 每块各带一个行号. 于是模型按 read_file 看到的行号去 edit_file 改, 要么改错
* 地方、要么报越界 —— 而它没有任何办法察觉. 工具描述和 README 都写着
* "read_file 打出来的行号可以直接喂给 edit_file 的 line", 这里把它钉住. */
* 每块各带一个行号. 于是模型按 read_file 看到的行号去 edit_file 改,
* 要么改错 地方、要么报越界 —— 而它没有任何办法察觉. 工具描述和 README
* 都写着 "read_file 打出来的行号可以直接喂给 edit_file 的 line",
* 这里把它钉住. */
puts("超长单行的行号口径");
{
FILE *lf = fopen("longline.txt", "wb");
for (int i = 0; i < 70000; i++) fputc('a', lf); /* 跨过 64 KB 那块缓冲 */
for (int i = 0; i < 70000; i++)
fputc('a', lf); /* 跨过 64 KB 那块缓冲 */
fputc('\n', lf);
fputs("second line\nthird line\n", lf);
fclose(lf);
@@ -537,17 +614,22 @@ int main(void)
CHECK(err == 0, "读超长行文件失败: %s", r);
CHECK(strstr(r, " 1\ta") != NULL, "第 1 行应以 a 开头:\n%.120s", r);
CHECK(strstr(r, " 2\tsecond line") != NULL,
"超长行之后的第 2 行应是 second line (它不能被算成好几行): \n%.200s", r);
CHECK(strstr(r, " 3\tthird line") != NULL, "第 3 行应是 third line");
"超长行之后的第 2 行应是 second line (它不能被算成好几行): "
"\n%.200s",
r);
CHECK(strstr(r, " 3\tthird line") != NULL,
"第 3 行应是 third line");
CHECK(strstr(r, " 4\t") == NULL, "不应该出现第 4 行");
free(r);
/* 拿 read_file 报出的行号去 edit_file 改, 必须命中同一处 */
r = call("edit_file",
"{\"path\":\"longline.txt\",\"line\":3,"
"\"old_string\":\"third line\",\"new_string\":\"THIRD LINE\"}", &err);
"\"old_string\":\"third line\",\"new_string\":\"THIRD LINE\"}",
&err);
CHECK(err == 0, "用 read_file 的行号改第 3 行失败: %s", r);
CHECK(strstr(r, "第 3 行替换 1 处") != NULL, "回执应说清改的是第 3 行: %s", r);
CHECK(strstr(r, "第 3 行替换 1 处") != NULL,
"回执应说清改的是第 3 行: %s", r);
free(r);
char *after = read_entire_file("longline.txt", NULL);
CHECK(after && strstr(after, "THIRD LINE") != NULL, "改动应落到文件里");
@@ -556,7 +638,8 @@ int main(void)
/* 单行本身就超过输出上限: 必须说清"这一行只给了开头", 不能说成
* "已截断, 从下一行继续" —— 那会让调用方以为拿到的是完整的行. */
FILE *hf = fopen("hugeline.txt", "wb");
for (int i = 0; i < 200000; i++) fputc('b', hf);
for (int i = 0; i < 200000; i++)
fputc('b', hf);
fputc('\n', hf);
fputs("tail\n", hf);
fclose(hf);
@@ -571,7 +654,8 @@ int main(void)
/* ---- 空目录: qsort 的第一个参数会拿到 NULL ---- */
puts("list_dir 空目录");
if (mkdir("emptydir", 0755) != 0) { /* 可能已存在 */ }
if (mkdir("emptydir", 0755) != 0) { /* 可能已存在 */
}
r = call("list_dir", "{\"path\":\"emptydir\"}", &err);
CHECK(err == 0 && strstr(r, "0 个条目") != NULL,
"空目录应报 0 个条目, 实际 %s", r);
@@ -586,7 +670,8 @@ int main(void)
/* ---- 输出截断 ---- */
puts("output truncation");
r = call("run_command",
"{\"command\":\"head -c 300000 /dev/zero | tr '\\\\0' 'a'\"}", &err);
"{\"command\":\"head -c 300000 /dev/zero | tr '\\\\0' 'a'\"}",
&err);
CHECK(strlen(r) <= LIGENT_TOOL_OUTPUT_LIMIT + 200,
"输出应被截断, 实际 %zu 字节", strlen(r));
CHECK(strstr(r, "截断") != NULL, "应提示已截断");