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

499 lines
19 KiB
C

/* 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;
}