Files
zszq-trs/YLErpWeb/fe-tests/bondCalc.test.js
T
hjhan ead2c01f0d fix: 债券三字段 keydown 守卫改用正向白名单,堵住"没改值却标REV"的缺口
根因:onBondPriceKeydown 的按键排除列表只排除回车/Ctrl-Alt-Meta/Tab/Home/End/←→/F5,
漏掉 ↑(38)/↓(40)/Insert(45)/PageUp(33)/PageDown(34)/F1-F4,F6-F12/纯Shift。
这些键不改变数值却会触发 applyBondManualEdit 标 REV——正是用户观察到的
"鼠标放输入框没改值、只按了方向键/功能键却有时出现REV"。

修复:
- swapCalc.js 新增纯函数 isBondPriceValueKey(event):正向白名单(数字/小数点/减号/Backspace/Delete)
  才返回 true,其余(回车/修饰键/导航键/功能键/纯Shift)返回 false,jest 可测。
- swapTradeEdit.js onBondPriceKeydown 改用该函数判定,并移除两行每次按键的 console.log 噪音。
- 纯鼠标聚焦(无按键)本就不会标REV(无 focus/click 处理;vue-number-input 的 input 仅在值变化(失焦/回车)触发),
  本次仅修正按键守卫缺口。
- 同步补 9 个单测覆盖各类按键(响应"最好单测阶段解决")。

约定1/约定2/约定3 累加REV 行为不受任何影响(仅改判定是否标REV的触发条件)。
测试:bondCalc 全套 81 例通过。
2026-07-30 10:06:42 +08:00

331 lines
19 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('错误反馈: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);
});
});