Files
InfoRSI/tests/config-check.test.ts
T
iomgaa d2bfb971ab feat: 配置 config 模块(任务书 04)
- src/config:research.json 的字段定义(TypeBox,全部必填、不认识的字段报错,每个字段带中文说明和类别)、一次列出全部错误的校验、步骤清单纯函数。
- 流程写成完整流程 / 朴素循环两选一,第零轮、消融写成开 / 关两选一,冲突组合写不出来,不另写检查。
- 规则名取自 rules 登记表,config 改为依赖 rules(结构.md 第二部分、.dependency-cruiser.cjs)。
- ledger 顺手两处(结构.md 第四部分):消融结算按轮次唯一、去掉消融编号;私人笔记去掉版本号;表结构版本号升到 2。
- 结构.md 第八部分补 config 对外内容、更新 ledger 一节;WORK_LOG 更新。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 08:08:18 -04:00

217 lines
9.1 KiB
TypeScript
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.
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { checkConfig, loadConfig, ResearchConfig } from "../src/config/index.ts";
import { fullConfig, naiveConfig } from "./config-helpers.ts";
type Obj = Record<string, unknown>;
/** 配置里每个对象字段的路径(JSON 指针),数组里的对象也走进去。 */
function fieldPaths(value: unknown, at = ""): string[] {
if (Array.isArray(value)) return value.flatMap((item, i) => fieldPaths(item, `${at}/${i}`));
if (typeof value !== "object" || value === null) return [];
return Object.entries(value).flatMap(([k, v]) => [`${at}/${k}`, ...fieldPaths(v, `${at}/${k}`)]);
}
function parentAndKey(root: unknown, path: string): [Obj, string] {
const keys = path.split("/").slice(1);
let node = root as Obj;
for (const k of keys.slice(0, -1)) node = node[k] as Obj;
return [node, keys[keys.length - 1]!];
}
/** 校验应失败,返回错误消息。 */
function errorOf(value: unknown): string {
try {
checkConfig(value);
} catch (error) {
return (error as Error).message;
}
assert.fail("应当报错,却通过了校验");
}
test("两份写全的合法配置能通过,返回的就是原内容", () => {
for (const config of [fullConfig(), naiveConfig()]) {
assert.deepEqual(checkConfig(structuredClone(config)), config);
}
});
test("逐个删掉每个字段都报错,报错里有这个字段的路径", () => {
for (const make of [fullConfig, naiveConfig]) {
const paths = fieldPaths(make());
assert.ok(paths.length > 10);
for (const path of paths) {
const config = make();
const [parent, key] = parentAndKey(config, path);
delete parent[key];
const message = errorOf(config);
assert.ok(message.includes(`${path}:`), `删掉 ${path},报错里应有这个路径:\n${message}`);
}
}
});
test("不认识的字段报错:顶层、模型里、两种流程里", () => {
const top = { ...fullConfig(), seed: "s" };
assert.match(errorOf(top), /^\/seed:不认识的字段$/m);
const model = fullConfig();
(model.models.researchers[1] as Obj).temperature = 0.2;
assert.match(errorOf(model), /^\/models\/researchers\/1\/temperature:不认识的字段$/m);
const full = fullConfig();
(full.flow as Obj).researcherNotes = false;
assert.match(errorOf(full), /^\/flow\/researcherNotes:不认识的字段$/m);
});
test("类型或取值不对报错,带字段路径", () => {
const cases: Array<[(c: Obj) => void, RegExp]> = [
[(c) => (c.runs = "13"), /^\/runs:应为整数$/m],
[(c) => (c.runs = 0), /^\/runs:不能小于 1$/m],
[(c) => (c.batchLimit = 1.5), /^\/batchLimit:应为整数$/m],
[(c) => (c.studyId = "../x"), /^\/studyId:格式不对/m],
[(c) => (c.task = " "), /^\/task:不能为空,也不能只有空白$/m],
[(c) => (c.devEntry = "false"), /^\/devEntry:应为true 或 false$/m],
[(c) => ((c.models as Obj).researchers = []), /^\/models\/researchers:至少要有 1 项$/m],
[(c) => (((c.models as Obj).verifier as Obj).thinking = null), /^\/models\/verifier\/thinking:只能是 "off"、"minimal"、"low"、"medium"、"high"、"xhigh"、"max" 之一$/m],
[(c) => (((c.models as Obj).verifier as Obj).thinking = "auto"), /^\/models\/verifier\/thinking:只能是/m],
[(c) => ((c.flow as Obj).openReserve = 0), /^\/flow\/openReserve:必须大于 0$/m],
[(c) => ((c.flow as Obj).decay = 1.5), /^\/flow\/decay:不能大于 1$/m],
[(c) => ((c.flow as Obj).roundZero = { enabled: true, discussions: 3 }), /^\/flow\/roundZero\/discussions:只能是 1、2 之一$/m],
[(c) => ((c.flow as Obj).preset = "fast"), /^\/flow\/preset:缺少或取值不对,只能是 "full"、"naive" 之一$/m],
[(c) => ((c.lightChecks as Obj).seconds = 0), /^\/lightChecks\/seconds:必须大于 0$/m],
];
for (const [change, expected] of cases) {
const config = fullConfig() as unknown as Obj;
change(config);
const message = errorOf(config);
assert.match(message, expected);
assert.match(message, /共 1 处/, `只改了一处,应只报一处:\n${message}`);
}
});
test("一次列出多处错误,超过 typebox 默认的 8 条、错在 Union 分支里的也都列出", () => {
const config = fullConfig() as unknown as Obj;
config.runs = -1;
delete config.promptDir;
config.extra = 1;
((config.models as Obj).experimenter as Obj).model = "";
((config.models as Obj).researchers as Obj[])[2]!.thinking = null;
config.devEntry = 0;
config.factorLimit = 0;
(config.lightChecks as Obj).maxCount = -1;
const flow = config.flow as Obj;
flow.batchRule = "best";
delete (flow.roundZero as Obj).enabled;
(flow.ablation as Obj).cellRule = "x";
flow.decay = -1;
const message = errorOf(config);
const paths = [
"/runs:",
"/promptDir:",
"/extra:",
"/models/experimenter/model:",
"/models/researchers/2/thinking:",
"/devEntry:",
"/factorLimit:",
"/lightChecks/maxCount:",
"/flow/batchRule:",
"/flow/roundZero/enabled:",
"/flow/ablation/cellRule:",
"/flow/decay:",
];
assert.match(message, new RegExp(`共 ${paths.length} 处`), message);
for (const path of paths) assert.ok(message.includes(path), `应报出 ${path}:\n${message}`);
});
test("流程写成了不是对象的值:报在流程这一项上", () => {
const config = fullConfig() as unknown as Obj;
config.flow = "full";
assert.match(errorOf(config), /^\/flow:应为对象$/m);
});
test("写不出来的组合:朴素循环里写开关、规则或它用不到的方法参数", () => {
const extras: Obj = {
critique: false,
firstPrediction: false,
roundZero: { enabled: false },
ablation: { enabled: false },
batchRule: "uniformRandom",
openReserve: 0.1,
knowledgeUse: 0.5,
decay: 0.5,
};
for (const [key, value] of Object.entries(extras)) {
const config = naiveConfig() as unknown as Obj;
(config.flow as Obj)[key] = value;
assert.match(errorOf(config), new RegExp(`^/flow/${key}:不认识的字段$`, "m"));
}
});
test("写不出来的组合:关掉的第零轮写讨论次数、关掉的消融写选格规则", () => {
const zero = fullConfig();
(zero.flow as Obj).roundZero = { enabled: false, discussions: 2 };
assert.match(errorOf(zero), /^\/flow\/roundZero\/discussions:不认识的字段$/m);
const ablation = fullConfig();
(ablation.flow as Obj).ablation = { enabled: false, cellRule: "all" };
assert.match(errorOf(ablation), /^\/flow\/ablation\/cellRule:不认识的字段$/m);
});
test("写不出来的组合:规则名不在 rules 登记表里", () => {
const batch = fullConfig();
(batch.flow as Obj).batchRule = "expected-best";
assert.match(errorOf(batch), /^\/flow\/batchRule:只能是 "bestExpected"、"uniformRandom" 之一$/m);
const cell = fullConfig();
(cell.flow as Obj).ablation = { enabled: true, cellRule: "voi" };
assert.match(
errorOf(cell),
/^\/flow\/ablation\/cellRule:只能是 "valueMinusCost"、"all"、"randomSameCount"、"none" 之一$/m,
);
});
test("字段定义里每个字段都有中文说明,并标明用户配置还是方法参数", () => {
const seen: string[] = [];
const visit = (schema: Obj, at: string): void => {
const props = schema.properties as Record<string, Obj> | undefined;
for (const [key, field] of Object.entries(props ?? {})) {
assert.equal(typeof field.description, "string", `${at}/${key} 缺说明`);
assert.ok(["用户配置", "方法参数"].includes(field.category as string), `${at}/${key} 没标类别`);
seen.push(`${at}/${key}`);
visit(field, `${at}/${key}`);
}
for (const branch of (schema.anyOf as Obj[] | undefined) ?? []) visit(branch, at);
if (schema.items) visit(schema.items as Obj, `${at}/0`);
};
visit(ResearchConfig as unknown as Obj, "");
// 抽查几项的类别。
const category = (path: string[]) => {
let node = ResearchConfig as unknown as Obj;
for (const k of path) node = (node.properties as Record<string, Obj>)[k]!;
return node.category;
};
assert.equal(category(["studyId"]), "用户配置");
assert.equal(category(["runs"]), "用户配置");
assert.equal(category(["factorLimit"]), "方法参数");
assert.equal(category(["flow"]), "方法参数");
assert.ok(seen.includes("/models/researchers/0/thinking"));
assert.ok(seen.includes("/flow/ablation/cellRule"));
});
test("loadConfig:读文件并校验;不是 JSON 时报错带文件路径", () => {
const dir = mkdtempSync(join(tmpdir(), "inforsi-config-"));
const good = join(dir, "research.json");
writeFileSync(good, JSON.stringify(fullConfig(), null, 2));
assert.deepEqual(loadConfig(good), fullConfig());
const bad = join(dir, "bad.json");
writeFileSync(bad, "{ runs: 13 }");
assert.throws(() => loadConfig(bad), (e: Error) => e.message.startsWith(`${bad} 不是合法的 JSON`));
const invalid = join(dir, "invalid.json");
writeFileSync(invalid, JSON.stringify({ ...fullConfig(), runs: 0 }));
assert.throws(() => loadConfig(invalid), /\/runs:不能小于 1/);
});