Files
one_divine_lot/scripts/test-r013-custom-fields.mjs
T
kyugao aac16b904c feat(R-013): 策略自定义字段完整实现 + 列显隐配置 + 单元格编辑
功能实现(迭代 11):
- 数据层: strategies.configSchema 自定义字段定义(text/number/boolean/enum + def + unit 单位可空)
  + strategy_holdings "values" JSON 列(幂等 ALTER)+ readValues/writeValues + 归一化
- API: holdings/values-update 端点(服务端按 configSchema 校验: number有限/enum在选项/boolean布尔)
- 设置页: 「策略分组」策略行展开配置字段(StrategyFieldsEditor 独立组件)
- 持仓表: 自定义字段列化 + 列显隐/排序(ColumnSettingsPopover,每策略独立 strategyColumns 配置)
- 单元格点击编辑(FieldCellEditor): 文本/数字输入框、布尔开关、枚举下拉 + ✓ 确认
- UI 细节: ✎ 可编辑标记(值后)、编辑列宽稳定、数字输入框固定 5 位宽

修复:
- SettingsSection Fragment/StrategyFieldsEditor 未导入导致的配置页白屏
- z.dict schema dts 推断 cosmokit 引用(显式类型注解)
- 自定义字段从展开交易明细移出(字段已列化)

文档: R-013/迭代11 技术方案/验收标准/迭代目标 同步(D6 演进 + unit + 列配置)
回归: test-r013-custom-fields 21/21 + test-r013-columns 16/16 + tabs 23/23
2026-09-02 17:21:41 +08:00

172 lines
7.9 KiB
JavaScript
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.
/**
* R-013 回归测试:策略自定义字段(定义随策略 configSchema + 值落 strategy_holdings.values
* 覆盖(独立数据目录,技术约束-011):
* 1. settings.configSchemaschema 归一化(旧策略无 configSchema → []);updateStrategies 保留定义
* 2. SqliteStore_ensureHoldingValuesColumn 幂等补列;openHolding 后 values=nullreadValues/writeValues 读写
* 3. PositionManager.updateHoldingValues:定位持仓 → 按所属策略 configSchema 校验(number/enum/boolean
* → 写回;空值放行;非法值拒绝(field-validation);额外键放行(可扩展);null 清除
* 4. strategy-positions:返回项附 values(该策略持仓行)
* 纯内存/临时目录,不触真实数据(技术约束-011)
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { SqliteStore } from '../src/storage/SqliteStore.js';
import { DataStore } from '../src/storage/DataStore.js';
import { PositionManager } from '../src/position/PositionManager.js';
import { getStrategies, updateStrategies, addStrategy } from '../src/settings.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 + "]"); }
/** 内存 mock settings scopeget/update */
function makeScope(initial) {
const store = structuredClone(initial ?? {});
return {
get: () => structuredClone(store),
update: async (patch) => { Object.assign(store, structuredClone(patch)); },
_store: store,
};
}
// 网格超市 configSchema(四类型)
const gridSchema = [
{ key: 'gridGap', label: '网格间距', type: 'text', def: '3%' },
{ key: 'gridAmount', label: '单格金额', type: 'number', def: 10000 },
{ key: 'hasStop', label: '设置止损', type: 'boolean', def: false },
{ key: 'riskLevel', label: '风险等级', type: 'enum', enum: ['低', '中', '高'], def: '中' },
];
const scope = makeScope({
strategies: [
{ id: 'grid-supermarket', name: '网格超市', configSchema: gridSchema },
{ id: 'manual-t', name: '手动做T' }, // 旧策略:无 configSchema
],
});
// ---------- 1. settings configSchema 归一化 ----------
section('1. settings.configSchema');
const all = getStrategies(scope);
const grid = all.find((s) => s.id === 'grid-supermarket');
assert(grid && Array.isArray(grid.configSchema) && grid.configSchema.length === 4, 'grid-supermarket configSchema 4 字段');
const manual = all.find((s) => s.id === 'manual-t');
assert(manual && Array.isArray(manual.configSchema) && manual.configSchema.length === 0, '旧策略 manual-t configSchema 归一化为 []Q4');
// updateStrategies 整表更新保留定义
await updateStrategies(scope, [{ id: 'manual-t', name: '手动做T' }, { id: 'grid-supermarket', name: '网格超市', configSchema: gridSchema }]);
assert(getStrategies(scope).find((s) => s.id === 'grid-supermarket').configSchema.length === 4, 'updateStrategies 整表保留 configSchema');
// ---------- 2. SqliteStore values 列 ----------
section('2. SqliteStore values 列');
const dataDir = mkdtempSync(join(tmpdir(), 'odl-r013-'));
const sqlite = new SqliteStore({ dataDir });
const store = new DataStore({ dataDir });
const pm = new PositionManager({
dataSource: { getPositions: async () => [] }, // 测试不拉真实持仓
storage: store,
getStrategySchema: (id) => getStrategies(scope).find((s) => s.id === id)?.configSchema ?? [],
});
const h1 = await store.openHolding('grid-supermarket', '601117.SH', 600);
const h2 = await store.openHolding('grid-supermarket', '300057.SZ', 1000);
const h3 = await store.openHolding('manual-t', '600719.SH', 1000);
assert(store.readValues(h1.holdingId) === null, '新建持仓 readValues = null');
assert(typeof h1.holdingId === 'number' && h1.values === null, 'openHolding 返回 values=null');
// 幂等补列(重复 init 不报错)
sqlite.init(); sqlite.init();
assert(true, 'init 幂等(_ensureHoldingValuesColumn 重复执行不报错)');
// ---------- 3. updateHoldingValues 校验与写回 ----------
section('3. updateHoldingValues');
// 正常写入(全量)
const r1 = await pm.updateHoldingValues(h1.holdingId, { gridGap: '3%', gridAmount: 10000, hasStop: true, riskLevel: '高' });
assert(r1.values.gridGap === '3%' && r1.values.gridAmount === 10000, '合法值写入成功');
assert(store.readValues(h1.holdingId).riskLevel === '高', '读回与写入一致(落库)');
// number 非法拒绝
try {
await pm.updateHoldingValues(h2.holdingId, { gridAmount: 'abc' });
assert(false, 'number 填非数字应拒绝');
} catch (e) {
assert(e.code === 'field-validation' && e.message.includes('单格金额'), 'number 非法值拒绝: ' + e.message);
}
// enum 越界拒绝
try {
await pm.updateHoldingValues(h2.holdingId, { riskLevel: '超高' });
assert(false, 'enum 越界应拒绝');
} catch (e) {
assert(e.code === 'field-validation' && e.message.includes('风险等级'), 'enum 越界拒绝: ' + e.message);
}
// boolean 类型错误拒绝
try {
await pm.updateHoldingValues(h2.holdingId, { hasStop: 'yes' });
assert(false, 'boolean 非布尔应拒绝');
} catch (e) {
assert(e.code === 'field-validation', 'boolean 非法值拒绝');
}
// 空值放行
await pm.updateHoldingValues(h2.holdingId, { gridAmount: null, riskLevel: '' });
assert(true, '空值/缺省放行(允许清空)');
// 额外键放行(可扩展)
const rExtra = await pm.updateHoldingValues(h2.holdingId, { gridAmount: 5000, customNote: '扩展键' });
assert(rExtra.values.customNote === '扩展键', '未定义 key 额外键放行(可扩展)');
// 不存在的持仓
try {
await pm.updateHoldingValues(99999, { gridGap: '1%' });
assert(false, '不存在持仓应拒绝');
} catch (e) {
assert(e.code === 'holding-not-found', '不存在持仓拒绝: ' + e.message);
}
// 清仓后不可写
await store.closeHolding('manual-t', '600719.SH');
try {
await pm.updateHoldingValues(h3.holdingId, { a: 1 });
assert(false, '已清仓持仓应拒绝');
} catch (e) {
assert(e.code === 'holding-not-found', '已清仓持仓拒绝');
}
// null 清除
await pm.updateHoldingValues(h2.holdingId, null);
assert(store.readValues(h2.holdingId) === null, 'null 清除该行值');
// ---------- 4. 旧策略(无 configSchema)任意键放行 ----------
section('4. 旧策略持仓(无 configSchema');
const h3b = await store.openHolding('manual-t', '600719.SH', 800);
const rOld2 = await pm.updateHoldingValues(h3b.holdingId, { note: '自由备注' });
assert(rOld2.values.note === '自由备注', '旧策略(schema=[])任意键放行');
// ---------- 5. strategy-positions 附 values(经 getStrategyPositions 需 dataSource 返回持仓) ----------
section('5. strategy-positions 附 values');
const pmReal = new PositionManager({
dataSource: {
getPositions: async () => [
{ code: '601117.SH', name: '中国交建', volume: 600, available: 600, avgPrice: 7.0, price: 7.2, marketValue: 4320 },
{ code: '300057.SZ', name: '万顺新材', volume: 1000, available: 1000, avgPrice: 5.0, price: 5.1, marketValue: 5100 },
],
},
storage: store,
getStrategySchema: (id) => getStrategies(scope).find((s) => s.id === id)?.configSchema ?? [],
});
const list = await pmReal.getStrategyPositions('grid-supermarket');
assert(list.length === 2, 'grid-supermarket 持仓 2 行');
const row1 = list.find((p) => p.code === '601117.SH');
assert(row1 && row1.holdingId === h1.holdingId, '返回含 holdingId');
assert(row1 && row1.values && row1.values.riskLevel === '高', '返回行附 values601117: riskLevel=高)');
assert(list.find((p) => p.code === '300057.SZ').values === null, '清值行 values=null');
console.log("");
console.log('结果: ' + pass + ' 通过, ' + fail + ' 失败');
rmSync(dataDir, { recursive: true, force: true });
process.exit(fail > 0 ? 1 : 0);