/** * 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); }); test('仅手填全价(DP):全价保持,净价/收益率被反算覆盖(最常用场景)', () => { const state = { cleanPrice: null, dirtyPrice: 100.5, ytm: null }; // 用户手填全价 100.5 SwapCalc.applyBondCalcResult(state, CALC, { CP: false, DP: true, YD: false }); expect(state.dirtyPrice).toBe(100.5); // 手填:绝不回写 expect(state.cleanPrice).toBe(99); // 未手填:被反算覆盖 expect(state.ytm).toBe(2.5); }); test('手填净价+收益率(CP&YD):两者保持,仅全价被覆盖', () => { const state = { cleanPrice: 98.5, dirtyPrice: null, ytm: 3.2 }; SwapCalc.applyBondCalcResult(state, CALC, { CP: true, DP: false, YD: true }); expect(state.cleanPrice).toBe(98.5); // 手填:保持 expect(state.ytm).toBe(3.2); // 手填:保持 expect(state.dirtyPrice).toBe(100); // 未手填:被反算覆盖 }); test('手填全价+收益率(DP&YD):两者保持,仅净价被覆盖', () => { const state = { cleanPrice: null, dirtyPrice: 101, ytm: 2.8 }; SwapCalc.applyBondCalcResult(state, CALC, { CP: false, DP: true, YD: true }); expect(state.dirtyPrice).toBe(101); // 手填:保持 expect(state.ytm).toBe(2.8); // 手填:保持 expect(state.cleanPrice).toBe(99); // 未手填:被反算覆盖 }); }); 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('派生值与当前值差异 { 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:proxy 统一用【展示态】,模型字段是【存储态】,回写时 bondCalcPriceToStorage。 test('浮动腿 item 写回:仅未手填字段被覆盖,且存储态转换正确', () => { // 用户输入净价 99.5 → vue-number-input(percent:true) 存为存储态 0.995 const item = { PosiNetNoFeePrice: 0.995, PosiGrossPrice: 1.0, InitYtm: 0.026, bondDriverType: 'CP', bondManual: { CP: true, DP: false, YD: false } }; const calc = { cleanPrice: 99, dirtyPrice: 100, ytm: 2.5 }; // 计算器返回展示态 const proxy = { cleanPrice: SwapCalc.bondPriceToCalc(item.PosiNetNoFeePrice), // 0.995 → 99.5(手填) dirtyPrice: SwapCalc.bondPriceToCalc(item.PosiGrossPrice), // 1.0 → 100 ytm: SwapCalc.bondPriceToCalc(item.InitYtm) // 0.026 → 2.6 }; SwapCalc.applyBondCalcResult(proxy, calc, item.bondManual); item.PosiNetNoFeePrice = SwapCalc.bondCalcPriceToStorage(proxy.cleanPrice); item.PosiGrossPrice = SwapCalc.bondCalcPriceToStorage(proxy.dirtyPrice); item.InitYtm = SwapCalc.bondCalcPriceToStorage(proxy.ytm); expect(item.PosiNetNoFeePrice).toBe(0.995); // 手填(净价):存储态保持 0.995(界面仍显示 99.5) expect(item.PosiGrossPrice).toBe(1.0); // 全价:被反算覆盖,存储态 100/100=1.0 expect(item.InitYtm).toBe(0.025); // 收益率:被反算覆盖,存储态 2.5/100=0.025 }); // 核心回归:用户输入净价 100(存储态 1.0)时,绝不能因 proxy 残留存储态而再 ÷100 变成 0.01 test('核心回归:输入净价 100 不会被二次缩小为 1(存储态 0.01)', () => { const item = { PosiNetNoFeePrice: 1.0, PosiGrossPrice: null, InitYtm: null, bondDriverType: 'CP', bondManual: { CP: true, DP: false, YD: false } }; const calc = { cleanPrice: 100, dirtyPrice: 102.17928767123287, ytm: 4.860445236 }; const proxy = { cleanPrice: SwapCalc.bondPriceToCalc(item.PosiNetNoFeePrice), // 1.0 → 100 dirtyPrice: SwapCalc.bondPriceToCalc(item.PosiGrossPrice), ytm: SwapCalc.bondPriceToCalc(item.InitYtm) }; SwapCalc.applyBondCalcResult(proxy, calc, item.bondManual); item.PosiNetNoFeePrice = SwapCalc.bondCalcPriceToStorage(proxy.cleanPrice); item.PosiGrossPrice = SwapCalc.bondCalcPriceToStorage(proxy.dirtyPrice); item.InitYtm = SwapCalc.bondCalcPriceToStorage(proxy.ytm); expect(item.PosiNetNoFeePrice).toBe(1.0); // 存储态仍是 1.0(界面显示 100) expect(item.PosiGrossPrice).toBeCloseTo(1.0217928767123287, 9); // 102.179.../100 expect(item.InitYtm).toBeCloseTo(0.04860445236, 9); // 4.860.../100 }); // 多轮不同净价:95 / 105,确保存储态始终与界面输入一致,不会被二次缩小 test.each([ [95, 97.5, 5.123456], [105, 107.3, 4.567890], [100, 102.17928767123287, 4.860445236] ])('多轮净价校验:输入净价 %s 时,存储态保持 %s/100 且不会被二次缩小', (inputClean, calcDirty, calcYtm) => { const item = { PosiNetNoFeePrice: inputClean / 100, PosiGrossPrice: null, InitYtm: null, bondDriverType: 'CP', bondManual: { CP: true, DP: false, YD: false } }; const calc = { cleanPrice: inputClean, dirtyPrice: calcDirty, ytm: calcYtm }; const proxy = { cleanPrice: SwapCalc.bondPriceToCalc(item.PosiNetNoFeePrice), dirtyPrice: SwapCalc.bondPriceToCalc(item.PosiGrossPrice), ytm: SwapCalc.bondPriceToCalc(item.InitYtm) }; SwapCalc.applyBondCalcResult(proxy, calc, item.bondManual); item.PosiNetNoFeePrice = SwapCalc.bondCalcPriceToStorage(proxy.cleanPrice); item.PosiGrossPrice = SwapCalc.bondCalcPriceToStorage(proxy.dirtyPrice); item.InitYtm = SwapCalc.bondCalcPriceToStorage(proxy.ytm); expect(item.PosiNetNoFeePrice).toBe(inputClean / 100); expect(item.PosiGrossPrice).toBeCloseTo(calcDirty / 100, 9); expect(item.InitYtm).toBeCloseTo(calcYtm / 100, 9); }); }); 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('防御值域:成功(errCode=0)但净价为负 → 拦截提示、不回写(用户实测 180205.IB 场景)', () => { const resp = { errCode: 0, dirtyPrice: 100, cleanPrice: -117.93, ytm: 6.37 }; const err = SwapCalc.getBondCalcErrorMessage(resp); expect(err).not.toBeNull(); expect(err).toContain("净价"); }); test('防御值域:成功但收益率量级爆炸(378543) → 拦截提示、不回写', () => { const resp = { errCode: 0, dirtyPrice: 100, cleanPrice: 97.82, ytm: 378543.526601942 }; const err = SwapCalc.getBondCalcErrorMessage(resp); expect(err).not.toBeNull(); expect(err).toContain("收益率"); }); test('防御值域:净价超过 1000(量纲错误)→ 拦截', () => { const resp = { errCode: 0, dirtyPrice: 100, cleanPrice: 9782, ytm: 6.37 }; expect(SwapCalc.getBondCalcErrorMessage(resp)).not.toBeNull(); }); test('防御值域:命中哨兵值 -999999 → 拦截', () => { const resp = { errCode: 0, dirtyPrice: 100, cleanPrice: 99, ytm: -999999 }; expect(SwapCalc.getBondCalcErrorMessage(resp)).not.toBeNull(); }); test('防御值域:合理范围内的正常值(净价97.82/收益率6.37) → 放行返回 null', () => { const resp = { errCode: 0, dirtyPrice: 100, cleanPrice: 97.82, ytm: 6.37 }; expect(SwapCalc.getBondCalcErrorMessage(resp)).toBeNull(); }); test('防御值域:负收益率但量级合理(-2.5%) → 放行(允许负利率债券)', () => { const resp = { errCode: 0, 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); }); }); describe('单位换算边界(前端↔债券计算器 存储态小数 ↔ 展示态百分比)', () => { test('bondPriceToCalc:存储态 0.995 → 发送计算器的展示态 99.5', () => { expect(SwapCalc.bondPriceToCalc(0.995)).toBeCloseTo(99.5, 6); expect(SwapCalc.bondPriceToCalc(1.0)).toBe(100); // 用户敲全价100 → percent:true 收成 1.0 → 发 100 }); test('bondCalcPriceToStorage:展示态 97.82 → 落库存储态 0.9782', () => { expect(SwapCalc.bondCalcPriceToStorage(97.82)).toBeCloseTo(0.9782, 6); expect(SwapCalc.bondCalcPriceToStorage(6.37)).toBeCloseTo(0.0637, 6); }); // 复刻用户实测的 -117.93 / 378543 离谱值根因,验证修复后不再出现: // 旧:模型 1.0(用户敲100) 漏×100 直接发 1.0 → 计算器当 1% of par → 返回 cleanPrice=-1.1793, ytm=37.8543 // → 漏÷100 原样写回 -1.1793 → percent:true 显示 ×100 → -117.93 / 378543 // 新:发前×100、回写÷100 → 模型 0.9782 / 1.0 / 0.0637 → 显示 97.82 / 100 / 6.37 test('端到端:用户敲全价100 → 修复后模型与显示均为合理值(不再 -117.93/378543)', () => { const modelGross = 1.0; // percent:true 把界面 100 收成 1.0(存储态) const sentToCalc = SwapCalc.bondPriceToCalc(modelGross); expect(sentToCalc).toBe(100); // 必须 ×100,不是 1.0 const calcResp = { errCode: 0, cleanPrice: 97.82, dirtyPrice: 100, ytm: 6.37 }; const modelNet = SwapCalc.bondCalcPriceToStorage(calcResp.cleanPrice); const modelGrossBack = SwapCalc.bondCalcPriceToStorage(calcResp.dirtyPrice); const modelYtm = SwapCalc.bondCalcPriceToStorage(calcResp.ytm); expect(modelNet).toBeCloseTo(0.9782, 6); expect(modelGrossBack).toBe(1.0); expect(modelYtm).toBeCloseTo(0.0637, 6); // percent:true 显示时再 ×100:97.82 / 100 / 6.37,与计算器一致,无离谱值 expect(modelNet * 100).toBeCloseTo(97.82, 4); expect(modelYtm * 100).toBeCloseTo(6.37, 4); }); });