mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-16 20:47:55 +00:00
451 lines
11 KiB
Go
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))
|
|
}
|
|
} |