diff --git a/YLErpWeb/fe-tests/bondCalc.integration.test.js b/YLErpWeb/fe-tests/bondCalc.integration.test.js new file mode 100644 index 00000000..e89839cf --- /dev/null +++ b/YLErpWeb/fe-tests/bondCalc.integration.test.js @@ -0,0 +1,735 @@ +/** + * bondCalc.integration.test.js — 债券三字段互算集成层测试 + * ============================================================================ + * 目的:覆盖 swapCalc.js 纯函数与宿主框架(Vue/jQuery/main.post)之间的"胶水代码", + * 守卫纯函数测试(bondCalc.test.js)无法触及的集成层断裂点。 + * + * 覆盖的根因(见 项目文档/互换债券三字段互算踩坑总结与测试指南.md): + * 根因1:vue-number-input .native 修饰符被 jQuery 拦截 → keydown 事件不触发 + * 根因2:Vue 2 非 data() 属性不可响应 → 直接赋值不触发视图更新 + * 根因3:$set 设置相同对象引用不触发更新 → 需创建新对象引用 + * 根因4:main.post 业务错误走 reject → .done() 不执行失败分支 + * + * 运行:cd YLErpWeb/fe-tests && npx jest bondCalc.integration + */ + +const { JSDOM } = require('jsdom'); +const dom = new JSDOM('
'); +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'); + +// ============================================================================ +// 根因 1:vue-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('根因1:vue-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 重绑了 keydown,addEventListener 仍能收到事件 + 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+3:Vue 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+3:Vue 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); + }); +}); + +// ============================================================================ +// 根因 4:main.post Promise 链——reject 走 .fail 不走 .done +// ============================================================================ +// 背景:main.post 包装了 jQuery $.ajax,在 resp.success===false(业务错误)时 +// 调用 deferred.reject(resp)。calcBondForItem 只挂了 .done(),reject 时不执行。 +// 修复:补 .fail() 处理 reject 路径。 +describe('根因4:main.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 是 NaN,applyBondCalcFailure 会把它设为 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 }); + }); +}); + +// ============================================================================ +// 回归守卫: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 + }); +}); diff --git a/项目文档/互换债券三字段互算踩坑总结与测试指南.md b/项目文档/互换债券三字段互算踩坑总结与测试指南.md new file mode 100644 index 00000000..d6e8b2c9 --- /dev/null +++ b/项目文档/互换债券三字段互算踩坑总结与测试指南.md @@ -0,0 +1,212 @@ +# 互换债券三字段互算踩坑总结与测试指南 + +> 本文档总结 EQD-6838 债券净价/全价/收益率三字段互算功能从初始实现到最终可用过程中踩过的所有坑, +> 包括根因分析、修复方案、能否被不同技术栈避免、以及如何用单测在早期拦截。 +> 关联文件:`swapTradeEdit.js` / `swapCalc.js` / `fastVue.base.js` / `TradeEdit.cshtml` / `base/main.js` + +--- + +## 一、功能背景 + +债券标的在互换交易录入页有三个价格字段:净价(`PosiNetNoFeePrice`)、全价(`PosiGrossPrice`)、收益率(`InitYtm`)。 +三者可互相推导(知道任一可算另两个,通过后端 `/Bond/CalcBond` 接口)。 + +**交互约定**: +- **约定1**:在任一字段**敲回车** → 以该字段为"源"调计算器,反算另两个并覆盖。 +- **约定2**:计算器调用成功→源字段标"源"、另两个标"AUTO";失败→保留源字段值、另两个清空、三标识全清。 +- **约定3**:编辑某字段(失焦/按键输入)但**未回车** → 不联动另两字段,清掉源/AUTO标识,仅本字段标"REV"(人工输入)。 + +--- + +## 二、踩坑时间线与根因 + +从 d662399a 到 56256cad 共 7 次提交,涉及 5 个独立根因。每次修复后部署测试发现问题仍未解决,再定位新根因。 + +### 根因 1:`.native` 修饰符被 jQuery 事件系统拦截 + +| 项目 | 内容 | +|------|------| +| **提交** | d662399a(初始实现)→ cee69a02(修复) | +| **现象** | 用户按键时"源/AUTO"标识不消失,`onBondPriceKeydown` 从未执行 | +| **根因** | `vue-number-input` 组件在 `mounted()` 中通过 jQuery `$(_el).on('keydown', __keyHandle)` 重新绑定了 keydown。jQuery 的事件绑定机制与 Vue 的 `.native` 修饰符冲突,导致父组件的 `v-on:keydown.native` 监听器收不到事件 | +| **修复** | 在组件 `mounted()` 内部用原生 `this.$el.addEventListener('keydown', fn)` + `self.$emit('keydown', e)` 绕过 jQuery 事件系统;cshtml 从 `v-on:keydown.native` 改为 `v-on:keydown` | +| **代码位置** | `fastVue.base.js` mounted() 钩子 | + +### 根因 2:Vue 2 响应式失效——非 `data()` 声明的属性不可响应 + +| 项目 | 内容 | +|------|------| +| **提交** | d662399a(初始实现)→ cee69a02(修复) | +| **现象** | `applyBondManualEdit` 等纯函数正确修改了 `item.bondDriverType` 等属性,但视图不更新 | +| **根因** | `bondDriverType`/`bondAuto`/`bondRev` 这三个属性不在后端返回的数据模型中,item 对象从未在 Vue 的 `data()` 中声明过这些属性。Vue 2 基于 `Object.defineProperty`,只能追踪初始化时已存在的属性,后加属性是非响应式的,赋值后不触发视图更新 | +| **修复** | 新增 `syncBondFlags(item)` 方法,用 `this.$set(item, 'bondDriverType', ...)` 强制将属性注册为响应式 | +| **代码位置** | `swapTradeEdit.js` syncBondFlags() | + +### 根因 3:`$set` 设置相同对象引用不触发更新 + +| 项目 | 内容 | +|------|------| +| **提交** | cee69a02(初版修复)→ 7c0572ad(二次修复) | +| **现象** | 加了 `syncBondFlags` 后标识仍不更新。排查发现 `$set` 传入了相同对象引用 | +| **根因** | Vue 2 的 `$set` 内部会做 `newVal === oldVal` 判断。如果 `applyBondManualEdit` 原地修改 `state.bondAuto` 后引用没变,`this.$set(item, 'bondAuto', item.bondAuto)` 的 newVal 与 oldVal 是同一个对象引用,Vue 会跳过更新 | +| **修复** | 每次都创建全新的对象字面量:`{ CP: !!item.bondAuto.CP, DP: !!item.bondAuto.DP, YD: !!item.bondAuto.YD }`,保证引用不同。再加 `this.$forceUpdate()` 兜底 | +| **代码位置** | `swapTradeEdit.js` syncBondFlags() | + +### 根因 4:`main.post` 业务错误走 `reject`,`.done()` 不执行 + +| 项目 | 内容 | +|------|------| +| **提交** | d662399a(初始实现)→ ce4ef629(修复) | +| **现象** | 计算器返回业务错误(如债券不存在)时,"另两字段清空+三标识全清"逻辑(约定2补充)不执行 | +| **根因** | `main.post` 是 jQuery `$.ajax` 的包装。`main.__post` 在 `resp.success===false`(业务错误)时调用 `deferred.reject(resp)`,而 `calcBondForItem` 只挂了 `.done()` 回调。Promise reject 不会触发 `.done()`,整个失败处理分支被静默跳过 | +| **修复** | 补 `.fail(function(resp) { ... applyBondCalcFailure + syncBondFlags })` 处理 reject 路径 | +| **代码位置** | `swapTradeEdit.js` calcBondForItem() + `base/main.js` __post() | + +### 根因 5:部署/缓存/构建路径问题 + +| 项目 | 内容 | +|------|------| +| **提交** | 8124bb46 + 56256cad | +| **现象** | 代码改了部署后不生效 | +| **根因** | ① `swapTradeEdit.js` 是独立 `