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)
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/kennethcheng/fuellog-api/internal/model"
|
||||
)
|
||||
|
||||
// fakeVehicleRepo / fakeRefuelRepo 是用于单测的 in-memory 假实现,
|
||||
// 不接 SQLite,直接塞到 service 的接口里。
|
||||
type fakeVehicleRepo struct{ v *model.Vehicle }
|
||||
|
||||
func (f *fakeVehicleRepo) Create(context.Context, *model.Vehicle) error { return nil }
|
||||
func (f *fakeVehicleRepo) Get(_ context.Context, id uint64) (*model.Vehicle, error) {
|
||||
if f.v != nil && f.v.ID == id {
|
||||
return f.v, nil
|
||||
}
|
||||
return nil, errNotFound
|
||||
}
|
||||
func (f *fakeVehicleRepo) List(context.Context) ([]model.Vehicle, error) { return nil, nil }
|
||||
func (f *fakeVehicleRepo) Update(context.Context, *model.Vehicle) error { return nil }
|
||||
func (f *fakeVehicleRepo) Delete(context.Context, uint64) error { return nil }
|
||||
func (f *fakeVehicleRepo) ClearDefault(context.Context, *gorm.DB) error { return nil }
|
||||
func (f *fakeVehicleRepo) SetDefault(context.Context, *gorm.DB, uint64) error { return nil }
|
||||
|
||||
// fakeRefuelRepo:用切片装记录,支持 ListByVehicle(忽略时间窗,业务测试够用)。
|
||||
type fakeRefuelRepo struct{ records []model.RefuelRecord }
|
||||
|
||||
func (f *fakeRefuelRepo) Create(context.Context, *model.RefuelRecord) error { return nil }
|
||||
func (f *fakeRefuelRepo) Get(_ context.Context, id uint64) (*model.RefuelRecord, error) {
|
||||
for i := range f.records {
|
||||
if f.records[i].ID == id {
|
||||
return &f.records[i], nil
|
||||
}
|
||||
}
|
||||
return nil, errNotFound
|
||||
}
|
||||
func (f *fakeRefuelRepo) Update(context.Context, *model.RefuelRecord) error { return nil }
|
||||
func (f *fakeRefuelRepo) Delete(context.Context, uint64) error { return nil }
|
||||
func (f *fakeRefuelRepo) ListByVehicle(_ context.Context, vehicleID uint64, _, _ *time.Time) ([]model.RefuelRecord, error) {
|
||||
out := make([]model.RefuelRecord, 0)
|
||||
for _, r := range f.records {
|
||||
if r.VehicleID == vehicleID {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
// 与真 repo 一致:按 refuel_time DESC,ID DESC
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].RefuelTime.Equal(out[j].RefuelTime) {
|
||||
return out[i].ID > out[j].ID
|
||||
}
|
||||
return out[i].RefuelTime.After(out[j].RefuelTime)
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
func (f *fakeRefuelRepo) PrevFullRefuel(context.Context, uint64, time.Time) (*model.RefuelRecord, error) {
|
||||
return nil, errNotFound
|
||||
}
|
||||
|
||||
var errNotFound = sentinel("NOT_FOUND")
|
||||
|
||||
type sentinel string
|
||||
|
||||
func (s sentinel) Error() string { return string(s) }
|
||||
|
||||
func newRefuelSvc(records []model.RefuelRecord) *refuelService {
|
||||
v := &model.Vehicle{ID: 1, Name: "test", FuelType: "gasoline"}
|
||||
return &refuelService{
|
||||
recRepo: &fakeRefuelRepo{records: records},
|
||||
vehicleRepo: &fakeVehicleRepo{v: v},
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatistics_TwoConsecutiveFull(t *testing.T) {
|
||||
t1 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
t2 := time.Date(2025, 1, 10, 0, 0, 0, 0, time.UTC)
|
||||
recs := []model.RefuelRecord{
|
||||
{ID: 1, VehicleID: 1, RefuelTime: t1, Odometer: 10000, Liters: 40, PricePerLiter: 8.0, TotalAmount: 320, IsFull: true},
|
||||
{ID: 2, VehicleID: 1, RefuelTime: t2, Odometer: 10500, Liters: 40, PricePerLiter: 8.0, TotalAmount: 320, IsFull: true},
|
||||
}
|
||||
svc := newRefuelSvc(recs)
|
||||
stats, err := svc.Statistics(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.TotalRecords != 2 {
|
||||
t.Errorf("TotalRecords = %d, want 2", stats.TotalRecords)
|
||||
}
|
||||
if !floatNear(stats.TotalLiters, 80) {
|
||||
t.Errorf("TotalLiters = %v, want 80", stats.TotalLiters)
|
||||
}
|
||||
if !floatNear(stats.TotalAmount, 640) {
|
||||
t.Errorf("TotalAmount = %v, want 640", stats.TotalAmount)
|
||||
}
|
||||
if !floatNear(stats.AvgPrice, 8.0) {
|
||||
t.Errorf("AvgPrice = %v, want 8.0", stats.AvgPrice)
|
||||
}
|
||||
// mileage = 500km, liters = 40, 40/500*100 = 8.0 L/100km
|
||||
if !floatNear(stats.AvgConsumption, 8.0) {
|
||||
t.Errorf("AvgConsumption = %v, want 8.0 (plan example)", stats.AvgConsumption)
|
||||
}
|
||||
// cost = 320/500 = 0.64
|
||||
if !floatNear(stats.CostPerKm, 0.64) {
|
||||
t.Errorf("CostPerKm = %v, want 0.64", stats.CostPerKm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatistics_HalfTankDoesNotPollute(t *testing.T) {
|
||||
t1 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
t2 := time.Date(2025, 1, 10, 0, 0, 0, 0, time.UTC)
|
||||
t3 := time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC)
|
||||
recs := []model.RefuelRecord{
|
||||
{ID: 1, VehicleID: 1, RefuelTime: t1, Odometer: 10000, Liters: 40, TotalAmount: 320, PricePerLiter: 8.0, IsFull: true},
|
||||
{ID: 2, VehicleID: 1, RefuelTime: t2, Odometer: 10500, Liters: 40, TotalAmount: 320, PricePerLiter: 8.0, IsFull: true},
|
||||
{ID: 3, VehicleID: 1, RefuelTime: t3, Odometer: 10600, Liters: 10, TotalAmount: 80, PricePerLiter: 8.0, IsFull: false}, // 半箱油
|
||||
}
|
||||
svc := newRefuelSvc(recs)
|
||||
stats, err := svc.Statistics(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.TotalRecords != 3 {
|
||||
t.Errorf("TotalRecords = %d, want 3", stats.TotalRecords)
|
||||
}
|
||||
if !floatNear(stats.TotalLiters, 90) {
|
||||
t.Errorf("TotalLiters = %v, want 90 (40+40+10)", stats.TotalLiters)
|
||||
}
|
||||
// 关键断言:半箱油不污染油耗采样,仍应是 8.0(只对 #1 → #2 计算)
|
||||
if !floatNear(stats.AvgConsumption, 8.0) {
|
||||
t.Errorf("AvgConsumption = %v, want 8.0 (half-tank must be excluded)", stats.AvgConsumption)
|
||||
}
|
||||
// avg price 全部三条:8.0, 8.0, 8.0 -> 8.0
|
||||
if !floatNear(stats.AvgPrice, 8.0) {
|
||||
t.Errorf("AvgPrice = %v, want 8.0", stats.AvgPrice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatistics_OnlyHalfTanks(t *testing.T) {
|
||||
// 全是半箱油,标准油耗应为 0(没有 is_full 序列)
|
||||
t1 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
t2 := time.Date(2025, 1, 5, 0, 0, 0, 0, time.UTC)
|
||||
recs := []model.RefuelRecord{
|
||||
{ID: 1, VehicleID: 1, RefuelTime: t1, Odometer: 10000, Liters: 20, TotalAmount: 160, PricePerLiter: 8.0, IsFull: false},
|
||||
{ID: 2, VehicleID: 1, RefuelTime: t2, Odometer: 10500, Liters: 20, TotalAmount: 160, PricePerLiter: 8.0, IsFull: false},
|
||||
}
|
||||
svc := newRefuelSvc(recs)
|
||||
stats, err := svc.Statistics(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !floatNear(stats.AvgConsumption, 0) {
|
||||
t.Errorf("AvgConsumption = %v, want 0 (no is_full sequence)", stats.AvgConsumption)
|
||||
}
|
||||
if !floatNear(stats.CostPerKm, 0) {
|
||||
t.Errorf("CostPerKm = %v, want 0 (no is_full sequence)", stats.CostPerKm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatistics_MultipleFullSamples(t *testing.T) {
|
||||
// 三次加满,应有两个油耗样本
|
||||
t1 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
t2 := time.Date(2025, 1, 10, 0, 0, 0, 0, time.UTC)
|
||||
t3 := time.Date(2025, 1, 20, 0, 0, 0, 0, time.UTC)
|
||||
recs := []model.RefuelRecord{
|
||||
{ID: 1, VehicleID: 1, RefuelTime: t1, Odometer: 10000, Liters: 40, TotalAmount: 320, PricePerLiter: 8.0, IsFull: true},
|
||||
{ID: 2, VehicleID: 1, RefuelTime: t2, Odometer: 10500, Liters: 40, TotalAmount: 320, PricePerLiter: 8.0, IsFull: true},
|
||||
{ID: 3, VehicleID: 1, RefuelTime: t3, Odometer: 11000, Liters: 40, TotalAmount: 320, PricePerLiter: 8.0, IsFull: true},
|
||||
}
|
||||
svc := newRefuelSvc(recs)
|
||||
stats, err := svc.Statistics(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !floatNear(stats.AvgConsumption, 8.0) {
|
||||
t.Errorf("AvgConsumption = %v, want 8.0 (3 full samples avg)", stats.AvgConsumption)
|
||||
}
|
||||
if !floatNear(stats.CostPerKm, 0.64) {
|
||||
t.Errorf("CostPerKm = %v, want 0.64", stats.CostPerKm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatistics_EmptyVehicle(t *testing.T) {
|
||||
svc := newRefuelSvc(nil)
|
||||
stats, err := svc.Statistics(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.TotalRecords != 0 {
|
||||
t.Errorf("TotalRecords = %d, want 0", stats.TotalRecords)
|
||||
}
|
||||
if stats.LastRefuel != nil {
|
||||
t.Errorf("LastRefuel should be nil, got %+v", stats.LastRefuel)
|
||||
}
|
||||
}
|
||||
|
||||
func floatNear(a, b float64) bool {
|
||||
const eps = 0.05
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
diff := a - b
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
return diff < eps
|
||||
}
|
||||
Reference in New Issue
Block a user