Files
zszq-trs/YLErpWeb/fe-tests/swapCalc.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

431 lines
24 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.
/**
* 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作为默认期末价');
});
// 3c5f25a5FloatPnlSum 必须保留 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
// 旧 bugClosePercent 是占期初口径(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 不跳变', () => {
// 旧 bugCloseQty = 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.999999996JS 浮点精度偏差)
// roundHalfAwayFromZero 应正确舍入为 25000000
test('GLMS-20260701-000632500000×(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 回归守卫:切换债券标的必须清空手动/源标志,避免旧债券手填状态污染新债券
// 旧 bugsetUnderlyingCode 切债券时未重置 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' };
// 切到债券 BsetUnderlyingCode 会调 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); // 估值日补填后成功 → 清标记
});
});