|
@@ -0,0 +1,743 @@
|
|
|
|
|
+package stage2
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "context"
|
|
|
|
|
+ "fmt"
|
|
|
|
|
+ "io"
|
|
|
|
|
+ "sort"
|
|
|
|
|
+ "strconv"
|
|
|
|
|
+ "strings"
|
|
|
|
|
+
|
|
|
|
|
+ "gorm.io/gorm"
|
|
|
|
|
+
|
|
|
|
|
+ "yuansiwei/unified-backend/internal/academic/dualrun/fixture"
|
|
|
|
|
+ appscope "yuansiwei/unified-backend/internal/infra/scope"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// 本文件是**只读存储过程的「结果集双跑」**骨架。
|
|
|
|
|
+//
|
|
|
|
|
+// # 为什么需要另一套机器
|
|
|
|
|
+//
|
|
|
|
|
+// 前面 55 条 case 比的是**写入终态**:跑 legacy SP 与 Go 实现 → 读回库里的行 → 逐项比。
|
|
|
|
|
+// 有三支 SP **只读、不写库**,天然进不了那套机制:
|
|
|
|
|
+//
|
|
|
|
|
+// sp_user_expense_get_bill 端点 17 学生账单(一行 9 列)
|
|
|
|
|
+// sp_user_expense_school_get_refundable_amount 可退金额(一行一列)
|
|
|
|
|
+// sp_course_payment_detail_get_teacher_detail 老师课酬配置(整行 17 列)
|
|
|
|
|
+//
|
|
|
|
|
+// 对它们来说,"写库"是零,Δ 全是 0 ⇒ 通用比对会把「双方都没动」当成「双方一致」——
|
|
|
|
|
+// 又一次假绿。所以这里的比对主语句换成:**同一份数据 + 同一组入参 → 两侧各拿结果集 → 逐列比**。
|
|
|
|
|
+//
|
|
|
|
|
+// # 三条设计红线
|
|
|
|
|
+//
|
|
|
|
|
+// 1. **列覆盖断言**(`columnCoverage`):`dump 结果集列数 == 已认领列数 + 显式豁免列数`,
|
|
|
|
|
+// 且每一个 dump 列**恰好**被一条声明认领。这条防的是"新加了一列但没人映射,于是它永远不被比对"——
|
|
|
|
|
+// 与双跑里"判定块整体不执行"同族,都是**没人看的那一块**。
|
|
|
|
|
+// 2. **NULL 与 0 是两态**(`Cell.Null`):`SUM(...)` 无行时是 NULL,不是 0。
|
|
|
|
|
+// 把 NULL 读成 "0.00" 会把"这列从来没被算过"伪装成"算出来正好是 0"。
|
|
|
|
|
+// 3. **精度口径显式化**:dump 是 `DECIMAL(10,3)`、target 是 `money.Amount`(2 位)⇒
|
|
|
|
|
+// 两侧按 2 位比,**并额外断言 dump 侧第 3 位小数为 0**。第 3 位非 0 时不许静默四舍五入,
|
|
|
|
|
+// 要如实报出来(`PrecisionCheck`)。这是 `arrears_compare.go` ⑤ 的同一做法。
|
|
|
|
|
+
|
|
|
|
|
+// CellKind 是一列的归一方式。
|
|
|
|
|
+type CellKind int
|
|
|
|
|
+
|
|
|
|
|
+const (
|
|
|
|
|
+ // CellText 原样比较(会 TrimSpace)。
|
|
|
|
|
+ CellText CellKind = iota
|
|
|
|
|
+ // CellMoney 金额:按「元、2 位小数」归一(`DECIMAL(10,3)` → 2 位;`money.Amount` 分 → 元)。
|
|
|
|
|
+ CellMoney
|
|
|
|
|
+ // CellInt 整数(接受 "01" 与 "1" 视为同值)。
|
|
|
|
|
+ CellInt
|
|
|
|
|
+ // CellFlag 布尔位:legacy `bit(1)` 驱动读回是 `\x00`/`\x01`,target 是 0/1。
|
|
|
|
|
+ CellFlag
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// Cell 是一个结果集单元格的**原始读数**(尚未归一)。
|
|
|
|
|
+//
|
|
|
|
|
+// `Null` 必须与 `Raw==""` 分开:空串是"有值且为空",NULL 是"没有值"。
|
|
|
|
|
+type Cell struct {
|
|
|
|
|
+ Null bool
|
|
|
|
|
+ Raw string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// String 渲染成报告里能看的形态(NULL 显式打出来,不折成 0)。
|
|
|
|
|
+func (c Cell) String() string {
|
|
|
|
|
+ if c.Null {
|
|
|
|
|
+ return "NULL"
|
|
|
|
|
+ }
|
|
|
|
|
+ return c.Raw
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TextCell 造一个非空文本单元格。
|
|
|
|
|
+func TextCell(raw string) Cell { return Cell{Raw: raw} }
|
|
|
|
|
+
|
|
|
|
|
+// NullCell 造一个 NULL 单元格。
|
|
|
|
|
+func NullCell() Cell { return Cell{Null: true} }
|
|
|
|
|
+
|
|
|
|
|
+// MoneyCell 把「分」造成 `money.Amount` 口径的单元格("元、2 位小数")。
|
|
|
|
|
+func MoneyCell(cents int64) Cell { return Cell{Raw: fixture.CentsToDecimal(cents)} }
|
|
|
|
|
+
|
|
|
|
|
+// IntCell 把整数造成单元格。
|
|
|
|
|
+func IntCell(v int64) Cell { return Cell{Raw: strconv.FormatInt(v, 10)} }
|
|
|
|
|
+
|
|
|
|
|
+// ResultSet 是**一侧的单个结果集**(本批三支 SP 都只产出一个结果集)。
|
|
|
|
|
+type ResultSet struct {
|
|
|
|
|
+ // Columns 是 SP 实际返回的列名(顺序即 dump 顺序)。
|
|
|
|
|
+ Columns []string
|
|
|
|
|
+ // Rows 是全部行;本批三支都是 0/1 行,但结构上不假设。
|
|
|
|
|
+ Rows [][]Cell
|
|
|
|
|
+ // Statement 是实际执行的 CALL(写进报告,可复现)。
|
|
|
|
|
+ Statement string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TargetPayload 是 target 侧 service 的返回值,已摊平成「字段名 → 单元格」。
|
|
|
|
|
+//
|
|
|
|
|
+// 为什么摊平而不是直接塞 struct:比对骨架要能对任意 service 结果工作,
|
|
|
|
|
+// 而每个 service 的返回类型都不同(`BillSummary` / `money.Amount` / `PayrollQuote`)。
|
|
|
|
|
+// 摊平由各 case 负责(逐字段映射见 case 文件),骨架只认名字。
|
|
|
|
|
+type TargetPayload struct {
|
|
|
|
|
+ // Label 是"这一侧到底调了什么"(报告用,例如 `BillService.GetBill`)。
|
|
|
|
|
+ Label string
|
|
|
|
|
+ // Values 是字段名 → 读数。
|
|
|
|
|
+ Values map[string]Cell
|
|
|
|
|
+ // Evidence 是实测摘要(如"命中 detailId=990002 / ruleTier=personal_classify")。
|
|
|
|
|
+ Evidence string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ResultColumn 是**一列对一列**的映射与说明。
|
|
|
|
|
+type ResultColumn struct {
|
|
|
|
|
+ // Legacy 是 dump 结果集里的列名(用于报错文案与覆盖断言)。
|
|
|
|
|
+ Legacy string
|
|
|
|
|
+ // Target 是 target 字段名(`TargetPayload.Values` 的键;用于报错文案)。
|
|
|
|
|
+ Target string
|
|
|
|
|
+ // Kind 决定归一方式。
|
|
|
|
|
+ Kind CellKind
|
|
|
|
|
+ // Extract 允许自定义取值(`l` 是该行该列的 raw 单元格,`t` 是整份 payload)。
|
|
|
|
|
+ //
|
|
|
|
|
+ // nil = 按 `Target` 名字在 payload 里直接取、legacy 侧用同名列。
|
|
|
|
|
+ Extract func(l Cell, t TargetPayload) (lv, tv Cell, ok bool)
|
|
|
|
|
+ // Exempt=true 表示本列**不比对**,必须同时给 Reason。
|
|
|
|
|
+ Exempt bool
|
|
|
|
|
+ // Reason 是豁免理由(Exempt=true 时必填,否则覆盖断言 FAIL)。
|
|
|
|
|
+ Reason string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TargetOnlyColumn 是**target 独有、dump 结果集里没有**的字段。
|
|
|
|
|
+//
|
|
|
|
|
+// 为什么要单独列:比对机器只保证"dump 的列都被认领",不保证"target 的字段都被看"。
|
|
|
|
|
+// 这些字段若不显式列出来,就会变成"测不到但也没人知道"的盲区。
|
|
|
|
|
+// `Expect` 非空时按声明值断言(由种子算出,不是照抄实现)。
|
|
|
|
|
+type TargetOnlyColumn struct {
|
|
|
|
|
+ Target string
|
|
|
|
|
+ Expect Cell
|
|
|
|
|
+ Kind CellKind
|
|
|
|
|
+ Note string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ResultSetInput 是一次结果集比对的全部输入。
|
|
|
|
|
+type ResultSetInput struct {
|
|
|
|
|
+ CaseName string
|
|
|
|
|
+ // Legacy 是 SP 实际返回的结果集。
|
|
|
|
|
+ Legacy ResultSet
|
|
|
|
|
+ // ExpectRows 是期望的行数(本批 = 1;0 行说明种子没命中,必须红而不是"无可比")。
|
|
|
|
|
+ ExpectRows int
|
|
|
|
|
+ // Target 是 target 侧摊平后的返回值。
|
|
|
|
|
+ Target TargetPayload
|
|
|
|
|
+ // Columns 是逐列映射。
|
|
|
|
|
+ Columns []ResultColumn
|
|
|
|
|
+ // TargetOnly 是 target 独有的派生字段。
|
|
|
|
|
+ TargetOnly []TargetOnlyColumn
|
|
|
|
|
+ // Expect 是"由种子声明算出的期望值"(列名 → 归一后的字符串)。
|
|
|
|
|
+ //
|
|
|
|
|
+ // 为什么要有第三腿:只有"两侧相等"时,**两侧同时错**会得到一条完全对称的绿。
|
|
|
|
|
+ // 期望值从声明算(不写死实现输出),改种子后期望自动跟着变,"算错了"才会红。
|
|
|
|
|
+ Expect map[string]string
|
|
|
|
|
+ // PrecisionCheck=true 时,逐列断言 dump 侧金额列第 3 位小数为 0。
|
|
|
|
|
+ PrecisionCheck bool
|
|
|
|
|
+ // ExtraAssertions 是该 case 自己的领域断言(账单恒等式 / FLOAT 可表示性 / 命中层级…)。
|
|
|
|
|
+ //
|
|
|
|
|
+ // 为什么留这个口子:骨架只懂"列对列",不懂"为什么这 9 列之间必须满足某条恒等式"。
|
|
|
|
|
+ // 把这些塞进骨架会把通用机器变成"每个 case 一个分支"。
|
|
|
|
|
+ ExtraAssertions func(lg ResultSet, tg TargetPayload) []Assertion
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TargetColumnValue 取 payload 里的字段(缺字段 = FAIL,不是"跳过")。
|
|
|
|
|
+func (p TargetPayload) TargetColumnValue(name string) (Cell, bool) {
|
|
|
|
|
+ c, ok := p.Values[name]
|
|
|
|
|
+ return c, ok
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// normalizeCell 按 Kind 归一成可比较的字符串。
|
|
|
|
|
+//
|
|
|
|
|
+// 金额走 `fixture.DecimalToCents` → `CentsToDecimal`:与迁移**同一个函数**,
|
|
|
|
|
+// 所以两侧不会出现"一个用 float 四舍五入、一个用 decimal 收敛"的口径分叉。
|
|
|
|
|
+func normalizeCell(kind CellKind, c Cell) (string, error) {
|
|
|
|
|
+ if c.Null {
|
|
|
|
|
+ return "NULL", nil
|
|
|
|
|
+ }
|
|
|
|
|
+ switch kind {
|
|
|
|
|
+ case CellMoney:
|
|
|
|
|
+ cents, err := fixture.DecimalToCents(c.Raw)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return "", fmt.Errorf("金额字面量无法解析(raw=%q):%w", c.Raw, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ return fixture.CentsToDecimal(cents), nil
|
|
|
|
|
+ case CellInt:
|
|
|
|
|
+ v, err := strconv.ParseInt(strings.TrimSpace(c.Raw), 10, 64)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return "", fmt.Errorf("整数字面量无法解析(raw=%q):%w", c.Raw, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ return strconv.FormatInt(v, 10), nil
|
|
|
|
|
+ case CellFlag:
|
|
|
|
|
+ raw := strings.TrimSpace(c.Raw)
|
|
|
|
|
+ switch raw {
|
|
|
|
|
+ case "\x00", "0", "false", "b'0'":
|
|
|
|
|
+ return "0", nil
|
|
|
|
|
+ case "\x01", "1", "true", "b'1'":
|
|
|
|
|
+ return "1", nil
|
|
|
|
|
+ }
|
|
|
|
|
+ return "", fmt.Errorf("布尔位无法解析(raw=%q)", c.Raw)
|
|
|
|
|
+ default:
|
|
|
|
|
+ return strings.TrimSpace(c.Raw), nil
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// RawCellOf 按列名从某一行取单元格(找不到 = 声明写错了列名,由覆盖断言先拦住)。
|
|
|
|
|
+func RawCellOf(rs ResultSet, row int, col string) (Cell, bool) {
|
|
|
|
|
+ if row < 0 || row >= len(rs.Rows) {
|
|
|
|
|
+ return Cell{}, false
|
|
|
|
|
+ }
|
|
|
|
|
+ for i, name := range rs.Columns {
|
|
|
|
|
+ if name == col {
|
|
|
|
|
+ if i >= len(rs.Rows[row]) {
|
|
|
|
|
+ return Cell{}, false
|
|
|
|
|
+ }
|
|
|
|
|
+ return rs.Rows[row][i], true
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return Cell{}, false
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// compareResultSet 是结果集比对的唯一入口。
|
|
|
|
|
+func compareResultSet(in ResultSetInput) ([]Diff, []Assertion) {
|
|
|
|
|
+ var diffs []Diff
|
|
|
|
|
+ var asserts []Assertion
|
|
|
|
|
+
|
|
|
|
|
+ addDiff := func(item string, l, t Cell, equal, skipped bool, note string) {
|
|
|
|
|
+ diffs = append(diffs, Diff{
|
|
|
|
|
+ Item: item, Legacy: l.String(), Target: t.String(),
|
|
|
|
|
+ Equal: equal, Skipped: skipped, Note: note,
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- ① 结果集形状:必须恰好 1 个结果集、期望行数 ----
|
|
|
|
|
+ //
|
|
|
|
|
+ // 0 行**不是**"无可比":它意味着种子没命中 / 入参不对 / SP 提前 LEAVE。
|
|
|
|
|
+ // 把 0 行当通过,等于"SP 一行都没返回"也算绿 —— 那是本文件最想防的空过。
|
|
|
|
|
+ rowOK := len(in.Legacy.Rows) == in.ExpectRows
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Kind: AssertionInfrastructure,
|
|
|
|
|
+ Name: fmt.Sprintf("结果集形状:%d 列 / 期望 %d 行", len(in.Legacy.Columns), in.ExpectRows),
|
|
|
|
|
+ Pass: rowOK && len(in.Legacy.Columns) > 0,
|
|
|
|
|
+ Detail: fmt.Sprintf("实际 %d 行;语句 `%s`。0 行 ⇒ FAIL(种子未命中或 SP 提前 LEAVE,不是「无可比」)",
|
|
|
|
|
+ len(in.Legacy.Rows), in.Legacy.Statement),
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // ---- ② 列覆盖断言(核心)----
|
|
|
|
|
+ coverPass, coverDetail := columnCoverage(in.Legacy.Columns, in.Columns)
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Kind: AssertionInfrastructure,
|
|
|
|
|
+ Name: "列覆盖:dump 结果集每一列**恰好**被一条声明认领(认领 + 豁免 = 总列数)",
|
|
|
|
|
+ Pass: coverPass, Detail: coverDetail,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // ---- ③ target 独有字段的覆盖:payload 里每个键都必须被看过 ----
|
|
|
|
|
+ claimedTargets := make(map[string]bool, len(in.Columns)+len(in.TargetOnly))
|
|
|
|
|
+ for _, c := range in.Columns {
|
|
|
|
|
+ if !c.Exempt && c.Target != "" {
|
|
|
|
|
+ claimedTargets[c.Target] = true
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ targetOnlyNames := make([]string, 0, len(in.TargetOnly))
|
|
|
|
|
+ for _, t := range in.TargetOnly {
|
|
|
|
|
+ targetOnlyNames = append(targetOnlyNames, t.Target)
|
|
|
|
|
+ claimedTargets[t.Target] = true
|
|
|
|
|
+ }
|
|
|
|
|
+ sort.Strings(targetOnlyNames)
|
|
|
|
|
+ var unclaimed []string
|
|
|
|
|
+ for name := range in.Target.Values {
|
|
|
|
|
+ if !claimedTargets[name] {
|
|
|
|
|
+ unclaimed = append(unclaimed, name)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ sort.Strings(unclaimed)
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Kind: AssertionInfrastructure,
|
|
|
|
|
+ Name: "侧向覆盖:target 返回的每个字段都被认领或被显式列为 target 独有",
|
|
|
|
|
+ Pass: len(unclaimed) == 0 && len(in.Target.Values) > 0,
|
|
|
|
|
+ Detail: fmt.Sprintf("target 字段 %d 个;未认领 %d 个 %v | target 独有 %v",
|
|
|
|
|
+ len(in.Target.Values), len(unclaimed), unclaimed, targetOnlyNames),
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // ---- ④ 逐列比对 + 期望值(第三腿)----
|
|
|
|
|
+ for _, col := range in.Columns {
|
|
|
|
|
+ if col.Exempt {
|
|
|
|
|
+ l, _ := RawCellOf(in.Legacy, 0, col.Legacy)
|
|
|
|
|
+ addDiff("列·"+col.Legacy, l, Cell{Null: false, Raw: "—"}, true, true, "豁免:"+col.Reason)
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ if !rowOK {
|
|
|
|
|
+ addDiff("列·"+col.Legacy+" → "+col.Target, Cell{Raw: "—"}, Cell{Raw: "—"}, true, true,
|
|
|
|
|
+ "结果集行数不符,本列无从比对")
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ lRaw, okL := RawCellOf(in.Legacy, 0, col.Legacy)
|
|
|
|
|
+ tv, okT := Cell{}, false
|
|
|
|
|
+ lv := lRaw
|
|
|
|
|
+ if col.Extract != nil {
|
|
|
|
|
+ lv, tv, okT = col.Extract(lRaw, in.Target)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ tv, okT = in.Target.TargetColumnValue(col.Target)
|
|
|
|
|
+ }
|
|
|
|
|
+ if !okL {
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Kind: AssertionBusiness,
|
|
|
|
|
+ Name: fmt.Sprintf("列·%s:dump 结果集里取不到这一列", col.Legacy),
|
|
|
|
|
+ Pass: false, Detail: "列名拼写与 SP 实际返回不一致(覆盖断言应已同时报红)",
|
|
|
|
|
+ })
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ if !okT {
|
|
|
|
|
+ addDiff("列·"+col.Legacy+" → "+col.Target, lv, Cell{Null: false, Raw: "缺字段"}, false, false,
|
|
|
|
|
+ "target payload 没有 "+col.Target)
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Kind: AssertionBusiness,
|
|
|
|
|
+ Name: fmt.Sprintf("列·%s → %s:target 未提供该字段", col.Legacy, col.Target),
|
|
|
|
|
+ Pass: false, Detail: "字段名与 payload 不一致 ⇒ 这一列等于没比",
|
|
|
|
|
+ })
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ lNorm, lErr := normalizeCell(col.Kind, lv)
|
|
|
|
|
+ tNorm, tErr := normalizeCell(col.Kind, tv)
|
|
|
|
|
+ equal := lErr == nil && tErr == nil && lNorm == tNorm
|
|
|
|
|
+ note := "按 " + kindName(col.Kind) + " 归一后比"
|
|
|
|
|
+ if lErr != nil || tErr != nil {
|
|
|
|
|
+ note = fmt.Sprintf("归一失败:legacy=%v | target=%v", lErr, tErr)
|
|
|
|
|
+ }
|
|
|
|
|
+ addDiff("列·"+col.Legacy+" → "+col.Target, lv, tv, equal, false, note)
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Kind: AssertionBusiness,
|
|
|
|
|
+ Name: fmt.Sprintf("列·%s → %s", col.Legacy, col.Target),
|
|
|
|
|
+ Pass: equal,
|
|
|
|
|
+ Detail: fmt.Sprintf("列名:%s | legacy=%s | target=%s(%s)",
|
|
|
|
|
+ col.Legacy, lNorm, tNorm, note),
|
|
|
|
|
+ })
|
|
|
|
|
+ // 第三腿:与"由种子声明算出的期望"比。
|
|
|
|
|
+ if exp, has := in.Expect[col.Legacy]; has {
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Kind: AssertionBusiness,
|
|
|
|
|
+ Name: fmt.Sprintf("列·%s:两侧一致 ≠ 都对 —— 与声明期望比", col.Legacy),
|
|
|
|
|
+ Pass: lNorm == exp && tNorm == exp,
|
|
|
|
|
+ Detail: fmt.Sprintf("声明期望 %s | legacy %s | target %s(期望由种子声明算出,不是照抄实现)",
|
|
|
|
|
+ exp, lNorm, tNorm),
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- ⑤ target 独有字段 ----
|
|
|
|
|
+ for _, t := range in.TargetOnly {
|
|
|
|
|
+ tv, ok := in.Target.TargetColumnValue(t.Target)
|
|
|
|
|
+ if !ok {
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Kind: AssertionBusiness,
|
|
|
|
|
+ Name: fmt.Sprintf("target 独有字段·%s:payload 缺该字段", t.Target),
|
|
|
|
|
+ Pass: false, Detail: t.Note,
|
|
|
|
|
+ })
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ tNorm, err := normalizeCell(t.Kind, tv)
|
|
|
|
|
+ expNorm, expErr := normalizeCell(t.Kind, t.Expect)
|
|
|
|
|
+ pass := err == nil && expErr == nil && tNorm == expNorm
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Kind: AssertionBusiness,
|
|
|
|
|
+ Name: fmt.Sprintf("target 独有字段·%s = 声明期望", t.Target),
|
|
|
|
|
+ Pass: pass,
|
|
|
|
|
+ Detail: fmt.Sprintf("target=%s | 期望=%s | %s(dump 结果集无此列,只能对声明断言)",
|
|
|
|
|
+ tNorm, expNorm, t.Note),
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- ⑥ 精度口径(PRECISION-CONVERGENCE 3→2)----
|
|
|
|
|
+ if in.PrecisionCheck {
|
|
|
|
|
+ var bad []string
|
|
|
|
|
+ moneyCols := 0
|
|
|
|
|
+ for _, col := range in.Columns {
|
|
|
|
|
+ if col.Exempt || col.Kind != CellMoney {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ c, ok := RawCellOf(in.Legacy, 0, col.Legacy)
|
|
|
|
|
+ if !ok || c.Null {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ moneyCols++
|
|
|
|
|
+ if d, has := thirdDecimal(c.Raw); has && d != '0' {
|
|
|
|
|
+ bad = append(bad, fmt.Sprintf("%s=%s", col.Legacy, c.Raw))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Kind: AssertionBusiness,
|
|
|
|
|
+ Name: fmt.Sprintf("精度口径:dump 侧 %d 个金额列第 3 位小数恒为 0 ⇒ 2 位比较无信息损失(PRECISION-CONVERGENCE 3→2)",
|
|
|
|
|
+ moneyCols),
|
|
|
|
|
+ Pass: len(bad) == 0 && moneyCols > 0,
|
|
|
|
|
+ Detail: fmt.Sprintf("两侧小数位本就不同(dump DECIMAL(10,3) vs target money.Amount 2 位);"+
|
|
|
|
|
+ "本断言把「本次种子下无损」从假设变成事实。第 3 位非零 %d 个:%v。"+
|
|
|
|
|
+ "非零时必须如实报出,**不许静默四舍五入**", len(bad), bad),
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- ⑦ 该 case 自己的领域断言 ----
|
|
|
|
|
+ if in.ExtraAssertions != nil {
|
|
|
|
|
+ asserts = append(asserts, in.ExtraAssertions(in.Legacy, in.Target)...)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return diffs, asserts
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// columnCoverage 检查「每个 dump 列恰好一条声明」+「每条声明的列名真实存在」+「豁免必带理由」。
|
|
|
|
|
+func columnCoverage(legacyCols []string, cols []ResultColumn) (bool, string) {
|
|
|
|
|
+ declByLegacy := make(map[string]int, len(cols))
|
|
|
|
|
+ for _, c := range cols {
|
|
|
|
|
+ declByLegacy[c.Legacy]++
|
|
|
|
|
+ }
|
|
|
|
|
+ var missing, dup, unknown, exemptNoReason []string
|
|
|
|
|
+ claimed, exempt := 0, 0
|
|
|
|
|
+ for _, name := range legacyCols {
|
|
|
|
|
+ switch declByLegacy[name] {
|
|
|
|
|
+ case 0:
|
|
|
|
|
+ missing = append(missing, name)
|
|
|
|
|
+ case 1:
|
|
|
|
|
+ // ok
|
|
|
|
|
+ default:
|
|
|
|
|
+ dup = append(dup, name)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ for name := range declByLegacy {
|
|
|
|
|
+ found := false
|
|
|
|
|
+ for _, real := range legacyCols {
|
|
|
|
|
+ if real == name {
|
|
|
|
|
+ found = true
|
|
|
|
|
+ break
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if !found {
|
|
|
|
|
+ unknown = append(unknown, name)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, c := range cols {
|
|
|
|
|
+ if c.Exempt {
|
|
|
|
|
+ exempt++
|
|
|
|
|
+ if strings.TrimSpace(c.Reason) == "" {
|
|
|
|
|
+ exemptNoReason = append(exemptNoReason, c.Legacy)
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ claimed++
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ sort.Strings(missing)
|
|
|
|
|
+ sort.Strings(dup)
|
|
|
|
|
+ sort.Strings(unknown)
|
|
|
|
|
+ sort.Strings(exemptNoReason)
|
|
|
|
|
+ pass := len(missing) == 0 && len(dup) == 0 && len(unknown) == 0 &&
|
|
|
|
|
+ len(exemptNoReason) == 0 && len(legacyCols) == claimed+exempt && claimed > 0
|
|
|
|
|
+ return pass, fmt.Sprintf(
|
|
|
|
|
+ "dump 列数 %d = 认领 %d + 豁免 %d | 无人认领 %v | 重复声明 %v | 声明了不存在的列 %v | 豁免缺理由 %v。"+
|
|
|
|
|
+ "无人认领 ⇒ 那一列**永远不被比对**,与「判定块整体不执行」同族",
|
|
|
|
|
+ len(legacyCols), claimed, exempt, missing, dup, unknown, exemptNoReason)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// kindName 把 Kind 渲染成报告里能看懂的名字。
|
|
|
|
|
+func kindName(k CellKind) string {
|
|
|
|
|
+ switch k {
|
|
|
|
|
+ case CellMoney:
|
|
|
|
|
+ return "金额"
|
|
|
|
|
+ case CellInt:
|
|
|
|
|
+ return "整数"
|
|
|
|
|
+ case CellFlag:
|
|
|
|
|
+ return "布尔位"
|
|
|
|
|
+ default:
|
|
|
|
|
+ return "文本"
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// thirdDecimal 取十进制字符串的第 3 位小数。
|
|
|
|
|
+//
|
|
|
|
|
+// 不做浮点比较:浮点在 0.005 附近抖动,而这里判的就是那个位。
|
|
|
|
|
+func thirdDecimal(raw string) (byte, bool) {
|
|
|
|
|
+ i := strings.IndexByte(raw, '.')
|
|
|
|
|
+ if i < 0 || len(raw) < i+4 {
|
|
|
|
|
+ return 0, false
|
|
|
|
|
+ }
|
|
|
|
|
+ return raw[i+3], true
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// printResultSetResult 打印一条结果集 case 的结论。
|
|
|
|
|
+//
|
|
|
|
|
+// 为什么不复用 `PrintResult`:那个函数的第一段是「账户前后快照」(写路径专用),
|
|
|
|
|
+// 结果集 case 没有 before/after —— 打印四个 0 会让人以为"账户余额是 0",是误导。
|
|
|
|
|
+func printResultSetResult(w io.Writer, r *CaseResult) {
|
|
|
|
|
+ fmt.Fprintf(w, "──────────────────────────────────────────────────────────────\n")
|
|
|
|
|
+ fmt.Fprintf(w, "▶ %s\n", r.Case.Name)
|
|
|
|
|
+ if r.Case.Note != "" {
|
|
|
|
|
+ fmt.Fprintf(w, " 说明:%s\n", r.Case.Note)
|
|
|
|
|
+ }
|
|
|
|
|
+ fmt.Fprintf(w, " 入参:userid=%d inst=%d campus=%d\n",
|
|
|
|
|
+ r.Case.LegacyUserID, r.Case.InstitutionID, r.Case.CampusID)
|
|
|
|
|
+
|
|
|
|
|
+ fmt.Fprintf(w, " ── 逐列比对 ──\n")
|
|
|
|
|
+ for _, d := range r.Diffs {
|
|
|
|
|
+ mark := "✔"
|
|
|
|
|
+ if !d.Equal {
|
|
|
|
|
+ mark = "✘"
|
|
|
|
|
+ }
|
|
|
|
|
+ if d.Skipped {
|
|
|
|
|
+ mark = "—"
|
|
|
|
|
+ }
|
|
|
|
|
+ fmt.Fprintf(w, " %s %-40s legacy=%-18s target=%-18s %s\n", mark, d.Item, d.Legacy, d.Target, d.Note)
|
|
|
|
|
+ }
|
|
|
|
|
+ fmt.Fprintf(w, " ── 断言 ──\n")
|
|
|
|
|
+ for _, a := range r.Assertions {
|
|
|
|
|
+ mark := "✔"
|
|
|
|
|
+ if !a.Pass {
|
|
|
|
|
+ mark = "✘"
|
|
|
|
|
+ }
|
|
|
|
|
+ if a.Skipped {
|
|
|
|
|
+ mark = "—"
|
|
|
|
|
+ }
|
|
|
|
|
+ if a.Kind == AssertionUnclassified {
|
|
|
|
|
+ mark = "⚠"
|
|
|
|
|
+ }
|
|
|
|
|
+ kind := ""
|
|
|
|
|
+ if a.Kind != AssertionBusiness {
|
|
|
|
|
+ kind = "[" + a.Kind.String() + "] "
|
|
|
|
|
+ }
|
|
|
|
|
+ fmt.Fprintf(w, " %s %s%s\n %s\n", mark, kind, a.Name, a.Detail)
|
|
|
|
|
+ }
|
|
|
|
|
+ verdict := "PASS"
|
|
|
|
|
+ if !r.OK() {
|
|
|
|
|
+ verdict = "FAIL"
|
|
|
|
|
+ }
|
|
|
|
|
+ fmt.Fprintf(w, " 结论:%s(不一致 %d 项)\n\n", verdict, r.FailCount())
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ResultSetCase 是一条「只读结果集」case。
|
|
|
|
|
+//
|
|
|
|
|
+// 与写路径的 `Case` 结构**不同构**(那套的主语是"写后终态"),所以不进 `DefaultCases`、
|
|
|
|
|
+// 不参与 55 条的分母。入口见 `RunResultSetCases`。
|
|
|
|
|
+type ResultSetCase struct {
|
|
|
|
|
+ Name string
|
|
|
|
|
+ Note string
|
|
|
|
|
+
|
|
|
|
|
+ // LegacyUserID / InstitutionID / CampusID 是两侧**同一组**入参的业务身份。
|
|
|
|
|
+ LegacyUserID int64
|
|
|
|
|
+ InstitutionID int64
|
|
|
|
|
+ CampusID int64
|
|
|
|
|
+
|
|
|
|
|
+ // SeedLegacy / SeedTarget 在各自事务内造同一份数据。
|
|
|
|
|
+ //
|
|
|
|
|
+ // 两者必须"同构":值由同一组常量声明,而不是各写一遍。
|
|
|
|
|
+ SeedLegacy func(ctx context.Context, tx *gorm.DB) error
|
|
|
|
|
+ SeedTarget func(ctx context.Context, tx *gorm.DB, uid string) error
|
|
|
|
|
+
|
|
|
|
|
+ // VerifyLegacy / VerifyTarget 读回校验(`compareSeedState` 的用法):
|
|
|
|
|
+ // 声明什么就该读到什么,读不回来说种子没种上 —— 那时比对再绿也没有意义。
|
|
|
|
|
+ VerifyLegacy func(ctx context.Context, tx *gorm.DB) (string, error)
|
|
|
|
|
+ VerifyTarget func(ctx context.Context, tx *gorm.DB, uid string) (string, error)
|
|
|
|
|
+
|
|
|
|
|
+ // LegacyCall / LegacyArgs 是 SP 调用(逐字照 dump 的参数顺序)。
|
|
|
|
|
+ LegacyCall string
|
|
|
|
|
+ LegacyArgs []any
|
|
|
|
|
+
|
|
|
|
|
+ // CallTarget 调真实 service,并把返回值摊平成 payload。
|
|
|
|
|
+ CallTarget func(ctx context.Context, tx *gorm.DB, uid string, sc *appscope.Scope) (TargetPayload, error)
|
|
|
|
|
+
|
|
|
|
|
+ // Compare 组装比对输入(列映射 + 期望值)。
|
|
|
|
|
+ Compare func(lg ResultSet, tg TargetPayload) ResultSetInput
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// RunResultSetCases 跑全部结果集 case。
|
|
|
|
|
+//
|
|
|
|
|
+// 每条 case:开两个事务 → 各种种子 → legacy `CALL` 读结果集(**读干净**)→
|
|
|
|
|
+// target 调 service → 比对 → **两侧都回滚**。
|
|
|
|
|
+func RunResultSetCases(ctx context.Context, cfg Config, w io.Writer) (bool, error) {
|
|
|
|
|
+ cases := ResultSetCases()
|
|
|
|
|
+ if len(cases) == 0 {
|
|
|
|
|
+ return false, fmt.Errorf("ResultSetCases 长度为 0:跑了个寂寞")
|
|
|
|
|
+ }
|
|
|
|
|
+ results := make([]*CaseResult, 0, len(cases))
|
|
|
|
|
+ for _, c := range cases {
|
|
|
|
|
+ r, err := runOneResultSetCase(ctx, cfg, c)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return false, fmt.Errorf("case %q 执行失败:%w", c.Name, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ printResultSetResult(w, r)
|
|
|
|
|
+ results = append(results, r)
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(results) != len(cases) {
|
|
|
|
|
+ return false, fmt.Errorf("分母收缩 —— 应跑 %d 条,实跑 %d 条", len(cases), len(results))
|
|
|
|
|
+ }
|
|
|
|
|
+ return summarizeResultSet(w, results), nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// runOneResultSetCase 跑单条结果集 case 的闭环。
|
|
|
|
|
+func runOneResultSetCase(ctx context.Context, cfg Config, c ResultSetCase) (*CaseResult, error) {
|
|
|
|
|
+ legacyDB, err := cfg.OpenWriteLegacy()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ defer closeDB(legacyDB)
|
|
|
|
|
+ targetDB, err := cfg.OpenTarget()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ defer closeDB(targetDB)
|
|
|
|
|
+
|
|
|
|
|
+ uid := CanonicalUIDFor(c.LegacyUserID)
|
|
|
|
|
+ res := &CaseResult{Case: Case{
|
|
|
|
|
+ Name: c.Name, Note: c.Note,
|
|
|
|
|
+ LegacyUserID: c.LegacyUserID, InstitutionID: c.InstitutionID, CampusID: c.CampusID,
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // ---- legacy 侧 ----
|
|
|
|
|
+ lgTx := legacyDB.WithContext(ctx).Begin()
|
|
|
|
|
+ if lgTx.Error != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("legacy 开启事务失败:%w", lgTx.Error)
|
|
|
|
|
+ }
|
|
|
|
|
+ defer func() { _ = lgTx.Rollback() }()
|
|
|
|
|
+ if err := c.SeedLegacy(ctx, lgTx); err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("legacy 种子失败:%w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ seedDetail, err := c.VerifyLegacy(ctx, lgTx)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("legacy 种子读回校验失败:%w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ lgRS, callErr := callReadOnlySP(ctx, lgTx, c.LegacyCall, c.LegacyArgs)
|
|
|
|
|
+ if callErr != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("legacy CALL `%s` 失败:%w", c.LegacyCall, callErr)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- target 侧 ----
|
|
|
|
|
+ tgTx := targetDB.WithContext(ctx).Begin()
|
|
|
|
|
+ if tgTx.Error != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("target 开启事务失败:%w", tgTx.Error)
|
|
|
|
|
+ }
|
|
|
|
|
+ defer func() { _ = tgTx.Rollback() }()
|
|
|
|
|
+ if err := c.SeedTarget(ctx, tgTx, uid); err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("target 种子失败:%w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ tgSeedDetail, err := c.VerifyTarget(ctx, tgTx, uid)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("target 种子读回校验失败:%w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ sc := &appscope.Scope{
|
|
|
|
|
+ InstitutionID: c.InstitutionID, CampusID: c.CampusID,
|
|
|
|
|
+ UserID: uid, Role: "finance",
|
|
|
|
|
+ }
|
|
|
|
|
+ payload, err := c.CallTarget(ctx, tgTx, uid, sc)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ res.Assertions = append(res.Assertions, Assertion{
|
|
|
|
|
+ Kind: AssertionBusiness,
|
|
|
|
|
+ Name: "target 侧调用", Pass: false,
|
|
|
|
|
+ Detail: fmt.Sprintf("调用失败:%v(两侧必须都成功,否则无从比对)", err),
|
|
|
|
|
+ })
|
|
|
|
|
+ return res, nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ diffs, asserts := compareResultSet(c.Compare(lgRS, payload))
|
|
|
|
|
+ res.Diffs = diffs
|
|
|
|
|
+ res.Assertions = append(res.Assertions, asserts...)
|
|
|
|
|
+ // 种子读回:**基建断言**(器材准不准,不是被测行为)。
|
|
|
|
|
+ res.Assertions = append(res.Assertions, Assertion{
|
|
|
|
|
+ Kind: AssertionInfrastructure,
|
|
|
|
|
+ Name: "种子读回与声明一致(两侧各自读回)",
|
|
|
|
|
+ Pass: true,
|
|
|
|
|
+ Detail: fmt.Sprintf("legacy:%s | target:%s", seedDetail, tgSeedDetail),
|
|
|
|
|
+ })
|
|
|
|
|
+ return res, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// callReadOnlySP 调一支只读 SP 并**把结果集读干净**。
|
|
|
|
|
+//
|
|
|
|
|
+// 两条必须知道的:
|
|
|
|
|
+// - 这些 SP 只有一个结果集,但驱动仍要求把 `NextResultSet` 走完,
|
|
|
|
|
+// 否则同一连接上的下一条语句会报 `Error 2014 Commands out of sync`;
|
|
|
|
|
+// - `rows.Columns()` 是 SP 的 SELECT 列名 —— 列覆盖断言就是拿它跟声明比的。
|
|
|
|
|
+func callReadOnlySP(ctx context.Context, tx *gorm.DB, stmt string, args []any) (ResultSet, error) {
|
|
|
|
|
+ var out ResultSet
|
|
|
|
|
+ out.Statement = stmt
|
|
|
|
|
+ rows, err := tx.WithContext(ctx).Raw(stmt, args...).Rows()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return out, err
|
|
|
|
|
+ }
|
|
|
|
|
+ defer func() { _ = rows.Close() }()
|
|
|
|
|
+ cols, err := rows.Columns()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return out, fmt.Errorf("读结果集列名失败:%w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ out.Columns = cols
|
|
|
|
|
+ for rows.Next() {
|
|
|
|
|
+ vals := make([]any, len(cols))
|
|
|
|
|
+ ptrs := make([]any, len(cols))
|
|
|
|
|
+ for i := range vals {
|
|
|
|
|
+ ptrs[i] = &vals[i]
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := rows.Scan(ptrs...); err != nil {
|
|
|
|
|
+ return out, fmt.Errorf("读结果集行失败:%w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ row := make([]Cell, len(cols))
|
|
|
|
|
+ for i, v := range vals {
|
|
|
|
|
+ if v == nil {
|
|
|
|
|
+ row[i] = Cell{Null: true}
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ row[i] = Cell{Raw: sqlCell(v)}
|
|
|
|
|
+ }
|
|
|
|
|
+ out.Rows = append(out.Rows, row)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := rows.Err(); err != nil {
|
|
|
|
|
+ return out, err
|
|
|
|
|
+ }
|
|
|
|
|
+ // drain 掉后续结果集(本批 SP 只有一个,但这条纪律不能靠"我记得")。
|
|
|
|
|
+ for rows.NextResultSet() {
|
|
|
|
|
+ for rows.Next() {
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return out, rows.Err()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// summarizeResultSet 打印结果集 case 的总览。
|
|
|
|
|
+func summarizeResultSet(w io.Writer, results []*CaseResult) bool {
|
|
|
|
|
+ total, pass, skipped, infra, unclassified := 0, 0, 0, 0, 0
|
|
|
|
|
+ for _, r := range results {
|
|
|
|
|
+ total++
|
|
|
|
|
+ if r.OK() {
|
|
|
|
|
+ pass++
|
|
|
|
|
+ }
|
|
|
|
|
+ skipped += r.SkippedCount()
|
|
|
|
|
+ infra += r.InfraCount()
|
|
|
|
|
+ unclassified += r.UnclassifiedCount()
|
|
|
|
|
+ }
|
|
|
|
|
+ fmt.Fprintf(w, "══════════════════════════════════════════════════════════════\n")
|
|
|
|
|
+ fmt.Fprintf(w, "总览:%d 个结果集 case,%d PASS,%d FAIL\n", total, pass, total-pass)
|
|
|
|
|
+ fmt.Fprintf(w, "Skipped:%d 项(显式豁免列 + 结构性不适用)| 断言分类:业务 %d 条 | 基建 %d 条 | 未分类 %d 条\n",
|
|
|
|
|
+ skipped, sumBusiness(results), infra, unclassified)
|
|
|
|
|
+ for _, r := range results {
|
|
|
|
|
+ st := "PASS"
|
|
|
|
|
+ if !r.OK() {
|
|
|
|
|
+ st = "FAIL"
|
|
|
|
|
+ }
|
|
|
|
|
+ fmt.Fprintf(w, " %s %-46s 不一致 %d Skipped %d 业务断言 %d\n",
|
|
|
|
|
+ st, r.Case.Name, r.FailCount(), r.SkippedCount(), r.LiveAssertionCount())
|
|
|
|
|
+ }
|
|
|
|
|
+ return pass == total && unclassified == 0
|
|
|
|
|
+}
|