- 估值日严格取互换起始日 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)。
431 lines
24 KiB
JavaScript
431 lines
24 KiB
JavaScript
/**
|
||
* swapCalc.test.js — 前端计算逻辑单元测试
|
||
* ============================================================================
|
||
* 双重目的:
|
||
* 1) 回归守卫:锁定 4 个曾出 bug 的纯函数(dcf649f2 / 20ea93d8 / 3c5f25a5 / f873239a)
|
||
* 2) 交叉校验:用 8 个场景(FC_001~FC_008)对齐 C# FrontendCalcCharacterizationTest 金标准,
|
||
* 一旦 JS 公式与后端 FrontendCalcReference 分叉,测试即红。
|
||
*
|
||
* 运行:cd YLErpWeb/fe-tests && npm i && npm test
|
||
*/
|
||
const SwapCalc = require('../wwwroot/Scripts/app/swaptrade/swapCalc.js');
|
||
|
||
const TOL = 1e-6;
|
||
function expectClose(actual, expected, msg) {
|
||
expect(Math.abs(actual - expected)).toBeLessThanOrEqual(TOL, msg || '');
|
||
}
|
||
|
||
describe('回归守卫:曾出 bug 的纯函数', () => {
|
||
// 20ea93d8 / dcf649f2:必须用全价(PosiGrossPrice) 且债券 ×100
|
||
test('deriveTradingAmountAvg 债券用全价并 ×100 (守卫 20ea93d8/dcf649f2)', () => {
|
||
expectClose(SwapCalc.deriveTradingAmountAvg(1.02, 100), 102, '债券: 1.02×100=102(界面百分比态)');
|
||
expectClose(SwapCalc.deriveTradingAmountAvg(1.02, 1), 1.02, '非债券: 不缩放');
|
||
// 若误用净价(PosiNetPrice) 会偏离,这里锁定"全价"语义
|
||
expect(SwapCalc.deriveTradingAmountAvg(1.02, 100)).toBe(102);
|
||
});
|
||
|
||
test('收益结算审批回显提交的期末价格', () => {
|
||
expectClose(
|
||
SwapCalc.resolveIncomeTradingAmountAvg(0.98, 0.80, 100, true),
|
||
80,
|
||
'债券审批应把已提交相对价0.80还原为界面值80');
|
||
expectClose(
|
||
SwapCalc.resolveIncomeTradingAmountAvg(0.98, 0.80, 100, false),
|
||
98,
|
||
'普通打开仍以期初全价98作为默认期末价');
|
||
});
|
||
|
||
// 3c5f25a5:FloatPnlSum 必须保留 2 位小数
|
||
test('calcFloatPnlSum 保留 2 位 (守卫 3c5f25a5)', () => {
|
||
expectClose(SwapCalc.calcFloatPnlSum(100.456, 1, 0.5, 0), 101.96, '100.456+1+0.5=101.956→101.96');
|
||
expectClose(SwapCalc.calcFloatPnlSum(30, 20, 0, 0), 50, '30+20=50');
|
||
expect(SwapCalc.calcFloatPnlSum(30, 20, 0, 0)).toBe(50);
|
||
});
|
||
|
||
// f873239a:名义本金 round 到 2 位
|
||
test('calcStockEqvNotional round 2 位 (守卫 f873239a)', () => {
|
||
expectClose(SwapCalc.calcStockEqvNotional(1.02, 100 * 100), 10200, '1.02×10000=10200');
|
||
expectClose(SwapCalc.calcStockEqvNotional(10.005, 100), 1000.5, '10.005×100=1000.50');
|
||
});
|
||
|
||
test('getPriceScale 债券=0.01 非债券=1', () => {
|
||
expect(SwapCalc.getPriceScale(100)).toBe(0.01);
|
||
expect(SwapCalc.getPriceScale(1)).toBe(1);
|
||
});
|
||
});
|
||
|
||
// ============================================================================
|
||
// 多次部分平仓:平仓比例 ↔ 平仓名义本金 换算必须以"剩余持仓名义本金"为基准
|
||
// 守卫 unwindSwapTrade.js changeCloseQty/changeClosePercent/changeCloseNotionalValue
|
||
// 旧 bug:用原始 NotionalValue 换算,首次部分平仓后 PosiNotionalValue 被扣减,
|
||
// 导致 ClosePercent 偏小 / CloseNotionalValue 偏大,后端预付金返还本金计算错误
|
||
// ============================================================================
|
||
describe('多次部分平仓:比例↔名义本金换算以 PosiNotionalValue 为基准', () => {
|
||
// 场景:原始名义本金 1,000,000,首次平 30% 后剩余 700,000
|
||
// 第 2 次按名义本金输入 350,000(意图平剩余 50%)
|
||
const NotionalValue = 1_000_000; // 原始(不变)
|
||
const PosiNotionalValue = 700_000; // 首次部分平仓后剩余
|
||
|
||
test('按名义本金反算比例:应=0.5(用剩余),旧 bug 会算成 0.35(用原始)', () => {
|
||
const closeNotionalValue = 350_000;
|
||
const correctPct = SwapCalc.calcClosePercentByRemaining(closeNotionalValue, PosiNotionalValue);
|
||
const buggyPct = SwapCalc.calcClosePercentByRemaining(closeNotionalValue, NotionalValue);
|
||
expectClose(correctPct, 0.5, '剩余本金为分母应得 50%');
|
||
expectClose(buggyPct, 0.35, '旧 bug 用原始本金会算成 0.35');
|
||
expect(Math.abs(correctPct - buggyPct)).toBeGreaterThan(TOL);
|
||
});
|
||
|
||
test('按比例正算名义本金:应=350,000(用剩余),旧 bug 会算成 500,000(用原始)', () => {
|
||
const closePercent = 0.5;
|
||
const correctNotional = SwapCalc.calcCloseNotionalByRemaining(closePercent, PosiNotionalValue);
|
||
const buggyNotional = SwapCalc.calcCloseNotionalByRemaining(closePercent, NotionalValue);
|
||
expectClose(correctNotional, 350_000, '剩余本金×50%应=350,000');
|
||
expectClose(buggyNotional, 500_000, '旧 bug 用原始本金会算成 500,000');
|
||
expect(Math.abs(correctNotional - buggyNotional)).toBeGreaterThan(TOL);
|
||
});
|
||
|
||
test('第 3 次全平剩余 350,000:比例应=1.0(用剩余),旧 bug 会算成 0.35', () => {
|
||
const remaining = 350_000;
|
||
const closeNotionalValue = 350_000;
|
||
const correctPct = SwapCalc.calcClosePercentByRemaining(closeNotionalValue, remaining);
|
||
const buggyPct = SwapCalc.calcClosePercentByRemaining(closeNotionalValue, NotionalValue);
|
||
expectClose(correctPct, 1.0, '剩余全平应=100%');
|
||
expectClose(buggyPct, 0.35, '旧 bug 用原始本金会算成 0.35');
|
||
});
|
||
|
||
test('多次部分平仓合计本金应=原始名义本金(3 次:30% / 50% / 全平)', () => {
|
||
// 模拟 3 次部分平仓的换算,每次以"当前剩余"为基准
|
||
let remaining = 1_000_000;
|
||
const steps = [0.3, 0.5, 1.0];
|
||
let totalClosed = 0;
|
||
steps.forEach((pct) => {
|
||
const closed = SwapCalc.calcCloseNotionalByRemaining(pct, remaining);
|
||
totalClosed += closed;
|
||
remaining -= closed;
|
||
});
|
||
expectClose(totalClosed, 1_000_000, '3 次部分平仓合计应=原始名义本金');
|
||
expectClose(remaining, 0, '剩余应为 0');
|
||
});
|
||
|
||
test('除零保护:剩余本金为 0 时比例返回 0', () => {
|
||
expect(SwapCalc.calcClosePercentByRemaining(100, 0)).toBe(0);
|
||
});
|
||
});
|
||
|
||
// ============================================================================
|
||
// 多次部分平仓:全部↔部分切换时 CloseQty 不应随 ClosePercent 口径变化而跳变
|
||
// 守卫 unwindSwapTrade.js changeCloseMethod/changeClosePercent/changeCloseQty/changeCloseNotionalValue
|
||
// 旧 bug:ClosePercent 是占期初口径(A),但 CloseQty=PositionQty*ClosePercent 错当占剩余(B)用
|
||
// 全部(ClosePercent=0.7,CloseQty=720000) → 部分(ClosePercent没变, CloseQty=504000) ❌ 跳变
|
||
// 修复:CloseQty = PositionQty × (ClosePercent / oriClosePercent)
|
||
// ============================================================================
|
||
describe('多次部分平仓:全部↔部分切换 CloseQty 不跳变(占期初口径自洽)', () => {
|
||
// 场景基于 GLMS-20260701-0013:
|
||
// 期初 NotionalValue=980000,已平两次剩 PosiNotionalValue=686000, PositionQty=720000
|
||
// oriClosePercent = 686000/980000 = 0.7
|
||
const NotionalValue = 980000;
|
||
const PosiNotionalValue = 686000;
|
||
const PositionQty = 720000;
|
||
const oriClosePercent = PosiNotionalValue / NotionalValue; // 0.7
|
||
|
||
test('全部平仓 ClosePercent=0.7 → CloseQty=720000(剩余全部)', () => {
|
||
const closePercent = oriClosePercent; // 全部平仓 = oriClosePercent
|
||
const closeQty = SwapCalc.calcCloseQtyByOriginalPercent(closePercent, oriClosePercent, PositionQty);
|
||
expectClose(closeQty, 720000, '全部平仓应平剩余全部数量');
|
||
});
|
||
|
||
test('全部→部分切换(ClosePercent 没变=0.7):CloseQty 应保持 720000 不跳变', () => {
|
||
// 旧 bug:CloseQty = 720000 * 0.7 = 504000(错误跳变)
|
||
// 修复:CloseQty = 720000 * (0.7/0.7) = 720000(不变)
|
||
const closePercent = oriClosePercent;
|
||
const closeQty = SwapCalc.calcCloseQtyByOriginalPercent(closePercent, oriClosePercent, PositionQty);
|
||
const buggyCloseQty = Math.round(PositionQty * closePercent * 100) / 100; // 旧 bug 公式
|
||
expectClose(closeQty, 720000, '修复后应不变');
|
||
expect(Math.abs(buggyCloseQty - 504000)).toBeLessThanOrEqual(TOL, '旧 bug 会跳变到 504000');
|
||
expect(Math.abs(closeQty - buggyCloseQty)).toBeGreaterThan(TOL, '修复与旧 bug 必须有差异');
|
||
});
|
||
|
||
test('改比例 0.5(占期初)→ CloseQty=720000×(0.5/0.7)=514285.71', () => {
|
||
const closePercent = 0.5;
|
||
const closeQty = SwapCalc.calcCloseQtyByOriginalPercent(closePercent, oriClosePercent, PositionQty);
|
||
expectClose(closeQty, 514285.71, '占期初 50% 对应占剩余 71.43%,×720000=514285.71');
|
||
});
|
||
|
||
test('逆运算:CloseQty=514285.71 → ClosePercent=0.5(占期初)', () => {
|
||
const closeQty = 514285.71;
|
||
const closePercent = SwapCalc.calcOriginalClosePercentByQty(closeQty, PositionQty, oriClosePercent);
|
||
expectClose(closePercent, 0.5, '逆运算应还原为占期初 0.5');
|
||
});
|
||
|
||
test('逆运算自洽:CloseQty=720000(=PositionQty)→ ClosePercent=0.7(=oriClosePercent)', () => {
|
||
const closeQty = PositionQty; // 720000
|
||
const closePercent = SwapCalc.calcOriginalClosePercentByQty(closeQty, PositionQty, oriClosePercent);
|
||
expectClose(closePercent, oriClosePercent, '平全部剩余 → ClosePercent=oriClosePercent');
|
||
});
|
||
|
||
test('除零保护:oriClosePercent=0(已全平完)→ CloseQty=0', () => {
|
||
expect(SwapCalc.calcCloseQtyByOriginalPercent(0.5, 0, 720000)).toBe(0);
|
||
});
|
||
|
||
test('除零保护:PositionQty=0 → ClosePercent=0', () => {
|
||
expect(SwapCalc.calcOriginalClosePercentByQty(100, 0, oriClosePercent)).toBe(0);
|
||
});
|
||
|
||
// GLMS-20260701-0006 生产 bug:第二次部分平仓 50%(占期初) 时
|
||
// 32500000 * (0.5 / 0.65) = 24999999.999999996(JS 浮点精度偏差)
|
||
// roundHalfAwayFromZero 应正确舍入为 25000000
|
||
test('GLMS-20260701-0006:32500000×(0.5/0.65) 应=25000000 而非 24999999.999999996', () => {
|
||
const closePercent = 0.5; // 占期初 50%
|
||
const oriClosePercent = 0.65; // 剩余/期初 = 32500000/50000000
|
||
const positionQty = 32500000; // 剩余持仓
|
||
const closeQty = SwapCalc.calcCloseQtyByOriginalPercent(closePercent, oriClosePercent, positionQty);
|
||
expectClose(closeQty, 25000000, '应=25000000 不受 JS 浮点偏差影响');
|
||
expect(closeQty).not.toBe(24999999.999999996);
|
||
});
|
||
});
|
||
|
||
describe('交叉校验:对齐 C# FrontendCalcCharacterizationTest 金标准', () => {
|
||
// FC_001 平仓-债券多头-默认
|
||
test('FC_001 平仓 债券多头 默认', () => {
|
||
const r = SwapCalc.calcUnwind({
|
||
multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 105,
|
||
closeQty: 1000, payDirection: 1, positionType: 1,
|
||
tradingFee: '20', tradingFeePending: '0', dividendIn: '0'
|
||
});
|
||
expectClose(r.MarkClosePnl, 30, 'MarkClosePnl');
|
||
expectClose(r.FloatPnlSum, 50, 'FloatPnlSum');
|
||
expectClose(r.SwapRealizedPnL, 50, 'SwapRealizedPnL');
|
||
});
|
||
|
||
// FC_002 改标的价格 105→110
|
||
test('FC_002 平仓 改标的价格', () => {
|
||
const r = SwapCalc.calcUnwind({
|
||
multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 110,
|
||
closeQty: 1000, payDirection: 1, positionType: 1,
|
||
tradingFee: '20', tradingFeePending: '0', dividendIn: '0'
|
||
});
|
||
expectClose(r.MarkClosePnl, 80, 'MarkClosePnl');
|
||
expectClose(r.FloatPnlSum, 100, 'FloatPnlSum');
|
||
});
|
||
|
||
// FC_003 改平仓数量 1000→500
|
||
test('FC_003 平仓 改平仓数量', () => {
|
||
const r = SwapCalc.calcUnwind({
|
||
multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 105,
|
||
closeQty: 500, payDirection: 1, positionType: 1,
|
||
tradingFee: '20', tradingFeePending: '10', dividendIn: '0'
|
||
});
|
||
expectClose(r.MarkClosePnl, 15, 'MarkClosePnl');
|
||
expectClose(r.FloatPnlSum, 45, 'FloatPnlSum');
|
||
});
|
||
|
||
// FC_004 改利息金额 +100
|
||
test('FC_004 平仓 改利息金额', () => {
|
||
const r = SwapCalc.calcUnwind({
|
||
multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 105,
|
||
closeQty: 1000, payDirection: 1, positionType: 1,
|
||
tradingFee: '20', tradingFeePending: '0', dividendIn: '0',
|
||
interestLegs: [{ interestClosePnL: 100 }]
|
||
});
|
||
expectClose(r.MarkClosePnl, 30, 'MarkClosePnl 不受利息影响');
|
||
expectClose(r.SwapRealizedPnL, 150, '含利息 SwapRealizedPnL');
|
||
});
|
||
|
||
// FC_005 非债券空头 方向因子
|
||
test('FC_005 平仓 非债券空头 方向因子', () => {
|
||
const r = SwapCalc.calcUnwind({
|
||
multiplier: 1, posiGrossPrice: 100, tradingAmountAvg: 105,
|
||
closeQty: 1000, payDirection: 1, positionType: 2,
|
||
tradingFee: '0', tradingFeePending: '0', dividendIn: '0'
|
||
});
|
||
expectClose(r.MarkClosePnl, -5000, '空头价格涨=亏损');
|
||
});
|
||
|
||
// FC_006 结息 债券多头 全量
|
||
test('FC_006 结息 债券多头 全量', () => {
|
||
const r = SwapCalc.calcIncome({
|
||
multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 105,
|
||
positionQty: 10000, contractSize: 1,
|
||
closeNotionalValue: 10200, closeQty: 0, payDirection: 1, positionType: 1,
|
||
tradingFee: '0', tradingFeePending: '0', dividendIn: '0'
|
||
});
|
||
expectClose(r.MarkClosePnl, 300, 'income MarkClosePnl');
|
||
expectClose(r.SwapRealizedPnL, 300, 'income SwapRealizedPnL');
|
||
});
|
||
|
||
// FC_007 结息 改标的价格 105→110
|
||
test('FC_007 结息 改标的价格', () => {
|
||
const r = SwapCalc.calcIncome({
|
||
multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 110,
|
||
positionQty: 10000, contractSize: 1,
|
||
closeNotionalValue: 10200, closeQty: 0, payDirection: 1, positionType: 1,
|
||
tradingFee: '0', tradingFeePending: '0', dividendIn: '0'
|
||
});
|
||
expectClose(r.MarkClosePnl, 800, '改价格后 income MarkClosePnl');
|
||
});
|
||
|
||
// FC_008 结息 含利息腿+预付金腿
|
||
test('FC_008 结息 含利息腿与预付金腿 总额', () => {
|
||
const r = SwapCalc.calcIncome({
|
||
multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 105,
|
||
positionQty: 10000, contractSize: 1,
|
||
closeNotionalValue: 10200, closeQty: 0, payDirection: 1, positionType: 1,
|
||
tradingFee: '0', tradingFeePending: '0', dividendIn: '0',
|
||
interestLegs: [{ interestClosePnL: 100 }],
|
||
marginLegs: [{ interestClosePnL: 50 }]
|
||
});
|
||
expectClose(r.SwapRealizedPnL, 450, '含利息+预付金 SwapRealizedPnL');
|
||
expectClose(r.SwapMarginRebatePnl, 50, 'SwapMarginRebatePnl');
|
||
});
|
||
|
||
// FC_009 真实回归:债券结息价差按数量计算,不能误用期初名义本金
|
||
test('FC_009 结息 债券价差按数量计算', () => {
|
||
const r = SwapCalc.calcIncome({
|
||
multiplier: 100, posiGrossPrice: 0.98, tradingAmountAvg: 80,
|
||
positionQty: 30000000, contractSize: 1,
|
||
closeNotionalValue: 29400000, closeQty: 0,
|
||
payDirection: 2, positionType: 1,
|
||
tradingFee: '0', tradingFeePending: '0', dividendIn: '-45000'
|
||
});
|
||
expectClose(r.MarkClosePnl, 5400000, 'income MarkClosePnl按数量计算');
|
||
expectClose(r.FloatPnlSum, 5355000, 'income FloatPnlSum包含分红');
|
||
});
|
||
});
|
||
|
||
// ============================================================================
|
||
// D1 回归守卫:切换债券标的必须清空手动/源标志,避免旧债券手填状态污染新债券
|
||
// 旧 bug:setUnderlyingCode 切债券时未重置 bondManual/bondDriverType,
|
||
// 旧债券标记过的字段在新债券上会被错误跳过/沿用旧态。
|
||
// 修复:setUnderlyingCode 调 SwapCalc.clearBondCalcFlags(item)。
|
||
// ============================================================================
|
||
describe('D1 切换标的清空债券互算手动/源标志', () => {
|
||
test('clearBondCalcFlags 把 bondManual 三字段归 false、bondDriverType 归 null', () => {
|
||
const item = {
|
||
bondManual: { CP: true, DP: false, YD: true },
|
||
bondDriverType: 'YD'
|
||
};
|
||
const out = SwapCalc.clearBondCalcFlags(item);
|
||
expect(out.bondManual).toEqual({ CP: false, DP: false, YD: false });
|
||
expect(out.bondDriverType).toBeNull();
|
||
});
|
||
|
||
test('clearBondCalcFlags 对全新未交互标的(无标志)也安全初始化', () => {
|
||
const item = { UnderlyingCode: '200000.IB', isBond: true };
|
||
const out = SwapCalc.clearBondCalcFlags(item);
|
||
expect(out.bondManual).toEqual({ CP: false, DP: false, YD: false });
|
||
expect(out.bondDriverType).toBeNull();
|
||
expect(out.UnderlyingCode).toBe('200000.IB'); // 其它字段不受影响
|
||
});
|
||
|
||
test('模拟"债券A手填→切债券B":B 不应继承 A 的手动标志', () => {
|
||
// 债券 A:用户手填了全价,标记手动 + 设源
|
||
const item = { UnderlyingCode: '190000.IB', isBond: true,
|
||
bondManual: { CP: false, DP: true, YD: false }, bondDriverType: 'DP' };
|
||
// 切到债券 B(setUnderlyingCode 会调 clearBondCalcFlags)
|
||
SwapCalc.clearBondCalcFlags(item);
|
||
item.UnderlyingCode = '200000.IB';
|
||
// 若不清空,applyBondCalcResult 会以旧的 bondManual.DP=true 跳过 B 的全价→错误
|
||
const manual = item.bondManual;
|
||
expect(manual.CP || manual.DP || manual.YD).toBe(false); // B 上无任何手动标志
|
||
expect(item.bondDriverType).toBeNull(); // B 无计算源,下一步反算不会误用 A 的源
|
||
});
|
||
});
|
||
|
||
// ============================================================================
|
||
// D2 回归守卫:重开(审批重开/刷新)已保存的债券成交单时,三字段互算的手动标志
|
||
// 随页面重置丢失 → 用户一旦编辑任一价格字段就会以它为源重新反算、覆盖当初保存的其他两格。
|
||
// 修复:加载路径对"债券且三字段齐全(净价/全价/收益率均有值)"的标的,调 markExistingBondManual
|
||
// 把三格一次性锁为手动(true),重开期间计算器不再自动推导;点"重算"才清除重新计算。
|
||
// ============================================================================
|
||
describe('D2 重开已保存债券单 → 三字段锁定、编辑不联动另两格', () => {
|
||
test('markExistingBondManual 把三字段全锁 true、不置计算源', () => {
|
||
const item = { isBond: true, PosiNetNoFeePrice: 0.995, PosiGrossPrice: 1.0, InitYtm: 0.026 };
|
||
const out = SwapCalc.markExistingBondManual(item);
|
||
expect(out.bondManual).toEqual({ CP: true, DP: true, YD: true });
|
||
expect(out.bondDriverType).toBeUndefined(); // 不置源 → calcBondForItem 早返回,重开零自动推导
|
||
});
|
||
|
||
test('重开后编辑某一格(如全价):另两格因 manual=true 被 applyBondCalcResult 跳过、不被覆盖', () => {
|
||
const item = { isBond: true, PosiNetNoFeePrice: 0.995, PosiGrossPrice: 1.0, InitYtm: 0.026 };
|
||
SwapCalc.markExistingBondManual(item);
|
||
// 模拟用户编辑全价(DP):onBondPriceInput 会置 bondDriverType='DP' 并重算
|
||
item.bondDriverType = 'DP';
|
||
const proxy = { cleanPrice: 99.5, dirtyPrice: 100.0, ytm: 6.37 };
|
||
const calc = { cleanPrice: 97.82, dirtyPrice: 98.5, ytm: 2.60 };
|
||
SwapCalc.applyBondCalcResult(proxy, calc, item.bondManual);
|
||
// 三格皆 manual=true → 一个都不回写,保存值(99.5/100.0/6.37)原样保留
|
||
expect(proxy.cleanPrice).toBe(99.5);
|
||
expect(proxy.dirtyPrice).toBe(100.0);
|
||
expect(proxy.ytm).toBe(6.37);
|
||
});
|
||
|
||
test('加载路径不会误锁"全新未填的债券"(三值不全)', () => {
|
||
// 全新债券标的,价格字段空 → 不应被标记为手动锁定
|
||
const item = { isBond: true, PosiNetNoFeePrice: null, PosiGrossPrice: null, InitYtm: null };
|
||
SwapCalc.markExistingBondManual(item);
|
||
// 全新标的本不应调用 markExistingBondManual;此处断言:即便误调也不应制造假锁定干扰后续交互
|
||
// (实际加载逻辑用 hasV 三值齐全判定,只在已保存单上调用,此用例验证函数本身不副作用其它字段)
|
||
expect(item.UnderlyingCode).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
// ============================================================================
|
||
// D3 回归守卫:不可算债券上用户逐键手填时,v-on:input 每次按键都触发一次失败计算并弹 toast,
|
||
// 会连刷相同提示。修复:shouldShowBondErr 按"同一错误文案连续出现只提示一次"抑制噪声。
|
||
// ============================================================================
|
||
describe('D3 债券计算器失败提示去重', () => {
|
||
test('同一错误连续出现只提示一次', () => {
|
||
const s = {};
|
||
expect(SwapCalc.shouldShowBondErr(s, '债券不存在')).toBe(true); // 首次 → 提示
|
||
expect(SwapCalc.shouldShowBondErr(s, '债券不存在')).toBe(false); // 连刷 → 抑制
|
||
expect(SwapCalc.shouldShowBondErr(s, '债券不存在')).toBe(false); // 再刷 → 抑制
|
||
});
|
||
|
||
test('错误文案变化仍照常提示', () => {
|
||
const s = {};
|
||
expect(SwapCalc.shouldShowBondErr(s, '债券不存在')).toBe(true);
|
||
expect(SwapCalc.shouldShowBondErr(s, '信息不全')).toBe(true); // 不同错误 → 提示
|
||
expect(SwapCalc.shouldShowBondErr(s, '信息不全')).toBe(false); // 同错误 → 抑制
|
||
});
|
||
|
||
test('成功(传入 null/空)清标记,下次不同错误仍能提示', () => {
|
||
const s = {};
|
||
expect(SwapCalc.shouldShowBondErr(s, '债券不存在')).toBe(true);
|
||
expect(SwapCalc.shouldShowBondErr(s, null)).toBe(false); // 成功清标记,不提示
|
||
expect(SwapCalc.shouldShowBondErr(s, '债券不存在')).toBe(true); // 新一次错误 → 重新提示
|
||
});
|
||
});
|
||
|
||
// ============================================================================
|
||
// 估值日(开始日)缺失守卫:需求——债券净价/全价/收益率以【互换起始日 StartDate】估值;
|
||
// 开始日现已默认即有,故不再"悄悄回退到交易日(TradeDate)",而是真的为空时返回错误文案,
|
||
// 由 calcBondForItem 报错提示并 return(不调用计算器、不覆盖手工输入)。
|
||
// getBondStartDateMissingMsg 为纯函数,jest 直接覆盖。
|
||
// ============================================================================
|
||
describe('估值日(开始日)缺失 → 报错提示、不悄悄回退交易日', () => {
|
||
test('开始日有值 → 返回 null(不报错、继续计算)', () => {
|
||
expect(SwapCalc.getBondStartDateMissingMsg('2026-07-23')).toBeNull();
|
||
expect(SwapCalc.getBondStartDateMissingMsg('2026-01-01')).toBeNull();
|
||
expect(SwapCalc.getBondStartDateMissingMsg('2026/07/23')).toBeNull();
|
||
});
|
||
|
||
test('开始日为空/undefined/null → 返回错误文案(须提示用户)', () => {
|
||
expect(typeof SwapCalc.getBondStartDateMissingMsg('')).toBe('string');
|
||
expect(typeof SwapCalc.getBondStartDateMissingMsg(null)).toBe('string');
|
||
expect(typeof SwapCalc.getBondStartDateMissingMsg(undefined)).toBe('string');
|
||
// 文案需同时引导"先填开始日"和"可手动填三数"(对应需求2)
|
||
const m = SwapCalc.getBondStartDateMissingMsg('');
|
||
expect(m).toMatch(/开始日/);
|
||
expect(m).toMatch(/手动填写/);
|
||
});
|
||
|
||
test('与 D3 去重配合:开始日缺失时同一文案只弹一次', () => {
|
||
const item = {};
|
||
const msg = SwapCalc.getBondStartDateMissingMsg('');
|
||
// calcBondForItem 中:if (SwapCalc.shouldShowBondErr(item, msg)) main.message(msg); return;
|
||
expect(SwapCalc.shouldShowBondErr(item, msg)).toBe(true); // 首次 → 提示
|
||
expect(SwapCalc.shouldShowBondErr(item, msg)).toBe(false); // 价格格逐键输入连刷 → 抑制
|
||
expect(SwapCalc.shouldShowBondErr(item, null)).toBe(false); // 估值日补填后成功 → 清标记
|
||
});
|
||
});
|