Files
zszq-trs/YLErpWeb/fe-tests/bondCalc.integration.test.js
hjhan 68a3f0b4fa debug(swap)+test: 平仓利息 otcdebug 埋点 + 修复3个前端红测试
1) otcdebug 埋点(unwindSwapTrade.js, 仅 ?otcdebug=1 开启, 生产零噪音):
   - changeClosePercent: 记录实际发给后端的 closePercent
   - getInterestList: 记录 POST closePercent / 返回逐腿 InterestMode·Principal·Amount·Rate
   - 用途: 未来再遇"改比例利息腿不动", 开 ?otcdebug=1 即可定位前端没传对还是后端没缩放

2) 修复分支上既有3个红测试(均为配置/生产已改、测试未跟上或契约过时):
   - swapPrecisionConfig.test.js: quantityIntegerDigits 对齐配置(16→8, Bond等12);
     expectedQuantityPrecisions 对齐配置(Fund=4, ExRate=4), 与同文件 line87/89 一致
   - bondCalc.integration.test.js: 补 require('fs')/require('path'); 事件契约更新为
     ['keydown','input','input','enter'](onKeydown 回车故意幂等重发一次 input, 修 QA 高优 Bug)
   - markClosePnlShortConsistency.test.js: 空头一致性测试由 RED 转 GREEN(income 已补 longRatio);
     CASE deliveryPrice 105→1.05 与 GOLD 的 105*scale(0.01) 单位对齐

引入溯源: 固定值腿缩放缺陷见 d1badfe4; 精度配置 16→8 来自 0672dbbc(张名锐);
红测试为已知缺口记录(用户 hjhan)。
2026-08-08 12:18:45 +08:00

918 lines
40 KiB
JavaScript
Raw Permalink 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.integration.test.js — 债券三字段互算集成层测试
* ============================================================================
* 目的:覆盖 swapCalc.js 纯函数与宿主框架(Vue/jQuery/main.post)之间的"胶水代码"
* 守卫纯函数测试(bondCalc.test.js)无法触及的集成层断裂点。
*
* 覆盖的根因(见 项目文档/互换债券三字段互算踩坑总结与测试指南.md):
* 根因1vue-number-input .native 修饰符被 jQuery 拦截 → keydown 事件不触发
* 根因2Vue 2 非 data() 属性不可响应 → 直接赋值不触发视图更新
* 根因3:$set 设置相同对象引用不触发更新 → 需创建新对象引用
* 根因4main.post 业务错误走 reject → .done() 不执行失败分支
*
* 运行:cd YLErpWeb/fe-tests && npx jest bondCalc.integration
*/
const fs = require('fs');
const path = require('path');
const { JSDOM } = require('jsdom');
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
global.window = dom.window;
global.document = dom.window.document;
global.navigator = dom.window.navigator;
// 桥接 FastVue 全局
Object.defineProperty(global, 'FastVue', {
get() { return global.window.FastVue; },
set(value) { global.window.FastVue = value; },
configurable: true
});
const $ = require('jquery');
global.$ = $;
global.jQuery = $;
// main mock(含 formatNumber + post 的 Deferred 模拟)
global.main = {
formatNumber: function (number, precision, options) {
const n = Number(number);
if (Number.isNaN(n)) return '0';
const useGrouping = options && options.grouping;
if (useGrouping) {
return n.toLocaleString('en-US', { minimumFractionDigits: precision, maximumFractionDigits: precision });
}
return n.toFixed(precision);
},
message: jest.fn(),
post: null // 各测试块自行设置
};
const SwapCalc = require('../wwwroot/Scripts/app/swaptrade/swapCalc.js');
describe('TradeEdit bond price input bindings', () => {
const view = fs.readFileSync(path.join(__dirname, '..', 'Views', 'SwapTrade2', 'TradeEdit.cshtml'), 'utf8');
const methods = fs.readFileSync(path.join(__dirname, '..', 'wwwroot', 'Scripts', 'app', 'swaptrade', 'swapTradeEdit.js'), 'utf8');
const precisionHelper = fs.readFileSync(path.join(__dirname, '..', 'wwwroot', 'Scripts', 'app', 'swaptrade', 'swapPricePrecisionHelper.js'), 'utf8');
test('binds the three bond price fields to current Vue methods', () => {
expect(view).toMatch(/v-model="item\.PosiGrossPrice"[^>]*v-on:input="onDpPriceInput\(item\)"/);
expect(view).toMatch(/v-model="item\.PosiNetNoFeePrice"[^>]*v-on:input="onBondPriceEdit\(item,'CP'\)"/);
expect(view).toMatch(/v-model="item\.InitYtm"[^>]*v-on:input="onBondPriceEdit\(item,'YD'\)"/);
expect(view).not.toContain('v-on:input="onBondPriceInput(');
expect(methods).toMatch(/^\s*onBondPriceEdit\s*\(/m);
});
test('precision input preserves the keydown, input, enter event contract', () => {
const helper = new Function('window', precisionHelper + '\nreturn swapPricePrecision;')({});
const component = helper.createVueInputComponent();
expect(component.template).toContain('@blur="onChange"');
expect(component.template).not.toContain('@change="onChange"');
const emitted = [];
const vm = {
text: '99.5',
enterPressed: false,
format: { precision: 4, percent: true },
$emit: (event, value) => emitted.push({ event, value })
};
Object.keys(component.methods).forEach(name => {
vm[name] = component.methods[name].bind(vm);
});
const target = {
value: vm.text,
blur: () => vm.onChange({ target })
};
vm.onKeydown({ keyCode: 13, target });
// onKeydown 回车时故意再派发一次 inputevent.target.blur() 触发 onChange 派发一次,
// onKeydown 又直接派发一次(见 swapPricePrecisionHelper.js,注释标明"幂等无害"),
// 用于修复「重输/重贴原值 + 回车」静默不计算的 QA 高优 Bug。
expect(emitted.map(x => x.event)).toEqual(['keydown', 'input', 'input', 'enter']);
expect(emitted[1].value).toBe('0.995');
expect(emitted[2].value).toBe('0.995');
expect(emitted[3].value).toBe('0.995');
});
test('equal format refresh does not overwrite uncommitted input', () => {
const helper = new Function('window', precisionHelper + '\nreturn swapPricePrecision;')({});
const component = helper.createVueInputComponent();
const format = { precision: 4, percent: true };
const vm = {
value: '0.995',
text: '99.5',
format,
formatSnapshot: JSON.stringify(format),
$emit: () => {}
};
Object.keys(component.methods).forEach(name => {
vm[name] = component.methods[name].bind(vm);
});
vm.onInput({ target: { value: '99.51' } });
component.watch.format.handler.call(vm);
expect(vm.text).toBe('99.51');
});
});
// ============================================================================
// 根因 1vue-number-input keydown 事件 emit 链路
// ============================================================================
// 背景:vue-number-input 在 mounted() 中用 jQuery $(_el).on('keydown', __keyHandle)
// 重新绑定了 keydown。.native 修饰符依赖 Vue 的根元素事件代理,
// 但 jQuery 绑定后原生事件不再冒泡到 Vue 的事件代理层,
// 导致 v-on:keydown.native 收不到事件。
// 修复:在 mounted() 内部 this.$el.addEventListener('keydown', fn) + self.$emit('keydown', e)
// 绕过 jQuery 事件系统直接 emit。
// 此测试验证修复后的 emit 链路是否正常。
describe('根因1vue-number-input keydown 事件 emit 链路', () => {
beforeEach(() => {
jest.resetModules();
// 重新加载 fastVue.base.js 获取最新组件定义
delete global.window.FastVue;
// 需要重新 require 以触发 IIFE 注册 FastVue
require('../wwwroot/Scripts/fast/fastVue.base.js');
});
/**
* 模拟 vueNumberInput 组件的 mounted() 钩子行为。
* 由于 jest 环境没有完整 Vue 运行时,这里手动执行 mounted 中的关键逻辑:
* addEventListener('keydown') + $emit('keydown')
*/
function mountComponent(el) {
const emitted = [];
const self = {
$el: el,
$emit(event, payload) { emitted.push({ event, payload }); },
onchange: function () {},
format: { precision: 2, percent: false, append: '' }
};
// 复刻 mounted() 中的 keydown 监听(修复后的代码)
el.addEventListener('keydown', function (e) {
self.$emit('keydown', e);
});
return { self, emitted };
}
test('按键(数字2)时组件应 $emit("keydown") —— 验证修复后事件链路正常', () => {
const el = document.createElement('input');
document.body.appendChild(el);
const { emitted } = mountComponent(el);
const keydown = new window.KeyboardEvent('keydown', { keyCode: 50, bubbles: true });
el.dispatchEvent(keydown);
expect(emitted.length).toBeGreaterThanOrEqual(1);
expect(emitted[0].event).toBe('keydown');
});
test('回车键(keyCode=13)时也 $emit("keydown") —— 回车除外逻辑在父组件而非组件内部', () => {
const el = document.createElement('input');
document.body.appendChild(el);
const { emitted } = mountComponent(el);
const keydown = new window.KeyboardEvent('keydown', { keyCode: 13, bubbles: true });
el.dispatchEvent(keydown);
// 组件内部不区分键码,一律 emit;回车除外逻辑由父组件 onBondPriceKeydown 处理
expect(emitted.length).toBeGreaterThanOrEqual(1);
expect(emitted[0].event).toBe('keydown');
});
test('修饰键(Ctrl+V)也 $emit("keydown") —— 修饰键除外逻辑在父组件', () => {
const el = document.createElement('input');
document.body.appendChild(el);
const { emitted } = mountComponent(el);
const keydown = new window.KeyboardEvent('keydown', {
keyCode: 86, ctrlKey: true, bubbles: true
});
el.dispatchEvent(keydown);
expect(emitted.length).toBeGreaterThanOrEqual(1);
});
test('模拟 .native 修饰符失效场景:jQuery 重绑后原生 addEventListener 仍能收到事件', () => {
const el = document.createElement('input');
document.body.appendChild(el);
// 模拟 jQuery $(_el).on('keydown', __keyHandle) 重绑 keydown
// 这会覆盖 Vue .native 的事件代理,但不影响直接 addEventListener
$(el).on('keydown', function () { /* jQuery handler */ });
const { emitted } = mountComponent(el);
// 即使 jQuery 重绑了 keydownaddEventListener 仍能收到事件
const keydown = new window.KeyboardEvent('keydown', { keyCode: 50, bubbles: true });
el.dispatchEvent(keydown);
expect(emitted.length).toBeGreaterThanOrEqual(1);
expect(emitted[0].event).toBe('keydown');
});
});
// ============================================================================
// 根因 2+3Vue 2 响应式——$set 新引用 vs 旧引用 vs 直接赋值
// ============================================================================
// 背景:swapCalc.js 的纯函数直接对 state 赋值(state.bondDriverType = null 等),
// 但 bondDriverType/bondAuto/bondRev 不在 Vue data() 中声明,是非响应式属性。
// Vue 2 基于 Object.defineProperty,只能追踪初始化时已存在的属性。
// 直接赋值不触发视图更新;$set 可注册响应式,但传入相同引用时 Vue 跳过更新。
// 修复:syncBondFlags 每次创建全新对象引用 + $forceUpdate 兜底。
describe('根因2+3Vue 2 响应式——$set 新引用 vs 旧引用 vs 直接赋值', () => {
/**
* 模拟 Vue 2 的响应式系统行为。
* Vue 2 用 Object.defineProperty,只能追踪已存在的属性。
* $set 可以添加新响应式属性,但对相同引用的对象会跳过更新。
*/
function createReactiveMock() {
// trackKeys: 已被 $set 注册为响应式的 (target, key) 对
const tracked = new WeakMap(); // target -> Set of keys
const updateLog = [];
function isTracked(target, key) {
const keys = tracked.get(target);
return !!(keys && keys.has(key));
}
function markTracked(target, key) {
if (!tracked.has(target)) tracked.set(target, new Set());
tracked.get(target).add(key);
}
const vm = {
$set(target, key, value) {
const oldVal = target[key];
const alreadyTracked = isTracked(target, key);
// Vue 2 $set 的核心行为:
// 1. 如果属性尚未被注册为响应式 → 注册 + 赋值 + 触发更新(即使值相同)
// 2. 如果属性已是响应式 → 按相同引用/值跳过优化
if (alreadyTracked && oldVal === value) {
updateLog.push({ key, skipped: true, reason: 'same_val' });
return;
}
if (!alreadyTracked) {
markTracked(target, key);
}
target[key] = value;
updateLog.push({ key, skipped: false, oldVal: oldVal, newVal: value });
},
$forceUpdate() {
updateLog.push({ key: '__forceUpdate', skipped: false });
},
getUpdateLog() { return updateLog; },
isReactive(target, key) {
// 支持两种调用方式:isReactive(key) 或 isReactive(target, key)
if (arguments.length >= 2) return isTracked(target, key);
// 兼容旧签名:遍历 tracked 查找(仅用于无 target 的场景)
return false;
}
};
return vm;
}
function makeBondItem() {
return {
PosiNetNoFeePrice: 0.995,
PosiGrossPrice: 1.0,
InitYtm: 0.026,
isBond: true,
UnderlyingCode: '190000.IB',
// bondDriverType/bondAuto/bondRev 初始不存在(模拟后端数据无这些字段)
};
}
test('根因2:直接赋值 bondDriverType 不注册响应式 → 视图不更新', () => {
const item = makeBondItem();
const vm = createReactiveMock();
// 模拟 swapCalc 纯函数直接赋值(无 $set)
SwapCalc.applyBondManualEdit(item, 'CP');
// item 上有了属性,但 Vue 不知道它是响应式的
expect(item.bondDriverType).toBeNull();
expect(item.bondRev).toEqual({ CP: true, DP: false, YD: false });
// 未通过 $set 注册 → 不响应式
expect(vm.isReactive(item, 'bondDriverType')).toBe(false);
});
test('根因2修复:$set 注册 bondDriverType 为响应式', () => {
const item = makeBondItem();
const vm = createReactiveMock();
SwapCalc.applyBondManualEdit(item, 'CP');
// applyBondManualEdit 已直接赋值 bondDriverType=null(非响应式)
expect(item.bondDriverType).toBeNull();
// 模拟 syncBondFlags 的 $set 调用:首次 $set 注册为响应式
vm.$set(item, 'bondDriverType', item.bondDriverType === undefined ? null : item.bondDriverType);
expect(vm.isReactive(item, 'bondDriverType')).toBe(true);
const log = vm.getUpdateLog();
expect(log[0].skipped).toBe(false); // 首次注册,即使值相同也触发
});
test('根因3$set 传入相同引用 → Vue 跳过更新(bondAuto 对象)', () => {
const item = makeBondItem();
// 先初始化 bondAuto
item.bondAuto = { CP: false, DP: true, YD: true };
const vm = createReactiveMock();
// 第一次 $set 注册
vm.$set(item, 'bondAuto', item.bondAuto);
// 纯函数原地修改 bondAuto(创建新对象)
SwapCalc.applyBondManualEdit(item, 'CP');
// 现在 item.bondAuto 是新对象 { CP: true, DP: false, YD: false }
// 如果错误地传入相同引用(模拟旧版 syncBondFlags bug
const sameRef = item.bondAuto;
vm.$set(item, 'bondAuto', sameRef);
const log = vm.getUpdateLog();
const lastSet = log[log.length - 1];
expect(lastSet.skipped).toBe(true); // 相同引用 → 跳过
expect(lastSet.reason).toBe('same_val');
});
test('根因3修复:$set 传入新对象引用 → Vue 触发更新', () => {
const item = makeBondItem();
item.bondAuto = { CP: false, DP: true, YD: true };
const vm = createReactiveMock();
// 第一次 $set 注册为响应式
vm.$set(item, 'bondAuto', item.bondAuto);
// 纯函数原地修改(applyBondManualEdit 创建新对象赋给 state.bondAuto
SwapCalc.applyBondManualEdit(item, 'CP');
// item.bondAuto 现在是新对象 { CP: true, DP: false, YD: false }
const afterEdit = item.bondAuto;
// 修复后的 syncBondFlags:每次创建全新对象字面量
const newRef = { CP: !!item.bondAuto.CP, DP: !!item.bondAuto.DP, YD: !!item.bondAuto.YD };
expect(newRef).not.toBe(afterEdit); // 新引用确实与旧引用不同
vm.$set(item, 'bondAuto', newRef);
const log = vm.getUpdateLog();
const lastSet = log[log.length - 1];
expect(lastSet.skipped).toBe(false); // 不同引用 → 触发更新
});
test('完整 syncBondFlags 模拟:$set + $forceUpdate 全链路', () => {
const item = makeBondItem();
const vm = createReactiveMock();
// 模拟 swapCalc.applyBondManualEdit(直接赋值,非响应式)
SwapCalc.applyBondManualEdit(item, 'DP');
// 模拟修复后的 syncBondFlags:$set 首次注册(都应触发更新)+ $forceUpdate
vm.$set(item, 'bondDriverType', item.bondDriverType === undefined ? null : item.bondDriverType);
vm.$set(item, 'bondAuto', { CP: !!item.bondAuto.CP, DP: !!item.bondAuto.DP, YD: !!item.bondAuto.YD });
vm.$set(item, 'bondRev', { CP: !!item.bondRev.CP, DP: !!item.bondRev.DP, YD: !!item.bondRev.YD });
vm.$forceUpdate();
const log = vm.getUpdateLog();
// 3 个 $set 都是首次注册(属性之前不是响应式)→ 都不跳过 + 1 个 forceUpdate
const sets = log.filter(e => e.key !== '__forceUpdate');
expect(sets.length).toBe(3);
sets.forEach(s => expect(s.skipped).toBe(false));
expect(log.some(e => e.key === '__forceUpdate')).toBe(true);
});
test('连续两次 applyBondManualEdit + syncBondFlags:第二次也触发更新(新引用)', () => {
const item = makeBondItem();
const vm = createReactiveMock();
// 第一次:CP 标 REV
SwapCalc.applyBondManualEdit(item, 'CP');
vm.$set(item, 'bondRev', { CP: !!item.bondRev.CP, DP: !!item.bondRev.DP, YD: !!item.bondRev.YD });
const log1 = vm.getUpdateLog().filter(e => !e.skipped);
// 第二次:DP 标 REV
SwapCalc.applyBondManualEdit(item, 'DP');
// applyBondManualEdit 把 state.bondRev 重新赋为新对象 { CP:false, DP:true, YD:false }
// syncBondFlags 再创建新引用 → $set 时新旧引用不同 → 触发更新
vm.$set(item, 'bondRev', { CP: !!item.bondRev.CP, DP: !!item.bondRev.DP, YD: !!item.bondRev.YD });
const log2 = vm.getUpdateLog().filter(e => !e.skipped);
// 第二次也应触发更新(新引用保证)
expect(log2.length).toBeGreaterThan(log1.length);
const lastSet = vm.getUpdateLog()[vm.getUpdateLog().length - 1];
expect(lastSet.skipped).toBe(false);
});
});
// ============================================================================
// 根因 4main.post Promise 链——reject 走 .fail 不走 .done
// ============================================================================
// 背景:main.post 包装了 jQuery $.ajax,在 resp.success===false(业务错误)时
// 调用 deferred.reject(resp)。calcBondForItem 只挂了 .done()reject 时不执行。
// 修复:补 .fail() 处理 reject 路径。
describe('根因4main.post Promise 链——reject 不走 .done', () => {
/**
* 模拟 main.__post 的 resolve/reject 行为。
* 返回 jQuery Deferred promise,可链式 .done()/.fail()。
*/
function mockPost(response, shouldReject) {
const d = $.Deferred();
if (shouldReject) {
d.reject(response);
} else {
d.resolve(response);
}
return d.promise();
}
test('resolve 时 .done 被调用、.fail 不被调用', () => {
const doneFn = jest.fn();
const failFn = jest.fn();
mockPost({ success: true, obj: { cleanPrice: 99 } }, false)
.done(doneFn)
.fail(failFn);
expect(doneFn).toHaveBeenCalled();
expect(failFn).not.toHaveBeenCalled();
});
test('reject 时 .fail 被调用、.done 不被调用', () => {
const doneFn = jest.fn();
const failFn = jest.fn();
mockPost({ success: false, msg: '债券不存在' }, true)
.done(doneFn)
.fail(failFn);
expect(doneFn).not.toHaveBeenCalled();
expect(failFn).toHaveBeenCalled();
});
test('仅挂 .done(旧 bug):reject 时失败分支静默跳过', () => {
const doneFn = jest.fn();
const failHandlerCalled = { value: false };
// 模拟旧代码:只有 .done,没有 .fail
mockPost({ success: false, msg: '债券不存在' }, true)
.done(function () {
// 这段代码永远不会执行
failHandlerCalled.value = true;
});
expect(doneFn).not.toHaveBeenCalled();
expect(failHandlerCalled.value).toBe(false); // 失败分支被跳过!
});
test('修复后:.done + .fail 都挂 → reject 时 .fail 中的 applyBondCalcFailure 被执行', () => {
const item = {
PosiNetNoFeePrice: 0.995,
PosiGrossPrice: 1.0,
InitYtm: 0.026,
isBond: true,
bondDriverType: 'CP',
bondAuto: { CP: false, DP: true, YD: true },
bondRev: { CP: false, DP: false, YD: false }
};
// 模拟修复后的 calcBondForItem Promise 链
mockPost({ success: false, msg: '债券不存在' }, true)
.done(function () {
// 成功分支(不会执行)
SwapCalc.applyBondCalcSuccess(item, 'CP');
})
.fail(function () {
// 修复:失败分支
SwapCalc.applyBondCalcFailure(item, 'CP');
});
// 验证失败分支正确执行了 applyBondCalcFailure
expect(item.PosiGrossPrice).toBeNull(); // 非源字段被清空
expect(item.InitYtm).toBeNull(); // 非源字段被清空
expect(item.PosiNetNoFeePrice).toBe(0.995); // 源字段保留
expect(item.bondDriverType).toBeNull(); // 标识全清
expect(item.bondAuto).toEqual({ CP: false, DP: false, YD: false });
expect(item.bondRev).toEqual({ CP: false, DP: false, YD: false });
});
test('网络异常也走 reject → .fail 被调用', () => {
const failFn = jest.fn();
const doneFn = jest.fn();
// 网络异常时 jQuery $.ajax().fail(deferred.reject) 也会触发 reject
mockPost(null, true)
.done(doneFn)
.fail(failFn);
expect(doneFn).not.toHaveBeenCalled();
expect(failFn).toHaveBeenCalled();
});
});
// ============================================================================
// 约定2补充:calcBondForItem 所有失败分支都调 applyBondCalcFailure + syncBondFlags
// ============================================================================
// 验证 calcBondForItem 的每个退出路径都正确清空了非源字段和标识。
// 这些测试模拟 calcBondForItem 内部的条件判断,验证纯函数层面的正确性。
describe('约定2补充:所有失败分支都清空非源字段+清标识', () => {
function makeBondItem(driver) {
return {
PosiNetNoFeePrice: 0.995,
PosiGrossPrice: 1.0,
InitYtm: 0.026,
isBond: true,
UnderlyingCode: '190000.IB',
bondDriverType: driver,
bondAuto: { CP: driver !== 'CP', DP: driver !== 'DP', YD: driver !== 'YD' },
bondRev: { CP: false, DP: false, YD: false }
};
}
test('分支1:源字段为空/非数 → applyBondCalcFailure', () => {
const item = makeBondItem('CP');
item.PosiNetNoFeePrice = null; // 源字段为空
SwapCalc.applyBondCalcFailure(item, 'CP');
expect(item.PosiNetNoFeePrice).toBeNull(); // 源字段保留 null
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 });
});
test('分支1b:源字段为 NaN → applyBondCalcFailure', () => {
const item = makeBondItem('DP');
item.PosiGrossPrice = NaN;
SwapCalc.applyBondCalcFailure(item, 'DP');
expect(item.PosiNetNoFeePrice).toBeNull(); // 非源清空
// PosiGrossPrice 是 NaNapplyBondCalcFailure 会把它设为 null
expect(item.InitYtm).toBeNull();
expect(item.bondDriverType).toBeNull();
});
test('分支2:估值日缺失 → applyBondCalcFailure', () => {
const item = makeBondItem('CP');
// 模拟 calcBondForItem 中估值日缺失的处理
const sdMsg = SwapCalc.getBondStartDateMissingMsg(null);
expect(sdMsg).not.toBeNull();
SwapCalc.applyBondCalcFailure(item, 'CP');
expect(item.PosiNetNoFeePrice).toBe(0.995); // 源字段保留
expect(item.PosiGrossPrice).toBeNull(); // 非源清空
expect(item.InitYtm).toBeNull();
expect(item.bondDriverType).toBeNull();
});
test('分支3:计算器返回业务错误(reject) → applyBondCalcFailure(经 .fail 路径)', () => {
const item = makeBondItem('YD');
// 模拟计算器返回业务错误
const respObj = { errCode: 1, errMsg: '债券不存在', cleanPrice: 0, dirtyPrice: 0, ytm: 0 };
const err = SwapCalc.getBondCalcErrorMessage(respObj);
expect(err).toBe('债券不存在');
// .fail 中调 applyBondCalcFailure
SwapCalc.applyBondCalcFailure(item, 'YD');
expect(item.InitYtm).toBe(0.026); // 源字段保留
expect(item.PosiNetNoFeePrice).toBeNull(); // 非源清空
expect(item.PosiGrossPrice).toBeNull(); // 非源清空
expect(item.bondDriverType).toBeNull();
});
test('分支4:计算器返回成功但值域异常 → applyBondCalcFailure(经 .done 内拦截)', () => {
const item = makeBondItem('CP');
// 模拟计算器返回 errCode=0 但净价为负
const respObj = { errCode: 0, dirtyPrice: 100, cleanPrice: -117.93, ytm: 6.37 };
const err = SwapCalc.getBondCalcErrorMessage(respObj);
expect(err).not.toBeNull();
expect(err).toContain('净价');
// .done 内检测到错误 → 调 applyBondCalcFailure
SwapCalc.applyBondCalcFailure(item, 'CP');
expect(item.PosiNetNoFeePrice).toBe(0.995); // 源字段保留
expect(item.PosiGrossPrice).toBeNull(); // 非源清空
expect(item.InitYtm).toBeNull();
});
test('分支5:计算器无响应(resp.obj 为 null) → applyBondCalcFailure', () => {
const item = makeBondItem('DP');
// 模拟 .done 内 !resp || !resp.obj 分支
const resp = null;
const err = SwapCalc.getBondCalcErrorMessage(resp);
expect(err).toBe('债券计算器无响应,已保留手工输入');
SwapCalc.applyBondCalcFailure(item, 'DP');
expect(item.PosiGrossPrice).toBe(1.0); // 源字段保留
expect(item.PosiNetNoFeePrice).toBeNull(); // 非源清空
expect(item.InitYtm).toBeNull();
});
test('分支6:计算器返回哨兵值(-999999) → applyBondCalcFailure', () => {
const item = makeBondItem('CP');
const respObj = { errCode: 0, dirtyPrice: 100, cleanPrice: 99, ytm: -999999 };
const err = SwapCalc.getBondCalcErrorMessage(respObj);
expect(err).not.toBeNull();
expect(err).toContain('哨兵值');
SwapCalc.applyBondCalcFailure(item, 'CP');
expect(item.PosiNetNoFeePrice).toBe(0.995);
expect(item.PosiGrossPrice).toBeNull();
expect(item.InitYtm).toBeNull();
});
});
// ============================================================================
// 约定3完整状态机:回车→源/AUTO,编辑→REV,回车→源/AUTO(覆盖REV
// ============================================================================
// 端到端验证约定1/2/3 的状态机转换顺序:
// 初始(无标识) → 回车CP(源CP+AUTO DP/YD) → 编辑DP(REV DP, 清源/AUTO) → 回车DP(源DP+AUTO CP/YD)
describe('约定1/2/3 状态机:端到端转换', () => {
function makeBondItem() {
return {
PosiNetNoFeePrice: 0.995,
PosiGrossPrice: 1.0,
InitYtm: 0.026,
bondDriverType: null,
bondAuto: { CP: false, DP: false, YD: false },
bondRev: { CP: false, DP: false, YD: false }
};
}
test('Step1 回车净价(CP) → 源=CP, AUTO={DP,YD}, REV 全清', () => {
const item = makeBondItem();
SwapCalc.applyBondCalcSuccess(item, 'CP');
expect(item.bondDriverType).toBe('CP');
expect(item.bondAuto).toEqual({ CP: false, DP: true, YD: true });
expect(item.bondRev).toEqual({ CP: false, DP: false, YD: false });
});
test('Step2 编辑全价(DP)未回车 → REV=DP, 清源/AUTO', () => {
const item = makeBondItem();
// 先回车 CP
SwapCalc.applyBondCalcSuccess(item, 'CP');
// 再编辑 DP(未回车)
SwapCalc.applyBondManualEdit(item, 'DP');
expect(item.bondDriverType).toBeNull(); // 源被清
expect(item.bondAuto).toEqual({ CP: false, DP: false, YD: false }); // AUTO 被清
expect(item.bondRev).toEqual({ CP: false, DP: true, YD: false }); // 仅 DP 标 REV
});
test('Step3 回车全价(DP) → 源=DP, AUTO={CP,YD}, REV 全清(REV 被覆盖)', () => {
const item = makeBondItem();
// 回车 CP
SwapCalc.applyBondCalcSuccess(item, 'CP');
// 编辑 DP(未回车)
SwapCalc.applyBondManualEdit(item, 'DP');
// 回车 DP
SwapCalc.applyBondCalcSuccess(item, 'DP');
expect(item.bondDriverType).toBe('DP'); // 新源
expect(item.bondAuto).toEqual({ CP: true, DP: false, YD: true }); // CP 降级为 AUTO
expect(item.bondRev).toEqual({ CP: false, DP: false, YD: false }); // REV 被清
});
test('Step4 回车收益率(YD) → 源=YD, AUTO={CP,DP}(最后回车者恒为源)', () => {
const item = makeBondItem();
SwapCalc.applyBondCalcSuccess(item, 'CP');
SwapCalc.applyBondCalcSuccess(item, 'DP');
SwapCalc.applyBondCalcSuccess(item, 'YD');
expect(item.bondDriverType).toBe('YD');
expect(item.bondAuto).toEqual({ CP: true, DP: true, YD: false });
});
test('Step5 计算器失败 → 保留源值、另两清空、标识全清', () => {
const item = makeBondItem();
SwapCalc.applyBondCalcSuccess(item, 'CP');
// 模拟计算器失败
SwapCalc.applyBondCalcFailure(item, 'CP');
expect(item.bondDriverType).toBeNull();
expect(item.bondAuto).toEqual({ CP: false, DP: false, YD: false });
expect(item.bondRev).toEqual({ CP: false, DP: false, YD: false });
// PosiNetNoFeePrice(源) 保留,另两被清空
});
test('Step6 加载已保存单 → 标识全清(clearBondCalcMarksOnLoad', () => {
const item = makeBondItem();
SwapCalc.applyBondCalcSuccess(item, 'DP');
// 模拟重开/刷新
SwapCalc.clearBondCalcMarksOnLoad(item);
expect(item.bondDriverType).toBeNull();
expect(item.bondAuto).toEqual({ CP: false, DP: false, YD: false });
expect(item.bondRev).toEqual({ CP: false, DP: false, YD: false });
// 价格值保留
expect(item.PosiNetNoFeePrice).toBe(0.995);
});
test('Step7 切换标的 → 标识全清(clearBondCalcFlags', () => {
const item = makeBondItem();
SwapCalc.applyBondCalcSuccess(item, 'CP');
SwapCalc.applyBondManualEdit(item, 'DP');
// 模拟切换标的
SwapCalc.clearBondCalcFlags(item);
expect(item.bondDriverType).toBeNull();
expect(item.bondAuto).toEqual({ CP: false, DP: false, YD: false });
expect(item.bondRev).toEqual({ CP: false, DP: false, YD: false });
});
// ---- 以下为步骤3-b 及多字段组合场景(文档明确列出但此前缺失) ----
test('Step3-b 回车CP→改DP→改YD(均不回车) → DP与YD均累计REV(修复后行为)', () => {
const item = makeBondItem();
// 步骤1:回车净价(CP)
SwapCalc.applyBondCalcSuccess(item, 'CP');
expect(item.bondDriverType).toBe('CP');
expect(item.bondRev).toEqual({ CP: false, DP: false, YD: false });
// 步骤2:编辑全价(DP)未回车 → REV={DP:true}
SwapCalc.applyBondManualEdit(item, 'DP');
expect(item.bondRev).toEqual({ CP: false, DP: true, YD: false });
// ★ 步骤3-b:不回车,又改收益率(YD) → DP的REV保留、YD也标REV(累计,不再被覆盖)
SwapCalc.applyBondManualEdit(item, 'YD');
expect(item.bondDriverType).toBeNull(); // 源已被清
expect(item.bondAuto).toEqual({ CP: false, DP: false, YD: false }); // AUTO 已清
// 核心断言:applyBondManualEdit 改为累计 REV,多字段连续手动编辑时每个改过的字段都留 REV
expect(item.bondRev).toEqual({ CP: false, DP: true, YD: true });
// 数值保持不变(约定3:编辑不联动)
expect(item.PosiNetNoFeePrice).toBe(0.995); // 净价保留
expect(item.PosiGrossPrice).toBe(1.0); // 全价保留
expect(item.InitYtm).toBe(0.026); // 收益率保留
});
test('Step3-b 变体:回车CP后连续编辑三字段(CP→DP→YD) → 三个字段均累计REV', () => {
const item = makeBondItem();
SwapCalc.applyBondCalcSuccess(item, 'CP');
// 连续编辑三个字段,都不回车
SwapCalc.applyBondManualEdit(item, 'CP'); // 先改源字段本身
expect(item.bondRev).toEqual({ CP: true, DP: false, YD: false });
SwapCalc.applyBondManualEdit(item, 'DP'); // 再改第二个 → CP的REV保留、DP也标REV
expect(item.bondRev).toEqual({ CP: true, DP: true, YD: false });
SwapCalc.applyBondManualEdit(item, 'YD'); // 最后改第三个 → 三者均累计
expect(item.bondRev).toEqual({ CP: true, DP: true, YD: true }); // 全部累计,不再"最后赢"
});
test('Step3-b 三字段轮换编辑(CP→DP→YD→CP) → 三个字段均累计REV', () => {
const item = makeBondItem();
SwapCalc.applyBondCalcSuccess(item, 'CP');
// 四次连续手工编辑,模拟用户在三个输入框之间来回跳转
SwapCalc.applyBondManualEdit(item, 'CP');
SwapCalc.applyBondManualEdit(item, 'DP');
SwapCalc.applyBondManualEdit(item, 'YD');
SwapCalc.applyBondManualEdit(item, 'CP'); // 回到第一个
// 无论轮换多少次,每次编辑的字段都累计REV(不再"只有最后一次留REV"
expect(item.bondRev).toEqual({ CP: true, DP: true, YD: true });
expect(item.bondDriverType).toBeNull();
expect(item.bondAuto).toEqual({ CP: false, DP: false, YD: false });
});
test('步骤4-1 回车CP→回车DP→改YD(不回车) → 从多源状态降级为单REV(YD)', () => {
const item = makeBondItem();
// 连续回车两个字段 → 多源状态
SwapCalc.applyBondCalcSuccess(item, 'CP');
SwapCalc.applyBondCalcSuccess(item, 'DP');
expect(item.bondDriverType).toBe('DP'); // 最后回车者为源
expect(item.bondAuto).toEqual({ CP: true, DP: false, YD: true }); // CP降级AUTO
// 编辑第三个字段但不回车 → 降级为单字段REV
SwapCalc.applyBondManualEdit(item, 'YD');
expect(item.bondDriverType).toBeNull(); // 源被清
expect(item.bondAuto).toEqual({ CP: false, DP: false, YD: false }); // AUTO全清
expect(item.bondRev).toEqual({ CP: false, DP: false, YD: true }); // 仅YD留REV
});
test('步骤4-1 变体:回车CP→回车DP→回车YD→改CP(不回车) → 最终仅CP留REV', () => {
const item = makeBondItem();
// 三字段都回车过 → YD是最终源
SwapCalc.applyBondCalcSuccess(item, 'CP');
SwapCalc.applyBondCalcSuccess(item, 'DP');
SwapCalc.applyBondCalcSuccess(item, 'YD');
expect(item.bondDriverType).toBe('YD');
// 手工编辑CP(不回车)→ 全部标识降级,仅CP留REV
SwapCalc.applyBondManualEdit(item, 'CP');
expect(item.bondDriverType).toBeNull();
expect(item.bondAuto).toEqual({ CP: false, DP: false, YD: false });
expect(item.bondRev).toEqual({ CP: true, DP: false, YD: false });
});
test('多字段连续编辑时数值始终保留、不联动(约定3核心语义)', () => {
const item = makeBondItem();
// 设定初始值
item.PosiNetNoFeePrice = 0.995; // 净价 99.5
item.PosiGrossPrice = 1.0; // 全价 100
item.InitYtm = 0.03; // 收益率 3%
// 回车CP触发计算(模拟成功——数值会被覆盖,但这里只看标识)
SwapCalc.applyBondCalcSuccess(item, 'CP');
// 连续编辑另两字段(不回车)→ 数值不应被联动修改
SwapCalc.applyBondManualEdit(item, 'DP');
expect(item.PosiNetNoFeePrice).toBe(0.995);
expect(item.PosiGrossPrice).toBe(1.0);
expect(item.InitYtm).toBe(0.03);
SwapCalc.applyBondManualEdit(item, 'YD');
expect(item.PosiNetNoFeePrice).toBe(0.995); // 净价不变
expect(item.PosiGrossPrice).toBe(1.0); // 全价不变
expect(item.InitYtm).toBe(0.03); // 收益率不变
// 再编辑回CP本身
SwapCalc.applyBondManualEdit(item, 'CP');
expect(item.PosiNetNoFeePrice).toBe(0.995); // 仍然不变
expect(item.PosiGrossPrice).toBe(1.0);
expect(item.InitYtm).toBe(0.03);
});
});
// ============================================================================
// 回归守卫:onBondPriceKeydown 的键码过滤逻辑
// ============================================================================
// 验证 onBondPriceKeydown 中"哪些键触发清标识、哪些不触发"的判断逻辑。
// 回车(13/108)除外——由 v-on:enter 驱动;修饰键(Ctrl/Alt/Meta)除外;
// Tab(9)/Home(35)/End(36)/左(37)/右(39)/F5(116)除外——不改变数值。
describe('onBondPriceKeydown 键码过滤逻辑回归', () => {
// 复刻 onBondPriceKeydown 的过滤判断(不含 Vue 调用)
function shouldClearFlags(event) {
var kc = event.which || event.keyCode;
if (kc === 13 || kc === 108) return false; // 回车
if (event.ctrlKey || event.altKey || event.metaKey) return false; // 修饰键
if (kc === 9 || kc === 35 || kc === 36 || kc === 37 || kc === 39 || kc === 116) return false; // 导航/功能键
return true; // 其余按键(数字/小数点/Backspace/Delete/减号)改变数值 → 清标识
}
test('回车键(13/108)不触发清标识', () => {
expect(shouldClearFlags({ keyCode: 13 })).toBe(false);
expect(shouldClearFlags({ keyCode: 108 })).toBe(false);
});
test('修饰键不触发清标识', () => {
expect(shouldClearFlags({ keyCode: 67, ctrlKey: true })).toBe(false); // Ctrl+C
expect(shouldClearFlags({ keyCode: 86, ctrlKey: true })).toBe(false); // Ctrl+V
expect(shouldClearFlags({ keyCode: 65, altKey: true })).toBe(false); // Alt+A
expect(shouldClearFlags({ keyCode: 82, metaKey: true })).toBe(false); // Cmd+R
});
test('导航/功能键不触发清标识', () => {
expect(shouldClearFlags({ keyCode: 9 })).toBe(false); // Tab
expect(shouldClearFlags({ keyCode: 35 })).toBe(false); // End
expect(shouldClearFlags({ keyCode: 36 })).toBe(false); // Home
expect(shouldClearFlags({ keyCode: 37 })).toBe(false); // Left
expect(shouldClearFlags({ keyCode: 39 })).toBe(false); // Right
expect(shouldClearFlags({ keyCode: 116 })).toBe(false); // F5
});
test('数字键触发清标识', () => {
expect(shouldClearFlags({ keyCode: 48 })).toBe(true); // 0
expect(shouldClearFlags({ keyCode: 57 })).toBe(true); // 9
expect(shouldClearFlags({ keyCode: 96 })).toBe(true); // Numpad 0
expect(shouldClearFlags({ keyCode: 105 })).toBe(true); // Numpad 9
});
test('Backspace(8)/Delete(46)触发清标识', () => {
expect(shouldClearFlags({ keyCode: 8 })).toBe(true); // Backspace
expect(shouldClearFlags({ keyCode: 46 })).toBe(true); // Delete
});
test('小数点(110/190)触发清标识', () => {
expect(shouldClearFlags({ keyCode: 110 })).toBe(true); // Numpad dot
expect(shouldClearFlags({ keyCode: 190 })).toBe(true); // Dot
});
test('减号(109/189)触发清标识', () => {
expect(shouldClearFlags({ keyCode: 109 })).toBe(true); // Numpad minus
expect(shouldClearFlags({ keyCode: 189 })).toBe(true); // Minus
});
});