update
This commit is contained in:
447
tests/unit/settings-schema.test.js
Normal file
447
tests/unit/settings-schema.test.js
Normal file
@@ -0,0 +1,447 @@
|
||||
// Stage 7 tests: shared/settings-schema.js
|
||||
//
|
||||
// 覆盖:
|
||||
// - DEFAULT_SETTINGS:导出 + 含必要字段
|
||||
// - coerceLoadedSettings:未知字段剥除 / 已知字段合并默认值
|
||||
// - validateKey:enum / boolean / number clamp / nullable-path
|
||||
// - validateAndSanitize:批量校验,遇到非法字段提前失败
|
||||
//
|
||||
// shared/ 是 CommonJS,直接 require 即可(不像 main/ 需要 _setApp 注入)。
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SCHEMA_PATH = resolve(__dirname, '../../shared/settings-schema.js');
|
||||
|
||||
const schema = require('../../shared/settings-schema.js');
|
||||
const {
|
||||
DEFAULT_SETTINGS,
|
||||
coerceLoadedSettings,
|
||||
validateKey,
|
||||
validateAndSanitize,
|
||||
SETTINGS_SCHEMA,
|
||||
} = schema;
|
||||
|
||||
describe('DEFAULT_SETTINGS', () => {
|
||||
it('导出且包含核心字段', () => {
|
||||
expect(DEFAULT_SETTINGS).toBeDefined();
|
||||
expect(DEFAULT_SETTINGS.theme).toBeDefined();
|
||||
expect(DEFAULT_SETTINGS.themePalette).toBeDefined();
|
||||
expect(DEFAULT_SETTINGS.dataDir).toBeDefined();
|
||||
expect(DEFAULT_SETTINGS.alwaysOnTop).toBe(false);
|
||||
});
|
||||
|
||||
it('AI 默认值:provider = openai;baseUrl / apiKey / model / systemPrompt 空串', () => {
|
||||
expect(DEFAULT_SETTINGS.aiProvider).toBe('openai');
|
||||
expect(DEFAULT_SETTINGS.aiBaseUrl).toBe('');
|
||||
expect(DEFAULT_SETTINGS.aiApiKey).toBe('');
|
||||
expect(DEFAULT_SETTINGS.aiModel).toBe('');
|
||||
expect(DEFAULT_SETTINGS.aiSystemPrompt).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('coerceLoadedSettings', () => {
|
||||
it('非对象 → 默认值', () => {
|
||||
expect(coerceLoadedSettings(null)).toEqual({ ...DEFAULT_SETTINGS });
|
||||
expect(coerceLoadedSettings('str')).toEqual({ ...DEFAULT_SETTINGS });
|
||||
});
|
||||
|
||||
it('已知字段被 raw 覆盖', () => {
|
||||
const out = coerceLoadedSettings({ theme: 'light', readerFontSize: 19 });
|
||||
expect(out.theme).toBe('light');
|
||||
expect(out.readerFontSize).toBe(19);
|
||||
});
|
||||
|
||||
it('未知字段被剥除', () => {
|
||||
const out = coerceLoadedSettings({ theme: 'light', bogus: 'x' });
|
||||
expect(out.theme).toBe('light');
|
||||
expect(out).not.toHaveProperty('bogus');
|
||||
});
|
||||
|
||||
it('缺省字段用默认值', () => {
|
||||
const out = coerceLoadedSettings({ theme: 'light' });
|
||||
expect(out.theme).toBe('light');
|
||||
expect(out.themePalette).toBe(DEFAULT_SETTINGS.themePalette);
|
||||
});
|
||||
|
||||
it('旧 autoSaveIntervalSec (0) → 迁移到 autoSaveDebounceMs (0)', () => {
|
||||
// 旧版 0 = 关闭;新版 0 = 关闭 —— 直接 1:1 映射
|
||||
const out = coerceLoadedSettings({ autoSaveIntervalSec: 0 });
|
||||
expect(out.autoSaveDebounceMs).toBe(0);
|
||||
expect(out).not.toHaveProperty('autoSaveIntervalSec');
|
||||
});
|
||||
|
||||
it('旧 autoSaveIntervalSec (3 / 10) → 迁移到 autoSaveDebounceMs (500)', () => {
|
||||
// 旧版「开启」档(3 秒、10 秒轮询)→ 新版「500ms 防抖」
|
||||
// 语义从「每隔 X 秒轮询」变成「停打 X ms 后保存」;统一映射到 500ms 默认值。
|
||||
expect(coerceLoadedSettings({ autoSaveIntervalSec: 3 }).autoSaveDebounceMs).toBe(500);
|
||||
expect(coerceLoadedSettings({ autoSaveIntervalSec: 10 }).autoSaveDebounceMs).toBe(500);
|
||||
});
|
||||
|
||||
it('autoSaveDebounceMs 越界 → clamp 到边界', () => {
|
||||
// 验证 number 类型规则生效:超界值被 clamp,不被丢弃
|
||||
expect(coerceLoadedSettings({ autoSaveDebounceMs: 99999 }).autoSaveDebounceMs).toBe(60000);
|
||||
expect(coerceLoadedSettings({ autoSaveDebounceMs: -100 }).autoSaveDebounceMs).toBe(0);
|
||||
});
|
||||
|
||||
it('string 字段超长 → 丢弃回默认(同步路径用 rule.max)', () => {
|
||||
// 同步路径(coerceLoadedSettings / sanitizeSync)一直用 rule.max;本次 audit
|
||||
// 修复的是 async validateKey 误写 opts.max 的问题。两条路径必须保持一致。
|
||||
const out = coerceLoadedSettings({ aiModel: 'a'.repeat(10_000) });
|
||||
expect(out.aiModel).toBe(DEFAULT_SETTINGS.aiModel);
|
||||
});
|
||||
|
||||
it('Phase N 修复:下划线前缀的元数据键(如 _hasAiKey)保留', () => {
|
||||
// 主进程 get-settings / save-settings 都会注入 _hasAiKey 作为「是否配置过 API Key」
|
||||
// 的只读元数据标记。旧实现只迭代 Object.keys(DEFAULT_SETTINGS),未知键全 drop,
|
||||
// 导致 renderer settingsStore.load() 后拿不到 _hasAiKey,「显示已填 Key」+ reveal
|
||||
// 流程全失效。下划线前缀约定为「只读元数据、不写盘」,coerce 阶段需保留。
|
||||
const out = coerceLoadedSettings({ theme: 'dark', _hasAiKey: true });
|
||||
expect(out.theme).toBe('dark');
|
||||
expect(out._hasAiKey).toBe(true);
|
||||
});
|
||||
|
||||
it('非下划线前缀的未知键仍被剥除(确保下划线特判不会过度放行)', () => {
|
||||
// 防御:下划线特判只放行 _ 前缀,普通 unknown 字段仍走原剥离路径,
|
||||
// 不让攻击者用 _privateName 这种「看上去像元数据」的字段把脏数据灌进内存。
|
||||
const out = coerceLoadedSettings({ theme: 'dark', _privateNote: 'secret', bogus: 42 });
|
||||
expect(out).not.toHaveProperty('_privateNote');
|
||||
expect(out).not.toHaveProperty('bogus');
|
||||
});
|
||||
|
||||
// fix(audit 2026-08):同步路径(sanitizeSync / coerceLoadedSettings)之前
|
||||
// 不校验 format 字段,导致手改 settings.json 写 aiBaseUrl: "ftp://x" /
|
||||
// "not-a-url" 会被静默接受,渲染端拿到无效 URL,真正 fetch 时才报 TypeError
|
||||
// 错误链很难定位到 settings。
|
||||
it('aiBaseUrl 非法 URL(同步路径) → 丢弃回默认空串', () => {
|
||||
expect(coerceLoadedSettings({ aiBaseUrl: 'ftp://x.y' }).aiBaseUrl).toBe('');
|
||||
expect(coerceLoadedSettings({ aiBaseUrl: 'not-a-url' }).aiBaseUrl).toBe('');
|
||||
expect(coerceLoadedSettings({ aiBaseUrl: 'javascript:alert(1)' }).aiBaseUrl).toBe('');
|
||||
// http:/missing-slash.com —— URL 构造器能解析但 fetch 行为不一致,必须拒绝
|
||||
expect(coerceLoadedSettings({ aiBaseUrl: 'http:/missing-slash.com' }).aiBaseUrl).toBe('');
|
||||
});
|
||||
|
||||
it('aiBaseUrl 合法 http(s) URL(同步路径) → 保留', () => {
|
||||
expect(coerceLoadedSettings({ aiBaseUrl: 'https://api.openai.com/v1' }).aiBaseUrl).toBe(
|
||||
'https://api.openai.com/v1',
|
||||
);
|
||||
expect(coerceLoadedSettings({ aiBaseUrl: 'http://10.0.0.1:11434/v1' }).aiBaseUrl).toBe(
|
||||
'http://10.0.0.1:11434/v1',
|
||||
);
|
||||
});
|
||||
|
||||
// fix(audit 2026-08):Boolean 输入不应被 Number() 强转。
|
||||
// Number(true) === 1、Number(false) === 0,旧版会让 autoSaveDebounceMs: true
|
||||
// 静默变 1ms(激进到每个 keystroke 都存),splitRatio: false 变 0(无预览)。
|
||||
it('number 字段 Boolean 输入 → 丢弃回默认', () => {
|
||||
expect(coerceLoadedSettings({ autoSaveDebounceMs: true }).autoSaveDebounceMs)
|
||||
.toBe(DEFAULT_SETTINGS.autoSaveDebounceMs);
|
||||
expect(coerceLoadedSettings({ splitRatio: false }).splitRatio)
|
||||
.toBe(DEFAULT_SETTINGS.splitRatio);
|
||||
expect(coerceLoadedSettings({ readerFontSize: true }).readerFontSize)
|
||||
.toBe(DEFAULT_SETTINGS.readerFontSize);
|
||||
});
|
||||
|
||||
// fix(audit 2026-08):round:1 会把 1.85 静默四舍五入到 1.9。UI 选项列出
|
||||
// 的是 [1.5, 1.7, 1.85, 2.0] 两位小数,round:2 才能保留用户的选择。
|
||||
it('readerLineHeight 1.85 不会被四舍五入到 1.9', () => {
|
||||
expect(coerceLoadedSettings({ readerLineHeight: 1.85 }).readerLineHeight).toBe(1.85);
|
||||
expect(coerceLoadedSettings({ readerLineHeight: 1.7 }).readerLineHeight).toBe(1.7);
|
||||
expect(coerceLoadedSettings({ readerLineHeight: 2.0 }).readerLineHeight).toBe(2.0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateKey', () => {
|
||||
it('enum 合法值 → ok', async () => {
|
||||
const r = await validateKey('theme', 'light');
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.value).toBe('light');
|
||||
});
|
||||
|
||||
it('enum 非法值 → 报错', async () => {
|
||||
const r = await validateKey('theme', 'rainbow');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toMatch(/theme 取值非法/);
|
||||
});
|
||||
|
||||
it('boolean 非法类型 → 报错', async () => {
|
||||
const r = await validateKey('alwaysOnTop', 'yes');
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('number clamp 越界 → clamp 到边界', async () => {
|
||||
const tooBig = await validateKey('readerFontSize', 99);
|
||||
expect(tooBig.ok).toBe(true);
|
||||
expect(tooBig.value).toBe(SETTINGS_SCHEMA.readerFontSize.max);
|
||||
|
||||
const tooSmall = await validateKey('readerFontSize', 1);
|
||||
expect(tooSmall.ok).toBe(true);
|
||||
expect(tooSmall.value).toBe(SETTINGS_SCHEMA.readerFontSize.min);
|
||||
});
|
||||
|
||||
it('number 非数字 → 报错', async () => {
|
||||
const r = await validateKey('readerFontSize', 'big');
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('nullable-path 空字符串 → null', async () => {
|
||||
const r = await validateKey('dataDir', ' ');
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.value).toBe(null);
|
||||
});
|
||||
|
||||
// audit fix (Round 9):nullable-number 与 number 分支对称拒绝空串。
|
||||
// 旧版 `case 'nullable-number'` 直接 `Number(raw)` → Number('') === 0 →
|
||||
// clamp 到 min,sidebarWidth='' 会被静默改成 200(默认 min)。
|
||||
it('nullable-number 空字符串 → 报错(不是 0)', async () => {
|
||||
const r = await validateKey('sidebarWidth', '');
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('nullable-number 全空白字符串 → 报错', async () => {
|
||||
const r = await validateKey('sidebarWidth', ' ');
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('nullable-number null → null(合法)', async () => {
|
||||
const r = await validateKey('sidebarWidth', null);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.value).toBe(null);
|
||||
});
|
||||
|
||||
it('nullable-number undefined → 视为 null(与同步路径一致)', async () => {
|
||||
// 审计修复 (Round 11 deep-fix P2-3):nullable 字段的 undefined 与 null 同义。
|
||||
// 旧版 async 路径把 undefined 当作错误(与同步 sanitizeSync 行为不一致),
|
||||
// 导致 load() 接受 + update() 拒绝同一字段。
|
||||
const r = await validateKey('sidebarWidth', undefined);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.value).toBeNull();
|
||||
});
|
||||
|
||||
it('nullable-path 非字符串 → 报错', async () => {
|
||||
const r = await validateKey('dataDir', 123);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('未知字段 → 报错', async () => {
|
||||
const r = await validateKey('unknown', 'anything');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toMatch(/未知设置项/);
|
||||
});
|
||||
|
||||
it('autoSaveDebounceMs 合法值 0 / 500 / 60000', async () => {
|
||||
expect((await validateKey('autoSaveDebounceMs', 0)).ok).toBe(true);
|
||||
expect((await validateKey('autoSaveDebounceMs', 500)).ok).toBe(true);
|
||||
expect((await validateKey('autoSaveDebounceMs', 60000)).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('autoSaveDebounceMs 越界 → clamp', async () => {
|
||||
// -1 → 0;99999 → 60000(clamp 行为)
|
||||
expect((await validateKey('autoSaveDebounceMs', -1)).value).toBe(0);
|
||||
expect((await validateKey('autoSaveDebounceMs', 99999)).value).toBe(60000);
|
||||
});
|
||||
|
||||
it('autoSaveDebounceMs 非数字 → 报错', async () => {
|
||||
const r = await validateKey('autoSaveDebounceMs', 'soon');
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('aiProvider openai / anthropic → ok', async () => {
|
||||
expect((await validateKey('aiProvider', 'openai')).value).toBe('openai');
|
||||
expect((await validateKey('aiProvider', 'anthropic')).value).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('aiProvider 非法值 → 报错', async () => {
|
||||
const r = await validateKey('aiProvider', 'gemini');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toMatch(/aiProvider 取值非法/);
|
||||
});
|
||||
|
||||
it('string 字段超过 rule.max → 报错', async () => {
|
||||
// audit fix:之前误用 opts.max,导致 aiApiKey/aiBaseUrl/aiModel/aiSystemPrompt
|
||||
// 声明的字符上限从未生效。这里锁定 rule.max 路径。
|
||||
expect((await validateKey('aiModel', 'a'.repeat(257))).ok).toBe(false);
|
||||
expect((await validateKey('aiBaseUrl', 'a'.repeat(4097))).ok).toBe(false);
|
||||
expect((await validateKey('aiApiKey', 'k'.repeat(4097))).ok).toBe(false);
|
||||
expect((await validateKey('aiSystemPrompt', 'x'.repeat(200_001))).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('string 字段在 rule.max 范围内 → ok', async () => {
|
||||
expect((await validateKey('aiModel', 'a'.repeat(256))).ok).toBe(true);
|
||||
// aiBaseUrl 现在带 url-https 格式校验 —— 用合法 URL 串代替 'a'.repeat,
|
||||
// 否则 URL 校验会拒绝非 URL 字符。最大长度限制的覆盖由上面的「超长」
|
||||
// 测试用例保证。12 字符 scheme/host + 4084 字符 path = 4096 总长。
|
||||
expect((await validateKey('aiBaseUrl', 'https://a.aa/' + 'a'.repeat(4083))).ok).toBe(true);
|
||||
expect((await validateKey('aiApiKey', 'k'.repeat(4096))).ok).toBe(true);
|
||||
expect((await validateKey('aiSystemPrompt', 'x'.repeat(200_000))).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('string 字段超长错误信息含上限值', async () => {
|
||||
const r = await validateKey('aiModel', 'a'.repeat(257));
|
||||
expect(r.error).toMatch(/超过 256 字符/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAndSanitize', () => {
|
||||
it('合法 batch → 返回 sanitized 对象', async () => {
|
||||
const r = await validateAndSanitize({ theme: 'light', readerFontSize: 18 });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.sanitized.theme).toBe('light');
|
||||
expect(r.sanitized.readerFontSize).toBe(18);
|
||||
});
|
||||
|
||||
it('任一字段非法 → 整个失败', async () => {
|
||||
const r = await validateAndSanitize({ theme: 'light', readerFontSize: 'huge' });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('非对象 → 报错', async () => {
|
||||
expect((await validateAndSanitize(null)).ok).toBe(false);
|
||||
expect((await validateAndSanitize('str')).ok).toBe(false);
|
||||
expect((await validateAndSanitize([])).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('空对象 → 空 sanitized', async () => {
|
||||
const r = await validateAndSanitize({});
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.sanitized).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// AI 字段的「空串是合法值」契约
|
||||
//
|
||||
// settings-dialog.js:303-326「清空 AI 配置」按钮依赖此契约:按钮设
|
||||
// aiKeyInput.value = '',保存时 diff 检测 '' !== initial.aiApiKey → patch 含
|
||||
// aiApiKey: '',走 settingsStore.update → settings-schema 校验 → 写盘。
|
||||
// 若未来 schema 把 '' 改成 null / undefined 或拒绝空串,清空按钮会无声失效。
|
||||
// ------------------------------------------------------------------
|
||||
describe('AI 字段空串是合法值(清空按钮依赖此契约)', () => {
|
||||
it('aiApiKey: "" → 接受且保留为空串', async () => {
|
||||
const r = await validateAndSanitize({ aiApiKey: '' });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.sanitized.aiApiKey).toBe('');
|
||||
});
|
||||
|
||||
it('aiBaseUrl / aiModel / aiSystemPrompt 同时清空 → 全部接受', async () => {
|
||||
const r = await validateAndSanitize({
|
||||
aiApiKey: '',
|
||||
aiBaseUrl: '',
|
||||
aiModel: '',
|
||||
aiSystemPrompt: '',
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.sanitized.aiApiKey).toBe('');
|
||||
expect(r.sanitized.aiBaseUrl).toBe('');
|
||||
expect(r.sanitized.aiModel).toBe('');
|
||||
expect(r.sanitized.aiSystemPrompt).toBe('');
|
||||
});
|
||||
|
||||
it('aiApiKey: 任意非空串 → 接受且原样保留', async () => {
|
||||
const r = await validateAndSanitize({ aiApiKey: 'sk-abc-123' });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.sanitized.aiApiKey).toBe('sk-abc-123');
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// nullable-string 类型支持
|
||||
//
|
||||
// audit fix:validateKey / sanitizeSync 的 switch 之前没有 nullable-string
|
||||
// case,遇到该类型的字符串值会落到 default → 「类型未定义」错误。
|
||||
// 当前 schema 没有 nullable-string 字段(aiApiKey 等用 string + 主进程判空),
|
||||
// 这里用代码存在性测试锁定修复,避免未来回归。
|
||||
// ------------------------------------------------------------------
|
||||
describe('nullable-string 类型支持(audit fix)', () => {
|
||||
const source = readFileSync(SCHEMA_PATH, 'utf8');
|
||||
|
||||
it('validateKey 的 switch 含 nullable-string case', () => {
|
||||
// 提取 validateKey 函数体的 switch 段,断言其中含 case 'nullable-string'
|
||||
const match = source.match(/async function validateKey[\s\S]*?^}/m);
|
||||
expect(match, 'validateKey 函数必须存在').toBeTruthy();
|
||||
expect(match[0]).toMatch(/case\s+['"]nullable-string['"]\s*:/);
|
||||
});
|
||||
|
||||
it('sanitizeSync 的 switch 含 nullable-string case', () => {
|
||||
// sanitizeSync 没有 async/function 关键字前缀 —— 直接匹配函数签名
|
||||
const match = source.match(/function sanitizeSync[\s\S]*?^}/m);
|
||||
expect(match, 'sanitizeSync 函数必须存在').toBeTruthy();
|
||||
expect(match[0]).toMatch(/case\s+['"]nullable-string['"]\s*:/);
|
||||
});
|
||||
|
||||
it('nullable-string 校验含 max 长度检查(与 string 一致)', () => {
|
||||
const validateMatch = source.match(/async function validateKey[\s\S]*?^}/m);
|
||||
expect(validateMatch).toBeTruthy();
|
||||
// nullable-string case 内必须包含 rule.max 长度检查
|
||||
const nullableCase = validateMatch[0].match(/case\s+['"]nullable-string['"]\s*:[\s\S]*?(?=\n\s+case\s|\n\s+default|\n\s+\})/);
|
||||
expect(nullableCase, 'validateKey 必须有 nullable-string case 体').toBeTruthy();
|
||||
expect(nullableCase[0]).toMatch(/rule\.max/);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Round 4 P1-1:nullable-path 同步路径空串归一为 null
|
||||
//
|
||||
// 之前 sanitizeSync 直接返回 '',与 validateKey 异步路径归一为 null 不同步。
|
||||
// coerceLoadedSettings 把磁盘上残留的 "dataDir": "" 保留为 '',但 validateKey
|
||||
// 会归一为 null → 任何依赖 dataDir === null 判断的代码失配。
|
||||
// ------------------------------------------------------------------
|
||||
describe('nullable-path 同步路径空串归一为 null(Round 4 P1-1)', () => {
|
||||
it('coerceLoadedSettings 把 dataDir: "" 归一为 null', () => {
|
||||
const result = coerceLoadedSettings({ ...DEFAULT_SETTINGS, dataDir: '' });
|
||||
expect(result.dataDir).toBe(null);
|
||||
});
|
||||
|
||||
it('coerceLoadedSettings 把 dataDir: " "(纯空白)归一为 null', () => {
|
||||
const result = coerceLoadedSettings({ ...DEFAULT_SETTINGS, dataDir: ' ' });
|
||||
expect(result.dataDir).toBe(null);
|
||||
});
|
||||
|
||||
it('coerceLoadedSettings 把 dataDir: "/some/path" 原样保留', () => {
|
||||
const result = coerceLoadedSettings({ ...DEFAULT_SETTINGS, dataDir: '/some/path' });
|
||||
expect(result.dataDir).toBe('/some/path');
|
||||
});
|
||||
|
||||
it('dataDir: null 仍是 null(不破坏既有契约)', () => {
|
||||
const result = coerceLoadedSettings({ ...DEFAULT_SETTINGS, dataDir: null });
|
||||
expect(result.dataDir).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Round 4 P2-3:nullable-number typeof 守卫对称
|
||||
//
|
||||
// 原版无 typeof 守卫,Number(true) === 1 隐式通过 isFinite。
|
||||
// 当前 schema 用 nullable-number 的字段(sidebarWidth / aiWidth)min 检查
|
||||
// 会拦下 1,但语义与 number 不一致 —— 加 typeof 守卫保持两条路径对称。
|
||||
// ------------------------------------------------------------------
|
||||
describe('nullable-number typeof 守卫对称(Round 4 P2-3)', () => {
|
||||
it('sidebarWidth: true → 拒绝(typeof 不匹配)', () => {
|
||||
const r = coerceLoadedSettings({ ...DEFAULT_SETTINGS, sidebarWidth: true });
|
||||
// sidebarWidth 不在 coerce 结果里(或保留 default),绝不能是 1
|
||||
expect(r.sidebarWidth).not.toBe(1);
|
||||
});
|
||||
|
||||
it('sidebarWidth: false → 拒绝', () => {
|
||||
const r = coerceLoadedSettings({ ...DEFAULT_SETTINGS, sidebarWidth: false });
|
||||
expect(r.sidebarWidth).not.toBe(0);
|
||||
});
|
||||
|
||||
it('sidebarWidth: 250 → 接受', () => {
|
||||
const r = coerceLoadedSettings({ ...DEFAULT_SETTINGS, sidebarWidth: 250 });
|
||||
expect(r.sidebarWidth).toBe(250);
|
||||
});
|
||||
|
||||
it('sidebarWidth: "300" → 接受(字符串数字合法)', () => {
|
||||
const r = coerceLoadedSettings({ ...DEFAULT_SETTINGS, sidebarWidth: '300' });
|
||||
expect(r.sidebarWidth).toBe(300);
|
||||
});
|
||||
|
||||
it('sidebarWidth: null → 仍是 null', () => {
|
||||
const r = coerceLoadedSettings({ ...DEFAULT_SETTINGS, sidebarWidth: null });
|
||||
expect(r.sidebarWidth).toBe(null);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user