diff --git a/fuellog-api/internal/service/refuel_record_test.go b/fuellog-api/internal/service/refuel_record_test.go new file mode 100644 index 0000000..a37c941 --- /dev/null +++ b/fuellog-api/internal/service/refuel_record_test.go @@ -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 +} diff --git a/fuellog-api/pkg/decimal/money_test.go b/fuellog-api/pkg/decimal/money_test.go new file mode 100644 index 0000000..2475cc9 --- /dev/null +++ b/fuellog-api/pkg/decimal/money_test.go @@ -0,0 +1,119 @@ +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) + } + }) + } +}