| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238 |
- 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)
- }
- }
|