| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171 |
- // 定点格式化纯函数单测(**不连库、不起 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)
- }
- }
- }
|