/* test_tools.c - 工具层测试 */ #include "image.h" #include "json.h" #include "tools.h" #include "util.h" #include #include #include #include #include static int failures = 0; static int checks = 0; #define CHECK(cond, ...) \ do { \ checks++; \ if (!(cond)) { \ failures++; \ printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ printf(__VA_ARGS__); \ printf("\n"); \ } \ } while (0) 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) { printf(" 测试用例 args JSON 非法: %s\n", err); exit(2); } char *r = tool_execute(name, args, is_error); json_free(args); return r; } 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; } /* ---- 工具 schema 合法性 ---- */ puts("tools_schema"); JsonValue *schema = tools_schema(); CHECK(json_array_size(schema) == 6, "应有 6 个工具, 实际 %zu", json_array_size(schema)); for (size_t i = 0; i < json_array_size(schema); i++) { JsonValue *t = json_array_get(schema, i); CHECK(strcmp(json_get_string(t, "type"), "function") == 0, "type"); JsonValue *fn = json_object_get(t, "function"); CHECK(json_get_string(fn, "name") != NULL, "缺 name"); JsonValue *params = json_object_get(fn, "parameters"); CHECK(json_get_string(params, "type") != NULL, "缺 parameters.type"); CHECK(json_object_get(params, "properties") != NULL, "缺 properties"); CHECK(json_object_get(params, "required") != NULL, "缺 required"); } { 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"); const char *nm = json_get_string(fn, "name"); if (nm && strcmp(nm, "read_image") == 0) has_image = 1; } CHECK(has_image, "schema 里应有 read_image"); } { /* 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"); const char *nm = json_get_string(fn, "name"); if (nm && strcmp(nm, "edit_file") == 0) { JsonValue *par = json_object_get(fn, "parameters"); props = json_object_get(par, "properties"); reqd = json_object_get(par, "required"); } } /* line 是可选的 (省略 = 全文件替换), 不该出现在 required 里 */ 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; } 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 : "(缺)"); 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 : "(缺)"); } /* 序列化一遍确保可发给服务端 */ char *schema_json = json_dump(schema); JsonValue *re = json_parse(schema_json, NULL, 0); CHECK(re != NULL, "schema 无法被自身解析器往返"); free(schema_json); json_free(re); json_free(schema); /* ---- run_command ---- */ puts("run_command"); int err = 0; char *r = call("run_command", "{\"command\":\"echo hello\"}", &err); CHECK(err == 0, "exit 0 不应算错误: %s", r); CHECK(strstr(r, "hello") != NULL, "应含 hello: %s", r); CHECK(strstr(r, "[exit code: 0]") != NULL, "应含退出码: %s", r); free(r); r = call("run_command", "{\"command\":\"exit 3\"}", &err); CHECK(err == 1, "非零退出应标记为错误"); CHECK(strstr(r, "[exit code: 3]") != NULL, "应报告退出码 3: %s", r); free(r); r = call("run_command", "{\"command\":\"echo err >&2; exit 1\"}", &err); CHECK(strstr(r, "err") != NULL, "stderr 应被合并捕获: %s", r); free(r); /* 超时 */ r = call("run_command", "{\"command\":\"sleep 5\",\"timeout\":1}", &err); CHECK(err == 1 && strstr(r, "超时") != NULL, "应超时: %s", r); free(r); r = call("run_command", "{}", &err); CHECK(err == 1 && strstr(r, "command") != NULL, "缺参数应报错: %s", r); free(r); /* ---- write_file / read_file ---- */ puts("write_file / read_file"); 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); r = call("read_file", "{\"path\":\"sub/dir/a.txt\"}", &err); CHECK(err == 0, "读取应成功: %s", r); CHECK(strstr(r, "line1") && strstr(r, "line3"), "内容应完整: %s", r); CHECK(strstr(r, "1\t") != NULL, "应带行号: %s", r); free(r); 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); r = call("read_file", "{\"path\":\"does_not_exist\"}", &err); CHECK(err == 1, "读不存在的文件应报错: %s", r); free(r); /* ---- edit_file: 替换由行号钉住 ---- */ puts("edit_file"); r = call("edit_file", "{\"path\":\"sub/dir/a.txt\",\"line\":2,\"old_string\":\"line2\"," "\"new_string\":\"LINE-TWO\"}", &err); CHECK(err == 0, "按行号替换应成功: %s", r); CHECK(strstr(r, "第 2 行") != NULL, "回执里应带上行号: %s", r); free(r); r = call("read_file", "{\"path\":\"sub/dir/a.txt\"}", &err); CHECK(strstr(r, "LINE-TWO") != NULL, "替换结果应落盘: %s", r); free(r); /* 省略 line 不再是错误: 那是"全文件替换"这条逃生门 (见下面那组用例) */ /* 行号对不上: 必须报出它**真正在哪一行**, 否则模型只能反复猜 */ 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); /* 越界行号: 要连文件总行数一起说清楚 */ r = call("edit_file", "{\"path\":\"sub/dir/a.txt\",\"line\":99,\"old_string\":\"x\"," "\"new_string\":\"y\"}", &err); CHECK(err == 1 && strstr(r, "只有 3 行") != NULL, "越界行号应报错并给出总行数: %s", r); free(r); /* 唯一性只要求**在这一行里**唯一: 同一个词出现在别的行不该拦住替换 * (这正是"全文唯一"最烦人的地方——文件一长, 随便一个短串都不唯一) */ r = call("edit_file", "{\"path\":\"sub/dir/a.txt\",\"line\":1,\"old_string\":\"line\"," "\"new_string\":\"LINE\"}", &err); CHECK(err == 0, "同一行里唯一即可, 别的行的重复不该拦: %s", r); free(r); /* 同一行里出现两处 → 仍然拒绝 (行内也要定得下来) */ 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\"}", &err); CHECK(err == 1 && strstr(r, "第 1 行里有 2 处") != NULL, "同一行里多匹配应拒绝: %s", r); free(r); /* replace_all 仍然换全文件, 但 line 必须指向其中真实的一处 */ r = call("edit_file", "{\"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); CHECK(strstr(r, "bar bar") != NULL && !strstr(r, "foo"), "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); CHECK(err == 1, "replace_all 也必须锚定在真实的一行上: %s", r); free(r); /* ---- edit_file: line 是集合型 ---- * 集合是**搜索范围** (old_string 的起点落在其中任一行即可), 不是 * "每行都改一次": 真改动的仍是唯一那一处. */ puts("edit_file: line 集合"); r = call("write_file", "{\"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); CHECK(err == 0, "集合圈住的替换应成功: %s", r); CHECK(strstr(r, "第 1、2 行") != NULL, "回执里应带上整个集合: %s", r); free(r); r = call("read_file", "{\"path\":\"set.txt\"}", &err); CHECK(strstr(r, "AAA") != NULL && !strstr(r, "aaa"), "替换应落盘: %s", r); free(r); /* old_string 跨行时, 把覆盖到的几行都填进集合——不必赌起点是第几行 */ 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); free(r); /* 集合里有多处匹配 → 仍然拒绝 (集合不是"每行改一次"的授权) */ 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); CHECK(err == 1 && strstr(r, "第 1、3 行里有 2 处") != NULL, "集合里多处匹配应拒绝: %s", r); free(r); /* 重复的行号要去重: 不然同一处匹配会被数成两处 */ 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); CHECK(err == 0, "重复行号应去重, 而不是当成两处: %s", r); free(r); /* 集合里有一行越界 → 和单个行号时一样, 报出文件总行数 */ r = call("edit_file", "{\"path\":\"set.txt\",\"line\":[1,99],\"old_string\":\"x\"," "\"new_string\":\"y\"}", &err); CHECK(err == 1 && strstr(r, "只有 4 行") != NULL, "集合里的越界行号应报错并给出总行数: %s", r); free(r); /* 写法本身不对的三种: 空数组、非整数元素、一次给太多 */ r = call("edit_file", "{\"path\":\"set.txt\",\"line\":[],\"old_string\":\"x\"," "\"new_string\":\"y\"}", &err); CHECK(err == 1 && strstr(r, "空数组") != NULL, "空数组应报错: %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); 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); CHECK(err == 0, "写入 all.txt: %s", r); free(r); r = call("edit_file", "{\"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); free(r); r = call("read_file", "{\"path\":\"all.txt\"}", &err); 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); 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); 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\":["); 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\"}"); r = call("edit_file", big, &err); CHECK(err == 1 && strstr(r, "一次最多") != NULL, "行号过多应报错: %s", r); free(r); } /* ---- list_dir ---- */ puts("list_dir"); r = call("list_dir", "{\"path\":\".\"}", &err); CHECK(err == 0 && strstr(r, "sub/") != NULL, "应列出子目录: %s", r); free(r); r = call("list_dir", "{\"path\":\"nope\"}", &err); CHECK(err == 1, "不存在的目录应报错: %s", r); free(r); /* 这两个工具都已经删掉: 搜索交给模型自己用 run_command 里的真 grep, * 子代理 (spawn_subagent)整个功能也去掉了. 留着断言是为了钉住 * "不要再冒出一个半吊子的替代品". */ r = call("grep", "{\"pattern\":\"x\",\"path\":\".\"}", &err); CHECK(err == 1 && strstr(r, "未知工具") != NULL, "不该再有 grep 工具: %s", r); free(r); r = call("spawn_subagent", "{}", &err); CHECK(err == 1 && strstr(r, "未知工具") != NULL, "不该再有子代理工具: %s", r); free(r); /* ---- 超大文件不能把内存吃干 ---- */ puts("超大文件防护"); { /* 稀疏文件, 创建几乎不耗时; 关键是客户端要在读之前就发现它太大. * 开头 16 KB 写成 'x': 这样它是"文本" (探测的前 8 KB 里没有 NUL), * 否则连 read_file 都会先被二进制检测拦掉 —— 这个用例就测不到 * "分段读大文件"了. */ FILE *big = fopen("huge.txt", "wb"); CHECK(big != NULL, "创建测试文件失败"); if (big) { char head[16 * 1024]; memset(head, 'x', sizeof head); fwrite(head, 1, sizeof head, big); fseek(big, 40L * 1024 * 1024, SEEK_SET); fputc('x', big); fclose(big); } r = call("edit_file", "{\"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); free(r); /* read_file 是分段读的, 应当照常可用 */ r = call("read_file", "{\"path\":\"huge.txt\",\"limit\":1}", &err); CHECK(err == 0, "分段读大文件应当可用: %.120s", r); free(r); 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); } } r = call("read_file", "{\"path\":\"bin.dat\"}", &err); CHECK(err == 1, "read_file 读二进制应被拒绝: %s", r); CHECK(strstr(r, "二进制") != NULL && strstr(r, "第 4 字节") != NULL, "拒绝理由要指出 NUL 在哪一字节: %s", r); free(r); r = call("edit_file", "{\"path\":\"bin.dat\",\"line\":1,\"old_string\":\"PNG\",\"new_" "string\":\"JPG\"}", &err); CHECK(err == 1 && strstr(r, "二进制") != NULL, "edit_file 改二进制应被拒绝: %s", r); free(r); r = call("write_file", "{\"path\":\"bin.dat\",\"content\":\"text\"}", &err); CHECK(err == 1 && strstr(r, "二进制") != NULL, "write_file 覆盖二进制应被拒绝: %s", r); free(r); /* 被拒绝的三次都不能动到文件本身 —— 拒绝的意义就在这里 */ { FILE *bf = fopen("bin.dat", "rb"); char keep[16]; memset(keep, 0, sizeof keep); size_t kn = bf ? fread(keep, 1, sizeof keep, bf) : 0; 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); free(r); r = call("edit_file", "{\"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("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 自己的目录里留备份 (副作用), * 但不改用户的任何东西 —— 它是"读", 不该每次都拦一道 */ CHECK(tool_mutating("read_image") == 0, "read_image 不改用户的文件, 不该打扰"); CHECK(tool_mutating("没有这个工具") == 0, "未知工具按只读处理"); CHECK(tool_mutating(NULL) == 0, "空名字不应崩"); /* ---- read_image: 只收真的能内联进去的图 ---- * * 两条拒绝路径是新的: 格式不认识、体积超限. 它们都必须**不备份** —— * 一张交不出去的图, 留备份只会让 ligent.archive 变成垃圾场. */ puts("read_image: 本地就把不合格的图拦掉"); { char btmp[] = "/tmp/ligent_tools_img_XXXXXX"; char *bdir = mkdtemp(btmp); CHECK(bdir != NULL, "建备份临时目录失败"); if (bdir) { image_set_bindir(bdir); image_pending_take(); /* 清干净, 以免上一段残留 */ /* 1) 内容是文本、扩展名骗人 */ FILE *f = fopen("notanimage.png", "wb"); 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. * 4/3 * 原始大小 = 编码后大小, 所以原始要 > 3/4 * 上限. */ size_t raw_needed = (size_t)(IMAGE_MAX_B64 / 4 * 3) + 4096; f = fopen("huge.png", "wb"); if (f) { fwrite("\x89PNG\r\n\x1a\n", 1, 8, f); char *pad = xcalloc(1, 65536); size_t left = raw_needed - 8; while (left > 0) { size_t chunk = left > 65536 ? 65536 : left; if (fwrite(pad, 1, chunk, f) != chunk) break; left -= chunk; } free(pad); fclose(f); } r = call("read_image", "{\"path\":\"huge.png\"}", &err); CHECK(err == 1 && strstr(r, "太大") != NULL, "超限的图应被当场拒掉: %.120s", r); CHECK(strstr(r, "内联上限") != NULL, "应说清上限是多少"); free(r); /* 3) 合法的小图: 成功, 并且挂上待发附件 */ f = fopen("ok.png", "wb"); 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); CHECK(strstr(r, "base64") != NULL, "应说明会以 base64 内联"); free(r); JsonValue *att = image_pending_take(); 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, "附件应是内部引用块"); json_free(att); /* 只有成功那一张留了备份 —— 两条拒绝路径都不该留下东西 */ 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); } free(ls); CHECK(cnt == 1, "只该留 1 份备份 (成功那张), 实际 %d", cnt); image_set_bindir("."); } } /* ---- 超长单行: read_file 报出的行号必须与 edit_file 的口径一致 ---- * * read_file 以前用 fgets + 固定 64 KB 缓冲, 一行比缓冲长时会被切成"多行"、 * 每块各带一个行号. 于是模型按 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 那块缓冲 */ fputc('\n', lf); fputs("second line\nthird line\n", lf); fclose(lf); r = call("read_file", "{\"path\":\"longline.txt\"}", &err); 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"); 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); CHECK(err == 0, "用 read_file 的行号改第 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, "改动应落到文件里"); free(after); /* 单行本身就超过输出上限: 必须说清"这一行只给了开头", 不能说成 * "已截断, 从下一行继续" —— 那会让调用方以为拿到的是完整的行. */ FILE *hf = fopen("hugeline.txt", "wb"); for (int i = 0; i < 200000; i++) fputc('b', hf); fputc('\n', hf); fputs("tail\n", hf); fclose(hf); r = call("read_file", "{\"path\":\"hugeline.txt\"}", &err); CHECK(strstr(r, "第 1 行本身就超过") != NULL, "应说清是这一行超过上限: %.160s", r); CHECK(strstr(r, " 2\t") == NULL, "不能把剩下的半个行伪装成第 2 行"); free(r); remove("hugeline.txt"); remove("longline.txt"); } /* ---- 空目录: qsort 的第一个参数会拿到 NULL ---- */ puts("list_dir 空目录"); if (mkdir("emptydir", 0755) != 0) { /* 可能已存在 */ } r = call("list_dir", "{\"path\":\"emptydir\"}", &err); CHECK(err == 0 && strstr(r, "0 个条目") != NULL, "空目录应报 0 个条目, 实际 %s", r); free(r); rmdir("emptydir"); /* ---- 未知工具 ---- */ r = call("no_such_tool", "{}", &err); CHECK(err == 1 && strstr(r, "未知工具") != NULL, "未知工具应报错: %s", r); free(r); /* ---- 输出截断 ---- */ puts("output truncation"); r = call("run_command", "{\"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, "应提示已截断"); free(r); printf("\n%d 项检查, %d 项失败\n", checks, failures); return failures ? 1 : 0; }