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
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* R-013 列显隐配置回归(迭代 11 扩展,2026-09-02)
|
||||
* 覆盖(纯内存 mock scope,技术约束-011):
|
||||
* 1. normalizeStrategyColumns 默认:基础列全显默认序 + configSchema 字段列全显
|
||||
* 2. 覆盖生效:visible=false 隐藏、排序调整
|
||||
* 3. configSchema 增删字段自动跟随(新增字段追加、删除字段移除)
|
||||
* 4. updateStrategyColumns 整表覆盖 + getStrategyColumns 读回
|
||||
*/
|
||||
import {
|
||||
normalizeStrategyColumns, getStrategyColumns, updateStrategyColumns,
|
||||
} 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 scope */
|
||||
function makeScope(initial) {
|
||||
const store = structuredClone(initial ?? {});
|
||||
return {
|
||||
get: () => structuredClone(store),
|
||||
update: async (patch) => { Object.assign(store, structuredClone(patch)); },
|
||||
_store: store,
|
||||
};
|
||||
}
|
||||
|
||||
const gridSchema = [
|
||||
{ key: 'gridGap', label: '网格间距', type: 'text', def: '3%' },
|
||||
{ key: 'riskLevel', label: '风险等级', type: 'enum', enum: ['低','中','高'], def: '中' },
|
||||
];
|
||||
|
||||
// ---------- 1. 默认归一化 ----------
|
||||
section('1. 默认列配置(无覆盖)');
|
||||
const scope = makeScope({
|
||||
strategies: [
|
||||
{ id: 'grid-supermarket', name: '网格超市', configSchema: gridSchema },
|
||||
{ id: 'manual-t', name: '手动做T' },
|
||||
],
|
||||
});
|
||||
const gridCols = normalizeStrategyColumns(scope, 'grid-supermarket');
|
||||
const baseKeys = ['lastPrice','pctChange','lastClose','avgPrice','lastTradePrice','shares'];
|
||||
assert(gridCols.length === 8, '默认 8 列(6 基础 + 2 字段)');
|
||||
assert(baseKeys.every((k) => gridCols.some((c) => c.key === k)), '含全部基础列');
|
||||
assert(gridCols[6].key === 'gridGap' && gridCols[7].key === 'riskLevel', '字段列按 configSchema 序追加末尾');
|
||||
assert(gridCols.every((c) => c.visible !== false), '默认全部可见');
|
||||
assert(gridCols[0].key === 'lastPrice', '默认序:现价在前');
|
||||
|
||||
const manualCols = normalizeStrategyColumns(scope, 'manual-t');
|
||||
assert(manualCols.length === 6, '旧策略(无 configSchema)仅 6 基础列');
|
||||
|
||||
// ---------- 2. 覆盖生效 ----------
|
||||
section('2. 覆盖(隐藏 + 排序)');
|
||||
await updateStrategyColumns(scope, 'grid-supermarket', [
|
||||
{ key: 'riskLevel', visible: true },
|
||||
{ key: 'shares', visible: true },
|
||||
{ key: 'gridGap', visible: false }, // 隐藏 gridGap
|
||||
{ key: 'lastPrice', visible: false }, // 隐藏现价
|
||||
{ key: 'pctChange', visible: true },
|
||||
{ key: 'lastClose', visible: true },
|
||||
{ key: 'avgPrice', visible: true },
|
||||
{ key: 'lastTradePrice', visible: true },
|
||||
]);
|
||||
const after = getStrategyColumns(scope, 'grid-supermarket');
|
||||
assert(after.length === 8, '读回 8 列(含不可见,保位)');
|
||||
assert(after[0].key === 'riskLevel', '排序覆盖:riskLevel 提到最前');
|
||||
assert(after.find((c) => c.key === 'gridGap').visible === false, 'gridGap 已隐藏');
|
||||
assert(after.find((c) => c.key === 'lastPrice').visible === false, 'lastPrice 已隐藏');
|
||||
assert(after[1].key === 'shares', '排序覆盖:shares 第二');
|
||||
|
||||
// ---------- 3. configSchema 增删跟随 ----------
|
||||
section('3. configSchema 增删字段自动跟随');
|
||||
await updateStrategyColumns(scope, 'grid-supermarket', after); // 已存覆盖
|
||||
// 模拟新增字段 hasStop
|
||||
scope._store.strategies = scope._store.strategies.map((s) => s.id === 'grid-supermarket'
|
||||
? { ...s, configSchema: [...gridSchema, { key: 'hasStop', label: '设置止损', type: 'boolean', def: false }] } : s);
|
||||
const afterAdd = normalizeStrategyColumns(scope, 'grid-supermarket');
|
||||
assert(afterAdd.some((c) => c.key === 'hasStop'), '新增字段 hasStop 自动加入列配置');
|
||||
assert(afterAdd.find((c) => c.key === 'hasStop').visible !== false, '新字段默认可见');
|
||||
assert(afterAdd.find((c) => c.key === 'gridGap').visible === false, '原隐藏 gridGap 保留隐藏');
|
||||
|
||||
// 模拟删除字段 riskLevel
|
||||
scope._store.strategies = scope._store.strategies.map((s) => s.id === 'grid-supermarket'
|
||||
? { ...s, configSchema: gridSchema.filter((f) => f.key !== 'riskLevel') } : s);
|
||||
const afterDel = normalizeStrategyColumns(scope, 'grid-supermarket');
|
||||
assert(!afterDel.some((c) => c.key === 'riskLevel'), '删除字段 riskLevel 列自动移除');
|
||||
assert(afterDel.some((c) => c.key === 'gridGap'), '其余列保留');
|
||||
|
||||
console.log("");
|
||||
console.log('结果: ' + pass + ' 通过, ' + fail + ' 失败');
|
||||
process.exit(fail > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* R-013 回归测试:策略自定义字段(定义随策略 configSchema + 值落 strategy_holdings.values)
|
||||
* 覆盖(独立数据目录,技术约束-011):
|
||||
* 1. settings.configSchema:schema 归一化(旧策略无 configSchema → []);updateStrategies 保留定义
|
||||
* 2. SqliteStore:_ensureHoldingValuesColumn 幂等补列;openHolding 后 values=null;readValues/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 scope(get/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 === '高', '返回行附 values(601117: 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);
|
||||
Reference in New Issue
Block a user