Ver código fonte

docs(ac15): 批C1 变异实验记录(实现侧 3 组 + 判据侧 4 组)+ 补齐的三条边界 case

变异分两侧打,分开记:
- §7.8.1 实现侧(3 组):改错实现能不能被抓住
- §7.8.2 判据侧(4 组,本批新增):改错判据还红不红 —— 不红就说明那条断言是空的

判据侧四组结果(预期 vs 实际全部一致):
- M1' expirable() 自营分支去掉 `|| campus_id==0` → 只 C1-1 红(活动 92 期望 2 实测 4),C1-2 保持绿
- M2' clearedBalanceExpected 漏 Special → C1-2 红 2 项(Δspecial_expenses 期望 0 实测 900)
- M3' signedAll(0)→signedAll(4) → C1-6 红 1 项;整表 map 全等那条仍绿(两条是不同防线,都得留)
- M4' 清零断言改成"期望全局归 0" → C1-1 红(实测 Δ−1200,期望 −5000)

新增 §7.10 / §7.11:
- 三个边界 case 的构型与"真实/合成"标注
- requestid 列宽实测:legacy logid varchar(32) vs target log_id/request_id varchar(64)
  → 归类 INTENTIONAL-WIDEN,以及判定原则(>32 放宽 / =32 一致 / <32 真缺陷)
- C1-9 的真正发现:legacy 半途失败(1406)只回滚失败语句,**留下无凭证的扣款**
- C1-11 挖出的 legacy 数据缺陷:建行语句只写三列,而 `t_user_expense_school` 的金额列是
  `decimal(10,3) NULL DEFAULT NULL` → 新行 NULL、扣不动、凭证照写;全局表 DEFAULT 0.000 却真扣了
  ⇒ 同一笔业务两表矛盾。判定 LEGACY-DEFECT(legacy 错、target 对,不跟)
Q 4 horas atrás
pai
commit
84d53b27cb
1 arquivos alterados com 98 adições e 2 exclusões
  1. 98 2
      internal/academic/dualrun/stage2/README.md

+ 98 - 2
internal/academic/dualrun/stage2/README.md

@@ -214,7 +214,7 @@ SP11 的签名只有 4 个参数(`inputUserid, inputSchoolid, inputCampusid, i
 同理,`seedTarget` 在 `campus_id == 0` 时**不建校区行、不建 `t_legacy_mapping` 的 campus 关联**——
 如果为了"让 case 能跑"而造一个 `campus_id=0` 的校区行,就绕过了要验证的守卫。
 
-## 3.7 全部 13 个 case 一览
+## 3.7 批 A / 批 B 的 13 个 case 一览(批 C1 见 §7,批 C1 补齐后全量 24 个)
 
 |case|Op|入参|期望|
 |---|---|---|---|
@@ -440,7 +440,13 @@ CampusMissing: true   // 本 case 故意用不存在的校区;不建 t_campus
 没有它,"读不到账户"会被读成 0 余额,两侧都是 0 → 基线相等断言**空过**,
 整个 case 看起来在跑、其实什么都没测。
 
-### 7.8 批 C1 变异实验(3 组,全部还原)
+### 7.8 批 C1 变异实验(全部还原)
+
+变异分两侧打,结论不一样,所以要分开记:
+**实现侧**变异证明"代码写错会被抓住",**判据侧**变异证明"判据本身不是摆设"
+(判据侧若改了还不红,说明那条断言是空的 —— 它绿不绿跟被测代码无关)。
+
+#### 7.8.1 实现侧(3 组)
 
 | # | 改错什么 | 靶子 | 实测 |
 |---|---|---|---|
@@ -448,6 +454,25 @@ CampusMissing: true   // 本 case 故意用不存在的校区;不建 t_campus
 | **M2** | `t_campus.self_support` 硬编码成 1 | 校区元数据一致 + 活动失效范围 | `✘ 两侧看到的校区元数据一致`(target 自营=true ≠ 声明 false);`✘ 活动失效范围 → target 多失效 92:2→4` |
 | **M3** | 取消活动把报名状态写成 **4**(过期)而非 **0**(取消) | `归 0 不是 4` | `✘ 报名状态归 **0** → target {91=4,…}`;`✘ 只有本活动的报名状态变` |
 
+> M1 的注:`68542` / `40583` 的**真实**余额都是"全局 == 该校区"(9740/9740、5400/5400),
+> 在这种基线上"减 campusBalance"与"归 0"结果**完全一样**,M1 改错也不会红。
+> 所以 C1-1/C1-2 用 `Baseline` 把两者拉开成 5000/1200、3000/900 —— 差值 3800 / 2100,
+> 这条断言才真正 load-bearing。**绿了不等于测到了**,这一条是本批最大的教训。
+
+#### 7.8.2 判据侧(4 组,批 C1 交付时补做)
+
+| # | 改错什么(改判据,不改实现) | 预期红 | 实际红 | 一致? |
+|---|---|---|---|---|
+| **M1'** | `expirable()` 自营分支删掉 `\|\| it.CampusID == 0` | 只 C1-1 红,C1-2 保持绿 | `✘ 活动失效范围`:期望 `{91=4, 92=2, …}` vs 实测 `{91=4, 92=4, …}`;C1-1 FAIL 1 项;**C1-2 仍 PASS** | ✅ 完全一致 |
+| **M2'** | `clearedBalanceExpected()` 删掉 `Special: campusBalance` | C1-1 / C1-2 红在 `Δspecial_expenses` | C1-2:`✘ 全局 Δspecial_expenses legacy=900.00 期望 0.00`、`✘ 转出校区 Δspecial_expenses …`,FAIL 2 项 | ✅ 一致(C1-1 同机理,未单独跑) |
+| **M3'** | `signedAll(c, lg, 0)` 的 `0` 改成 `4` | C1-6 红 | `✘ 取消充值活动:报名状态归 0(不是 4)`,FAIL 1 项 | ✅ 一致 |
+| **M4'** | 清零断言改成"期望全局归 0"(`-Before.Global.Balance`) | C1-1 红 | `✘ 清零:全局余额只减 campusBalance`(实测 Δ−1200.00,期望 −5000.00),FAIL 1 项 | ✅ 一致 |
+
+M3' 有一个值得记的细节:改错 `signedAll` 之后,**`只有本活动的报名状态变`(整张 map 全等那条)仍然是绿的**。
+这两条不是重复:`signedAll` 只盯"活动 91 是不是 0",整表全等盯"别的活动有没有被顺手动"。
+判据 M3' 只动前者 → 只有前者红;实现 M3(改实现)两条都红。**一条断言只钉一件事**,
+所以两条都要留。
+
 M2 特别值得记:它同时红了两条,而且**第一条直接指出原因**(元数据不一致),
 第二条才是指标(失效范围错)。"断言拆得细"不是为了严格,是为了**出错时能定位**。
 
@@ -459,3 +484,74 @@ C1-1 的余额根本不该为负。
 
 改成显式声明:`Case.ExpectNegativeBalance`,只有 R2 设它。
 **"用到了某个字段"不等于"这个字段的原意成立"** —— 判据要写在 case 上,不要从别的字段反推。
+
+### 7.10 补齐的三个边界 case(C1-9 / C1-10 / C1-11)
+
+前 8 条都在"正常金额 + 正常账户 + 正常请求号"的区间里,三个边界一条都没踩到。
+
+| case | 构型 | 真实/合成 | 结论 |
+|---|---|---|---|
+| **C1-9** | `requestid` **33 字符** | 真实构型(68542 + campus 5),请求号是**构造**的(生产不会有 33 字符) | `INTENTIONAL-WIDEN` |
+| **C1-10** | 余额 100.00 退 500.00 | 金额**构造**(`Baseline` 压到 100.00),用户/校区真实 | 两侧都 −400.00(R15 第 3 条来源) |
+| **C1-11** | 校区在、账户行**不存在** | 真实构型:40585 全局 5280.00、campus 5 无账户行(已核) | `LEGACY-DEFECT`(见 7.11) |
+
+**C1-9 的真正发现不是"列宽不同",是"legacy 半途失败会留下无凭证的扣款"**:
+
+```
+legacy: UPDATE balance=balance-m, total_income=total_income-m   ← 成功
+        INSERT t_user_expense_log(logid=33 字符)                 ← 1406,整个 CALL 中止
+        ⇒ money 扣了,log 没有
+target: log_id / request_id 都是 varchar(64) ⇒ 正常入库
+```
+
+MySQL 只回滚**失败的那条语句**,不回滚同一次 CALL 里已经执行过的前置语句。
+所以 legacy 的终态是"余额少了 30,但查不到任何凭证"。这不是我们引入的差异,
+但**必须在台账里写清楚**:迁移后如果只按流水对账,这 30 元会变成"凭空消失"。
+
+列宽实测(`information_schema`):
+
+| 列 | legacy | target |
+|---|---|---|
+| `t_user_expense_log.logid` / `log_id` | `varchar(32)` | `varchar(64)` |
+| (target 独有)`request_id` | — | `varchar(64)` |
+
+判定原则:target 列宽 **>** 32 → `INTENTIONAL-WIDEN`(我们更宽,不是缺陷,也不并进
+`IntentionalStrict` —— Strict 是"我们更严",Widen 是"我们更宽",台账要能分开数);
+**=** 32 → 两侧一致,按普通 case;**<** 32 → 真缺陷(我们比 legacy 窄,部分历史请求号无法复用)。
+本次实测是 **64 > 32**。
+
+### 7.11 C1-11 挖出的 legacy 数据缺陷:建行之后金额列是 NULL
+
+dump 的建行语句只写三列:
+
+```sql
+IF logCount=0 THEN
+  INSERT INTO `t_user_expense_school`(`userid`,`schoolid`,`campusid`) VALUES(...);
+END IF;
+```
+
+而两张表的列定义**不一样**(`information_schema.COLUMNS`):
+
+| 表 | 列 | 类型 | 默认值 |
+|---|---|---|---|
+| `t_user_expense_school` | balance / spend / total_income / total_expenditure / special_expenses | `decimal(10,3) **NULL**` | **无(DEFAULT NULL)** |
+| `t_user_expense` | balance / total_income | `float(10,3)` | **`0.000`** |
+
+于是:
+
+```
+校区分支:INSERT → balance=NULL → UPDATE balance=balance-30 → NULL-30 = **NULL**(NULL 传染)
+全局分支:INSERT → balance=0.000 → UPDATE balance=balance-30 → **-30.000**
+```
+
+**同一笔业务,校区一分没扣、全局扣了 30,而 type=209 的凭证照写。** 两表从此永久不一致。
+
+target 侧同名列是 `decimal(12,2) NOT NULL DEFAULT 0.00`,建行即 0,真扣成 −30.00,两表一致。
+所以这条判定是 **`LEGACY-DEFECT`:legacy 错、target 对,不跟**。
+
+> 附带一个工程教训:这第一条 case 一跑就在**读回阶段**崩了
+> (`converting NULL to string is unsupported`),连断言都走不到。
+> 当时的直觉是"case 写错了",实际是"读的工具不够宽容" ——
+> `snapshotLegacy` 的校区列必须用 `sql.NullString`,并用
+> `SideSnapshot.CampusNullBalance` 把"行在、但金额是 NULL"显式记下来。
+> **读不出来的状态,等于没有这个状态。**