// Package money 金额值对象单测(纯逻辑,不连库)。 // // 覆盖重点(AC-02 验收①②): // - NewFromYuanString 严格解析:合法形态、"12."/".5"/"1.2.3"/非数字/空串拒绝; // - 四位化舍入:0.005 → 0.01、-0.005 → -0.01、0.004 → 0.00、12.345 → 12.35(远离零方向); // - String 恒两位小数、负数带号、零为 "0.00"; // - 运算 Add/Sub/MulInt/Neg/Cmp/IsZero/LessThan/GreaterThan; // - JSON 序列化为**字符串**,反序列化只接受字符串(拒绝 number/null); // - Value/Scan 互转(decimal 列 ↔ 分),Scan 拒绝 nil 与 float64(**无 float64 路径**)。 package money import ( stderrors "errors" "testing" ) // TestNewFromYuanString 解析合法/非法输入(表驱动,边界逐条覆盖)。 func TestNewFromYuanString(t *testing.T) { okCases := []struct { name string in string want Amount }{ {"两位小数", "12.34", 1234}, {"整数无小数", "12", 1200}, {"一位小数", "100.5", 10050}, {"负一分", "-0.01", -1}, {"正号", "+100.50", 10050}, {"零", "0", 0}, {"零两位小数", "0.00", 0}, {"前后空格", " 100.10 ", 10010}, {"第三位四舍五入进位", "0.005", 1}, {"第三位不足舍去", "0.004", 0}, {"负值四舍五入远离零", "-0.005", -1}, {"多位小数进位", "12.345", 1235}, {"多位小数舍去", "12.3449", 1234}, {"长小数尾部忽略", "1.00000000000001", 100}, {"负零归一", "-0.004", 0}, {"decimal 上限", "9999999999.99", MaxAmount}, {"decimal 下限", "-9999999999.99", MinAmount}, } for _, tc := range okCases { tc := tc t.Run(tc.name, func(t *testing.T) { got, err := NewFromYuanString(tc.in) if err != nil { t.Fatalf("NewFromYuanString(%q) 意外失败: %v", tc.in, err) } if got != tc.want { t.Fatalf("NewFromYuanString(%q) = %d,期望 %d", tc.in, got, tc.want) } }) } badCases := []struct { name string in string want error }{ {"空串", "", ErrInvalidFormat}, {"仅空格", " ", ErrInvalidFormat}, {"仅负号", "-", ErrInvalidFormat}, {"非数字", "abc", ErrInvalidFormat}, {"数字混字母", "12a", ErrInvalidFormat}, {"两个小数点", "1.2.3", ErrInvalidFormat}, {"尾随小数点", "12.", ErrInvalidFormat}, {"缺整数位", ".5", ErrInvalidFormat}, {"中文数字", "一百", ErrInvalidFormat}, {"科学计数法", "1e5", ErrInvalidFormat}, {"千分位", "1,000.00", ErrInvalidFormat}, {"超上限", "10000000000.00", ErrOverflow}, {"超下限", "-10000000000.00", ErrOverflow}, {"整数位溢出 int64", "99999999999999999999999", ErrOverflow}, } for _, tc := range badCases { tc := tc t.Run("非法_"+tc.name, func(t *testing.T) { _, err := NewFromYuanString(tc.in) if !stderrors.Is(err, tc.want) { t.Fatalf("NewFromYuanString(%q) 错误 = %v,期望 %v", tc.in, err, tc.want) } }) } } // TestAmountString 输出格式恒定两位小数(前端契约)。 func TestAmountString(t *testing.T) { cases := []struct { in Amount want string }{ {0, "0.00"}, {1, "0.01"}, {5, "0.05"}, {1234, "12.34"}, {-1, "-0.01"}, {-1234, "-12.34"}, {100, "1.00"}, {MaxAmount, "9999999999.99"}, {MinAmount, "-9999999999.99"}, } for _, tc := range cases { if got := tc.in.String(); got != tc.want { t.Fatalf("Amount(%d).String() = %q,期望 %q", int64(tc.in), got, tc.want) } } } // TestAmountArithmetic 加减乘负与比较(资金链核心运算,无浮点)。 func TestAmountArithmetic(t *testing.T) { a, _ := NewFromYuanString("100.00") b, _ := NewFromYuanString("0.01") if got := a.Add(b).String(); got != "100.01" { t.Fatalf("Add = %s,期望 100.01", got) } if got := a.Sub(b).String(); got != "99.99" { t.Fatalf("Sub = %s,期望 99.99", got) } if got := b.MulInt(300).String(); got != "3.00" { t.Fatalf("MulInt = %s,期望 3.00", got) } if got := a.Neg().String(); got != "-100.00" { t.Fatalf("Neg = %s,期望 -100.00", got) } if a.Cmp(b) != 1 || b.Cmp(a) != -1 || a.Cmp(a) != 0 { t.Fatalf("Cmp 三元语义错误: a.Cmp(b)=%d b.Cmp(a)=%d a.Cmp(a)=%d", a.Cmp(b), b.Cmp(a), a.Cmp(a)) } if !Amount(0).IsZero() || a.IsZero() { t.Fatalf("IsZero 语义错误") } if !b.LessThan(a) || !a.GreaterThan(b) { t.Fatalf("LessThan/GreaterThan 语义错误") } if !a.Neg().IsNegative() { t.Fatalf("IsNegative 语义错误") } if a.Cents() != 10000 || FromCents(10000) != a { t.Fatalf("Cents/FromCents 互转错误: %d", a.Cents()) } } // TestAmountValidate 写库前越界断言。 func TestAmountValidate(t *testing.T) { if err := MaxAmount.Validate(); err != nil { t.Fatalf("MaxAmount 应合法: %v", err) } if err := MinAmount.Validate(); err != nil { t.Fatalf("MinAmount 应合法: %v", err) } if err := (MaxAmount + 1).Validate(); !stderrors.Is(err, ErrOverflow) { t.Fatalf("越界应返回 ErrOverflow,实得 %v", err) } } // TestAmountJSON JSON 恒输出字符串,反序列化拒绝非字符串(前后端金额契约)。 func TestAmountJSON(t *testing.T) { a, _ := NewFromYuanString("-12.30") buf, err := a.MarshalJSON() if err != nil { t.Fatalf("MarshalJSON 失败: %v", err) } if string(buf) != `"-12.30"` { t.Fatalf("MarshalJSON = %s,期望 \"-12.30\"(字符串)", buf) } var back Amount if err := back.UnmarshalJSON(buf); err != nil { t.Fatalf("UnmarshalJSON 失败: %v", err) } if back != a { t.Fatalf("JSON 往返不一致: %d vs %d", back, a) } if err := back.UnmarshalJSON([]byte(`100.00`)); !stderrors.Is(err, ErrInvalidFormat) { t.Fatalf("数字 JSON 应被拒绝,实得 %v", err) } if err := back.UnmarshalJSON([]byte(`null`)); !stderrors.Is(err, ErrInvalidFormat) { t.Fatalf("null JSON 应被拒绝,实得 %v", err) } if err := back.UnmarshalJSON([]byte(`"abc"`)); !stderrors.Is(err, ErrInvalidFormat) { t.Fatalf("非法金额字符串应被拒绝,实得 %v", err) } var nilRecv *Amount if err := nilRecv.UnmarshalJSON([]byte(`"1.00"`)); !stderrors.Is(err, ErrNilReceiver) { t.Fatalf("nil 接收者应返回 ErrNilReceiver,实得 %v", err) } } // TestAmountValueScan decimal(12,2) ↔ Amount 互转(驱动返回 []byte,落库输出字符串)。 func TestAmountValueScan(t *testing.T) { a, _ := NewFromYuanString("1234.56") v, err := a.Value() if err != nil { t.Fatalf("Value 失败: %v", err) } str, ok := v.(string) if !ok { t.Fatalf("Value 必须返回 string(避免 float 精度),实得 %T", v) } if str != "1234.56" { t.Fatalf("Value = %q,期望 1234.56", str) } // MySQL 驱动 decimal 列返回 []byte var fromBytes Amount if err := fromBytes.Scan([]byte("1234.56")); err != nil { t.Fatalf("Scan([]byte) 失败: %v", err) } if fromBytes != a { t.Fatalf("Scan([]byte) = %d,期望 %d", fromBytes, a) } // 部分驱动/单测桩返回 string var fromString Amount if err := fromString.Scan("1234.56"); err != nil { t.Fatalf("Scan(string) 失败: %v", err) } if fromString != a { t.Fatalf("Scan(string) = %d,期望 %d", fromString, a) } // NULL 拒绝(金额列 DDL 全 NOT NULL) var nullAmt Amount if err := nullAmt.Scan(nil); !stderrors.Is(err, ErrScanNull) { t.Fatalf("Scan(nil) 应返回 ErrScanNull,实得 %v", err) } // float64 拒绝(资金链禁止浮点) var floatAmt Amount if err := floatAmt.Scan(float64(1.23)); !stderrors.Is(err, ErrUnsupportedScanType) { t.Fatalf("Scan(float64) 应返回 ErrUnsupportedScanType,实得 %v", err) } var nilRecv *Amount if err := nilRecv.Scan("1.00"); !stderrors.Is(err, ErrNilReceiver) { t.Fatalf("nil 接收者应返回 ErrNilReceiver,实得 %v", err) } } // TestAmountGormDataType 落库类型恒为 decimal(12,2)(与 DDL 收敛口径一致)。 func TestAmountGormDataType(t *testing.T) { if got := Amount(0).GormDataType(); got != "decimal(12,2)" { t.Fatalf("GormDataType = %q,期望 decimal(12,2)", got) } }