根因:applyBondManualEdit 每次调用先重置全部 bondRev 再只标当前字段,
导致连续手动编辑多字段(不回车)时只有最后编辑的字段留 REV,
中间手工编辑字段的 REV 被静默抹掉,UI 与步骤3-b需求不符。
修复:applyBondManualEdit 改为累计 REV(仅置当前字段、保留其它已手工编辑字段的 REV),
driver/AUTO 仍清。单字段编辑(步骤2/3)行为不变,无回归。
配套:
- 更新 a2d2bb31 中3个旧断言("仅最后留REV")为断言累加,避免测试守护错误行为
- 文档约定3 补充"连续编辑多字段时各手动字段累计REV"
swapCalc.js 为独立 script(?v=JsVersion),部署 dotnet publish 即生效,无需重建 bundle。
测试:bondCalc 全套 72 例通过。
12 KiB
互换债券三字段互算踩坑总结与测试指南
本文档总结 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"(人工输入)。连续手动编辑多字段(均不回车)时各被改过的字段均累计REV(不清空其它已手工编辑字段的REV),使 UI 正确显示"全部为人工输入"、且后续计算器回写时不会被误覆盖。
二、踩坑时间线与根因
从 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 是独立 <script> 引入(不在 bundle.js),需 dotnet publish 刷新 JsVersion 缓存键才生效;② Linux 上 bundle 输入路径大小写敏感导致构建失败;③ bundle 缺失输入时应非致命否则连累 publish |
| 修复 | 加版本标记日志确认部署生效;补 rebuild-bundles.py 路径大小写注释 + 非致命处理 |
| 代码位置 | rebuild-bundles.py + YLErpWeb.csproj |
三、根因分类与技术栈对比
| # | 根因 | 类别 | 纯Vue2原生组件能否避免 | 前后端分离能否避免 | 单测能否拦截 |
|---|---|---|---|---|---|
| 1 | .native 被 jQuery 拦截 |
jQuery/Vue 混用 | ✅ | ✅ | ✅ 组件测试 |
| 2 | 非 data() 属性不可响应 | Vue 2 限制 | ⚠️ 需注意 | ⚠️ Vue3 可避免 | ✅ 响应式测试 |
| 3 | $set 相同引用不触发 | Vue 2 优化 | ⚠️ 需注意 | ⚠️ Vue3 可避免 | ✅ 响应式测试 |
| 4 | main.post reject 不走 .done | jQuery Promise | ✅ | ✅ (async/await) | ✅ mock 测试 |
| 5 | 部署缓存/路径 | 构建/部署 | 无关 | 无关 | ❌ 需本地验证 |
核心结论
- 根因1-4 都是 jQuery 与 Vue 混用导致的。
vue-number-input是用 jQuery 包装的"伪 Vue 组件",破坏了 Vue 的响应式和事件契约。纯 Vue2 原生组件能消除这些坑。 - Vue 2 的
Object.defineProperty响应式限制(根因2-3)在 Vue 3 中通过 Proxy 解决,但仍需注意reactive()的使用方式。 - 纯函数测试只能守卫逻辑层。现有
bondCalc.test.js测试swapCalc.js纯函数全绿——因为纯函数逻辑本身正确。问题出在纯函数与宿主框架的集成层(事件绑定、响应式、Promise 链),这层完全没有测试覆盖。
四、为什么多次改动都没修好
4.1 纯函数全绿带来的虚假信心
纯函数层(swapCalc.js) ← bondCalc.test.js 全绿 ✅
↓ 直接赋值 state.xxx
Vue 响应式层(syncBondFlags) ← 无测试 ❌
↓ $set / $forceUpdate
jQuery 事件层(vue-number-input) ← 无测试 ❌
↓ $emit('keydown')
事件处理层(onBondPriceKeydown) ← 无测试 ❌
↓ main.post
Promise 链(.done/.fail) ← 无测试 ❌
每一层都是潜在的断裂点,但只有最底层的纯函数有测试。上层任何一层断裂,表现都是"标识不更新/逻辑不生效",难以区分是哪层的问题。
4.2 发布循环成本极高
每次修改 → dotnet publish → 部署测试环境 → 用户验证 → 发现问题 → 重新修改。一轮循环 10+ 分钟,7 次提交意味着至少 70 分钟的等待。如果当时有组件集成测试,可以在本地 5 秒内发现"keydown 事件没触发"或"响应式没更新"。
4.3 不得不靠 console.log 试错
7c0572ad 提交在 onBondPriceKeydown 中加了 console.log before/after,帮助确认了"keydown 事件能触发、状态值也正确变了,但视图没刷新"——从而定位到 Vue 响应式问题。这是被动的、发布后才发现的调试方式。正确的做法是用组件测试在 CI 中主动拦截。
五、测试覆盖方案
5.1 已有测试(纯函数层)
| 测试文件 | 覆盖范围 | 状态 |
|---|---|---|
bondCalc.test.js |
swapCalc.js 纯函数:applyBondCalcResult/Success/Failure/ManualEdit、单位换算、错误判断 | ✅ 全绿 |
swapCalc.test.js |
swapCalc.js 纯函数:回归守卫4个历史bug + C#金标准交叉校验 + D1/D2/D3 回归 | ✅ 全绿 |
fastVue.enter.test.js |
FastVue.numberInput 回车链路 + vueNumberInput $emit enter/input 分发 | ✅ 全绿 |
numberInput.paste.test.js |
FastVue.numberInput 粘贴路径回归 | ✅ 全绿 |
5.2 新增测试(集成层)
| 测试文件 | 覆盖的根因 | 测试要点 |
|---|---|---|
bondCalc.integration.test.js |
根因1-4 | 见下方详细说明 |
测试要点 1:vue-number-input keydown emit(根因1)
验证组件 mounted() 中的 addEventListener('keydown') + $emit('keydown') 链路。
模拟 keydown 事件,断言组件是否正确 emit 了 keydown 事件给父组件。
如果 .native 修饰符仍被 jQuery 拦截,此测试会红。
测试要点 2:Vue 2 响应式——$set 新引用 vs 旧引用(根因2-3)
验证 syncBondFlags 的三种写法:
- ❌ 直接赋值(非响应式,视图不更新)
- ❌
$set传入相同引用(Vue 跳过更新) - ✅
$set传入新对象引用(正确触发更新)
用 mock Vue 实例模拟 $set 行为,断言新旧引用是否不同。
测试要点 3:main.post Promise 链——reject 走 .fail 不走 .done(根因4)
用 jQuery $.Deferred 模拟 main.post 的 resolve/reject 行为。
验证:
- resolve 时
.done()被调用、.fail()不被调用 - reject 时
.fail()被调用、.done()不被调用 - 仅挂
.done()时 reject 会导致失败分支静默跳过
测试要点 4:约定2补充——所有失败分支都调 applyBondCalcFailure + syncBondFlags
验证 calcBondForItem 的所有退出路径都正确清空了非源字段和标识:
- 源字段为空/非数
- 估值日缺失
- 计算器返回业务错误(reject 路径)
- 计算器返回网络错误(reject 路径)
- 计算器返回成功但值域异常(done 内拦截)
5.3 运行方式
cd YLErpWeb/fe-tests && npm i && npm test
# 或单独运行集成测试
cd YLErpWeb/fe-tests && npx jest bondCalc.integration
六、预防清单
| 阶段 | 措施 | 预防的根因 |
|---|---|---|
| 设计阶段 | 画出"DOM事件→jQuery处理→Vue emit→handler→纯函数→syncBondFlags→视图"的完整链路图,识别每层风险 | 1-4 |
| 编码阶段 | 状态属性在 Vue data() 或 created() 中预先声明;不用 jQuery 包装 Vue 组件 |
2, 1 |
| 测试阶段 | 纯函数测试 + 组件集成测试 + Promise 链测试三层覆盖 | 1-4 |
| 调试阶段 | 用 Vue DevTools 观察响应式数据变化,比 console.log 更高效 | 缩短调试周期 |
| 部署阶段 | 本地 dotnet publish 验证 + 版本标记日志确认缓存刷新 |
5 |
通用规则
- 纯函数是必要条件,不是充分条件。纯函数测试全绿不代表集成层没问题。
- jQuery 与 Vue 混用时,jQuery 的事件绑定会覆盖 Vue 的事件系统。如必须混用,在组件内部用原生
addEventListener+$emit桥接。 - Vue 2 中动态添加的属性必须用
$set,且$set传入的对象引用必须与旧值不同,否则不触发更新。 - jQuery Promise 的 reject 不走
.done()。凡要区分成功/失败,必须同时挂.done()和.fail()。改用async/await+try/catch可根本消除此问题。 - 独立
<script>文件的改动需刷新 JsVersion 缓存键。调试时加版本标记日志确认部署生效。
七、提交历史索引
| 提交 | 说明 | 修复的根因 |
|---|---|---|
d662399a |
初始实现:约定1/2/3 + 簿记逻辑1 | — |
cee69a02 |
修复Vue响应式 + 组件内emit keydown + 去掉重算按钮 | 1, 2 |
7c0572ad |
按键清标识加排查日志 + forceUpdate兜底 + 新对象引用 | 3 |
8124bb46 |
加版本标记日志确认部署是否加载最新代码 | 5 |
f5ed65aa |
补网络错误/响应异常分支的约定2补充处理 | 4(部分) |
ce4ef629 |
修复 main.post reject 未走 .done(约定2补充静默失效) | 4(完整) |
56256cad |
补充踩坑注释:main.post reject 机制 + 部署机制 + bundle 路径 | 文档化 |