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 }