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

499 lines
19 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* test_config.c - 配置默认值 / 环境变量 / 配置文件覆盖 */
#include "config.h"
#include "util.h"
#include "i18n.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <limits.h>
#include <sys/stat.h>
static int failures = 0;
static int checks = 0;
#define CHECK(cond, ...) \
do { \
checks++; \
if (!(cond)) { \
failures++; \
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
printf(__VA_ARGS__); \
printf("\n"); \
} \
} while (0)
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_TEMPERATURE", "LIGENT_MAX_TOKENS", "LIGENT_TIMEOUT",
"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)
{
puts("默认值");
unset_all();
LigentConfig c;
config_defaults(&c);
CHECK(strcmp(c.model, "deepseek-flash") == 0,
"默认模型应为 deepseek-flash,实际 %s", c.model);
CHECK(strcmp(c.base_url, "https://api.deepseek.com/v1") == 0,
"默认 base_url 应为 DeepSeek,实际 %s", c.base_url);
CHECK(c.api_key && c.api_key[0] == '\0', "默认 api_key 应为空");
CHECK(c.stream == 1, "默认应开启流式");
CHECK(c.use_tools == 1, "默认应启用工具");
/* 默认不限往返次数:0(或负数)= 不限,只有正数才是上限 */
CHECK(c.max_steps == 0, "默认应不限往返次数,实际 %d", c.max_steps);
config_free(&c);
}
static void test_env_key(void)
{
puts("环境变量取 key");
unset_all();
LigentConfig c;
config_defaults(&c);
/* 没有任何 key */
config_apply_env(&c);
CHECK(c.api_key[0] == '\0', "无环境变量时 key 应为空");
/* 只设 DEEPSEEK_API_KEY:默认就应读它 */
setenv("DEEPSEEK_API_KEY", "sk-deepseek", 1);
config_apply_env(&c);
CHECK(strcmp(c.api_key, "sk-deepseek") == 0,
"应读 $DEEPSEEK_API_KEY,实际 [%s]", c.api_key);
/* LIGENT_API_KEY 作为显式覆盖优先 */
setenv("LIGENT_API_KEY", "sk-explicit", 1);
config_apply_env(&c);
CHECK(strcmp(c.api_key, "sk-explicit") == 0,
"LIGENT_API_KEY 应覆盖,实际 [%s]", c.api_key);
/* OPENAI_API_KEY 只在端点确实是 OpenAI 时才兜底 ——
* 把 OpenAI 的密钥发给 DeepSeek 的端点是没有意义的 */
unset_all();
setenv("OPENAI_API_KEY", "sk-openai", 1);
/* 模拟"配置文件没写 api_key":必须先清掉上一步留下的值 */
free(c.api_key);
c.api_key = xstrdup("");
config_apply_env(&c);
CHECK(c.api_key[0] == '\0', "DeepSeek 端点不应兜底到 OPENAI_API_KEY");
free(c.base_url);
c.base_url = xstrdup("https://api.openai.com/v1");
config_apply_env(&c);
CHECK(strcmp(c.api_key, "sk-openai") == 0,
"OpenAI 端点应兜底到 OPENAI_API_KEY,实际 %s", c.api_key);
unset_all();
config_free(&c);
}
static void test_env_others(void)
{
puts("环境变量其他项");
unset_all();
LigentConfig c;
config_defaults(&c);
setenv("LIGENT_MODEL", "my-model", 1);
setenv("LIGENT_MAX_STEPS", "7", 1);
setenv("LIGENT_STREAM", "0", 1);
setenv("LIGENT_TOOLS", "false", 1);
setenv("LIGENT_THINKING", "off", 1);
setenv("LIGENT_REASONING_EFFORT", "max", 1);
config_apply_env(&c);
CHECK(strcmp(c.model, "my-model") == 0, "LIGENT_MODEL 应生效");
CHECK(c.max_steps == 7, "LIGENT_MAX_STEPS 应生效");
CHECK(c.stream == 0, "LIGENT_STREAM=0 应关闭流式");
CHECK(c.use_tools == 0, "LIGENT_TOOLS=false 应关闭工具");
CHECK(c.thinking == THINKING_OFF, "LIGENT_THINKING=off 应关闭思考");
CHECK(c.reasoning_effort && strcmp(c.reasoning_effort, "max") == 0,
"LIGENT_REASONING_EFFORT=max 应生效");
unset_all();
config_free(&c);
/* 环境变量是临时开关:写错了应当被忽略,而不是把程序拦下来 */
config_defaults(&c);
setenv("LIGENT_THINKING", "随便写的", 1);
setenv("LIGENT_REASONING_EFFORT", "超高", 1);
config_apply_env(&c);
CHECK(c.thinking == THINKING_AUTO, "无法识别的 LIGENT_THINKING 应被忽略");
CHECK(c.reasoning_effort == NULL, "无法识别的级别应被忽略");
unset_all();
config_free(&c);
}
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("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("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("maybe", &t) != 0, "无法识别的值应报错");
CHECK(config_parse_thinking(NULL, &t) != 0, "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 不是合法级别");
CHECK(!config_effort_valid(""), "空串不是合法级别");
CHECK(!config_effort_valid(NULL), "NULL 不是合法级别");
CHECK(strstr(config_effort_list(), "ultra") != NULL, "展示串应含全部级别");
CHECK(config_thinking_name(THINKING_AUTO) != NULL, "THINKING_AUTO 有说明");
CHECK(config_thinking_name(THINKING_ON) != NULL, "THINKING_ON 有说明");
CHECK(config_thinking_name(THINKING_OFF) != NULL, "THINKING_OFF 有说明");
}
static void test_file_thinking(void)
{
puts("配置文件里的 thinking / reasoning_effort");
char err[256];
LigentConfig c;
/* 布尔写法 */
char t1[] = "/tmp/ligent_think_XXXXXX";
int fd = mkstemp(t1);
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);
CHECK(w == (ssize_t)strlen(j1), "写临时配置失败");
config_defaults(&c);
CHECK(config_load_file(&c, t1, err, sizeof err) == 0, "加载失败: %s", err);
CHECK(c.thinking == THINKING_OFF, "thinking:false 应为关闭");
CHECK(c.reasoning_effort && strcmp(c.reasoning_effort, "medium") == 0,
"reasoning_effort 应被读入");
config_free(&c);
unlink(t1);
/* 字符串写法 */
char t2[] = "/tmp/ligent_think_XXXXXX";
fd = mkstemp(t2);
if (fd >= 0) {
const char *j2 = "{\"thinking\":\"on\"}";
w = write(fd, j2, strlen(j2));
close(fd);
CHECK(w == (ssize_t)strlen(j2), "写临时配置失败");
config_defaults(&c);
CHECK(config_load_file(&c, t2, err, sizeof err) == 0, "加载失败: %s", err);
CHECK(c.thinking == THINKING_ON, "thinking:\"on\" 应为开启");
config_free(&c);
unlink(t2);
}
/* 写错了必须报错,不能静默忽略 —— 配置文件是长期存在的,静默会让人困惑 */
char t3[] = "/tmp/ligent_think_XXXXXX";
fd = mkstemp(t3);
if (fd >= 0) {
const char *j3 = "{\"thinking\":\"大概吧\"}";
w = write(fd, j3, strlen(j3));
close(fd);
CHECK(w == (ssize_t)strlen(j3), "写临时配置失败");
config_defaults(&c);
CHECK(config_load_file(&c, t3, err, sizeof err) != 0,
"非法的 thinking 应加载失败");
config_free(&c);
unlink(t3);
}
char t4[] = "/tmp/ligent_think_XXXXXX";
fd = mkstemp(t4);
if (fd >= 0) {
const char *j4 = "{\"reasoning_effort\":\"超级高\"}";
w = write(fd, j4, strlen(j4));
close(fd);
CHECK(w == (ssize_t)strlen(j4), "写临时配置失败");
config_defaults(&c);
CHECK(config_load_file(&c, t4, err, sizeof err) != 0,
"非法的 reasoning_effort 应加载失败");
config_free(&c);
unlink(t4);
}
}
static void test_file_override(void)
{
puts("配置文件覆盖 + ${VAR} 展开");
unset_all();
setenv("MY_SECRET", "sk-from-env", 1);
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}";
ssize_t w = write(fd, json, strlen(json));
close(fd);
CHECK(w == (ssize_t)strlen(json), "写临时配置失败");
LigentConfig c;
config_defaults(&c);
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.model, "qwen2.5:14b") == 0, "model 覆盖");
CHECK(strcmp(c.api_key, "sk-from-env") == 0,
"${MY_SECRET} 应被展开,实际 [%s]", c.api_key);
CHECK(c.temperature > 0.29 && c.temperature < 0.31, "temperature 覆盖");
CHECK(c.max_tokens == 4096, "max_tokens 覆盖");
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, "记录配置文件路径");
/* 坏配置应报错而不是静默通过。
*
* 解析器的报错跟着界面语言走(json_parse_ui),所以下面这段断语先把语言
* 钉住,再顺手把英文那份也验一遍 —— 位置信息两种语言都得有。 */
char tmpl2[] = "/tmp/ligent_bad_XXXXXX";
int fd2 = mkstemp(tmpl2);
if (fd2 >= 0) {
const char *bad = "{ not json ";
ssize_t w2 = write(fd2, bad, strlen(bad));
(void)w2;
close(fd2);
LigentConfig c2;
i18n_set_lang(LIGENT_LANG_ZH);
config_defaults(&c2);
CHECK(config_load_file(&c2, tmpl2, err, sizeof err) != 0,
"非法 JSON 应加载失败");
CHECK(err[0] != '\0', "应给出错误信息");
/* 报错必须带上文件路径与出错位置,否则用户无从下手 */
CHECK(strstr(err, tmpl2) != NULL, "错误信息应含文件路径: %s", err);
CHECK(strstr(err, "行") != NULL, "错误信息应含出错位置: %s", err);
config_free(&c2);
LigentConfig c3;
i18n_set_lang(LIGENT_LANG_EN);
config_defaults(&c3);
CHECK(config_load_file(&c3, tmpl2, err, sizeof err) != 0 &&
strstr(err, "line") != NULL,
"英文界面下报错也带位置(且是英文): %s", err);
config_free(&c3);
i18n_set_lang(LIGENT_LANG_ZH);
unlink(tmpl2);
}
config_free(&c);
unlink(tmpl);
unset_all();
}
/* 配置只认二进制所在目录:不做用户级、系统级,也不做当前目录查找 */
static void test_binary_dir_lookup(void)
{
puts("配置位置 = 二进制所在目录");
char err[256];
char *dir = config_binary_dir(NULL);
char *canon = config_default_path(NULL);
CHECK(dir && *dir, "应能解析出二进制所在目录");
CHECK(canon && str_ends_with(canon, "/ligent.json"),
"规范路径应以 /ligent.json 结尾: %s", canon ? canon : "(null)");
CHECK(canon && dir && str_starts_with(canon, dir),
"规范路径应位于二进制目录下: %s / %s", canon, dir);
/* 当前目录放一份 ligent.json,它绝不能被加载 */
char tmpl[] = "/tmp/ligent_cwd_XXXXXX";
char *tmp = mkdtemp(tmpl);
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); }
char *saved = getcwd(NULL, 0);
int loaded_from_cwd = 0;
char *got_dir = NULL;
if (chdir(tmp) == 0) {
LigentConfig c;
config_init(&c, NULL, NULL, err, sizeof err);
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);
}
CHECK(!loaded_from_cwd, "不应加载当前工作目录下的 ligent.json");
CHECK(got_dir && dir && strcmp(got_dir, dir) == 0,
"config_dir 应是二进制目录,实际 %s", got_dir ? got_dir : "(null)");
free(got_dir);
free(saved);
unlink(fake); /* 先删文件再释放路径字符串 */
free(fake);
rmdir(tmp);
free(dir);
free(canon);
}
/* 写一个临时配置文件并加载 */
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;
ssize_t w = write(fd, json, strlen(json));
close(fd);
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,
* shell 里残留的 DEEPSEEK_API_KEY 把它顶掉,于是 A 家的密钥发给了 B 家。 */
static void test_env_key_precedence(void)
{
puts("api_key 优先级:厂商环境变量不得越权");
unset_all();
setenv("DEEPSEEK_API_KEY", "sk-leftover-in-shell", 1);
/* 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, "加载配置失败");
config_apply_env(&c);
CHECK(strcmp(c.api_key, "sk-or-v1-from-file") == 0,
"配置文件里的 key 不应被残留环境变量顶掉,实际 %s", c.api_key);
config_free(&c);
/* 2) 配置文件没写 key + 端点就是 DeepSeek => 用 DEEPSEEK_API_KEY 兜底 */
config_defaults(&c);
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,
"DeepSeek 端点应能用 DEEPSEEK_API_KEY 兜底,实际 %s", c.api_key);
config_free(&c);
/* 3) 配置文件没写 key + 端点是别家 => 不猜,宁可空着让用户看到明确提示 */
config_defaults(&c);
CHECK(load_temp_config(&c, "{\"base_url\":\"https://openrouter.ai/api/v1\"}") == 0,
"加载配置失败");
config_apply_env(&c);
CHECK(c.api_key[0] == '\0',
"base_url 指向别家时不应拿 DeepSeek 的密钥去猜,实际 %s", c.api_key);
config_free(&c);
/* 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, "加载配置失败");
config_apply_env(&c);
CHECK(strcmp(c.api_key, "sk-explicit") == 0,
"LIGENT_API_KEY 应最高优先,实际 %s", c.api_key);
config_free(&c);
unset_all();
}
/* 软链接:配置目录要跟着"用户实际调用那个文件"走。
* 否则把 ligent 软链到 ~/bin 之后,配置会被悄悄挪到源文件所在的目录,
* 用户放在 ~/bin 旁边的 ligent.json 就永远读不到。 */
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; }
self[n] = '\0';
char tmpl[] = "/tmp/ligent_link_XXXXXX";
char *dir = mkdtemp(tmpl);
if (!dir) { puts(" (mkdtemp 失败,跳过)"); return; }
char *link = xasprintf("%s/ligent", dir);
if (symlink(self, link) != 0) {
puts(" (symlink 失败,跳过)");
free(link); rmdir(dir);
return;
}
/* 核心:指向软链接时取软链接所在目录,而不是源文件所在目录 */
char *got = config_binary_dir(link);
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);
/* argv[0] 只是个名字时经 $PATH 找到的仍应是软链接本身 */
char *saved = getenv("PATH") ? xstrdup(getenv("PATH")) : NULL;
char *newpath = xasprintf("%s:%s", dir, saved ? saved : "");
setenv("PATH", newpath, 1);
got = config_binary_dir("ligent");
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);
/* 直接用真实文件时,取的还是真实目录 */
char *selfdir = xstrdup(self);
char *slash = strrchr(selfdir, '/');
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);
unlink(link);
free(link);
rmdir(dir);
}
int main(void)
{
test_defaults();
test_env_key();
test_env_others();
test_thinking_parse();
test_file_thinking();
test_file_override();
test_binary_dir_lookup();
test_env_key_precedence();
test_symlink_dir();
printf("\n%d 项检查,%d 项失败\n", checks, failures);
return failures ? 1 : 0;
}