// 定点格式化纯函数单测(**不连库、不起 Fiber、不依赖时区**)。 // // --------------------------------------------------------------------------- // 为什么这组用例必须存在(而不是"顺手写的字符串断言") // --------------------------------------------------------------------------- // // 之前的断言都活在 HTTP + SQLite 用例里,于是只能写 `mustDecimal`(比数值不比字节)—— // 因为 SQLite 的 numeric 亲和性会把 "85.0" 存回 "85"。**这恰好把本层的价值测掉了**: // 字节层面的"位数"正是量纲指纹(百分比 1 位 / 金额 2 位 / 教龄 3 位), // 数值断言全绿也照样让「比例被按金额格式化」这个 bug 混过去。 // // 所以这里**脱离数据库直接调纯函数**,断言字节: // // 输入 "85" → 百分比出口 "85.0" (不是 "85"、不是 "85.00") // 输入 "100" → 金额出口 "100.00" (不是 "100"、不是 "100.0") // // 三条变异(把 FormatPercentage 改成 ScaleYuan / 去掉补零 / 去掉进位)都会让本文件变红, // 而在 DB 用例里前两条**不会**红。这就是这组用例存在的唯一理由。 package service import ( "strings" "testing" ) // TestFormatPercentageBytes 断言百分数出口恒为 1 位小数(字节级)。 // // 关键用例:`"85"` → `"85.0"`。这条来自团队裁决原文("输入 "85" → 输出 "85.0""), // 也是 SQLite 环境下最容易漏掉的一条——库里读出来就是 "85",不补零就原样漏给前端。 func TestFormatPercentageBytes(t *testing.T) { cases := []struct { name string in string want string }{ {"整数补零(SQLite 读出的形态)", "85", "85.0"}, {"已是一位小数", "85.0", "85.0"}, // ⚠ 这条专门钉"补零位置":一位小数必须补在**右侧**("1.5" → "1.5")。 // 若实现把补零补在左侧(frac 从 5 变成 "05"),会吐出 "1.05" —— 数值变了 10 倍, // 而 "85"/"100" 这类整数输入**测不出来**(它们的 frac 本来就是 0)。 {"一位小数不能被左补零", "1.5", "1.5"}, {"两位小数中的有效位", "65.5", "65.5"}, {"两位小数截断(不能四舍五入成 85.1)", "85.04", "85.0"}, {"第三位 ≥5 进位", "85.05", "85.1"}, {"100 的百分数(不是金额 100.00)", "100", "100.0"}, {"零", "0", "0.0"}, {"带空格", " 85 ", "85.0"}, {"负零归一(不产出 -0.0)", "-0.0", "0.0"}, } for _, tc := range cases { got, err := FormatPercentage(tc.in) if err != nil { t.Fatalf("%s:FormatPercentage(%q) 意外报错 %v", tc.name, tc.in, err) } if got != tc.want { t.Fatalf("%s:FormatPercentage(%q) = %q,期望 %q(百分比是 decimal(5,1),"+ "位数就是量纲指纹:\"85.00\" 说明被当成金额格式化了)", tc.name, tc.in, got, tc.want) } } } // TestFormatYuanBytes 断言金额出口恒为 2 位小数(字节级)。 // // 关键用例:`"100"` → `"100.00"`(团队裁决原文),以及 `"12.3"` → `"12.30"`(一位补成两位)。 func TestFormatYuanBytes(t *testing.T) { cases := []struct { name string in string want string }{ {"整数补两位(MySQL/SQLite 都可能读出这个形态)", "100", "100.00"}, {"一位小数补零", "12.3", "12.30"}, {"已是两位", "300.00", "300.00"}, {"第三位 ≥5 进位(与 money 同规则)", "0.005", "0.01"}, {"第三位 <5 舍去", "0.004", "0.00"}, {"空串按 0(与 parseAmount 一致)", "", "0.00"}, {"负数保留符号", "-1.5", "-1.50"}, {"负零归一", "-0.00", "0.00"}, } for _, tc := range cases { got, err := FormatYuan(tc.in) if err != nil { t.Fatalf("%s:FormatYuan(%q) 意外报错 %v", tc.name, tc.in, err) } if got != tc.want { t.Fatalf("%s:FormatYuan(%q) = %q,期望 %q", tc.name, tc.in, got, tc.want) } } } // TestFormatYearsBytes 断言教龄区间端点恒为 3 位小数(`decimal(10,3)`)。 func TestFormatYearsBytes(t *testing.T) { cases := []struct { name string in string want string }{ {"整数补三位", "1", "1.000"}, {"零", "0", "0.000"}, {"一位补三位", "2.5", "2.500"}, {"已三位", "5.000", "5.000"}, {"第四位进位", "1.2345", "1.235"}, } for _, tc := range cases { got, err := FormatYears(tc.in) if err != nil { t.Fatalf("%s:FormatYears(%q) 意外报错 %v", tc.name, tc.in, err) } if got != tc.want { t.Fatalf("%s:FormatYears(%q) = %q,期望 %q", tc.name, tc.in, got, tc.want) } } } // TestFormatFixedRejectsBadInput 非法输入必须报错(**不静默返回 0**)。 // // 为什么这条重要:格式化发生在**出库之后**,喂进来的都是库里的值。若非法值被静默当成 0, // 前端会看到一条"比例 0.0% / 教龄 0 年"的合法规则 ⇒ 课酬被算成 0 却查不出原因。 // 报错(→ 5002 数据不一致)才能让人去查那条坏数据。 func TestFormatFixedRejectsBadInput(t *testing.T) { bad := []string{"", " ", "-", "+", "abc", ".5", "12.", "1.2.3", "1a", "1 ", "0x10", "1e5"} for _, in := range bad { if in == "1 " { // 两侧空格是允许的(TrimSpace 后合法) // "1 " 去掉尾部空格后是 "1",属于合法输入,跳过 if got, err := FormatPercentage(in); err != nil || got != "1.0" { t.Fatalf("FormatPercentage(%q) = (%q,%v),期望 (\"1.0\",nil):两侧空白应被容忍", in, got, err) } continue } if got, err := FormatPercentage(in); err == nil { t.Fatalf("FormatPercentage(%q) = %q 且无错,期望报错(非法值静默当 0 会让课酬静默为 0)", in, got) } } // 整数位过长(超出任何 decimal 列的量级)必须拒绝,不能静默溢出 long := strings.Repeat("9", 13) if got, err := FormatPercentage(long); err == nil { t.Fatalf("FormatPercentage(%q) = %q 且无错,期望报错(整数位 >12 位视为数据坏)", long, got) } } // TestFormatFixedNoFloat64Residue 定点化不得引入浮点残渣("用 float 拼字符串"的典型翻车形态)。 // // 0.1 / 0.3 在二进制浮点里不可精确表示,若实现走 `fmt.Sprintf("%.1f", x)` 之类路径, // 会在某些值上吐出 84.99999999999999 / 0.30000000000000004 这类残渣。 // 本用例断言"每个结果都只含数字、小数点与一个可选负号",且小数位数恰好等于 scale。 func TestFormatFixedNoFloat64Residue(t *testing.T) { inputs := []string{"85", "84.999999", "0.1", "0.3", "65.5", "99.95", "33.333333"} for _, in := range inputs { got, err := FormatPercentage(in) if err != nil { t.Fatalf("FormatPercentage(%q) 意外报错 %v", in, err) } dot := strings.IndexByte(got, '.') if dot < 0 { t.Fatalf("FormatPercentage(%q) = %q,缺少小数点(1 位小数必须显式补 0)", in, got) } if len(got)-dot-1 != ScalePercentage { t.Fatalf("FormatPercentage(%q) = %q,小数位 %d 位,期望 %d 位", in, got, len(got)-dot-1, ScalePercentage) } body := strings.TrimPrefix(got, "-") for i := 0; i < len(body); i++ { ch := body[i] if ch == '.' || (ch >= '0' && ch <= '9') { continue } t.Fatalf("FormatPercentage(%q) = %q,出现非数字字符 %q(浮点残渣/科学计数法)", in, got, ch) } } }