feat(swap): 债券净价/全价/收益率互算核心(逐字段手动锁定+错误不覆盖纯函数+测试)
- swapCalc.applyBondCalcResult(state, calc, manualSet):回写时跳过"用户手填过的字段"(逐字段手动锁定,永不覆盖),只反向填充未手填字段;EPS 去抖避免光标跳动。 - 纯函数 getBondCalcErrorMessage(resp):空响应/errCode!=0 返回文案、否则 null,与 C# errCode 守卫一一对应,保证"坏结果"不静默回写。 - 新增 jest 用例覆盖:三字段互算、逐字段锁定(手填全部三个无一被回写)、业务错误不覆盖手工输入、getBondCalcErrorMessage。85→86 全绿。
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* bondCalc.test.js — 债券净价/全价/收益率三字段互算的"逐字段手动锁定"守卫
|
||||
* ============================================================================
|
||||
* 核心保证(用户最担心的,也是第一天讨论的诉求):
|
||||
* 用户手填过的字段 = 被锁定,回写时**绝不**覆盖它;
|
||||
* 只有"未手填"的字段才从源反算得出、被回写;计算器未返回的值也不覆盖。
|
||||
* 因此用户可逐个手填全部三个,互算绝不会冲掉其中任何一个。
|
||||
*
|
||||
* 运行:cd YLErpWeb/fe-tests && npm i && npm test
|
||||
* 纯逻辑在 swapCalc.js::applyBondCalcResult(UMD,node 可直接 require)。
|
||||
*/
|
||||
const SwapCalc = require('../wwwroot/Scripts/app/swaptrade/swapCalc.js');
|
||||
|
||||
const CALC = { cleanPrice: 99, dirtyPrice: 100, ytm: 2.5 };
|
||||
|
||||
describe('逐字段手动锁定:手填过的字段永不回写', () => {
|
||||
test('仅手填净价(CP):净价保持,全价/收益率被反算覆盖', () => {
|
||||
const state = { cleanPrice: 99.5, dirtyPrice: null, ytm: null }; // 用户手填净价 99.5
|
||||
SwapCalc.applyBondCalcResult(state, CALC, { CP: true, DP: false, YD: false });
|
||||
expect(state.cleanPrice).toBe(99.5); // 手填:绝不回写
|
||||
expect(state.dirtyPrice).toBe(100); // 未手填:被反算覆盖
|
||||
expect(state.ytm).toBe(2.5);
|
||||
});
|
||||
|
||||
test('手填净价+全价(CP&DP):两者保持,仅收益率被覆盖', () => {
|
||||
const state = { cleanPrice: 99.5, dirtyPrice: 100.5, ytm: null };
|
||||
SwapCalc.applyBondCalcResult(state, CALC, { CP: true, DP: true, YD: false });
|
||||
expect(state.cleanPrice).toBe(99.5); // 手填:保持
|
||||
expect(state.dirtyPrice).toBe(100.5); // 手填:保持
|
||||
expect(state.ytm).toBe(2.5); // 未手填:被反算覆盖
|
||||
});
|
||||
|
||||
test('用户不认可计算结果、手填全部三个:全部保留,无一被回写(核心场景)', () => {
|
||||
const state = { cleanPrice: 98, dirtyPrice: 101, ytm: 3.0 }; // 用户全手填
|
||||
SwapCalc.applyBondCalcResult(state, CALC, { CP: true, DP: true, YD: true });
|
||||
expect(state.cleanPrice).toBe(98); // 全部手填 → 全部保留
|
||||
expect(state.dirtyPrice).toBe(101);
|
||||
expect(state.ytm).toBe(3.0);
|
||||
});
|
||||
|
||||
test('手填收益率(YD):收益率保持,净价/全价被覆盖', () => {
|
||||
const state = { cleanPrice: null, dirtyPrice: null, ytm: 2.6 };
|
||||
SwapCalc.applyBondCalcResult(state, CALC, { CP: false, DP: false, YD: true });
|
||||
expect(state.ytm).toBe(2.6);
|
||||
expect(state.cleanPrice).toBe(99);
|
||||
expect(state.dirtyPrice).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界:计算器未返回的值不覆盖、无变化不写', () => {
|
||||
test('手填净价 且 计算器未返收益率:源与缺失值均不写', () => {
|
||||
const state = { cleanPrice: null, dirtyPrice: null, ytm: 2.6 };
|
||||
const partial = { cleanPrice: 99, dirtyPrice: 100, ytm: undefined };
|
||||
SwapCalc.applyBondCalcResult(state, partial, { CP: true, DP: false, YD: false });
|
||||
expect(state.cleanPrice).toBe(null); // CP 手填:即便 calc 返 99 也不写
|
||||
expect(state.dirtyPrice).toBe(100); // 未手填:被覆盖
|
||||
expect(state.ytm).toBe(2.6); // 计算器没返收益率 → 保持用户值
|
||||
});
|
||||
|
||||
test('派生值与当前值差异<EPS:不写(避免光标跳动)', () => {
|
||||
const state = { cleanPrice: 99.5, dirtyPrice: 100.0, ytm: 2.5 };
|
||||
const near = { cleanPrice: 99.5, dirtyPrice: 100.00001, ytm: 2.5 };
|
||||
SwapCalc.applyBondCalcResult(state, near, { CP: true, DP: false, YD: false });
|
||||
expect(state.cleanPrice).toBe(99.5); // 手填:不写
|
||||
expect(state.dirtyPrice).toBe(100.0); // 差异 1e-5 < EPS → 不写
|
||||
expect(state.ytm).toBe(2.5);
|
||||
});
|
||||
|
||||
// 模拟 calcBondForItem:以既有三字段为代理调纯函数,再写回 item
|
||||
test('浮动腿 item 写回:仅未手填字段被覆盖', () => {
|
||||
const item = { PosiNetNoFeePrice: 99.5, PosiGrossPrice: 100.0, InitYtm: 2.6,
|
||||
bondDriverType: 'CP', bondManual: { CP: true, DP: false, YD: false } };
|
||||
const calc = { cleanPrice: 99, dirtyPrice: 100, ytm: 2.5 };
|
||||
const proxy = {
|
||||
cleanPrice: item.PosiNetNoFeePrice, // 净价 = 手填
|
||||
dirtyPrice: item.PosiGrossPrice,
|
||||
ytm: item.InitYtm
|
||||
};
|
||||
SwapCalc.applyBondCalcResult(proxy, calc, item.bondManual);
|
||||
item.PosiNetNoFeePrice = proxy.cleanPrice;
|
||||
item.PosiGrossPrice = proxy.dirtyPrice;
|
||||
item.InitYtm = proxy.ytm;
|
||||
expect(item.PosiNetNoFeePrice).toBe(99.5); // 手填(净价):保持
|
||||
expect(item.PosiGrossPrice).toBe(100); // 全价:被反算覆盖
|
||||
expect(item.InitYtm).toBe(2.5); // 收益率:被反算覆盖
|
||||
});
|
||||
});
|
||||
|
||||
describe('错误反馈:getBondCalcErrorMessage(对齐 C# BondCalcHepler 的 errCode 守卫)', () => {
|
||||
test('空响应 → 提示"无响应",且绝不回写', () => {
|
||||
expect(SwapCalc.getBondCalcErrorMessage(null)).toBe("债券计算器无响应,已保留手工输入");
|
||||
});
|
||||
|
||||
test('业务错误码 errCode!=0(债券不存在/信息不全/参数非法)→ 返回真实 errMsg', () => {
|
||||
const resp = { errCode: 1, errMsg: "债券不存在或信息不全", dirtyPrice: 0, cleanPrice: 0, ytm: 0 };
|
||||
expect(SwapCalc.getBondCalcErrorMessage(resp)).toBe("债券不存在或信息不全");
|
||||
});
|
||||
|
||||
test('业务错误码 errCode!=0 但缺 errMsg → 返回兜底文案', () => {
|
||||
const resp = { errCode: 2, dirtyPrice: 0, cleanPrice: 0, ytm: 0 };
|
||||
expect(SwapCalc.getBondCalcErrorMessage(resp)).toBe("债券计算失败,请检查标的或参数");
|
||||
});
|
||||
|
||||
test('正常成功响应(errCode=0) → 返回 null(应继续回写)', () => {
|
||||
const resp = { errCode: 0, errMsg: null, dirtyPrice: 100, cleanPrice: 99, ytm: 2.5 };
|
||||
expect(SwapCalc.getBondCalcErrorMessage(resp)).toBeNull();
|
||||
});
|
||||
|
||||
test('无 errCode 字段的正常响应 → 返回 null(应继续回写)', () => {
|
||||
const resp = { dirtyPrice: 100, cleanPrice: 99, ytm: 2.5 };
|
||||
expect(SwapCalc.getBondCalcErrorMessage(resp)).toBeNull();
|
||||
});
|
||||
|
||||
test('端到端:业务错误时不覆盖手工输入(仅提示)', () => {
|
||||
const item = { PosiNetNoFeePrice: 99.5, PosiGrossPrice: 100.0, InitYtm: 2.6,
|
||||
bondDriverType: 'CP', bondManual: { CP: true, DP: false, YD: false } };
|
||||
const respObj = { errCode: 1, errMsg: "债券不存在", dirtyPrice: 0, cleanPrice: 0, ytm: 0 };
|
||||
const err = SwapCalc.getBondCalcErrorMessage(respObj);
|
||||
expect(err).toBe("债券不存在"); // 有错 → 调用方会 main.message(err) 并 return
|
||||
if (!err) {
|
||||
const proxy = { cleanPrice: item.PosiNetNoFeePrice, dirtyPrice: item.PosiGrossPrice, ytm: item.InitYtm };
|
||||
SwapCalc.applyBondCalcResult(proxy, respObj, item.bondManual);
|
||||
item.PosiNetNoFeePrice = proxy.cleanPrice;
|
||||
item.PosiGrossPrice = proxy.dirtyPrice;
|
||||
item.InitYtm = proxy.ytm;
|
||||
}
|
||||
expect(item.PosiNetNoFeePrice).toBe(99.5);
|
||||
expect(item.PosiGrossPrice).toBe(100.0);
|
||||
expect(item.InitYtm).toBe(2.6);
|
||||
});
|
||||
});
|
||||
@@ -200,7 +200,50 @@
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 债券净价/全价/收益率三字段互算:回写时跳过"用户手填过的字段"(逐字段手动锁定,永不覆盖)。
|
||||
* - manualSet: { CP:bool, DP:bool, YD:bool },标记哪些字段是用户本次会话中手动输入/修改过的
|
||||
* - calc: { cleanPrice, dirtyPrice, ytm } 来自 /Bond/CalcBond 的 resp.obj
|
||||
* - state: 持有三个字段的对象(直接原地写回)
|
||||
* 设计(回应"计算结果不认可时如何优雅手动覆盖"):
|
||||
* 用户每手填一个字段,该字段即被锁定;反算只填充"未手填"的字段,已手填的(含刚编辑的)一律不回写。
|
||||
* 因此用户可逐个手填全部三个,互算绝不会冲掉其中任何一个。
|
||||
* 纯函数,jest 可直接测(见 fe-tests/bondCalc.test.js)。
|
||||
*/
|
||||
function applyBondCalcResult(state, calc, manualSet) {
|
||||
var EPS = 1e-4;
|
||||
function write(field, type, value) {
|
||||
if (manualSet && manualSet[type]) return; // 用户手填过的字段:绝不回写
|
||||
if (value === undefined || value === null) return; // 计算器未返回该值则不覆盖
|
||||
var cur = state[field];
|
||||
if (typeof cur === 'number' && Math.abs(cur - value) < EPS) return; // 无变化不写,避免光标跳动
|
||||
state[field] = value;
|
||||
}
|
||||
write('cleanPrice', 'CP', calc && calc.cleanPrice);
|
||||
write('dirtyPrice', 'DP', calc && calc.dirtyPrice);
|
||||
write('ytm', 'YD', calc && calc.ytm);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 /Bond/CalcBond 响应(resp.obj,即 CalBondResult)中提取应展示给用户的错误文案;
|
||||
* 无错误返回 null(调用方据此决定是否回写、是否提示)。
|
||||
* 覆盖:
|
||||
* - 空响应(计算器无响应)
|
||||
* - 业务错误码 errCode!=0(债券不存在 / 债券信息不全 / 参数非法)
|
||||
* 与 C# BondCalcHepler 的 errCode 守卫一一对应,确保"坏结果"不会静默回写覆盖手工输入。
|
||||
* 纯函数,jest 可直接测(见 fe-tests/bondCalc.test.js)。
|
||||
*/
|
||||
function getBondCalcErrorMessage(resp) {
|
||||
if (!resp) return "债券计算器无响应,已保留手工输入";
|
||||
if (resp.errCode && resp.errCode !== 0) {
|
||||
return resp.errMsg || "债券计算失败,请检查标的或参数";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
applyBondCalcResult: applyBondCalcResult,
|
||||
getBondCalcErrorMessage: getBondCalcErrorMessage,
|
||||
roundHalfAwayFromZero: roundHalfAwayFromZero,
|
||||
getPriceScale: getPriceScale,
|
||||
deriveTradingAmountAvg: deriveTradingAmountAvg,
|
||||
|
||||
Reference in New Issue
Block a user