Files
FuelLog/fuellog-api/pkg/decimal/money_test.go
T
KennethCheng 07ebce1ea3 test(backend): 添加核心计算单测与 Statistics 业务单测
Phase 1.4。AGENTS.md §19 与 plan §6 都把单测作为 MVP 硬要求,这条 commit
把核心计算覆盖完毕。

新增:
- pkg/decimal/money_test.go   5 个函数 × table-driven,共 22 个子测试
  * PricePerLiter:覆盖 plan §6.1 的 176.33/23.67 = 7.45,以及简单整数除、
    liters<=0 边界、四位精度等
  * ConsumptionL100km:覆盖 plan §6.3 的 (10500-10000)/40*100 = 8.0,以及
    mileage<=0 边界
  * CostPerKm:覆盖 plan §6.4 的 256/500 = 0.512
  * Mileage:正常递增 / 相等(没开) / 倒推(里程倒写,应为 0) / 首次记录(prev=0)
  * AvgFloat64:空切片 / 单值 / 多值 / 舍入

- internal/service/refuel_record_test.go  5 个测试,覆盖 plan §6.3 的
  "半箱油不进入标准油耗"硬规则
  * TestStatistics_TwoConsecutiveFull:两条 is_full → mileage=500,
    consumption=8.0, cost=0.64, 命中 plan 案例
  * TestStatistics_HalfTankDoesNotPollute:两条全 + 一条半箱,标准油耗仍 8.0,
    半箱油被正确跳过
  * TestStatistics_OnlyHalfTanks:全是半箱,标准油耗 = 0(没采样)
  * TestStatistics_MultipleFullSamples:三条全,平均油耗/成本正确
  * TestStatistics_EmptyVehicle:零记录时 LastRefuel 应为 nil

测试方法:
- 纯单测,不接 SQLite,自己写 fakeVehicleRepo / fakeRefuelRepo
  实现 repository 接口;fake 也按 refuel_time DESC 排,与真 repo 行为一致
- sentinel 错误与 apperror.IsCode 风格一致,后续接 apperror 时改一行即可

期间踩到的一个坑(已修):
- 第一版 fake ListByVehicle 不排序,Statistics 倒序迭代拿到错误顺序,
  mileage 算成负 → 全部回 0。修法:fake 加上 sort.Slice DESC,与真 repo 对齐

验证:
  go vet ./...   0 issues
  go test ./pkg/decimal ./internal/service
    ok  pkg/decimal     22 子测试通过
    ok  internal/service  5 测试通过

未做(留待后续):
- handler 单测(纯 HTTP 表层,可加,目前 smoke.ps1 已覆盖)
- repository 单测(需要 sqlite,end-to-end smoke 已经覆盖)
- CI 接入(留到 Phase 1.5 之后用 GitHub Actions / Gitea Actions)
2026-09-13 16:15:48 +08:00

120 lines
3.0 KiB
Go

package decimal
import (
"math"
"testing"
)
const epsilon = 0.001
func almostEqual(a, b float64) bool { return math.Abs(a-b) < epsilon }
func TestPricePerLiter(t *testing.T) {
cases := []struct {
name string
total, liters float64
expect float64
}{
{"plan example 176.33/23.67", 176.33, 23.67, 7.45},
{"simple 320/40", 320, 40, 8.0},
{"simple 256/32", 256, 32, 8.0},
{"zero liters", 100, 0, 0},
{"negative liters", 100, -1, 0},
{"precision 23.67/3.18", 23.67, 3.18, 7.4434}, // 23.67/3.18 = 7.4433...
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := PricePerLiter(c.total, c.liters)
if !almostEqual(got, c.expect) {
t.Errorf("PricePerLiter(%v,%v) = %v, want %v", c.total, c.liters, got, c.expect)
}
})
}
}
func TestConsumptionL100km(t *testing.T) {
cases := []struct {
name string
liters, mileage float64
expect float64
}{
{"plan example (40/500*100)", 40, 500, 8.0},
{"32/500*100", 32, 500, 6.4},
{"zero mileage", 40, 0, 0},
{"negative mileage", 40, -1, 0},
{"rounding 2dp", 7.123, 100, 7.12}, // 7.123/100*100 = 7.123 -> round 2dp
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := ConsumptionL100km(c.liters, c.mileage)
if !almostEqual(got, c.expect) {
t.Errorf("ConsumptionL100km(%v,%v) = %v, want %v", c.liters, c.mileage, got, c.expect)
}
})
}
}
func TestCostPerKm(t *testing.T) {
cases := []struct {
name string
amount, mileage float64
expect float64
}{
{"plan example 256/500", 256, 500, 0.512},
{"zero mileage", 100, 0, 0},
{"negative mileage", 100, -1, 0},
{"typical 200/400", 200, 400, 0.5},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := CostPerKm(c.amount, c.mileage)
if !almostEqual(got, c.expect) {
t.Errorf("CostPerKm(%v,%v) = %v, want %v", c.amount, c.mileage, got, c.expect)
}
})
}
}
func TestMileage(t *testing.T) {
cases := []struct {
name string
current, prev float64
expect float64
}{
{"normal increase", 11000, 10500, 500},
{"equal (no travel)", 10500, 10500, 0},
{"odometer rollback (typo)", 10400, 10500, 0},
{"zero prev (first record)", 10000, 0, 10000},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := Mileage(c.current, c.prev)
if !almostEqual(got, c.expect) {
t.Errorf("Mileage(%v,%v) = %v, want %v", c.current, c.prev, got, c.expect)
}
})
}
}
func TestAvgFloat64(t *testing.T) {
cases := []struct {
name string
xs []float64
expect float64
}{
{"empty", nil, 0},
{"single", []float64{8.0}, 8.0},
{"two", []float64{7.0, 9.0}, 8.0},
{"three typical", []float64{6.0, 7.0, 8.0}, 7.0},
{"rounding", []float64{0.123, 0.456, 0.789}, 0.46}, // avg = 0.456
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := AvgFloat64(c.xs)
if !almostEqual(got, c.expect) {
t.Errorf("AvgFloat64(%v) = %v, want %v", c.xs, got, c.expect)
}
})
}
}