amount_test.go 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. // Package money 金额值对象单测(纯逻辑,不连库)。
  2. //
  3. // 覆盖重点(AC-02 验收①②):
  4. // - NewFromYuanString 严格解析:合法形态、"12."/".5"/"1.2.3"/非数字/空串拒绝;
  5. // - 四位化舍入:0.005 → 0.01、-0.005 → -0.01、0.004 → 0.00、12.345 → 12.35(远离零方向);
  6. // - String 恒两位小数、负数带号、零为 "0.00";
  7. // - 运算 Add/Sub/MulInt/Neg/Cmp/IsZero/LessThan/GreaterThan;
  8. // - JSON 序列化为**字符串**,反序列化只接受字符串(拒绝 number/null);
  9. // - Value/Scan 互转(decimal 列 ↔ 分),Scan 拒绝 nil 与 float64(**无 float64 路径**)。
  10. package money
  11. import (
  12. stderrors "errors"
  13. "testing"
  14. )
  15. // TestNewFromYuanString 解析合法/非法输入(表驱动,边界逐条覆盖)。
  16. func TestNewFromYuanString(t *testing.T) {
  17. okCases := []struct {
  18. name string
  19. in string
  20. want Amount
  21. }{
  22. {"两位小数", "12.34", 1234},
  23. {"整数无小数", "12", 1200},
  24. {"一位小数", "100.5", 10050},
  25. {"负一分", "-0.01", -1},
  26. {"正号", "+100.50", 10050},
  27. {"零", "0", 0},
  28. {"零两位小数", "0.00", 0},
  29. {"前后空格", " 100.10 ", 10010},
  30. {"第三位四舍五入进位", "0.005", 1},
  31. {"第三位不足舍去", "0.004", 0},
  32. {"负值四舍五入远离零", "-0.005", -1},
  33. {"多位小数进位", "12.345", 1235},
  34. {"多位小数舍去", "12.3449", 1234},
  35. {"长小数尾部忽略", "1.00000000000001", 100},
  36. {"负零归一", "-0.004", 0},
  37. {"decimal 上限", "9999999999.99", MaxAmount},
  38. {"decimal 下限", "-9999999999.99", MinAmount},
  39. }
  40. for _, tc := range okCases {
  41. tc := tc
  42. t.Run(tc.name, func(t *testing.T) {
  43. got, err := NewFromYuanString(tc.in)
  44. if err != nil {
  45. t.Fatalf("NewFromYuanString(%q) 意外失败: %v", tc.in, err)
  46. }
  47. if got != tc.want {
  48. t.Fatalf("NewFromYuanString(%q) = %d,期望 %d", tc.in, got, tc.want)
  49. }
  50. })
  51. }
  52. badCases := []struct {
  53. name string
  54. in string
  55. want error
  56. }{
  57. {"空串", "", ErrInvalidFormat},
  58. {"仅空格", " ", ErrInvalidFormat},
  59. {"仅负号", "-", ErrInvalidFormat},
  60. {"非数字", "abc", ErrInvalidFormat},
  61. {"数字混字母", "12a", ErrInvalidFormat},
  62. {"两个小数点", "1.2.3", ErrInvalidFormat},
  63. {"尾随小数点", "12.", ErrInvalidFormat},
  64. {"缺整数位", ".5", ErrInvalidFormat},
  65. {"中文数字", "一百", ErrInvalidFormat},
  66. {"科学计数法", "1e5", ErrInvalidFormat},
  67. {"千分位", "1,000.00", ErrInvalidFormat},
  68. {"超上限", "10000000000.00", ErrOverflow},
  69. {"超下限", "-10000000000.00", ErrOverflow},
  70. {"整数位溢出 int64", "99999999999999999999999", ErrOverflow},
  71. }
  72. for _, tc := range badCases {
  73. tc := tc
  74. t.Run("非法_"+tc.name, func(t *testing.T) {
  75. _, err := NewFromYuanString(tc.in)
  76. if !stderrors.Is(err, tc.want) {
  77. t.Fatalf("NewFromYuanString(%q) 错误 = %v,期望 %v", tc.in, err, tc.want)
  78. }
  79. })
  80. }
  81. }
  82. // TestAmountString 输出格式恒定两位小数(前端契约)。
  83. func TestAmountString(t *testing.T) {
  84. cases := []struct {
  85. in Amount
  86. want string
  87. }{
  88. {0, "0.00"},
  89. {1, "0.01"},
  90. {5, "0.05"},
  91. {1234, "12.34"},
  92. {-1, "-0.01"},
  93. {-1234, "-12.34"},
  94. {100, "1.00"},
  95. {MaxAmount, "9999999999.99"},
  96. {MinAmount, "-9999999999.99"},
  97. }
  98. for _, tc := range cases {
  99. if got := tc.in.String(); got != tc.want {
  100. t.Fatalf("Amount(%d).String() = %q,期望 %q", int64(tc.in), got, tc.want)
  101. }
  102. }
  103. }
  104. // TestAmountArithmetic 加减乘负与比较(资金链核心运算,无浮点)。
  105. func TestAmountArithmetic(t *testing.T) {
  106. a, _ := NewFromYuanString("100.00")
  107. b, _ := NewFromYuanString("0.01")
  108. if got := a.Add(b).String(); got != "100.01" {
  109. t.Fatalf("Add = %s,期望 100.01", got)
  110. }
  111. if got := a.Sub(b).String(); got != "99.99" {
  112. t.Fatalf("Sub = %s,期望 99.99", got)
  113. }
  114. if got := b.MulInt(300).String(); got != "3.00" {
  115. t.Fatalf("MulInt = %s,期望 3.00", got)
  116. }
  117. if got := a.Neg().String(); got != "-100.00" {
  118. t.Fatalf("Neg = %s,期望 -100.00", got)
  119. }
  120. if a.Cmp(b) != 1 || b.Cmp(a) != -1 || a.Cmp(a) != 0 {
  121. t.Fatalf("Cmp 三元语义错误: a.Cmp(b)=%d b.Cmp(a)=%d a.Cmp(a)=%d", a.Cmp(b), b.Cmp(a), a.Cmp(a))
  122. }
  123. if !Amount(0).IsZero() || a.IsZero() {
  124. t.Fatalf("IsZero 语义错误")
  125. }
  126. if !b.LessThan(a) || !a.GreaterThan(b) {
  127. t.Fatalf("LessThan/GreaterThan 语义错误")
  128. }
  129. if !a.Neg().IsNegative() {
  130. t.Fatalf("IsNegative 语义错误")
  131. }
  132. if a.Cents() != 10000 || FromCents(10000) != a {
  133. t.Fatalf("Cents/FromCents 互转错误: %d", a.Cents())
  134. }
  135. }
  136. // TestAmountValidate 写库前越界断言。
  137. func TestAmountValidate(t *testing.T) {
  138. if err := MaxAmount.Validate(); err != nil {
  139. t.Fatalf("MaxAmount 应合法: %v", err)
  140. }
  141. if err := MinAmount.Validate(); err != nil {
  142. t.Fatalf("MinAmount 应合法: %v", err)
  143. }
  144. if err := (MaxAmount + 1).Validate(); !stderrors.Is(err, ErrOverflow) {
  145. t.Fatalf("越界应返回 ErrOverflow,实得 %v", err)
  146. }
  147. }
  148. // TestAmountJSON JSON 恒输出字符串,反序列化拒绝非字符串(前后端金额契约)。
  149. func TestAmountJSON(t *testing.T) {
  150. a, _ := NewFromYuanString("-12.30")
  151. buf, err := a.MarshalJSON()
  152. if err != nil {
  153. t.Fatalf("MarshalJSON 失败: %v", err)
  154. }
  155. if string(buf) != `"-12.30"` {
  156. t.Fatalf("MarshalJSON = %s,期望 \"-12.30\"(字符串)", buf)
  157. }
  158. var back Amount
  159. if err := back.UnmarshalJSON(buf); err != nil {
  160. t.Fatalf("UnmarshalJSON 失败: %v", err)
  161. }
  162. if back != a {
  163. t.Fatalf("JSON 往返不一致: %d vs %d", back, a)
  164. }
  165. if err := back.UnmarshalJSON([]byte(`100.00`)); !stderrors.Is(err, ErrInvalidFormat) {
  166. t.Fatalf("数字 JSON 应被拒绝,实得 %v", err)
  167. }
  168. if err := back.UnmarshalJSON([]byte(`null`)); !stderrors.Is(err, ErrInvalidFormat) {
  169. t.Fatalf("null JSON 应被拒绝,实得 %v", err)
  170. }
  171. if err := back.UnmarshalJSON([]byte(`"abc"`)); !stderrors.Is(err, ErrInvalidFormat) {
  172. t.Fatalf("非法金额字符串应被拒绝,实得 %v", err)
  173. }
  174. var nilRecv *Amount
  175. if err := nilRecv.UnmarshalJSON([]byte(`"1.00"`)); !stderrors.Is(err, ErrNilReceiver) {
  176. t.Fatalf("nil 接收者应返回 ErrNilReceiver,实得 %v", err)
  177. }
  178. }
  179. // TestAmountValueScan decimal(12,2) ↔ Amount 互转(驱动返回 []byte,落库输出字符串)。
  180. func TestAmountValueScan(t *testing.T) {
  181. a, _ := NewFromYuanString("1234.56")
  182. v, err := a.Value()
  183. if err != nil {
  184. t.Fatalf("Value 失败: %v", err)
  185. }
  186. str, ok := v.(string)
  187. if !ok {
  188. t.Fatalf("Value 必须返回 string(避免 float 精度),实得 %T", v)
  189. }
  190. if str != "1234.56" {
  191. t.Fatalf("Value = %q,期望 1234.56", str)
  192. }
  193. // MySQL 驱动 decimal 列返回 []byte
  194. var fromBytes Amount
  195. if err := fromBytes.Scan([]byte("1234.56")); err != nil {
  196. t.Fatalf("Scan([]byte) 失败: %v", err)
  197. }
  198. if fromBytes != a {
  199. t.Fatalf("Scan([]byte) = %d,期望 %d", fromBytes, a)
  200. }
  201. // 部分驱动/单测桩返回 string
  202. var fromString Amount
  203. if err := fromString.Scan("1234.56"); err != nil {
  204. t.Fatalf("Scan(string) 失败: %v", err)
  205. }
  206. if fromString != a {
  207. t.Fatalf("Scan(string) = %d,期望 %d", fromString, a)
  208. }
  209. // NULL 拒绝(金额列 DDL 全 NOT NULL)
  210. var nullAmt Amount
  211. if err := nullAmt.Scan(nil); !stderrors.Is(err, ErrScanNull) {
  212. t.Fatalf("Scan(nil) 应返回 ErrScanNull,实得 %v", err)
  213. }
  214. // float64 拒绝(资金链禁止浮点)
  215. var floatAmt Amount
  216. if err := floatAmt.Scan(float64(1.23)); !stderrors.Is(err, ErrUnsupportedScanType) {
  217. t.Fatalf("Scan(float64) 应返回 ErrUnsupportedScanType,实得 %v", err)
  218. }
  219. var nilRecv *Amount
  220. if err := nilRecv.Scan("1.00"); !stderrors.Is(err, ErrNilReceiver) {
  221. t.Fatalf("nil 接收者应返回 ErrNilReceiver,实得 %v", err)
  222. }
  223. }
  224. // TestAmountGormDataType 落库类型恒为 decimal(12,2)(与 DDL 收敛口径一致)。
  225. func TestAmountGormDataType(t *testing.T) {
  226. if got := Amount(0).GormDataType(); got != "decimal(12,2)" {
  227. t.Fatalf("GormDataType = %q,期望 decimal(12,2)", got)
  228. }
  229. }