| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246 |
- // 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)
- }
- }
|