decimal_format_test.go 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. // 定点格式化纯函数单测(**不连库、不起 Fiber、不依赖时区**)。
  2. //
  3. // ---------------------------------------------------------------------------
  4. // 为什么这组用例必须存在(而不是"顺手写的字符串断言")
  5. // ---------------------------------------------------------------------------
  6. //
  7. // 之前的断言都活在 HTTP + SQLite 用例里,于是只能写 `mustDecimal`(比数值不比字节)——
  8. // 因为 SQLite 的 numeric 亲和性会把 "85.0" 存回 "85"。**这恰好把本层的价值测掉了**:
  9. // 字节层面的"位数"正是量纲指纹(百分比 1 位 / 金额 2 位 / 教龄 3 位),
  10. // 数值断言全绿也照样让「比例被按金额格式化」这个 bug 混过去。
  11. //
  12. // 所以这里**脱离数据库直接调纯函数**,断言字节:
  13. //
  14. // 输入 "85" → 百分比出口 "85.0" (不是 "85"、不是 "85.00")
  15. // 输入 "100" → 金额出口 "100.00" (不是 "100"、不是 "100.0")
  16. //
  17. // 三条变异(把 FormatPercentage 改成 ScaleYuan / 去掉补零 / 去掉进位)都会让本文件变红,
  18. // 而在 DB 用例里前两条**不会**红。这就是这组用例存在的唯一理由。
  19. package service
  20. import (
  21. "strings"
  22. "testing"
  23. )
  24. // TestFormatPercentageBytes 断言百分数出口恒为 1 位小数(字节级)。
  25. //
  26. // 关键用例:`"85"` → `"85.0"`。这条来自团队裁决原文("输入 "85" → 输出 "85.0""),
  27. // 也是 SQLite 环境下最容易漏掉的一条——库里读出来就是 "85",不补零就原样漏给前端。
  28. func TestFormatPercentageBytes(t *testing.T) {
  29. cases := []struct {
  30. name string
  31. in string
  32. want string
  33. }{
  34. {"整数补零(SQLite 读出的形态)", "85", "85.0"},
  35. {"已是一位小数", "85.0", "85.0"},
  36. // ⚠ 这条专门钉"补零位置":一位小数必须补在**右侧**("1.5" → "1.5")。
  37. // 若实现把补零补在左侧(frac 从 5 变成 "05"),会吐出 "1.05" —— 数值变了 10 倍,
  38. // 而 "85"/"100" 这类整数输入**测不出来**(它们的 frac 本来就是 0)。
  39. {"一位小数不能被左补零", "1.5", "1.5"},
  40. {"两位小数中的有效位", "65.5", "65.5"},
  41. {"两位小数截断(不能四舍五入成 85.1)", "85.04", "85.0"},
  42. {"第三位 ≥5 进位", "85.05", "85.1"},
  43. {"100 的百分数(不是金额 100.00)", "100", "100.0"},
  44. {"零", "0", "0.0"},
  45. {"带空格", " 85 ", "85.0"},
  46. {"负零归一(不产出 -0.0)", "-0.0", "0.0"},
  47. }
  48. for _, tc := range cases {
  49. got, err := FormatPercentage(tc.in)
  50. if err != nil {
  51. t.Fatalf("%s:FormatPercentage(%q) 意外报错 %v", tc.name, tc.in, err)
  52. }
  53. if got != tc.want {
  54. t.Fatalf("%s:FormatPercentage(%q) = %q,期望 %q(百分比是 decimal(5,1),"+
  55. "位数就是量纲指纹:\"85.00\" 说明被当成金额格式化了)", tc.name, tc.in, got, tc.want)
  56. }
  57. }
  58. }
  59. // TestFormatYuanBytes 断言金额出口恒为 2 位小数(字节级)。
  60. //
  61. // 关键用例:`"100"` → `"100.00"`(团队裁决原文),以及 `"12.3"` → `"12.30"`(一位补成两位)。
  62. func TestFormatYuanBytes(t *testing.T) {
  63. cases := []struct {
  64. name string
  65. in string
  66. want string
  67. }{
  68. {"整数补两位(MySQL/SQLite 都可能读出这个形态)", "100", "100.00"},
  69. {"一位小数补零", "12.3", "12.30"},
  70. {"已是两位", "300.00", "300.00"},
  71. {"第三位 ≥5 进位(与 money 同规则)", "0.005", "0.01"},
  72. {"第三位 <5 舍去", "0.004", "0.00"},
  73. {"空串按 0(与 parseAmount 一致)", "", "0.00"},
  74. {"负数保留符号", "-1.5", "-1.50"},
  75. {"负零归一", "-0.00", "0.00"},
  76. }
  77. for _, tc := range cases {
  78. got, err := FormatYuan(tc.in)
  79. if err != nil {
  80. t.Fatalf("%s:FormatYuan(%q) 意外报错 %v", tc.name, tc.in, err)
  81. }
  82. if got != tc.want {
  83. t.Fatalf("%s:FormatYuan(%q) = %q,期望 %q", tc.name, tc.in, got, tc.want)
  84. }
  85. }
  86. }
  87. // TestFormatYearsBytes 断言教龄区间端点恒为 3 位小数(`decimal(10,3)`)。
  88. func TestFormatYearsBytes(t *testing.T) {
  89. cases := []struct {
  90. name string
  91. in string
  92. want string
  93. }{
  94. {"整数补三位", "1", "1.000"},
  95. {"零", "0", "0.000"},
  96. {"一位补三位", "2.5", "2.500"},
  97. {"已三位", "5.000", "5.000"},
  98. {"第四位进位", "1.2345", "1.235"},
  99. }
  100. for _, tc := range cases {
  101. got, err := FormatYears(tc.in)
  102. if err != nil {
  103. t.Fatalf("%s:FormatYears(%q) 意外报错 %v", tc.name, tc.in, err)
  104. }
  105. if got != tc.want {
  106. t.Fatalf("%s:FormatYears(%q) = %q,期望 %q", tc.name, tc.in, got, tc.want)
  107. }
  108. }
  109. }
  110. // TestFormatFixedRejectsBadInput 非法输入必须报错(**不静默返回 0**)。
  111. //
  112. // 为什么这条重要:格式化发生在**出库之后**,喂进来的都是库里的值。若非法值被静默当成 0,
  113. // 前端会看到一条"比例 0.0% / 教龄 0 年"的合法规则 ⇒ 课酬被算成 0 却查不出原因。
  114. // 报错(→ 5002 数据不一致)才能让人去查那条坏数据。
  115. func TestFormatFixedRejectsBadInput(t *testing.T) {
  116. bad := []string{"", " ", "-", "+", "abc", ".5", "12.", "1.2.3", "1a", "1 ", "0x10", "1e5"}
  117. for _, in := range bad {
  118. if in == "1 " { // 两侧空格是允许的(TrimSpace 后合法)
  119. // "1 " 去掉尾部空格后是 "1",属于合法输入,跳过
  120. if got, err := FormatPercentage(in); err != nil || got != "1.0" {
  121. t.Fatalf("FormatPercentage(%q) = (%q,%v),期望 (\"1.0\",nil):两侧空白应被容忍", in, got, err)
  122. }
  123. continue
  124. }
  125. if got, err := FormatPercentage(in); err == nil {
  126. t.Fatalf("FormatPercentage(%q) = %q 且无错,期望报错(非法值静默当 0 会让课酬静默为 0)", in, got)
  127. }
  128. }
  129. // 整数位过长(超出任何 decimal 列的量级)必须拒绝,不能静默溢出
  130. long := strings.Repeat("9", 13)
  131. if got, err := FormatPercentage(long); err == nil {
  132. t.Fatalf("FormatPercentage(%q) = %q 且无错,期望报错(整数位 >12 位视为数据坏)", long, got)
  133. }
  134. }
  135. // TestFormatFixedNoFloat64Residue 定点化不得引入浮点残渣("用 float 拼字符串"的典型翻车形态)。
  136. //
  137. // 0.1 / 0.3 在二进制浮点里不可精确表示,若实现走 `fmt.Sprintf("%.1f", x)` 之类路径,
  138. // 会在某些值上吐出 84.99999999999999 / 0.30000000000000004 这类残渣。
  139. // 本用例断言"每个结果都只含数字、小数点与一个可选负号",且小数位数恰好等于 scale。
  140. func TestFormatFixedNoFloat64Residue(t *testing.T) {
  141. inputs := []string{"85", "84.999999", "0.1", "0.3", "65.5", "99.95", "33.333333"}
  142. for _, in := range inputs {
  143. got, err := FormatPercentage(in)
  144. if err != nil {
  145. t.Fatalf("FormatPercentage(%q) 意外报错 %v", in, err)
  146. }
  147. dot := strings.IndexByte(got, '.')
  148. if dot < 0 {
  149. t.Fatalf("FormatPercentage(%q) = %q,缺少小数点(1 位小数必须显式补 0)", in, got)
  150. }
  151. if len(got)-dot-1 != ScalePercentage {
  152. t.Fatalf("FormatPercentage(%q) = %q,小数位 %d 位,期望 %d 位",
  153. in, got, len(got)-dot-1, ScalePercentage)
  154. }
  155. body := strings.TrimPrefix(got, "-")
  156. for i := 0; i < len(body); i++ {
  157. ch := body[i]
  158. if ch == '.' || (ch >= '0' && ch <= '9') {
  159. continue
  160. }
  161. t.Fatalf("FormatPercentage(%q) = %q,出现非数字字符 %q(浮点残渣/科学计数法)", in, got, ch)
  162. }
  163. }
  164. }