package catalog import ( "context" "sync" "testing" "time" "github.com/x0gp/server/internal/catalog/seeddefs" ) // withFrozenClock is retained for compatibility with older tests that // depended on deterministic timestamps. The Service no longer reads // timeNowMillis directly; timestamps come from time.Now() inside // normalizeTrack / normalizeCar, but those are only filled when // CreatedMs / UpdatedMs are zero. func withFrozenClock(t *testing.T) { t.Helper() // Intentionally a no-op; tests below no longer need clock control // because the Service relies on time.Now() and we just check ranges. } // loadSeeds is a helper that converts seeddefs into catalog types and // loads them into a memoryStore for use in tests. func loadSeeds() (*Service, error) { store := newMemoryStore() ctx := context.Background() for _, t := range seeddefs.DefaultTracks() { cm, err := seedTrackToCatalog(t) if err != nil { return nil, err } if err := store.UpsertTrack(ctx, cm); err != nil { return nil, err } } for _, c := range seeddefs.DefaultCars() { cm := seedCarToCatalog(c) if err := store.UpsertCar(ctx, cm); err != nil { return nil, err } } return NewService(ctx, store) } func seedTrackToCatalog(t seeddefs.Track) (TrackMeta, error) { now := time.Now().UnixMilli() return TrackMeta{ ID: t.ID, Name: t.Name, Description: t.Description, AuthorID: t.AuthorID, Visibility: Visibility(t.Visibility), Scale: t.Scale, LengthM: t.LengthM, WidthM: t.WidthM, LaneWidthM: t.LaneWidthM, Bounds: Bounds{ MinX: t.Bounds.MinX, MinY: t.Bounds.MinY, MaxX: t.Bounds.MaxX, MaxY: t.Bounds.MaxY, LengthM: t.Bounds.LengthM, WidthM: t.Bounds.WidthM, }, Surface: Surface(t.Surface), Tags: append([]string(nil), t.Tags...), Centerline: waypointsFromSeed(t.Centerline), BestLapMs: t.BestLapMs, CreatedMs: now, UpdatedMs: now, }, nil } func seedCarToCatalog(c seeddefs.Car) CarMeta { now := time.Now().UnixMilli() return CarMeta{ ID: c.ID, Name: c.Name, OwnerID: c.OwnerID, Visibility: Visibility(c.Visibility), Scale: c.Scale, LengthMm: c.LengthMm, WidthMm: c.WidthMm, HeightMm: c.HeightMm, WeightG: c.WeightG, Chassis: ChassisSpec{ Model: c.Chassis.Model, Material: c.Chassis.Material, Printed: c.Chassis.Printed, WheelbaseMm: c.Chassis.WheelbaseMm, TrackMm: c.Chassis.TrackMm, }, Motor: MotorSpec{ Kind: c.Motor.Kind, Class: c.Motor.Class, KV: c.Motor.KV, PowerW: c.Motor.PowerW, }, Battery: BatterySpec{ VoltageV: c.Battery.VoltageV, CapacityMah: c.Battery.CapacityMah, Cells: c.Battery.Cells, Chemistry: c.Battery.Chemistry, }, Drive: Drivetrain(c.Drive), TopSpeedMs: c.TopSpeedMs, ColorHex: c.ColorHex, Active: c.Active, TotalRaces: c.TotalRaces, TotalLaps: c.TotalLaps, BestLapMs: c.BestLapMs, CreatedMs: now, UpdatedMs: now, } } func waypointsFromSeed(in []seeddefs.Waypoint) []Waypoint { if in == nil { return nil } out := make([]Waypoint, len(in)) for i, w := range in { out[i] = Waypoint{ X: w.X, Y: w.Y, HeadingRad: w.HeadingRad, SpeedMs: w.SpeedMs, Curvature: w.Curvature, } } return out } func TestSeedsFitInRoom(t *testing.T) { tracks := seeddefs.DefaultTracks() cars := seeddefs.DefaultCars() if len(tracks) == 0 { t.Fatal("no default tracks") } if len(cars) == 0 { t.Fatal("no default cars") } const ( roomLen = 4.46 roomWid = 3.19 ) for _, tr := range tracks { if tr.Bounds.LengthM > roomLen { t.Errorf("track %s: length %.2f m exceeds room 4.46 m", tr.ID, tr.Bounds.LengthM) } if tr.Bounds.WidthM > roomWid { t.Errorf("track %s: width %.2f m exceeds room 3.19 m", tr.ID, tr.Bounds.WidthM) } if len(tr.Centerline) < 8 { t.Errorf("track %s: centerline too short (%d)", tr.ID, len(tr.Centerline)) } } for _, c := range cars { if c.Scale != 24 { t.Errorf("car %s: scale=%d, expected 24 (F1 1/24)", c.ID, c.Scale) } if c.Visibility != "system" { t.Errorf("car %s: not system visibility", c.ID) } if c.LengthMm < 200 || c.LengthMm > 260 { t.Errorf("car %s: length %.0f mm out of F1 1/24 range", c.ID, c.LengthMm) } } } func TestCreateAndGetTrack(t *testing.T) { s, err := loadSeeds() if err != nil { t.Fatalf("loadSeeds: %v", err) } defer s.Stop() got, ok := s.GetTrack("monaco") if !ok { t.Fatal("expected to find monaco") } if got.ID != "monaco" { t.Errorf("got id %q", got.ID) } } func TestCreateUserTrack(t *testing.T) { s, err := loadSeeds() if err != nil { t.Fatalf("loadSeeds: %v", err) } defer s.Stop() ctx := context.Background() tr, err := s.CreateTrack(ctx, CreateTrackOptions{ Name: "My Garage", AuthorID: "driver-1", Visibility: VisibilityPublic, LengthM: 3.5, WidthM: 2.2, Surface: SurfaceTile, }) if err != nil { t.Fatalf("create: %v", err) } if tr.Visibility != VisibilityPublic { t.Errorf("got visibility %q", tr.Visibility) } if tr.AuthorID != "driver-1" { t.Errorf("got author %q", tr.AuthorID) } if tr.ID != "my-garage" { t.Errorf("slug: got %q", tr.ID) } } func TestCreateRejectsOversizedTrack(t *testing.T) { s, _ := loadSeeds() defer s.Stop() ctx := context.Background() _, err := s.CreateTrack(ctx, CreateTrackOptions{ Name: "Too Big", LengthM: 6.0, WidthM: 4.0, }) if err == nil { t.Fatal("expected error for oversized track") } } func TestUpdateSystemTrackFails(t *testing.T) { s, _ := loadSeeds() defer s.Stop() ctx := context.Background() vis := VisibilityPublic _, err := s.UpdateTrack(ctx, "monaco", UpdateTrackOptions{Visibility: &vis}) if err == nil { t.Fatal("expected error when mutating system track") } } func TestDeleteUserTrack(t *testing.T) { s, _ := loadSeeds() defer s.Stop() ctx := context.Background() tr, _ := s.CreateTrack(ctx, CreateTrackOptions{Name: "Temp", Visibility: VisibilityPrivate}) if err := s.DeleteTrack(ctx, tr.ID); err != nil { t.Fatalf("delete: %v", err) } if _, ok := s.GetTrack(tr.ID); ok { t.Fatal("track still present") } } func TestDeleteSystemTrackFails(t *testing.T) { s, _ := loadSeeds() defer s.Stop() ctx := context.Background() if err := s.DeleteTrack(ctx, "monaco"); err == nil { t.Fatal("expected error when deleting system track") } } func TestCreateAndUpdateCar(t *testing.T) { s, _ := loadSeeds() defer s.Stop() ctx := context.Background() car, err := s.CreateCar(ctx, CreateCarOptions{ Name: "Bumblebee", OwnerID: "driver-1", LengthMm: 78, WidthMm: 34, TopSpeedMs: 4.5, ColorHex: "#d29922", }) if err != nil { t.Fatalf("create: %v", err) } if car.ID != "bumblebee" { t.Errorf("slug: got %q", car.ID) } color := "#3fb950" updated, err := s.UpdateCar(ctx, car.ID, UpdateCarOptions{ColorHex: &color}) if err != nil { t.Fatalf("update: %v", err) } if updated.ColorHex != color { t.Errorf("got color %q", updated.ColorHex) } } func TestCreateCarRejectsInvalidDims(t *testing.T) { s, _ := loadSeeds() defer s.Stop() ctx := context.Background() _, err := s.CreateCar(ctx, CreateCarOptions{Name: "Tiny", LengthMm: 10, WidthMm: 5}) if err == nil { t.Fatal("expected error for too-small car") } _, err = s.CreateCar(ctx, CreateCarOptions{Name: "Big", LengthMm: 500, WidthMm: 200}) if err == nil { t.Fatal("expected error for too-big car") } } func TestSubscribeReceivesChange(t *testing.T) { s, _ := loadSeeds() defer s.Stop() ctx := context.Background() ch, cancel := s.Subscribe() defer cancel() <-ch // drain initial snapshot _, err := s.CreateTrack(ctx, CreateTrackOptions{Name: "E2E", Visibility: VisibilityPublic}) if err != nil { t.Fatalf("create: %v", err) } select { case ev := <-ch: if ev.Kind != EventTrackUpsert { t.Fatalf("kind=%s", ev.Kind) } if ev.TrackID != "e2e" { t.Errorf("track_id=%s", ev.TrackID) } case <-time.After(time.Second): t.Fatal("no event") } } func TestStatsCounters(t *testing.T) { s, _ := loadSeeds() defer s.Stop() st := s.Stats() if st.TracksSystem < 5 { t.Errorf("expected at least 5 system tracks, got %d", st.TracksSystem) } if st.CarsSystem < 5 { t.Errorf("expected at least 5 system cars, got %d", st.CarsSystem) } } func TestConcurrentCreatesSafe(t *testing.T) { store := newMemoryStore() s, err := NewService(context.Background(), store) if err != nil { t.Fatalf("new: %v", err) } defer s.Stop() ctx := context.Background() const n = 20 var wg sync.WaitGroup wg.Add(n) for i := 0; i < n; i++ { go func(i int) { defer wg.Done() name := "Concurrent" _, _ = s.CreateTrack(ctx, CreateTrackOptions{ Name: name, LengthM: 2.0, WidthM: 1.2, }) }(i) } wg.Wait() if got := len(s.ListTracks()); got != 1 { t.Errorf("expected 1 track, got %d", got) } } func TestSetCarStatsUpdatesFields(t *testing.T) { s, _ := loadSeeds() defer s.Stop() ctx := context.Background() err := s.SetCarStats(ctx, "f1-2024-redbull-rb20", func(c *CarMeta) { c.TotalLaps = 12 c.BestLapMs = 9450 c.TotalDistanceM = 320.5 }) if err != nil { t.Fatalf("set stats: %v", err) } got, _ := s.GetCar("f1-2024-redbull-rb20") if got.TotalLaps != 12 || got.BestLapMs != 9450 || got.TotalDistanceM != 320.5 { t.Errorf("stats not updated: %+v", got) } } func TestServiceWithoutStore(t *testing.T) { s, err := NewService(context.Background(), nil) if err != nil { t.Fatalf("new: %v", err) } defer s.Stop() ctx := context.Background() _, err = s.CreateTrack(ctx, CreateTrackOptions{Name: "X", LengthM: 1.5, WidthM: 1.0}) if err == nil { t.Fatal("expected error when store is nil") } } func TestTrackCalendar(t *testing.T) { s, _ := loadSeeds() defer s.Stop() ctx := context.Background() // 1. Initially calendar should be empty entries, err := s.GetTrackCalendar(ctx) if err != nil { t.Fatalf("get calendar: %v", err) } if len(entries) != 0 { t.Errorf("expected 0 entries, got %d", len(entries)) } // 2. Schedule a track start := time.Now().Add(-1 * time.Hour) end := time.Now().Add(1 * time.Hour) created, err := s.CreateTrackCalendar(ctx, "monaco", start, end) if err != nil { t.Fatalf("create calendar: %v", err) } if created.TrackID != "monaco" || !created.StartAt.Equal(start) || !created.EndAt.Equal(end) { t.Errorf("invalid created entry: %+v", created) } // 3. Get calendar should return the entry entries, err = s.GetTrackCalendar(ctx) if err != nil { t.Fatalf("get calendar: %v", err) } if len(entries) != 1 || entries[0].ID != created.ID { t.Errorf("expected 1 entry with id %d, got %v", created.ID, entries) } // 4. Delete entry err = s.DeleteTrackCalendar(ctx, created.ID) if err != nil { t.Fatalf("delete calendar: %v", err) } // 5. Get calendar should be empty again entries, err = s.GetTrackCalendar(ctx) if err != nil { t.Fatalf("get calendar: %v", err) } if len(entries) != 0 { t.Errorf("expected 0 entries, got %d", len(entries)) } }