Files
zszq-trs/项目文档/互换债券三字段互算踩坑总结与测试指南.md
hjhan 0747deb909 fix: 步骤3-b 多字段连续手动编辑应累计REV(不再仅最后赢)
根因: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 例通过。
2026-07-30 09:55:57 +08:00

213 lines
12 KiB
Markdown
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.
# 互换债券三字段互算踩坑总结与测试指南
> 本文档总结 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() 钩子 |
### 根因 2Vue 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 | 见下方详细说明 |
#### 测试要点 1vue-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` 行为,断言新旧引用是否不同。
#### 测试要点 3main.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 路径 | 文档化 |