/** * 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 fs = require('fs'); const path = require('path'); 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'); 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 回车时故意再派发一次 input:event.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'); }); }); // ============================================================================ // 根因 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 }); }); // ---- 以下为步骤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 }); });