Files
zszq-trs/YLErpWeb/fe-tests/bondCalc.test.js
T
hjhan 8694e6e3f4 债券互换三字段互算:估值日(开始日)缺失报错 + 补提漏交回归用例
- 估值日严格取互换起始日 StartDate,去掉"悄悄回退交易日(TradeDate)":
  真的为空时返回错误提示("请先填写开始日…或手动填写三项数值")并 return,
  不调用计算器、不覆盖手工输入,与需求(按开始日估值)完全一致。
- 抽纯函数 getBondStartDateMissingMsg(StartDate 空→文案/null) + 导出;
  复用 D3 的 shouldShowBondErr 去重,避免空开始日时价格格逐键连刷。
- BondCalcHepler.cs 仅补 XML 注释说明前端已强制 StartDate 必填;
  targetDate 空→代理默认 T+1 行为不变(兜底 RealtimePnlCalc 旧链路)。
- 补提此前漏提交的回归用例:bondCalc.test.js(×100/÷100 端到端 + 逐字段手动锁定)、
  swapCalc.test.js(D1/D2/D3 + 本次估值日缺失守卫)。
- jest bondCalc+swapCalc 全绿(68 passed)。
2026-07-23 16:49:40 +08:00

265 lines
16 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.
/**
* bondCalc.test.js — 债券净价/全价/收益率三字段互算的"逐字段手动锁定"守卫
* ============================================================================
* 核心保证(用户最担心的,也是第一天讨论的诉求):
* 用户手填过的字段 = 被锁定,回写时**绝不**覆盖它;
* 只有"未手填"的字段才从源反算得出、被回写;计算器未返回的值也不覆盖。
* 因此用户可逐个手填全部三个,互算绝不会冲掉其中任何一个。
*
* 运行:cd YLErpWeb/fe-tests && npm i && npm test
* 纯逻辑在 swapCalc.js::applyBondCalcResultUMDnode 可直接 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('派生值与当前值差异<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);
});
// 模拟 calcBondForItemproxy 统一用【展示态】,模型字段是【存储态】,回写时 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 显示时再 ×10097.82 / 100 / 6.37,与计算器一致,无离谱值
expect(modelNet * 100).toBeCloseTo(97.82, 4);
expect(modelYtm * 100).toBeCloseTo(6.37, 4);
});
});