根因: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 例通过。
213 lines
12 KiB
Markdown
213 lines
12 KiB
Markdown
# 互换债券三字段互算踩坑总结与测试指南
|
||
|
||
> 本文档总结 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. **根因1-4 都是 jQuery 与 Vue 混用导致的**。`vue-number-input` 是用 jQuery 包装的"伪 Vue 组件",破坏了 Vue 的响应式和事件契约。纯 Vue2 原生组件能消除这些坑。
|
||
2. **Vue 2 的 `Object.defineProperty` 响应式限制(根因2-3)在 Vue 3 中通过 Proxy 解决**,但仍需注意 `reactive()` 的使用方式。
|
||
3. **纯函数测试只能守卫逻辑层**。现有 `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 运行方式
|
||
|
||
```bash
|
||
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 |
|
||
|
||
### 通用规则
|
||
|
||
1. **纯函数是必要条件,不是充分条件**。纯函数测试全绿不代表集成层没问题。
|
||
2. **jQuery 与 Vue 混用时,jQuery 的事件绑定会覆盖 Vue 的事件系统**。如必须混用,在组件内部用原生 `addEventListener` + `$emit` 桥接。
|
||
3. **Vue 2 中动态添加的属性必须用 `$set`**,且 `$set` 传入的对象引用必须与旧值不同,否则不触发更新。
|
||
4. **jQuery Promise 的 reject 不走 `.done()`**。凡要区分成功/失败,必须同时挂 `.done()` 和 `.fail()`。改用 `async/await` + `try/catch` 可根本消除此问题。
|
||
5. **独立 `<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 路径 | 文档化 |
|