Files
zszq-trs/YLErpWeb/fe-tests/bondCalc.test.js
T

349 lines
20 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 — 债券净价/全价/收益率三字段互算(driver 源 + 交互约定2/3 状态机)
* ============================================================================
* 模型(交互约定1/2/3):
* - driver(bondDriverType):回车的"源"字段(CP净价 / DP全价 / YD收益率),恒为最后回车者。
* - bondAuto:计算器算出的另两个字段("AUTO")。
* - bondRev:编辑未回车的人工输入字段("REV")。
* - applyBondCalcResult(state, calc, driver):回写时跳过 driver 源字段(保留用户手输值),
* 另两字段用计算器结果覆盖——即"回车某字段即以它为源反算另两个"。
*
* 运行:cd YLErpWeb/fe-tests && npm i && npm test
* 纯逻辑在 swapCalc.jsUMDnode 可直接 require)。
*/
const SwapCalc = require('../wwwroot/Scripts/app/swaptrade/swapCalc.js');
const CALC = { cleanPrice: 99, dirtyPrice: 100, ytm: 2.5 };
describe('以 driver 为源:回写时源字段保持、另两字段被反算覆盖', () => {
test('回车净价(CP)为源:净价保持,全价/收益率被反算覆盖', () => {
const state = { cleanPrice: 99.5, dirtyPrice: null, ytm: null };
SwapCalc.applyBondCalcResult(state, CALC, 'CP');
expect(state.cleanPrice).toBe(99.5); // 源:保持
expect(state.dirtyPrice).toBe(100); // 非源:被反算覆盖
expect(state.ytm).toBe(2.5);
});
test('回车全价(DP)为源:全价保持,净价/收益率被反算覆盖', () => {
const state = { cleanPrice: null, dirtyPrice: 100.5, ytm: null };
SwapCalc.applyBondCalcResult(state, CALC, 'DP');
expect(state.dirtyPrice).toBe(100.5); // 源:保持
expect(state.cleanPrice).toBe(99);
expect(state.ytm).toBe(2.5);
});
test('回车收益率(YD)为源:收益率保持,净价/全价被反算覆盖', () => {
const state = { cleanPrice: null, dirtyPrice: null, ytm: 2.6 };
SwapCalc.applyBondCalcResult(state, CALC, 'YD');
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');
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');
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' };
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, 'CP');
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' };
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, 'CP');
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' };
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, 'CP');
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('疑似到期债券值域闸门:ytm=0 且 净/全价均为面值100UAT 060203.IB 实证的退化形态)', () => {
test('三条件同时成立 → 命中闸门返回真实原因(不回写)', () => {
const resp = { cleanPrice: 100, dirtyPrice: 100, ytm: 0 };
const msg = SwapCalc.getBondCalcErrorMessage(resp);
expect(msg).toContain('疑似已到期');
expect(msg).toContain('手工填写');
});
test('正常券不命中 → 返回 null 放行回写', () => {
expect(SwapCalc.getBondCalcErrorMessage({ cleanPrice: 97.43, dirtyPrice: 100.00001369863016, ytm: 6.738278242318886 })).toBeNull();
});
test('ytm=0 但价格非面值(真实零息平价券)→ 不命中', () => {
expect(SwapCalc.getBondCalcErrorMessage({ cleanPrice: 99.5, dirtyPrice: 100, ytm: 0 })).toBeNull();
});
test('价格=面值 但 ytm 非 0(正常息票平价券)→ 不命中', () => {
expect(SwapCalc.getBondCalcErrorMessage({ cleanPrice: 100, dirtyPrice: 100.5, ytm: 3.2 })).toBeNull();
});
});
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('端到端:业务错误时保留源字段、清空另两字段与标识(交互约定2 失败)', () => {
const item = { PosiNetNoFeePrice: 99.5, PosiGrossPrice: 100.0, InitYtm: 2.6, bondDriverType: 'CP' };
const respObj = { errCode: 1, errMsg: "债券不存在", dirtyPrice: 0, cleanPrice: 0, ytm: 0 };
const err = SwapCalc.getBondCalcErrorMessage(respObj);
expect(err).toBe("债券不存在"); // 有错 → 调用方会 main.message(err) 并调 applyBondCalcFailure
if (err) SwapCalc.applyBondCalcFailure(item, 'CP');
expect(item.PosiNetNoFeePrice).toBe(99.5); // 源:保留用户输入
expect(item.PosiGrossPrice).toBeNull(); // 非源:清空
expect(item.InitYtm).toBeNull();
expect(item.bondDriverType).toBeNull(); // 三字段标识全清
expect(item.bondAuto).toEqual({ CP: false, DP: false, YD: false });
expect(item.bondRev).toEqual({ CP: false, DP: false, YD: false });
});
});
// ============================================================================
// 交互约定1/2/3 状态机:applyBondCalcSuccess / applyBondCalcFailure / applyBondManualEdit
// ============================================================================
describe('交互约定2/3 状态机:applyBondCalcSuccess / Failure / ManualEdit', () => {
test('applyBondCalcSuccess(CP):源=CPAUTO={DP,YD}REV 清空', () => {
const s = { bondDriverType: null, bondAuto: { CP: false, DP: false, YD: false }, bondRev: { CP: true, DP: false, YD: false } };
SwapCalc.applyBondCalcSuccess(s, 'CP');
expect(s.bondDriverType).toBe('CP');
expect(s.bondAuto).toEqual({ CP: false, DP: true, YD: true });
expect(s.bondRev).toEqual({ CP: false, DP: false, YD: false });
});
test('applyBondCalcSuccess(DP):源=DPAUTO={CP,YD}', () => {
const s = {};
SwapCalc.applyBondCalcSuccess(s, 'DP');
expect(s.bondDriverType).toBe('DP');
expect(s.bondAuto).toEqual({ CP: true, DP: false, YD: true });
});
test('applyBondCalcFailure(CP):源值保留,另两字段清空,标识全清', () => {
const s = { PosiNetNoFeePrice: 99.5, PosiGrossPrice: 100, InitYtm: 2.6,
bondDriverType: 'CP', bondAuto: { CP: false, DP: true, YD: true }, bondRev: { CP: false, DP: false, YD: false } };
SwapCalc.applyBondCalcFailure(s, 'CP');
expect(s.PosiNetNoFeePrice).toBe(99.5); // 源:保留
expect(s.PosiGrossPrice).toBeNull(); // 非源:清空
expect(s.InitYtm).toBeNull();
expect(s.bondDriverType).toBeNull();
expect(s.bondAuto).toEqual({ CP: false, DP: false, YD: false });
expect(s.bondRev).toEqual({ CP: false, DP: false, YD: false });
});
test('applyBondManualEdit(DP):清 源/AUTO,仅 DP 标 REV(交互约定3', () => {
const s = { bondDriverType: 'CP', bondAuto: { CP: false, DP: true, YD: true }, bondRev: { CP: false, DP: false, YD: false } };
SwapCalc.applyBondManualEdit(s, 'DP');
expect(s.bondDriverType).toBeNull();
expect(s.bondAuto).toEqual({ CP: false, DP: false, YD: false });
expect(s.bondRev).toEqual({ CP: false, DP: true, YD: false }); // 仅本字段 REV
});
test('全价回车后净价再回车:净价成新源、全价降级为 AUTO(最后回车者恒为源)', () => {
const s = { PosiNetNoFeePrice: null, PosiGrossPrice: null, InitYtm: null,
bondDriverType: null, bondAuto: { CP: false, DP: false, YD: false }, bondRev: { CP: false, DP: false, YD: false } };
SwapCalc.applyBondCalcSuccess(s, 'DP'); // 全价回车
expect(s.bondDriverType).toBe('DP');
expect(s.bondAuto).toEqual({ CP: true, DP: false, YD: true });
SwapCalc.applyBondCalcSuccess(s, 'CP'); // 净价再回车
expect(s.bondDriverType).toBe('CP');
expect(s.bondAuto).toEqual({ CP: false, DP: true, YD: true }); // 全价从源降级为 AUTO
});
});
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);
});
});
describe('isBondPriceValueKey:只有真正改值的按键才标REV(防止"没改值却REV"', () => {
const K = (keyCode, extra) => Object.assign({ which: keyCode, keyCode: keyCode }, extra || {});
test('数字键(主键盘/小键盘) → 改值', () => {
expect(SwapCalc.isBondPriceValueKey(K(49))).toBe(true); // 主键盘 1
expect(SwapCalc.isBondPriceValueKey(K(97))).toBe(true); // 小键盘 1
});
test('小数点/减号(主键盘/小键盘) → 改值', () => {
expect(SwapCalc.isBondPriceValueKey(K(190))).toBe(true); // 主键盘 .
expect(SwapCalc.isBondPriceValueKey(K(110))).toBe(true); // 小键盘 .
expect(SwapCalc.isBondPriceValueKey(K(189))).toBe(true); // 主键盘 -
expect(SwapCalc.isBondPriceValueKey(K(109))).toBe(true); // 小键盘 -
});
test('Backspace / Delete → 改值', () => {
expect(SwapCalc.isBondPriceValueKey(K(8))).toBe(true);
expect(SwapCalc.isBondPriceValueKey(K(46))).toBe(true);
});
test('回车 → 不改标识(由 enter 事件处理)', () => {
expect(SwapCalc.isBondPriceValueKey(K(13))).toBe(false);
expect(SwapCalc.isBondPriceValueKey(K(108))).toBe(false);
});
test('方向键 ←↑→↓ → 不改标识(修复:此前漏掉 ↑↓ 会误标REV)', () => {
expect(SwapCalc.isBondPriceValueKey(K(37))).toBe(false);
expect(SwapCalc.isBondPriceValueKey(K(38))).toBe(false);
expect(SwapCalc.isBondPriceValueKey(K(39))).toBe(false);
expect(SwapCalc.isBondPriceValueKey(K(40))).toBe(false);
});
test('Home/End/Tab/Insert/PageUp/PageDown → 不改标识(修复:此前漏掉 Insert/PageUp/PageDown)', () => {
expect(SwapCalc.isBondPriceValueKey(K(35))).toBe(false);
expect(SwapCalc.isBondPriceValueKey(K(36))).toBe(false);
expect(SwapCalc.isBondPriceValueKey(K(9))).toBe(false);
expect(SwapCalc.isBondPriceValueKey(K(45))).toBe(false);
expect(SwapCalc.isBondPriceValueKey(K(33))).toBe(false);
expect(SwapCalc.isBondPriceValueKey(K(34))).toBe(false);
});
test('F1-F12 → 不改标识(修复:此前漏掉除 F5 外的功能键)', () => {
[112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123].forEach(function (kc) {
expect(SwapCalc.isBondPriceValueKey(K(kc))).toBe(false);
});
});
test('纯 Shift / Ctrl / Alt / Meta 组合键 → 不改标识', () => {
expect(SwapCalc.isBondPriceValueKey(K(16))).toBe(false); // 纯 Shift
expect(SwapCalc.isBondPriceValueKey(K(65, { ctrlKey: true }))).toBe(false); // Ctrl+A
expect(SwapCalc.isBondPriceValueKey(K(86, { ctrlKey: true }))).toBe(false); // Ctrl+V
expect(SwapCalc.isBondPriceValueKey(K(67, { altKey: true }))).toBe(false);
expect(SwapCalc.isBondPriceValueKey(K(67, { metaKey: true }))).toBe(false);
});
test('无事件对象 → 安全返回 false', () => {
expect(SwapCalc.isBondPriceValueKey(null)).toBe(false);
expect(SwapCalc.isBondPriceValueKey(undefined)).toBe(false);
});
});