/** * zcode-thinking-mode 测试:档位读取 / 切换 / 备份 / 边界与失败路径。 * * 用 ZCODE_V2_CONFIG + ZCODE_SESSION_DB 把两个写入目标都重定向到临时目录, * 全程不碰真实 ~/.zcode 与真实模型配置。 * * 跑法: node --test tests/ */ import { test } from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; import { DatabaseSync } from "node:sqlite"; const SCRIPT = path.resolve(import.meta.dirname, "..", "scripts", "thinking.mjs"); /* ---------- 沙箱 ---------- */ function sandbox() { const root = fs.mkdtempSync(path.join(os.tmpdir(), "ztm-")); const cfg = path.join(root, "v2", "config.json"); const db = path.join(root, "db.sqlite"); fs.mkdirSync(path.dirname(cfg), { recursive: true }); return { root, cfg, db, defaults: path.join(root, "thinking-defaults.json"), state: path.join(root, "thinking-state.json") }; } /** 一份典型模型配置:两个 provider,三个带 reasoning 的模型 + 一个不带的 */ function configFixture() { return { provider: { "builtin:bigmodel": { name: "BigModel", kind: "builtin", enabled: true, models: { "GLM-5.3": { reasoning: { enabled: true, variants: ["low", "max", "high"], defaultVariant: "max" } }, "GLM-5.3-Flash": { reasoning: { enabled: true, variants: ["low", "max", "high"], defaultVariant: "max" } }, "qwen3-flash": {}, }, }, "custom-a": { name: "CustomA", kind: "custom", models: { "deepseek-v4": { reasoning: { enabled: true, variants: ["low", "high"], defaultVariant: "low" } }, }, }, }, }; } function writeConfig(sb, doc) { fs.mkdirSync(path.dirname(sb.cfg), { recursive: true }); fs.writeFileSync(sb.cfg, JSON.stringify(doc, null, 2)); } function readConfig(sb) { return JSON.parse(fs.readFileSync(sb.cfg, "utf8")); } /** 造 session 库;level 为 null 时建表但不插 reasoningLevel 行 */ function makeDb(dbPath, level = null) { const db = new DatabaseSync(dbPath); db.exec(`CREATE TABLE local_setting ( scope TEXT NOT NULL, scope_id TEXT NOT NULL, namespace TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, schema_version INTEGER, time_created INTEGER, time_updated INTEGER, PRIMARY KEY (scope, scope_id, namespace, key))`); if (level !== null) { const now = Date.now(); db.prepare("INSERT INTO local_setting VALUES ('user','default','model','reasoningLevel',?,1,?,?)") .run(JSON.stringify({ level }), now, now); } db.close(); } /** 只建一个无关表:模拟"这不是有效的 session 库" */ function makeAlienDb(dbPath) { const db = new DatabaseSync(dbPath); db.exec("CREATE TABLE whatever (id INTEGER)"); db.close(); } function readHot(dbPath) { const db = new DatabaseSync(dbPath, { readOnly: true }); try { const row = db.prepare( "SELECT value FROM local_setting WHERE scope='user' AND scope_id='default' AND namespace='model' AND key='reasoningLevel'" ).get(); return row ? JSON.parse(row.value).level : null; } finally { db.close(); } } function backups(sb) { const dir = path.dirname(sb.cfg); return fs.readdirSync(dir).filter((f) => f.startsWith(path.basename(sb.cfg) + ".bak-")); } function run(sb, args, env = {}) { const r = spawnSync(process.execPath, [SCRIPT, ...args], { env: { ...process.env, ZCODE_V2_CONFIG: sb.cfg, ZCODE_SESSION_DB: sb.db, // 必须重定向:否则脚本会读真实 ~/.zcode/zcode-thinking-mode.json,测试就不隔离了 ZCODE_THINKING_CONFIG: sb.defaults, ZCODE_THINKING_STATE: sb.state, ...env, }, encoding: "utf8", timeout: 60000, }); return { code: r.status, out: r.stdout ?? "", err: r.stderr ?? "" }; } /* ---------- status / variants ---------- */ test("status:列出带 reasoning 的模型与可选档位,并标出无 reasoning 的", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeDb(sb.db, "high"); const r = run(sb, ["status"]); assert.equal(r.code, 0, r.err); assert.match(r.out, /## 思考模式现状/); assert.match(r.out, /全局热档位:`high`/, "应显示库里读到的热档位"); assert.match(r.out, /\*\*GLM-5\.3\*\* \(provider `builtin:bigmodel`\):持久默认 `max`,可选 `low \/ max \/ high`/); assert.match(r.out, /\*\*deepseek-v4\*\* \(provider `custom-a`\):持久默认 `low`,可选 `low \/ high`/); assert.match(r.out, /qwen3-flash.*无 reasoning 配置/, "不支持的模型也要列出并标注"); assert.match(r.out, /\/thinking:set high/, "应给出切换示例"); }); test("status --json:结构化输出,含热档位与每个模型的档位信息", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeDb(sb.db, "max"); const r = run(sb, ["status", "--json"]); assert.equal(r.code, 0, r.err); const j = JSON.parse(r.out); assert.equal(j.hotLevel, "max"); assert.equal(j.v2config, sb.cfg); assert.equal(j.sessionDb, sb.db); const glm = j.models.find((m) => m.model === "GLM-5.3"); assert.deepEqual(glm.reasoning.variants, ["low", "max", "high"]); assert.equal(glm.reasoning.defaultVariant, "max"); const qwen = j.models.find((m) => m.model === "qwen3-flash"); assert.equal(qwen.reasoning, null); }); test("status:库里没有 reasonleLevel 行时如实说明,而不是假装读到档位", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeDb(sb.db, null); const r = run(sb, ["status"]); assert.equal(r.code, 0, r.err); assert.match(r.out, /全局热档位:读失败\(库里没有 reasoningLevel 行/); const j = JSON.parse(run(sb, ["status", "--json"]).out); assert.equal(j.hotLevel, null); assert.match(String(j.hotError), /没有 reasoningLevel 行/); }); test("variants:只列带 reasoning 的模型,不带的不出现", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeDb(sb.db, "low"); const r = run(sb, ["variants"]); assert.equal(r.code, 0, r.err); assert.match(r.out, /## 可选思考档位/); assert.match(r.out, /GLM-5\.3/); assert.match(r.out, /deepseek-v4/); assert.doesNotMatch(r.out, /qwen3-flash/, "无 reasoning 的模型不该出现在可选档位表里"); const j = JSON.parse(run(sb, ["variants", "--json"]).out); assert.equal(j.hotLevel, "low"); assert.deepEqual(j.models.map((m) => m.model).sort(), ["GLM-5.3", "GLM-5.3-Flash", "deepseek-v4"]); }); test("status --model:名称不对时给明确提示,而不是静默空表", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeDb(sb.db, "max"); const r = run(sb, ["status", "--model", "不存在的模型"]); assert.equal(r.code, 0, r.err); assert.match(r.out, /没找到模型\(或 --model 名称不对\)/); }); /* ---------- set:正常路径 ---------- */ test("set high:热档位与持久默认同时改,并留备份", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeDb(sb.db, "low"); const r = run(sb, ["set", "high"]); assert.equal(r.code, 0, r.err); assert.match(r.out, /## 思考模式已切换\(热 \+ 持久\)/); assert.match(r.out, /全局热档位:low → `high`/); assert.match(r.out, /\/effort high/, "要保留当前会话热切入口提示"); assert.equal(readHot(sb.db), "high", "热档位库应被写成 high"); const doc = readConfig(sb); for (const [pid, name] of [["builtin:bigmodel", "GLM-5.3"], ["builtin:bigmodel", "GLM-5.3-Flash"], ["custom-a", "deepseek-v4"]]) { assert.equal(doc.provider[pid].models[name].reasoning.defaultVariant, "high", `${name} 持久默认应改成 high`); } assert.equal(backups(sb).length, 1, "改写模型配置前必须留一份备份"); }); test("set:本来就是目标档位 → 不改写配置、不产生多余备份", () => { const sb = sandbox(); const doc = configFixture(); for (const p of Object.values(doc.provider)) for (const m of Object.values(p.models)) { if (m.reasoning) m.reasoning.defaultVariant = "high"; } writeConfig(sb, doc); makeDb(sb.db, "low"); const r = run(sb, ["set", "high"]); assert.equal(r.code, 0, r.err); assert.match(r.out, /模型配置持久默认:本来就是目标档位,未改写/); assert.equal(backups(sb).length, 0, "没有改动就不该留备份"); assert.equal(readHot(sb.db), "high", "热档位仍应更新(它本来就是 low)"); }); test("set --model:只改指定模型,其它模型纹丝不动", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeDb(sb.db, "max"); const r = run(sb, ["set", "low", "--model", "GLM-5.3-Flash"]); assert.equal(r.code, 0, r.err); const doc = readConfig(sb); assert.equal(doc.provider["builtin:bigmodel"].models["GLM-5.3-Flash"].reasoning.defaultVariant, "low"); assert.equal(doc.provider["builtin:bigmodel"].models["GLM-5.3"].reasoning.defaultVariant, "max", "同 provider 的另一个模型不该被动"); assert.equal(doc.provider["custom-a"].models["deepseek-v4"].reasoning.defaultVariant, "low", "别的 provider 不该被动"); // --model 只影响持久默认;热档位是全局一格,仍按请求的档位写 assert.equal(readHot(sb.db), "low"); }); test("set:HIGH 大小写归一成模型自带写法,而不是写回大写", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeDb(sb.db, "max"); const r = run(sb, ["set", "HIGH"]); assert.equal(r.code, 0, r.err); assert.equal(readHot(sb.db), "high"); assert.equal(readConfig(sb).provider["builtin:bigmodel"].models["GLM-5.3"].reasoning.defaultVariant, "high"); }); test("set --json:给出旧档位与改动清单,便于脚本化与回滚", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeDb(sb.db, "max"); // fixture 里 deepseek-v4 本来就是 low,切 low 时它不该出现在改动清单里 const r = run(sb, ["set", "low", "--json"]); assert.equal(r.code, 0, r.err); const j = JSON.parse(r.out); assert.equal(j.hotPrevious, "max"); assert.equal(j.hotLevel, "low"); assert.equal(j.changed.length, 2, "只有真正变了的模型才该进 changed"); assert.ok(j.changed.every((c) => c.defaultVariant === "low")); assert.ok(!j.changed.some((c) => c.model === "deepseek-v4"), "原本就是 low 的模型不算改动"); assert.ok(j.backup && fs.existsSync(j.backup), "应给出真实存在的备份路径"); }); /* ---------- set:失败路径(关键:失败时不许留下半截改动) ---------- */ test("set:模型不支持该档位 → 拒绝,且配置与热档位都不动", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeDb(sb.db, "low"); const before = fs.readFileSync(sb.cfg, "utf8"); // deepseek-v4 只支持 low/high,没有 max —— 收紧校验先拦下(非法档位,两边都不写) const r = run(sb, ["set", "max", "--model", "deepseek-v4"]); assert.equal(r.code, 1); assert.match(r.out, /非法档位 max\(全部目标的可选:low\/high\)/); assert.equal(fs.readFileSync(sb.cfg, "utf8"), before, "校验失败不得改写配置"); assert.equal(readHot(sb.db), "low", "校验失败不得改热档位"); assert.equal(backups(sb).length, 0); }); test("set:多个目标里有一个不支持 → 整批中止,不做部分切换", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeDb(sb.db, "low"); const before = fs.readFileSync(sb.cfg, "utf8"); // 不带 --model:GLM 支持 max,deepseek-v4 不支持 —— 不能出现"改了一半" const r = run(sb, ["set", "max"]); assert.equal(r.code, 1); assert.match(r.out, /模型 deepseek-v4 不支持档位 max/); assert.equal(fs.readFileSync(sb.cfg, "utf8"), before, "整批中止时配置必须原样"); assert.equal(readHot(sb.db), "low", "整批中止时热档位必须原样"); }); test("set:模型名不存在 → 明确报错并退出 1", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeDb(sb.db, "max"); const r = run(sb, ["set", "high", "--model", "压根没有"]); assert.equal(r.code, 1); assert.match(r.out, /没找到带 reasoning 的模型:压根没有/); assert.equal(readHot(sb.db), "max"); }); test("session 库不是有效 session 库 → 热档位写失败,模型配置不动", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeAlienDb(sb.db); const before = fs.readFileSync(sb.cfg, "utf8"); const r = run(sb, ["set", "high"]); assert.equal(r.code, 1); assert.match(r.out, /写热档位库失败/); assert.match(r.out, /模型配置未动/, "报错要明确告诉用户配置没被改"); assert.equal(fs.readFileSync(sb.cfg, "utf8"), before); assert.equal(backups(sb).length, 0); }); test("缺模型配置 → 退出 1,并指出配置路径", () => { const sb = sandbox(); makeDb(sb.db, "max"); // 不写 config.json for (const args of [["status"], ["variants"], ["set", "high"]]) { const r = run(sb, args); assert.equal(r.code, 1, `${args.join(" ")} 应失败`); assert.match(r.out, /思考模式切换失败:读模型配置失败/); assert.match(r.out, new RegExp(sb.cfg.replace(/\\/g, "\\\\")), "应指出配置路径"); } }); test("set:缺档位参数 / 档位被当成选项 → 用法报错,不误改", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeDb(sb.db, "max"); const before = fs.readFileSync(sb.cfg, "utf8"); for (const args of [["set"], ["set", "--json"], ["set", "--model", "GLM-5.3"]]) { const r = run(sb, args); assert.equal(r.code, 1, `${args.join(" ")} 应报用法错误`); assert.match(r.out, /用法: node thinking\.mjs set/); } assert.equal(fs.readFileSync(sb.cfg, "utf8"), before); assert.equal(readHot(sb.db), "max"); }); test("set:覆盖本机已有热档位后,旧值可从输出读回(回滚入口)", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeDb(sb.db, "low"); // 用 high:fixture 里三个带 reasoning 的模型都支持(deepseek-v4 只有 low/high) const up = run(sb, ["set", "high"]); assert.equal(up.code, 0, `${up.out}\n${up.err}`); assert.match(up.out, /全局热档位:low → `high`/); const down = run(sb, ["set", "low"]); assert.equal(down.code, 0, `${down.out}\n${down.err}`); assert.match(down.out, /全局热档位:high → `low`/, "第二次切换应报出上一次的值"); assert.equal(readHot(sb.db), "low"); }); test("set:反复切换不留垃圾,备份按时间戳递增", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeDb(sb.db, "low"); assert.equal(run(sb, ["set", "high"]).code, 0); assert.equal(run(sb, ["set", "low"]).code, 0); assert.equal(run(sb, ["set", "high"]).code, 0); const bks = backups(sb); assert.ok(bks.length >= 2, `每次改写配置都该留备份,实际 ${bks.length} 个(同一秒内连切也不许撞名覆盖)`); for (const b of bks) assert.match(b, /\.bak-\d{8}-\d{6}(\.\d+)?$/, `备份名应带时间戳:${b}`); // 备份内容应是改写前的原文,可据此回滚 assert.equal(JSON.parse(fs.readFileSync(path.join(path.dirname(sb.cfg), bks[0]), "utf8")).provider["custom-a"].models["deepseek-v4"].reasoning.defaultVariant !== undefined, true); }); /* ---------- 默认值文件(userConfig 的落地形态) ---------- */ test("defaults:未设时如实报未设,并说明优先级", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeDb(sb.db, "max"); const r = run(sb, ["defaults"]); assert.equal(r.code, 0, r.err); assert.match(r.out, /默认档位:`\(未设\)`/); assert.match(r.out, /默认模型:`\(全部\)`/); assert.match(r.out, /尚未创建/); assert.match(r.out, /命令行参数 > 环境变量.*> 默认值文件 > 内置兜底/); const j = JSON.parse(run(sb, ["defaults", "--json"]).out); assert.equal(j.exists, false); assert.equal(j.effective.variant, null); assert.equal(j.effective.model, null); }); test("defaults set:写入后可读回,且默认模型会作用于 status/set", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeDb(sb.db, "low"); const w = run(sb, ["defaults", "set", "--variant", "high", "--model", "GLM-5.3-Flash"]); assert.equal(w.code, 0, w.err); assert.match(w.out, /## 默认值已写入/); assert.ok(fs.existsSync(sb.defaults), "应真的落盘"); assert.deepEqual(JSON.parse(fs.readFileSync(sb.defaults, "utf8")), { variant: "high", model: "GLM-5.3-Flash" }); // set 不带档位 → 用默认档位;不带 --model → 用默认模型 const r = run(sb, ["set"]); assert.equal(r.code, 0, `${r.out}\n${r.err}`); assert.match(r.out, /档位来自默认值.*`high`/); assert.match(r.out, /作用模型来自默认值.*`GLM-5\.3-Flash`/); const doc = readConfig(sb); assert.equal(doc.provider["builtin:bigmodel"].models["GLM-5.3-Flash"].reasoning.defaultVariant, "high"); assert.equal(doc.provider["builtin:bigmodel"].models["GLM-5.3"].reasoning.defaultVariant, "max", "默认模型之外的模型不该被动"); assert.equal(readHot(sb.db), "high"); }); test("defaults:环境变量优先于文件,命令行又优先于环境变量", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeDb(sb.db, "max"); fs.writeFileSync(sb.defaults, JSON.stringify({ variant: "low", model: "GLM-5.3" })); const envRun = run(sb, ["set"], { ZCODE_THINKING_VARIANT: "high" }); assert.equal(envRun.code, 0, `${envRun.out}\n${envRun.err}`); assert.match(envRun.out, /档位来自默认值\(环境变量\):`high`/); assert.equal(readHot(sb.db), "high"); const cliRun = run(sb, ["set", "low"], { ZCODE_THINKING_VARIANT: "high" }); assert.equal(cliRun.code, 0, `${cliRun.out}\n${cliRun.err}`); assert.doesNotMatch(cliRun.out, /档位来自默认值/, "命令行给了档位就不该再提默认值"); assert.equal(readHot(sb.db), "low"); }); test("defaults clear:删除文件后回到内置兜底", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeDb(sb.db, "max"); fs.writeFileSync(sb.defaults, JSON.stringify({ variant: "high" })); const c = run(sb, ["defaults", "clear"]); assert.equal(c.code, 0, c.err); assert.match(c.out, /默认值已清空/); assert.equal(fs.existsSync(sb.defaults), false, "文件应被删掉"); // 兜底:没档位可用必须明确报错,不能猜一个档位就改配置 const before = fs.readFileSync(sb.cfg, "utf8"); const r = run(sb, ["set"]); assert.equal(r.code, 1); assert.match(r.out, /没给档位,默认值文件里也没设/); assert.equal(fs.readFileSync(sb.cfg, "utf8"), before); }); test("defaults set:不给任何键时报用法错,不写空文件", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeDb(sb.db, "max"); const r = run(sb, ["defaults", "set"]); assert.equal(r.code, 1); assert.match(r.out, /用法: node thinking\.mjs defaults set/); assert.equal(fs.existsSync(sb.defaults), false, "参数不合法不该留下文件"); }); test("defaults:文件坏了(非法 JSON)按未设处理,不崩", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeDb(sb.db, "max"); fs.writeFileSync(sb.defaults, "{ 这不是 json"); const r = run(sb, ["defaults"]); assert.equal(r.code, 0, r.err); assert.match(r.out, /默认档位:`\(未设\)`/); }); /* ---------- doctor ---------- */ test("doctor:环境就绪时无 fail", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeDb(sb.db, "max"); const r = run(sb, ["doctor"]); assert.equal(r.code, 0, `${r.out}\n${r.err}`); assert.match(r.out, /# zcode-thinking-mode 自检/); assert.match(r.out, /✓ 可切换模型:3 个带 reasoning/); assert.match(r.out, /✓ 配置可写/); assert.doesNotMatch(r.out, /✗/, "就绪环境不该有 fail 项"); const j = JSON.parse(run(sb, ["doctor", "--json"]).out); assert.equal(j.fails, 0); assert.equal(j.v2config, sb.cfg); assert.equal(j.defaultsFile, sb.defaults); }); test("doctor:缺模型配置 → fail 并给修复建议,退出码 1", () => { const sb = sandbox(); makeDb(sb.db, "max"); const r = run(sb, ["doctor"]); assert.equal(r.code, 1); assert.match(r.out, /✗ 模型配置:读不到/); assert.match(r.out, /ZCODE_V2_CONFIG/); assert.match(r.out, /结论:有 .* 项必须先修/); }); test("doctor:热档位库缺 local_setting 表 → fail", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeAlienDb(sb.db); const r = run(sb, ["doctor"]); assert.equal(r.code, 1); assert.match(r.out, /✗ 热档位库结构:缺 local_setting 表/); }); test("doctor:没有模型声明 reasoning → warn 但不算失败", () => { const sb = sandbox(); writeConfig(sb, { provider: { p1: { models: { "plain-model": {} } } } }); makeDb(sb.db, "max"); const r = run(sb, ["doctor"]); assert.equal(r.code, 0, `只有 warn 时不该失败\n${r.out}`); assert.match(r.out, /⚠ 可切换模型:0 个带 reasoning/); assert.match(r.out, /没有任何模型声明 reasoning\.variants/); }); /* ---------- 0.3.0:分写 / 历史 / 回滚 / 校验收紧 ---------- */ function readState(sb) { if (!fs.existsSync(sb.state)) return []; return JSON.parse(fs.readFileSync(sb.state, "utf8")).entries ?? []; } test("set --hot-only:只写热档位,模型配置不动但仍记历史", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeDb(sb.db, "low"); const before = fs.readFileSync(sb.cfg, "utf8"); const r = run(sb, ["set", "high", "--hot-only"]); assert.equal(r.code, 0, `${r.out}\n${r.err}`); assert.match(r.out, /仅热/); assert.equal(readHot(sb.db), "high"); assert.equal(fs.readFileSync(sb.cfg, "utf8"), before, "仅热时配置必须原样"); assert.equal(backups(sb).length, 0); const st = readState(sb); assert.equal(st.length, 1); assert.equal(st[0].hotOnly, true); assert.equal(st[0].hotLevel, "high"); }); test("set --persist-only:只写模型配置,热档位不动", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeDb(sb.db, "low"); const r = run(sb, ["set", "high", "--persist-only"]); assert.equal(r.code, 0, `${r.out}\n${r.err}`); assert.match(r.out, /仅持久/); assert.equal(readHot(sb.db), "low", "仅持久时热档位必须原样"); assert.equal(readConfig(sb).provider["builtin:bigmodel"].models["GLM-5.3"].reasoning.defaultVariant, "high"); const st = readState(sb); assert.equal(st[0].persistOnly, true); }); test("set:两个分写开关同时用 → 用法报错,两边都不动", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeDb(sb.db, "low"); const before = fs.readFileSync(sb.cfg, "utf8"); const r = run(sb, ["set", "high", "--hot-only", "--persist-only"]); assert.equal(r.code, 1); assert.match(r.out, /不能同时用/); assert.equal(fs.readFileSync(sb.cfg, "utf8"), before); assert.equal(readHot(sb.db), "low"); }); test("set:非法档位 ultra → 收紧校验拒绝,两边都不写", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeDb(sb.db, "low"); const before = fs.readFileSync(sb.cfg, "utf8"); const r = run(sb, ["set", "ultra"]); assert.equal(r.code, 1); assert.match(r.out, /非法档位 ultra/); assert.equal(fs.readFileSync(sb.cfg, "utf8"), before); assert.equal(readHot(sb.db), "low"); assert.equal(backups(sb).length, 0); }); test("history:列出切换记录;rollback:热+持久都写回去", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeDb(sb.db, "low"); assert.equal(run(sb, ["set", "high"]).code, 0); const h = run(sb, ["history"]); assert.equal(h.code, 0, h.err); assert.match(h.out, /## 思考模式切换历史/); assert.match(h.out, /low → `high`/); const hj = JSON.parse(run(sb, ["history", "--json"]).out); assert.equal(hj.count, 1); assert.equal(hj.entries[0].hotLevel, "high"); const rb = run(sb, ["rollback"]); assert.equal(rb.code, 0, `${rb.out}\n${rb.err}`); assert.match(rb.out, /已回滚/); assert.equal(readHot(sb.db), "low", "热档位应回到 low"); assert.equal(readConfig(sb).provider["builtin:bigmodel"].models["GLM-5.3"].reasoning.defaultVariant, "max", "持久默认应回到 fixture 的 max"); }); test("rollback:无历史时明确报错退出 1", () => { const sb = sandbox(); writeConfig(sb, configFixture()); makeDb(sb.db, "low"); const r = run(sb, ["rollback"]); assert.equal(r.code, 1); assert.match(r.out, /没有切换历史可回滚/); });