Files
thzxx 9b45c445bf
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m8s
CI / 全量测试 (Electron ABI) (push) Failing after 6m0s
CI / 产物编译验证 (push) Successful in 10m58s
feat: v0.8.1 记忆深化 · 观测闭环 · 体验收口 — 窗口/输出上限全局单一配置 · 2478 用例全量回归 + E2E 冒烟
硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(llm.contextWindow)
与「最大输出上限」(llm.maxTokens),跨 Provider/模型原样透传。

P0 正确性收口:
- 迁移 11/12(SCHEMA_VERSION 5):记忆表 embedding 列 + 分 Provider 窗口键清理
- 记忆生命周期接线:会话终态清理 working memory / episodic 90 天 TTL / access_count 回写
- 回放缓冲模块化 + 会话终态清理(杜绝 4MB/会话内存滞留)
- i18n 收口:主进程 main-locale(zh/en,ui.locale 热切换)+ 渲染层 17 处出层

P1 能力演进:
- 本地向量混合检索:0.6×向量余弦 + 0.4×TF-IDF,Ollama embeddings 首次投产,
  存量记忆惰性回填,嵌入不可用自动回退 TF-IDF
- MEMORY.md 维护闭环:固化去重消除截断盲区;两阶段维护(AI 建议 → 用户确认 →
  原子改写 + 语义记忆双轨同步 + 审计);>50KB 告警
- 可观测闭环:cacheTokens 引擎→前端透传(Token 面板命中率/成本行)+ 输入框
  上下文占用指示条
- MCP Prompts/Resources 对话可用:/mcp:{server}:{prompt} 与 @mcp:{server}:{uri}

P2 体验补全:
- 工具自定义策略(正则白/黑名单 + 频率 + 强制确认,热生效)
- 连续 ≥3 同类工具确认聚合为单弹框
- 会话消息游标分页(首屏 200 条向上翻页)
- 开机自启;Playwright + Electron E2E 冒烟(本地 mock LLM 零外联)

Review 回归修复:MCP 大小写失配 / 分页状态复位 / 清空=未配置语义(Number(null)=0
隐患)/ MEMORY.md 告警位置 / working_memories FK(迁移 13)/ 全局配置层废键清理;
附带根治权限加固启动时序、代理回环放行、safeStorage 降级、悬空 symlink 逃逸。

验证:typecheck/lint 0 问题;test:electron 2478/2478(0 跳过);E2E 2/2;
docs/v0.8.1-迭代实施清单.md 全项留档。
2026-09-08 09:35:58 +08:00

561 lines
23 KiB
TypeScript
Raw Permalink 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.
/**
* 配置分层测试(v0.7.2 覆盖补齐 —— config.service / global-config.service 此前零测试)
*
* 锁定跨工作空间配置层契约(v0.3.17 引入的分层机制的回归防线):
* 1. 读取顺序:工作空间 DB → 全局 JSON(仅全局 key
* 2. 空值回退:DB 被 seedDefaults 灌入空值/默认值时回退全局层真实值
* 3. 双写:set 同步写 DB 与全局 JSON;敏感 key 双侧密文
* 4. getAll 合并语义与 migrateFromWorkspaceDB 幂等迁移
* 5. 敏感 key 加密回环(P0-1
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { readFileSync, rmSync } from 'fs';
import { join } from 'path';
import Database from 'better-sqlite3';
// better-sqlite3 的 ABI 可用性在模块顶层探测(describe.skipIf 在注册期求值)
let dbAvailable = false;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const DatabaseProbe = require('better-sqlite3');
new DatabaseProbe(':memory:').close();
dbAvailable = true;
} catch {
dbAvailable = false;
}
// 可控 safeStorage(可逆 fakeenc: 前缀)+ 可定位的 userData 目录。
// 注意:mock 工厂内不允许 require(仓库 lint 禁令),os/path/fs 经参数注入。
const mockState = vi.hoisted(() => ({
encryptionAvailable: true,
userDataDir: '',
}));
vi.mock('electron', async () => {
const { mkdtempSync } = await import('fs');
const { join } = await import('path');
const { tmpdir } = await import('os');
mockState.userDataDir = mkdtempSync(join(tmpdir(), 'metona-gcfg-'));
return {
app: { getPath: () => mockState.userDataDir },
safeStorage: {
isEncryptionAvailable: () => mockState.encryptionAvailable,
encryptString: (value: string) => Buffer.from(`enc:${value}`, 'utf-8'),
decryptString: (buffer: Buffer) => {
const raw = buffer.toString('utf-8');
if (!raw.startsWith('enc:')) throw new Error('decryption failed');
return raw.slice(4);
},
},
};
});
vi.mock('electron-log', () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
import { ConfigService } from '../config.service';
import {
GlobalConfigService,
isGlobalKey,
isUnconfiguredGlobalKey,
} from '../global-config.service';
// better-sqlite3 的默认导出同时是构造函数与命名空间 —— 实例类型经
// InstanceType<typeof Database> 获取,避免与命名空间冲突
type SqliteDatabase = InstanceType<typeof Database>;
let db: SqliteDatabase | undefined;
let globalConfig: GlobalConfigService;
let configService: ConfigService;
let tempRoots: string[] = [];
function makeDb(): SqliteDatabase {
const db = new Database(':memory:');
db.exec(`
CREATE TABLE app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'general',
updated_at INTEGER NOT NULL DEFAULT 0
);
`);
return db;
}
beforeEach(() => {
if (!dbAvailable) return;
// 测试隔离:清除上一个用例落盘的全局 JSONGLOBAL_CONFIG_FILE 在模块导入期
// 固化为单一路径,文件内状态会跨用例泄漏 —— 迁移用例会误判"已有 key"
rmSync(join(mockState.userDataDir, 'global-config.json'), { force: true });
db = makeDb();
globalConfig = new GlobalConfigService();
globalConfig.initialize();
configService = new ConfigService(() => db!);
configService.setGlobalConfig(globalConfig);
});
afterEach(() => {
if (db) db.close();
for (const dir of tempRoots) {
try {
rmSync(dir, { recursive: true, force: true });
} catch {
/* ignore */
}
}
tempRoots = [];
});
describe.skipIf(!dbAvailable)('isGlobalKey / isUnconfiguredGlobalKey — 全局 key 判定', () => {
it('llm./agent./ui./security./onboarding. 前缀为全局 keytasks/searxng 不属于', () => {
expect(isGlobalKey('llm.provider')).toBe(true);
expect(isGlobalKey('agent.maxIterations')).toBe(true);
expect(isGlobalKey('onboarding.completed')).toBe(true);
expect(isGlobalKey('searxng.enabled')).toBe(false);
expect(isGlobalKey('tools.write_file.enabled')).toBe(false);
});
it('空串/null/默认值视为未配置(触发全局回退);非默认值视为已配置', () => {
expect(isUnconfiguredGlobalKey('llm.apiKey', '')).toBe(true);
expect(isUnconfiguredGlobalKey('llm.apiKey', null)).toBe(true);
expect(isUnconfiguredGlobalKey('agent.maxIterations', 20)).toBe(true); // seedDefaults 默认值
expect(isUnconfiguredGlobalKey('agent.maxIterations', 50)).toBe(false); // 用户主动设置
expect(isUnconfiguredGlobalKey('llm.apiKey', 'sk-real')).toBe(false);
});
});
describe.skipIf(!dbAvailable)('ConfigService — 基本读写与敏感加密', () => {
it('set/get 回环(JSON 序列化语义)', () => {
configService.set('llm.model', 'deepseek-v4-pro');
expect(configService.get<string>('llm.model')).toBe('deepseek-v4-pro');
configService.set('agent.maxIterations', 33);
expect(configService.get<number>('agent.maxIterations')).toBe(33);
});
it('敏感 key 落盘为密文、读取自动解密(P0-1)', () => {
configService.set('llm.apiKey', 'sk-plain-secret');
const raw = db!.prepare("SELECT value FROM app_config WHERE key = 'llm.apiKey'").get() as {
value: string;
};
expect(raw.value).toContain('metona-enc:v1:'); // DB 内是密文
expect(raw.value).not.toContain('sk-plain-secret');
expect(configService.get<string>('llm.apiKey')).toBe('sk-plain-secret'); // 读取解密
});
it('get 未配置 key 返回 null(无全局回退对象时)', () => {
expect(configService.get<string>('nonexistent.key')).toBeNull();
});
it('set 保留已有 category(不回退默认值)', () => {
db!
.prepare(
"INSERT INTO app_config (key, value, category) VALUES ('ui.theme', '\"dark\"', 'ui')",
)
.run();
configService.set('ui.theme', 'light');
const row = db!.prepare("SELECT category FROM app_config WHERE key = 'ui.theme'").get() as {
category: string;
};
expect(row.category).toBe('ui');
});
});
describe.skipIf(!dbAvailable)('配置分层 — DB 与全局 JSON 回退/双写', () => {
it('DB miss + 全局层有值 → 回退全局层', () => {
globalConfig.set('llm.provider', 'deepseek');
expect(configService.get<string>('llm.provider')).toBe('deepseek');
});
it('DB 有真实值 → 不回退(DB 优先)', () => {
globalConfig.set('llm.provider', 'deepseek');
configService.set('llm.provider', 'ollama');
expect(configService.get<string>('llm.provider')).toBe('ollama');
});
it('DB 有 seedDefaults 空值 + 全局层有真实值 → 回退全局层(新工作空间场景)', () => {
// 模拟新工作空间 DB 被 seedDefaults 灌入空 apiKey
db!
.prepare("INSERT INTO app_config (key, value, category) VALUES ('llm.apiKey', '\"\"', 'llm')")
.run();
globalConfig.set('llm.apiKey', 'sk-global-real');
expect(configService.get<string>('llm.apiKey')).toBe('sk-global-real');
});
it('set 全局 key 双写:DB + 全局 JSON(跨工作空间共享)', () => {
configService.set('llm.model', 'mimo-v2.5');
// DB 有值
expect(configService.get<string>('llm.model')).toBe('mimo-v2.5');
// 全局 JSON 有值(新 ConfigService 实例 + 空 DB 可读到)
const db2 = makeDb();
const freshConfig = new ConfigService(() => db2);
freshConfig.setGlobalConfig(globalConfig);
expect(freshConfig.get<string>('llm.model')).toBe('mimo-v2.5');
db2.close();
});
it('非全局 key 不写全局层(工作空间级数据不跨空间泄漏)', () => {
configService.set('workspace.path', '/tmp/private');
expect(globalConfig.get<string>('workspace.path')).toBeNull();
});
it('getAll 合并:DB 为主,miss 的全局 key 用全局层补齐', () => {
configService.set('ui.theme', 'dark');
globalConfig.set('llm.provider', 'openai');
const all = configService.getAll();
expect(all['ui.theme']).toBe('dark');
expect(all['llm.provider']).toBe('openai');
});
it('getAll 中敏感 key 解密返回明文(配合导出层脱敏)', () => {
configService.set('llm.apiKey', 'sk-export-test');
expect(configService.getAll()['llm.apiKey']).toBe('sk-export-test');
});
});
describe.skipIf(!dbAvailable)('migrateFromWorkspaceDB — 幂等迁移', () => {
it('仅迁移已配置的全局 key;空值/默认值/既有 key 跳过', () => {
// 构造工作空间 DB 配置快照
const workspaceConfig: Record<string, unknown> = {
'llm.provider': 'deepseek', // 已配置 → 迁移
'llm.apiKey': '', // 空值 → 跳过
'agent.maxIterations': 20, // seedDefaults 默认值 → 跳过
'agent.confirmationTimeoutMs': 300000, // 用户设置的非默认值 → 迁移
'searxng.enabled': true, // 非全局 key → 跳过
};
const migrated = globalConfig.migrateFromWorkspaceDB(workspaceConfig);
expect(migrated).toBe(2);
expect(globalConfig.get('llm.provider')).toBe('deepseek');
expect(globalConfig.get('agent.confirmationTimeoutMs')).toBe(300000);
expect(globalConfig.get('llm.apiKey')).toBeNull();
// 幂等:第二次迁移 0 条(既有 key 不覆盖)
expect(globalConfig.migrateFromWorkspaceDB(workspaceConfig)).toBe(0);
});
it('全局层已有真实值时不被迁移覆盖', () => {
globalConfig.set('llm.provider', 'anthropic');
const migrated = globalConfig.migrateFromWorkspaceDB({ 'llm.provider': 'deepseek' });
expect(migrated).toBe(0);
expect(globalConfig.get('llm.provider')).toBe('anthropic');
});
});
describe.skipIf(!dbAvailable)('敏感 key — 全局层密文落盘', () => {
it('全局 JSON 文件中敏感值为密文(无明文泄漏)', () => {
configService.set('llm.apiKey', 'sk-raw-in-global');
// GlobalConfigService flush 后,磁盘上的 global-config.json 不应包含明文
const rawFile = readFileSync(join(mockState.userDataDir, 'global-config.json'), 'utf-8');
expect(rawFile).not.toContain('sk-raw-in-global');
expect(rawFile).toContain('metona-enc:v1:');
});
});
describe.skipIf(!dbAvailable)('isUnconfiguredGlobalKey — 边界值矩阵', () => {
it.each([
// [key, value, expected]
['llm.apiKey', '', true],
['llm.apiKey', null, true],
['llm.apiKey', undefined, true],
['llm.apiKey', 0, false], // 数字 0 非空串非默认值
['llm.model', '', true],
// 默认值 → 未配置
['agent.maxIterations', 20, true],
['agent.totalTimeoutMs', 600000, true],
['agent.enableThinking', true, true],
['llm.maxTokens', 63488, true],
['onboarding.completed', false, true],
['ui.theme', 'auto', true],
// 非默认值 → 已配置
['agent.maxIterations', 21, false],
['agent.totalTimeoutMs', 1, false],
['agent.enableThinking', false, false], // 用户主动关闭
['llm.maxTokens', 1000, false],
['onboarding.completed', true, false], // 用户已完成引导
['ui.theme', 'dark', false],
// 非全局 key 恒未配置判定(不被 SEED_DEFAULTS 覆盖)
['searxng.enabled', 'anything', false],
['tools.write_file.enabled', false, false],
])('isUnconfiguredGlobalKey(%s, %j) → %j', (key, value, expected) => {
expect(isUnconfiguredGlobalKey(key, value)).toBe(expected);
});
it('isGlobalKey 边界:完整前缀匹配而非子串(v0.8.1: provider 前缀已废除)', () => {
// v0.8.1: deepseek/agnes/mimo/openai/anthropic/ollama 前缀从全局清单移除
//(其唯一键 contextWindow/numCtx 已废除,由全局 llm.contextWindow 取代)
expect(isGlobalKey('deepseek.contextWindow')).toBe(false);
expect(isGlobalKey('deepseek.apiKey')).toBe(false);
expect(isGlobalKey('memory.consolidationEnabled')).toBe(true);
expect(isGlobalKey('memory.custom')).toBe(true);
// 非全局前缀
expect(isGlobalKey('workspace.path')).toBe(false);
expect(isGlobalKey('searxng.apiKey')).toBe(false);
expect(isGlobalKey('mcp.autoReconnect')).toBe(false);
});
});
describe.skipIf(!dbAvailable)('ConfigService.setBatch — 批量写入', () => {
it('setBatch 多 key 一次写入 DB 与全局层(全局 key 双写)', () => {
configService.setBatch([
{ key: 'llm.model', value: 'mimo-v3' },
{ key: 'agent.maxIterations', value: 40 },
{ key: 'ui.theme', value: 'light' },
]);
expect(configService.get<string>('llm.model')).toBe('mimo-v3');
expect(configService.get<number>('agent.maxIterations')).toBe(40);
expect(configService.get<string>('ui.theme')).toBe('light');
// 全局层同步
expect(globalConfig.get<string>('llm.model')).toBe('mimo-v3');
expect(globalConfig.get<number>('agent.maxIterations')).toBe(40);
});
it('setBatch 敏感 key 加密落盘、读取解密', () => {
configService.setBatch([{ key: 'llm.apiKey', value: 'sk-batch-secret' }]);
const raw = db!.prepare("SELECT value FROM app_config WHERE key = 'llm.apiKey'").get() as {
value: string;
};
expect(raw.value).toContain('metona-enc:v1:');
expect(configService.get<string>('llm.apiKey')).toBe('sk-batch-secret');
expect(globalConfig.get<string>('llm.apiKey')).toBe('sk-batch-secret');
});
it('setBatch 保留已有 category(与 set 语义一致)', () => {
db!
.prepare(
"INSERT INTO app_config (key, value, category) VALUES ('ui.theme', '\"blue\"', 'ui')",
)
.run();
configService.setBatch([{ key: 'ui.theme', value: 'dark' }]);
const row = db!.prepare("SELECT category FROM app_config WHERE key = 'ui.theme'").get() as {
category: string;
};
expect(row.category).toBe('ui');
});
it('setBatch 非全局 key 不写全局层', () => {
configService.setBatch([{ key: 'workspace.localOnly', value: 'x' }]);
expect(globalConfig.get<string>('workspace.localOnly')).toBeNull();
});
it('setBatch 空数组不抛错且无副作用', () => {
expect(() => configService.setBatch([])).not.toThrow();
expect(globalConfig.getAll()).toEqual({});
});
it('setBatch 部分失败(JSON.stringify 抛错)→ DB 事务整体回滚,不产生部分写入', () => {
const circular: Record<string, unknown> = {};
circular.self = circular; // 无法 JSON 序列化
expect(() =>
configService.setBatch([
{ key: 'ui.theme', value: 'dark' },
{ key: 'agent.maxIterations', value: circular },
]),
).toThrow();
// 事务回滚:第一个 key 也不应残留
const row = db!.prepare("SELECT value FROM app_config WHERE key = 'ui.theme'").get();
expect(row).toBeUndefined();
// 全局层不被触碰(事务异常中断在 DB 阶段)
expect(globalConfig.get<string>('ui.theme')).toBeNull();
});
it('setBatch 更新 updated_at 时间戳', () => {
configService.setBatch([{ key: 'llm.model', value: 'a' }]);
const row = db!.prepare("SELECT updated_at FROM app_config WHERE key = 'llm.model'").get() as {
updated_at: number;
};
expect(row.updated_at).toBeGreaterThan(0);
});
});
describe.skipIf(!dbAvailable)('ConfigService — getByCategory / delete / 值类型', () => {
it('getByCategory 只返回该分类下的 key', () => {
// 直接按真实库的 category 灌入(set 默认 category=general,需先有分类)
configService.set('ui.theme', 'dark');
configService.set('llm.provider', 'deepseek');
configService.set('ui.fontSize', 14);
db!
.prepare("UPDATE app_config SET category = 'ui' WHERE key IN ('ui.theme', 'ui.fontSize')")
.run();
const ui = configService.getByCategory('ui');
expect(Object.keys(ui).sort()).toEqual(['ui.fontSize', 'ui.theme']);
expect(ui['ui.theme']).toBe('dark');
expect(ui['ui.fontSize']).toBe(14);
expect('llm.provider' in ui).toBe(false);
});
it('getByCategory 不存在的分类返回空对象', () => {
expect(configService.getByCategory('nonexistent')).toEqual({});
});
it('getByCategory 返回的敏感 key 值为密文(getByCategory 未做解密 —— 实际行为契约)', () => {
db!
.prepare("INSERT INTO app_config (key, value, category) VALUES ('llm.apiKey', '\"\"', 'llm')")
.run();
configService.set('llm.apiKey', 'sk-cat-secret');
const llm = configService.getByCategory('llm');
expect(llm['llm.apiKey']).toContain('metona-enc:v1:');
expect(llm['llm.apiKey']).not.toContain('sk-cat-secret');
// 对照:get() 仍解密
expect(configService.get<string>('llm.apiKey')).toBe('sk-cat-secret');
});
it('getByCategory 容忍损坏 JSON(原样字符串返回)', () => {
db!
.prepare("INSERT INTO app_config (key, value, category) VALUES ('ui.raw', '{bad-json', 'ui')")
.run();
const ui = configService.getByCategory('ui');
expect(ui['ui.raw']).toBe('{bad-json');
});
it('delete 非全局 key:删除后 get 返回 null(无全局回退)', () => {
configService.set('workspace.localFlag', 'x');
expect(configService.delete('workspace.localFlag')).toBe(true);
expect(configService.get('workspace.localFlag')).toBeNull();
expect(configService.delete('workspace.localFlag')).toBe(false);
expect(configService.delete('never-existed')).toBe(false);
});
it('delete 全局 keyDB 行删除但 get 经全局层回退仍可读(双写不联动删除)', () => {
configService.set('llm.model', 'mimo');
expect(configService.delete('llm.model')).toBe(true);
const row = db!.prepare("SELECT value FROM app_config WHERE key = 'llm.model'").get();
expect(row).toBeUndefined();
// 全局层保留 → get 回退命中
expect(globalConfig.get<string>('llm.model')).toBe('mimo');
expect(configService.get<string>('llm.model')).toBe('mimo');
});
it('set/get 支持对象与数组值(JSON 序列化语义)', () => {
configService.set('ui.pinnedSessions', ['a', 'b']);
configService.set('agent.metadata', { enabled: true, max: 3 });
expect(configService.get<Array<string>>('ui.pinnedSessions')).toEqual(['a', 'b']);
expect(configService.get<{ enabled: boolean; max: number }>('agent.metadata')).toEqual({
enabled: true,
max: 3,
});
});
it('set 布尔与 null 值可回读', () => {
configService.set('ui.flag', false);
configService.set('ollama.numCtx', null);
expect(configService.get<boolean>('ui.flag')).toBe(false);
expect(configService.get<null>('ollama.numCtx')).toBeNull();
});
it('历史明文敏感 key 平滑兼容:DB 存明文时 get 原样返回', () => {
// 模拟旧版本未加密落盘的 apiKey
db!
.prepare(
"INSERT INTO app_config (key, value, category) VALUES ('llm.apiKey', '\"sk-legacy-plain\"', 'llm')",
)
.run();
expect(configService.get<string>('llm.apiKey')).toBe('sk-legacy-plain');
});
});
describe.skipIf(!dbAvailable)('migrateFromWorkspaceDB — 边界矩阵', () => {
it('false 值且默认值非 false → 迁移(用户主动关闭)', () => {
const migrated = globalConfig.migrateFromWorkspaceDB({ 'agent.enableThinking': false });
expect(migrated).toBe(1);
expect(globalConfig.get<boolean>('agent.enableThinking')).toBe(false);
});
it('false 值且默认值即 false → 跳过(seedDefaults 灌入)', () => {
expect(globalConfig.migrateFromWorkspaceDB({ 'onboarding.completed': false })).toBe(0);
expect(globalConfig.migrateFromWorkspaceDB({ 'llm.multimodalEnabled': false })).toBe(0);
});
it('true 值且默认值 false → 迁移(用户主动开启)', () => {
expect(globalConfig.migrateFromWorkspaceDB({ 'llm.multimodalEnabled': true })).toBe(1);
expect(globalConfig.get<boolean>('llm.multimodalEnabled')).toBe(true);
});
it('true 值且默认值 true → 跳过', () => {
expect(globalConfig.migrateFromWorkspaceDB({ 'security.promptInjectionDefense': true })).toBe(
0,
);
expect(globalConfig.migrateFromWorkspaceDB({ 'agent.enableThinking': true })).toBe(0);
});
it('0 与空对象等非空非默认值 → 迁移', () => {
expect(globalConfig.migrateFromWorkspaceDB({ 'llm.temperature': 0 })).toBe(0); // 默认 0 → 跳过
expect(globalConfig.migrateFromWorkspaceDB({ 'llm.temperature': 0.5 })).toBe(1);
expect(globalConfig.migrateFromWorkspaceDB({ 'ui.customFlag': 'on' })).toBe(1);
});
it('敏感 key 迁移:全局 JSON 中为密文(无明文泄漏)', () => {
const migrated = globalConfig.migrateFromWorkspaceDB({ 'llm.apiKey': 'sk-mig-secret' });
expect(migrated).toBe(1);
expect(globalConfig.get<string>('llm.apiKey')).toBe('sk-mig-secret');
const rawFile = readFileSync(join(mockState.userDataDir, 'global-config.json'), 'utf-8');
expect(rawFile).not.toContain('sk-mig-secret');
expect(rawFile).toContain('metona-enc:v1:');
});
it('数值边界:默认值 float 精确相等判定', () => {
// v0.8.1: deepseek.contextWindow 键已废除(迁移 12)——改用存续键 llm.maxTokens(默认 63488
expect(globalConfig.migrateFromWorkspaceDB({ 'llm.maxTokens': 63488 })).toBe(0);
expect(globalConfig.migrateFromWorkspaceDB({ 'llm.maxTokens': 64000 })).toBe(1);
});
it('迁移后新 ConfigService 读取(跨空间共享生效)', () => {
const migrated = globalConfig.migrateFromWorkspaceDB({ 'llm.provider': 'ollama' });
expect(migrated).toBe(1);
const db2 = makeDb();
const freshConfig = new ConfigService(() => db2);
freshConfig.setGlobalConfig(globalConfig);
expect(freshConfig.get<string>('llm.provider')).toBe('ollama');
db2.close();
});
});
describe.skipIf(!dbAvailable)('配置分层 — 空值回退细节', () => {
it('DB 值等于 seed 默认值(0)时视为未配置 → 回退全局层真实值', () => {
// llm.temperature seed 默认 0 —— DB 写入 0 与默认值不可区分 → 回退全局层
db!
.prepare(
"INSERT INTO app_config (key, value, category) VALUES ('llm.temperature', '0', 'llm')",
)
.run();
globalConfig.set('llm.temperature', 1);
expect(configService.get<number>('llm.temperature')).toBe(1);
});
it('DB 值为非默认数字 0(ui.theme 默认是字符串)→ 视为已配置不回退', () => {
db!
.prepare("INSERT INTO app_config (key, value, category) VALUES ('ui.theme', '0', 'ui')")
.run();
globalConfig.set('ui.theme', 'dark');
expect(configService.get<number>('ui.theme')).toBe(0);
});
it('DB 有非空字符串但全局层为 null → 不覆盖为 null(返回 DB 值)', () => {
configService.set('llm.model', 'local-model');
// 全局层无该值
expect(configService.get<string>('llm.model')).toBe('local-model');
});
it('DB miss 且全局层为 null → get 返回 null', () => {
expect(configService.get<string>('llm.provider')).toBeNull();
});
it('getAll 合并时全局层未配置的 key 不进结果', () => {
const all = configService.getAll();
expect('llm.provider' in all).toBe(false);
});
it('getAll 中 DB 未配置但全局层已配置 → 全局值补齐', () => {
globalConfig.set('llm.provider', 'openai');
configService.set('ui.theme', 'dark');
const all = configService.getAll();
expect(all['llm.provider']).toBe('openai');
expect(all['ui.theme']).toBe('dark');
});
});