/** * R-027 回归测试:策略 tab 静态化 + 字段配置存储归一化(迭代 22 阶段一) * 覆盖: * 1. 策略常量:getStrategies 恒为内置两项(存量自建策略被忽略) * 2. 旧数据兼容:strategies[].configSchema → 读取回填;strategyFields 优先 * 3. 字段归一化:类型白名单 / decimals 夹取 / formula 字段不带 def * 4. 单策略写入:updateStrategyFields 只改目标策略;未知 strategyId 抛 strategy-not-found * 5. tabs 归一化:丢弃自建策略条目、保留 visible/order、缺失补齐、旧布尔对象兼容 * 6. updateTabs 只接受内置条目 * 7. 端点:strategies/schema-update 校验 + 落库;已退役端点调用被拒 * 纯内存 mock(技术约束-011:不触真实数据) */ import { BUILTIN_STRATEGIES, getStrategies, getStrategyFields, updateStrategyFields, normalizeFields, getTabs, updateTabs, normalizeTabs, strategySchema, } from '../src/settings.js'; import { STRATEGY_METHODS, handleStrategy } from '../src/api/strategies.js'; let pass = 0, fail = 0; function assert(cond, msg) { if (cond) { pass++; console.log(' ✅ ' + msg); } else { fail++; console.log(' ❌ ' + msg); } } function section(t) { console.log('\n[' + t + ']'); } function makeScope(initial) { const store = structuredClone(initial ?? {}); return { get: () => structuredClone(store), update: async (patch) => { Object.assign(store, structuredClone(patch)); }, _store: store, }; } async function throwsAsync(fn, code, msg) { try { await fn(); assert(false, msg + '(未抛错)'); } catch (e) { assert(e?.code === code, msg + '(code=' + e?.code + ')'); } } // ---------- 1. 策略常量 ---------- section('1. 策略恒为内置两项'); const legacyScope = makeScope({ strategies: [ { id: 'grid-supermarket', name: '网格超市', configSchema: [{ key: 'g', label: '网格大小', type: 'number', def: 1, unit: '元' }] }, { id: 'manual-t', name: '手动做T', configSchema: [{ key: 't', label: 'T仓成本价', type: 'number', unit: '元' }] }, { id: 'long-term', name: '长线持有', configSchema: [{ key: 'x', label: '目标价', type: 'number' }] }, ], }); const list = getStrategies(legacyScope); assert(list.length === 2, '策略表恒 2 项(自建策略被忽略)'); assert(list.map((s) => s.id).join(',') === BUILTIN_STRATEGIES.map((s) => s.id).join(','), '顺序与内置常量一致'); assert(!list.some((s) => s.id === 'long-term'), '自建策略不出现在读取结果'); // ---------- 2. 旧数据兼容 ---------- section('2. 字段定义读取(旧数据回填 / 新键优先)'); assert(getStrategyFields(legacyScope, 'grid-supermarket').length === 1, '旧 strategies[].configSchema 回填'); assert(getStrategyFields(legacyScope, 'grid-supermarket')[0].label === '网格大小', '回填内容正确'); const newScope = makeScope({ strategies: [{ id: 'grid-supermarket', name: '网格超市', configSchema: [{ key: 'old', label: '旧的', type: 'text' }] }], strategyFields: { 'grid-supermarket': [{ key: 'new', label: '新的', type: 'number', def: 1 }] }, }); assert(getStrategyFields(newScope, 'grid-supermarket')[0].label === '新的', 'strategyFields 优先于旧键'); assert(getStrategyFields(newScope, 'manual-t').length === 0, '另一策略无定义 → []'); // ---------- 3. 字段归一化 ---------- section('3. normalizeFields'); const norm = normalizeFields([ { key: 'a', label: 'A', type: '不合法', def: 1 }, { key: 'b', label: 'B', type: 'formula', formula: ' 现价 × 份额 ', decimals: 9, def: 123 }, { key: 'c', label: 'C', type: 'number', def: 5, unit: '元' }, ]); assert(norm[0].type === 'text', '非法类型回退 text'); assert(norm[1].formula === '现价 × 份额', '公式去首尾空白'); assert(norm[1].decimals === 4, 'decimals 夹取上限 4'); assert(!('def' in norm[1]), 'formula 字段不带 def'); assert(norm[2].def === 5 && norm[2].unit === '元', '普通字段保留 def/unit'); // ---------- 4. 单策略写入 ---------- section('4. updateStrategyFields'); const wScope = makeScope({ strategyFields: { 'manual-t': [{ key: 't', label: 'T仓成本价', type: 'number' }] } }); const updated = await updateStrategyFields(wScope, 'grid-supermarket', [{ key: 'g', label: '网格大小', type: 'number', def: 1 }]); assert(updated.configSchema.length === 1, '返回更新后的策略对象'); assert(wScope._store.strategyFields['grid-supermarket'].length === 1, '写入 strategyFields[目标策略]'); assert(wScope._store.strategyFields['manual-t'].length === 1, '另一策略定义不受影响'); assert(!wScope._store.strategies, '不写 strategies[] 键(真相源单一)'); await throwsAsync(() => updateStrategyFields(wScope, 'long-term', []), 'strategy-not-found', '未知策略 id 被拒'); // ---------- 5. tabs 归一化 ---------- section('5. normalizeTabs(静态化)'); const tScope = makeScope({ tabs: [ { id: 'tab-strategy-long-term', kind: 'strategy', refId: 'long-term', name: '长线持有', visible: true, order: 0 }, { id: 'tab-strategy-manual-t', kind: 'strategy', refId: 'manual-t', name: '手动做T', visible: false, order: 1 }, { id: 'tab-strategy-grid-supermarket', kind: 'strategy', refId: 'grid-supermarket', name: '网格超市', visible: true, order: 2 }, { id: 'tab-trade-records', kind: 'builtin', refKey: 'tradeRecords', name: '交易记录', visible: true, order: 3 }, ], }); const tabs = normalizeTabs(tScope); assert(tabs.length === 5, 'tabs 恒 5 条(3 内置 + 2 策略)'); assert(!tabs.some((t) => t.refId === 'long-term'), '自建策略 tab 条目被丢弃'); const mt = tabs.find((t) => t.refId === 'manual-t'); assert(mt && mt.visible === false, '保留存量 visible 偏好(手动做T 隐藏)'); assert(tabs.findIndex((t) => t.refId === 'manual-t') < tabs.findIndex((t) => t.refId === 'grid-supermarket'), '保留存量 order 相对次序(手动做T 在网格超市前)'); assert(tabs.some((t) => t.refKey === 'allPositions'), '缺失的内置条目被补齐'); const legacyBool = normalizeTabs(makeScope({ tabs: { allPositions: false, tradeRecords: true, watchlist: false } })); assert(legacyBool.find((t) => t.refKey === 'allPositions').visible === false, '旧布尔对象格式兼容'); assert(legacyBool.some((t) => t.kind === 'strategy'), '旧格式下策略行补齐'); assert(getTabs(tScope).length === 5, 'getTabs 走归一化'); // ---------- 6. updateTabs 过滤 ---------- section('6. updateTabs 只接受内置条目'); const uScope = makeScope({}); await updateTabs(uScope, [ { id: 'tab-strategy-long-term', kind: 'strategy', refId: 'long-term', visible: true, order: 0 }, { id: 'tab-all-positions', kind: 'builtin', refKey: 'allPositions', visible: true, order: 1 }, ]); assert(uScope._store.tabs.length === 1 && uScope._store.tabs[0].id === 'tab-all-positions', '未知条目被过滤'); // ---------- 7. 端点 ---------- section('7. 端点:schema-update 与退役端点'); assert(STRATEGY_METHODS.has('strategies/schema-update'), 'STRATEGY_METHODS 含 schema-update'); assert(!STRATEGY_METHODS.has('strategies/add') && !STRATEGY_METHODS.has('strategies/remove') && !STRATEGY_METHODS.has('strategies/update'), '退役端点已从方法表移除'); const runtime = { manager: {}, settings: makeScope({}), storage: {}, marketHub: null }; await throwsAsync(() => handleStrategy('strategies/add', { name: 'x' }, runtime), 'not-found', 'strategies/add 被拒'); await throwsAsync(() => handleStrategy('strategies/update', { strategies: [] }, runtime), 'not-found', 'strategies/update 被拒'); await throwsAsync(() => handleStrategy('strategies/remove', { strategyId: 'manual-t' }, runtime), 'not-found', 'strategies/remove 被拒'); const apiScope = makeScope({}); const apiRuntime = { manager: {}, settings: apiScope, storage: {}, marketHub: null }; const saved = await handleStrategy('strategies/schema-update', { strategyId: 'grid-supermarket', configSchema: [{ key: 'grid_size', label: '网格大小', type: 'number', def: 1, unit: '元' }], }, apiRuntime); assert(saved.configSchema.length === 1 && saved.configSchema[0].label === '网格大小', 'schema-update 写入成功'); assert(apiScope._store.strategyFields['grid-supermarket'].length === 1, '落库到 strategyFields'); await throwsAsync(() => handleStrategy('strategies/schema-update', { strategyId: 'nope', configSchema: [] }, apiRuntime), 'strategy-not-found', '未知策略写入被拒'); assert(strategySchema != null, 'strategySchema 导出可用'); console.log('\n结果: ' + pass + ' 通过 / ' + fail + ' 失败'); process.exit(fail === 0 ? 0 : 1);