Files
zszq-trs/YLErpWeb/fe-tests/swapCalc.test.js
T
hjhan b9f2bd14f6 feat(swaptrade): 债券三字段计算器交互改为回车触发并加源/AUTO/REV 三态标识
- 约定1:仅在净价/全价/收益率输入框敲回车触发计算器,失焦不触发
- 约定2:计算成功->源字段标"源"、另两字段标"AUTO";失败->保留源值、清空另两字段与标识
- 约定3:编辑某字段未回车->不联动另两字段,清源/AUTO、仅本字段标"REV"
- swapCalc.js 新增 applyBondCalcSuccess/Failure/ManualEdit 纯函数,applyBondCalcResult 第三参由 manualSet 改为 driver(跳过源字段写回)
- fastVue.base.js 新增 enter 事件区分回车/失焦,向后兼容旧的 input 行为
- TradeEdit.cshtml 三字段 input/enter 分离绑定 + 源/AUTO/REV 三态标签(去除旧铅笔图标)
- 重写 bondCalc.test.js / 更新 swapCalc.test.js 覆盖状态机;jest 68 项全过
2026-07-29 14:34:25 +08:00

429 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 回归守卫:切换债券标的必须清空 源/AUTO/REV 标志,避免旧债券状态污染新债券
// 旧 bugsetUnderlyingCode 切债券时未重置状态,旧债券标记过的字段在新债券上会被错误沿用。
// 修复:setUnderlyingCode 调 SwapCalc.clearBondCalcFlags(item)。
// ============================================================================
describe('D1 切换标的清空债券互算源/AUTO/REV 标志', () => {
test('clearBondCalcFlags 把 bondDriverType/bondAuto/bondRev 全清', () => {
const item = {
bondDriverType: 'YD',
bondAuto: { CP: true, DP: true, YD: false },
bondRev: { CP: false, DP: false, YD: true }
};
const out = SwapCalc.clearBondCalcFlags(item);
expect(out.bondDriverType).toBeNull();
expect(out.bondAuto).toEqual({ CP: false, DP: false, YD: false });
expect(out.bondRev).toEqual({ CP: false, DP: false, YD: false });
});
test('clearBondCalcFlags 对全新未交互标的(无标志)也安全初始化', () => {
const item = { UnderlyingCode: '200000.IB', isBond: true };
const out = SwapCalc.clearBondCalcFlags(item);
expect(out.bondDriverType).toBeNull();
expect(out.bondAuto).toEqual({ CP: false, DP: false, YD: false });
expect(out.bondRev).toEqual({ CP: false, DP: false, YD: false });
expect(out.UnderlyingCode).toBe('200000.IB'); // 其它字段不受影响
});
test('模拟"债券A回车→切债券B"B 不应继承 A 的源/AUTO/REV', () => {
// 债券 A:用户回车了全价,标记源 + AUTO
const item = { UnderlyingCode: '190000.IB', isBond: true,
bondDriverType: 'DP',
bondAuto: { CP: true, DP: false, YD: true },
bondRev: { CP: false, DP: false, YD: false } };
// 切到债券 BsetUnderlyingCode 会调 clearBondCalcFlags
SwapCalc.clearBondCalcFlags(item);
item.UnderlyingCode = '200000.IB';
expect(item.bondDriverType).toBeNull(); // B 无计算源
expect(item.bondAuto).toEqual({ CP: false, DP: false, YD: false });
expect(item.bondRev).toEqual({ CP: false, DP: false, YD: false });
});
});
// ============================================================================
// D2 回归守卫:重开(审批重开/刷新)已保存的债券成交单时,三字段互算标识随页面重置丢失 →
// 加载路径调 clearBondCalcMarksOnLoad 把 源/AUTO/REV 全部清空,纯展示保存值、无锁定。
// 交互约定3规定"编辑未回车不联动",故用户载入后即使改某格(未回车)也不会反算另两格;
// 只有用户主动回车某格时,才以该格为源重新推导(符合交互约定1)。
// ============================================================================
describe('D2 重开已保存债券单 → 三字段无标识、编辑不联动另两格', () => {
test('clearBondCalcMarksOnLoad 把 源/AUTO/REV 全清、不置计算源、不动价格值', () => {
const item = { isBond: true, PosiNetNoFeePrice: 0.995, PosiGrossPrice: 1.0, InitYtm: 0.026,
bondDriverType: 'CP',
bondAuto: { CP: false, DP: true, YD: true },
bondRev: { CP: false, DP: false, YD: false } };
const out = SwapCalc.clearBondCalcMarksOnLoad(item);
expect(out.bondDriverType).toBeNull();
expect(out.bondAuto).toEqual({ CP: false, DP: false, YD: false });
expect(out.bondRev).toEqual({ CP: false, DP: false, YD: false });
expect(out.PosiNetNoFeePrice).toBe(0.995); // 价格值本身不动,仅清标识
expect(out.PosiGrossPrice).toBe(1.0);
});
test('重开后仅编辑某一格(未回车):交互约定3 使另两格不被反算覆盖', () => {
const item = { isBond: true, PosiNetNoFeePrice: 0.995, PosiGrossPrice: 1.0, InitYtm: 0.026 };
SwapCalc.clearBondCalcMarksOnLoad(item);
// 模拟用户编辑全价(DP)但未回车:onBondPriceEdit 调 applyBondManualEdit → 清源/AUTO、DP 标 REV,不调计算器
SwapCalc.applyBondManualEdit(item, 'DP');
expect(item.bondDriverType).toBeNull(); // 无计算源 → 不会反算
expect(item.bondAuto).toEqual({ CP: false, DP: false, YD: false });
expect(item.bondRev).toEqual({ CP: false, DP: true, YD: false });
expect(item.PosiNetNoFeePrice).toBe(0.995); // 另两格原值保留
expect(item.InitYtm).toBe(0.026);
});
});
// ============================================================================
// 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); // 估值日补填后成功 → 清标记
});
});