Files

451 lines
11 KiB
Go

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))
}
}