package fixture import ( "database/sql" "testing" "time" ) // 本文件单测**纯映射规则**(不连库):取整、空值语义、时间、D1 id、确定性 ULID。 // 库相关的端到端核对由 cmd/ac15fixture verify 完成(需要真库,不进单测)。 func TestDecimalToCents(t *testing.T) { cases := []struct { in string want int64 }{ {"0.000", 0}, {"0", 0}, {"0.788", 79}, // 第 3 位 8 进位 {"4999.212", 499921}, // 第 3 位 2 舍去 {"-2160.489", -216049}, // 负数同向(half away from zero) {"-12.015", -1202}, // 恰好 x.xx5:进位 {"12.015", 1202}, {"5000.000", 500000}, {"-160.036", -16004}, {"333.328", 33333}, {" 7.5 ", 750}, // 空格与补齐 {"-0.004", 0}, // 舍到 0 {"-0.005", -1}, // 进位到 -0.01 } for _, c := range cases { got, err := DecimalToCents(c.in) if err != nil { t.Fatalf("DecimalToCents(%q) 出错:%v", c.in, err) } if got != c.want { t.Errorf("DecimalToCents(%q) = %d, want %d", c.in, got, c.want) } } // 拒绝静默截断:4 位小数必须报错 if _, err := DecimalToCents("1.2345"); err == nil { t.Error("DecimalToCents(1.2345) 应报错(拒绝静默截断),实际未报错") } } func TestCentsToDecimal(t *testing.T) { cases := []struct { in int64 want string }{ {0, "0.00"}, {79, "0.79"}, {-216049, "-2160.49"}, {499921, "4999.21"}, {-16004, "-160.04"}, {-1, "-0.01"}, } for _, c := range cases { if got := CentsToDecimal(c.in); got != c.want { t.Errorf("CentsToDecimal(%d) = %q, want %q", c.in, got, c.want) } } } // TestRoundTrip 保证「字符串 → 分 → 字符串」在 2 位小数上稳定(幂等)。 func TestRoundTrip(t *testing.T) { for _, s := range []string{"0.00", "0.79", "-2160.49", "4999.21", "5000.00"} { c := MustCents(s) if got := CentsToDecimal(c); got != s { t.Errorf("round trip %q → %d → %q", s, c, got) } } } // TestFixtureCanonicalUserIDDeterministic 证明 fixture ULID 是确定性的: // 同一 legacy id 每次跑得到同一个值,不同 legacy id 不撞车。 func TestFixtureCanonicalUserIDDeterministic(t *testing.T) { a1 := FixtureCanonicalUserID(53691) a2 := FixtureCanonicalUserID(53691) if a1 != a2 { t.Fatalf("同一 legacy id 得到不同 ULID:%s vs %s", a1, a2) } if len(a1) != 26 { t.Fatalf("ULID 长度应为 26,实际 %d(%q)", len(a1), a1) } seen := map[string]bool{} for _, id := range []int64{53691, 20052, 34369, 45014, 17230, 17735} { u := FixtureCanonicalUserID(id) if seen[u] { t.Fatalf("不同 legacy id 撞到同一 ULID:%s", u) } seen[u] = true } } func ptrNStr(s string) sql.NullString { return sql.NullString{String: s, Valid: true} } func nullNStr() sql.NullString { return sql.NullString{} } // TestMapExpenseLogNullSemantics 是空值语义的单测: // create_user_id=0 → NULL、request_id → NULL、balance_after → NULL、id 原样保留。 // // ⚠ legacy 真实数据里 create_userid=0 的行数是 **0**(见报告 §5), // 所以这条规则只能靠单测 + 变异实验证明,不能靠真实数据证明。 func TestMapExpenseLogNullSemantics(t *testing.T) { legacy := LegacyExpenseLog{ ID: 33451, UserID: 53691, Type: 100, LogID: ptrNStr("fa5d7262e03636c4b69475d731f7388e"), Money: ptrNStr("5000.000"), CreateUserID: sql.NullInt64{Int64: 0, Valid: true}, // 系统/未知创建人 CreateTime: sql.NullTime{Time: time.Date(2019, 9, 7, 12, 25, 49, 0, time.Local), Valid: true}, Remark: ptrNStr("充值缴费"), } resolver := func(id int64) (string, bool) { return "", false } got, warns, err := MapExpenseLog(legacy, "01ABC", resolver) if err != nil { t.Fatalf("MapExpenseLog 出错:%v", err) } if got.ID != 33451 { t.Errorf("D1:id 应原样保留 33451,实际 %d", got.ID) } if got.CreateUserID.Valid { t.Errorf("create_user_id=0 应映射为 NULL,实际落成 %q", got.CreateUserID.String) } if got.RequestID.Valid { t.Error("request_id 应恒为 NULL(legacy 无此列)") } if got.BalanceAfter.Valid { t.Error("balance_after 应恒为 NULL(legacy 无快照)") } if got.MoneyCents != 500000 { t.Errorf("money 5000.000 → 500000 分,实际 %d", got.MoneyCents) } if got.CreateTime.Nanosecond() != 0 { t.Errorf("create_time 毫秒部分应为 0(DATETIME(3) 补 .000),实际 %d", got.CreateTime.Nanosecond()) } if len(warns) != 0 { t.Errorf("不应有告警,实际 %v", warns) } } // TestMapExpenseLogResolvesCreator 证明「legacy int create_userid → ULID」走的是 resolver。 func TestMapExpenseLogResolvesCreator(t *testing.T) { legacy := LegacyExpenseLog{ ID: 1, Type: 201, Money: ptrNStr("-3000.000"), CreateUserID: sql.NullInt64{Int64: 34369, Valid: true}, CreateTime: sql.NullTime{Time: time.Date(2019, 9, 7, 12, 37, 12, 0, time.Local), Valid: true}, } resolver := func(id int64) (string, bool) { if id == 34369 { return "01RESOLVED0000000000000000", true } return "", false } got, _, err := MapExpenseLog(legacy, "01ABC", resolver) if err != nil { t.Fatalf("MapExpenseLog 出错:%v", err) } if !got.CreateUserID.Valid || got.CreateUserID.String != "01RESOLVED0000000000000000" { t.Errorf("create_user_id 应解析成 ULID,实际 %+v", got.CreateUserID) } } // TestMapExpenseLogRejectsNullCreateTime 证明「create_time 为 NULL 时拒绝用当前时间填充」。 func TestMapExpenseLogRejectsNullCreateTime(t *testing.T) { legacy := LegacyExpenseLog{ID: 1, Money: ptrNStr("1.000"), CreateTime: sql.NullTime{}} if _, _, err := MapExpenseLog(legacy, "01ABC", func(int64) (string, bool) { return "", false }); err == nil { t.Error("create_time 为 NULL 时应报错(不得用当前时间填充),实际未报错") } } // TestMapExpenseRoundsPerRow 证明金额是**逐列**取整到分(不是取整求和)。 func TestMapExpenseRoundsPerRow(t *testing.T) { l := LegacyExpense{ UserID: 53691, Balance: ptrNStr("0.788"), Spend: ptrNStr("4999.212"), TotalIncome: ptrNStr("5000.000"), TotalExpenditure: ptrNStr("4999.212"), TotalRecommendedAmount: ptrNStr("0.000"), SpecialExpenses: ptrNStr("0.000"), } got, warns, err := MapExpense(l, "01ABC") if err != nil { t.Fatalf("MapExpense 出错:%v", err) } if got.BalanceCents != 79 { t.Errorf("balance 0.788 → 0.79(79 分),实际 %d", got.BalanceCents) } if got.SpendCents != 499921 { t.Errorf("spend 4999.212 → 4999.21,实际 %d", got.SpendCents) } if got.TotalIncomeCents != 500000 { t.Errorf("total_income 5000.000 → 5000.00,实际 %d", got.TotalIncomeCents) } if len(warns) != 0 { t.Errorf("不应有告警,实际 %v", warns) } } // TestMapExpenseNullToZero 证明 legacy NULL 金额落 0 且**必须产生告警**。 func TestMapExpenseNullToZero(t *testing.T) { l := LegacyExpense{ UserID: 1, Balance: nullNStr(), Spend: ptrNStr("0.000"), TotalIncome: ptrNStr("0.000"), TotalExpenditure: ptrNStr("0.000"), TotalRecommendedAmount: nullNStr(), SpecialExpenses: nullNStr(), } got, warns, err := MapExpense(l, "01ABC") if err != nil { t.Fatalf("MapExpense 出错:%v", err) } if got.BalanceCents != 0 || got.TotalRecommendedCents != 0 { t.Error("NULL 金额应落 0") } if len(warns) != 3 { t.Errorf("3 个 NULL 列应产生 3 条告警,实际 %d 条:%v", len(warns), warns) } } // TestTruncateRunes 证明 remark 按字符截断(不会把汉字截成半个)。 func TestTruncateRunes(t *testing.T) { s := "充值缴费abc" if got := truncateRunes(s, 4); got != "充值缴费" { t.Errorf("按字符截断到 4 应得 %q,实际 %q", "充值缴费", got) } if got := truncateRunes("abc", 10); got != "abc" { t.Errorf("未超长应原样返回,实际 %q", got) } }