package live_test import ( "bytes" "compress/flate" "encoding/base64" "encoding/json" "fmt" "testing" "time" "github.com/AmanTahiliani/box-box/internal/live" ) func TestProcessMessageFullState(t *testing.T) { state := live.NewState() msg := []byte(`{ "R": { "TimingData": {"Lines": {"1": {"Position": "1", "RacingNumber": "1", "LastLapTime": {"Value": "1:32.456"}}}}, "DriverList": {"1": {"RacingNumber": "1", "Tla": "VER", "TeamName": "Red Bull"}}, "LapCount": {"CurrentLap": 12, "TotalLaps": 57}, "TrackStatus": {"Status": "1", "Message": "AllClear"}, "WeatherData": {"AirTemp": "24.5", "TrackTemp": "38.0", "Humidity": "55", "WindSpeed": "2.1", "WindDirection": "180", "Rainfall": "0"}, "SessionInfo": {"Meeting": {"Name": "Monaco Grand Prix"}, "Name": "Race", "Type": "Race"}, "ExtrapolatedClock": {"Remaining": "0:45:00", "Utc": "2025-05-25T14:00:00Z", "Extrapolating": true} } }`) if !state.ProcessMessage(msg) { t.Fatal("expected full-state message to produce updates") } snap := state.Snapshot() if snap.Drivers["1"].Position != 1 { t.Errorf("driver position = %d, want 1", snap.Drivers["1"].Position) } if snap.Drivers["1"].LastLapTime != "1:32.456" { t.Errorf("last lap = %q, want 1:32.456", snap.Drivers["1"].LastLapTime) } if snap.DriverInfo["1"].Tla != "VER" { t.Errorf("TLA = %q, want VER", snap.DriverInfo["1"].Tla) } if snap.CurrentLap != 12 || snap.TotalLaps != 57 { t.Errorf("laps = %d/%d, want 12/57", snap.CurrentLap, snap.TotalLaps) } if snap.TrackStatus != "1" { t.Errorf("track status = %q, want 1", snap.TrackStatus) } if snap.Weather.AirTemp != 24.5 || snap.Weather.TrackTemp != 38.0 { t.Errorf("weather temps = %.1f/%.1f, want 24.5/38.0", snap.Weather.AirTemp, snap.Weather.TrackTemp) } if snap.Session.MeetingName != "Monaco Grand Prix" || snap.Session.SessionName != "Race" { t.Errorf("session = %+v", snap.Session) } if snap.Clock != "0:45:00" || !snap.ClockExtrapolating { t.Errorf("clock = %q extrapolating=%v", snap.Clock, snap.ClockExtrapolating) } } func TestProcessMessageIncremental(t *testing.T) { state := live.NewState() msg := []byte(`{ "M": [{ "A": ["TimingData", {"Lines": {"44": {"Position": "2", "GapToLeader": "+1.234", "IntervalToPositionAhead": {"Value": "+0.456"}}}}] }] }`) if !state.ProcessMessage(msg) { t.Fatal("expected incremental message to produce updates") } d := state.Snapshot().Drivers["44"] if d.Position != 2 { t.Errorf("position = %d, want 2", d.Position) } if d.GapToLeader != "+1.234" { t.Errorf("gap = %q, want +1.234", d.GapToLeader) } if d.Interval != "+0.456" { t.Errorf("interval = %q, want +0.456", d.Interval) } } func TestProcessCoreMessageCompletionAndFeed(t *testing.T) { state := live.NewState() msg := []byte(`{"type":3,"invocationId":"1","result":{ "ExtrapolatedClock":{"Remaining":"00:04:23","Utc":"2026-06-06T14:42:36.0491737Z","Extrapolating":true}, "TimingData":{"Lines":{"12":{"Position":"1","RacingNumber":"12","Sectors":[{"Value":"22.430"},{"Value":"40.119"},{"Value":""}],"Speeds":{"ST":{"Value":"254"}},"BestLapTime":{"Value":"1:12.704","Lap":15}}}}, "DriverList":{"12":{"RacingNumber":"12","Tla":"ANT","TeamName":"Mercedes","TeamColour":"00D7B6"}} }}` + "\x1e") if !state.ProcessCoreMessage(msg) { t.Fatal("expected SignalR Core completion to produce updates") } snap := state.Snapshot() if snap.Clock != "00:04:23" || !snap.ClockExtrapolating { t.Errorf("clock = %q extrapolating=%v", snap.Clock, snap.ClockExtrapolating) } if snap.Drivers["12"].Position != 1 || snap.Drivers["12"].BestLapTime != "1:12.704" { t.Errorf("driver = %+v", snap.Drivers["12"]) } if snap.Drivers["12"].Sectors[1].Value != "40.119" || snap.Drivers["12"].SpeedTrap != "254" { t.Errorf("driver sectors/speed = %+v", snap.Drivers["12"]) } if snap.DriverInfo["12"].Tla != "ANT" { t.Errorf("driver info = %+v", snap.DriverInfo["12"]) } feed := []byte(`{"type":1,"target":"feed","arguments":["TimingData",{"Lines":{"12":{"LastLapTime":{"Value":"1:13.000","PersonalFastest":true}}}},"2026-06-06T14:42:37Z"]}` + "\x1e") if !state.ProcessCoreMessage(feed) { t.Fatal("expected SignalR Core feed frame to produce updates") } if state.Snapshot().Drivers["12"].LastLapTime != "1:13.000" { t.Errorf("last lap = %q", state.Snapshot().Drivers["12"].LastLapTime) } } func TestProcessTopicTimingData(t *testing.T) { state := live.NewState() data := json.RawMessage(`{ "Lines": { "16": { "Position": 3, "GapToLeader": 2.5, "NumberOfLaps": "15", "Sectors": { "0": {"Value": "28.123", "PersonalFastest": true}, "1": {"Value": "31.456"}, "2": {"Value": ""} }, "Speeds": {"ST": {"Value": "312"}} } } }`) if !state.ProcessTopic("TimingData", data) { t.Fatal("TimingData should update state") } d := state.Snapshot().Drivers["16"] if d.Position != 3 { t.Errorf("position = %d, want 3", d.Position) } if d.GapToLeader != "+2.500" { t.Errorf("gap = %q, want +2.500", d.GapToLeader) } if d.NumberOfLaps != 15 { t.Errorf("laps = %d, want 15", d.NumberOfLaps) } if d.Sectors[0].Value != "28.123" || !d.Sectors[0].PersonalFastest { t.Errorf("sector 0 = %+v", d.Sectors[0]) } if d.Sectors[2].Value != "" { t.Errorf("sector 2 should be cleared, got %q", d.Sectors[2].Value) } if !d.OnFlyingLap { t.Error("expected OnFlyingLap=true when S1/S2 set and S3 empty") } if d.SpeedTrap != "312" { t.Errorf("speed trap = %q, want 312", d.SpeedTrap) } } func TestProcessTopicCompressedPositionAndCarData(t *testing.T) { state := live.NewState() positionPayload := `{ "Position": [ {"Timestamp": "2026-07-03T14:00:00Z", "Entries": { "1": {"Status": "OnTrack", "X": 1000, "Y": -200, "Z": 3}, "44": {"Status": "OffTrack", "X": 1200, "Y": -250, "Z": 2} }} ] }` if !state.ProcessTopic("Position.z", encodedDeflatePayload(t, positionPayload)) { t.Fatal("Position.z should update state") } snap := state.Snapshot() if !snap.PositionUpdated || snap.SnapshotUpdated { t.Fatalf("position flags = position:%v snapshot:%v", snap.PositionUpdated, snap.SnapshotUpdated) } if got := snap.Positions["1"]; got.X != 1000 || got.Y != -200 || got.Z != 3 || got.Status != "OnTrack" { t.Fatalf("position 1 = %+v", got) } if got := snap.Positions["44"]; got.Status != "OffTrack" { t.Fatalf("position 44 = %+v", got) } carPayload := `{ "Entries": [ {"Utc": "2026-07-03T14:00:00Z", "Cars": { "1": {"Channels": {"0": 11234, "2": 318, "3": 8, "4": 92, "5": 0, "45": 10}} }} ] }` if !state.ProcessTopic("CarData.z", encodedDeflatePayload(t, carPayload)) { t.Fatal("CarData.z should update state") } snap = state.Snapshot() if !snap.SnapshotUpdated { t.Fatal("CarData should mark snapshot updated") } tel := snap.Telemetry["1"] if tel.RPM != 11234 || tel.Speed != 318 || tel.NGear != 8 || tel.Throttle != 92 || tel.Brake != 0 || tel.DRS != 10 { t.Fatalf("telemetry = %+v", tel) } } func TestProcessTopicSessionInfoCircuitName(t *testing.T) { state := live.NewState() state.ProcessTopic("SessionInfo", json.RawMessage(`{ "Meeting": {"Name": "British Grand Prix", "Circuit": {"ShortName": "Silverstone"}}, "Name": "Race", "Type": "Race", "Path": "2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/" }`)) s := state.Snapshot().Session if s.MeetingName != "British Grand Prix" || s.CircuitName != "Silverstone" { t.Fatalf("session = %+v", s) } if s.Path != "2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/" { t.Fatalf("session path = %q", s.Path) } } func TestProcessTopicDriverList(t *testing.T) { state := live.NewState() data := json.RawMessage(`{"63": {"RacingNumber": "63", "Tla": "RUS", "TeamName": "Mercedes", "TeamColour": "27F4D2"}}`) state.ProcessTopic("DriverList", data) if len(state.Snapshot().DriverInfo) != 1 { t.Fatalf("expected 1 driver info entry") } // Entries without TLA are ignored. data2 := json.RawMessage(`{"99": {"RacingNumber": "99", "TeamName": "Unknown"}}`) state.ProcessTopic("DriverList", data2) if _, ok := state.Snapshot().DriverInfo["99"]; ok { t.Error("driver without TLA should be ignored") } } func TestProcessTopicLapCount(t *testing.T) { state := live.NewState() data := json.RawMessage(`{"CurrentLap": "5", "TotalLaps": "78"}`) state.ProcessTopic("LapCount", data) snap := state.Snapshot() if snap.CurrentLap != 5 || snap.TotalLaps != 78 { t.Errorf("laps = %d/%d, want 5/78", snap.CurrentLap, snap.TotalLaps) } } func TestProcessTopicExtrapolatedClock(t *testing.T) { state := live.NewState() data := json.RawMessage(`{"Remaining": "1:00:00", "Utc": "2025-05-25T15:04:05.123Z", "Extrapolating": true}`) state.ProcessTopic("ExtrapolatedClock", data) snap := state.Snapshot() if snap.Clock != "1:00:00" || !snap.ClockExtrapolating { t.Errorf("clock = %q extrapolating=%v", snap.Clock, snap.ClockExtrapolating) } want := time.Date(2025, 5, 25, 15, 4, 5, 123000000, time.UTC) if !snap.ClockRefTime.Equal(want) { t.Errorf("ClockRefTime = %v, want %v", snap.ClockRefTime, want) } } func TestProcessTopicTrackStatus(t *testing.T) { state := live.NewState() state.ProcessTopic("TrackStatus", json.RawMessage(`{"Status": "4", "Message": "SC DEPLOYED"}`)) if state.Snapshot().TrackStatus != "4" { t.Errorf("track status = %q, want 4", state.Snapshot().TrackStatus) } } func TestProcessTopicSessionStatus(t *testing.T) { state := live.NewState() if !state.ProcessTopic("SessionStatus", json.RawMessage(`{"Status": "Finished"}`)) { t.Fatal("SessionStatus should update state") } snap := state.Snapshot() if snap.SessionStatus != "Finished" { t.Fatalf("session status = %q, want Finished", snap.SessionStatus) } if !snap.SnapshotUpdated { t.Fatal("SessionStatus should mark snapshot updated") } } func TestSessionStatusIsActive(t *testing.T) { tests := []struct { status string want bool }{ {"Started", true}, {"Resumed", true}, {"Finished", false}, {"Finalised", false}, {"Ends", false}, {"Aborted", false}, {"", false}, } for _, tt := range tests { if got := live.SessionStatusIsActive(tt.status); got != tt.want { t.Errorf("SessionStatusIsActive(%q) = %v, want %v", tt.status, got, tt.want) } } } func TestSessionStatusIsTerminal(t *testing.T) { tests := []struct { status string want bool }{ {"Finished", true}, {"Finalised", true}, {"Finalized", true}, {"Ends", true}, {"Aborted", true}, {"Started", false}, {"Resumed", false}, {"Inactive", false}, {"", false}, } for _, tt := range tests { if got := live.SessionStatusIsTerminal(tt.status); got != tt.want { t.Errorf("SessionStatusIsTerminal(%q) = %v, want %v", tt.status, got, tt.want) } } } func TestProcessTopicRaceControlMessages(t *testing.T) { state := live.NewState() data := json.RawMessage(`{ "Messages": { "1": {"Utc": "2025-05-25T15:04:30Z", "Category": "Flag", "Flag": "YELLOW", "Message": "Yellow in sector 2", "Lap": 8} } }`) state.ProcessTopic("RaceControlMessages", data) rc := state.Snapshot().RCMessages if len(rc) != 1 { t.Fatalf("expected 1 RC message, got %d", len(rc)) } if rc[0].Time != "15:04" || rc[0].Flag != "YELLOW" || rc[0].Message != "Yellow in sector 2" || rc[0].Lap != 8 { t.Errorf("RC message = %+v", rc[0]) } } func TestProcessTopicTeamRadioSnapshotAndPatch(t *testing.T) { state := live.NewState() snapshot := json.RawMessage(`{ "Captures": [ {"Utc": "2026-07-05T14:05:30Z", "RacingNumber": "4", "Path": "TeamRadio/NOR-1.mp3"}, {"Utc": "2026-07-05T14:04:10Z", "RacingNumber": "16", "Path": "TeamRadio/LEC-1.mp3"} ] }`) if !state.ProcessTopic("TeamRadio", snapshot) { t.Fatal("TeamRadio snapshot should update state") } patch := json.RawMessage(`{ "Captures": { "2": {"Utc": "2026-07-05T14:06:00Z", "RacingNumber": "44", "Path": "TeamRadio/HAM-1.mp3"} } }`) if !state.ProcessTopic("TeamRadio", patch) { t.Fatal("TeamRadio keyed patch should update state") } radio := state.Snapshot().TeamRadio if len(radio) != 3 { t.Fatalf("radio captures = %d, want 3", len(radio)) } if radio[0].RacingNumber != "16" || radio[1].RacingNumber != "4" || radio[2].RacingNumber != "44" { t.Fatalf("radio order = %+v", radio) } } func TestProcessTopicTeamRadioMalformed(t *testing.T) { state := live.NewState() if state.ProcessTopic("TeamRadio", json.RawMessage(`{"Captures": {"1": {"Utc": "2026-07-05T14:06:00Z", "Path": "missing-driver.mp3"}}}`)) { t.Fatal("incomplete TeamRadio capture should not update state") } if state.ProcessTopic("TeamRadio", json.RawMessage(`{"Captures": "not-a-list"}`)) { t.Fatal("unexpected TeamRadio captures shape should not update state") } if len(state.Snapshot().TeamRadio) != 0 { t.Fatalf("malformed captures mutated state: %+v", state.Snapshot().TeamRadio) } } func TestProcessTopicTeamRadioCapsAtTwenty(t *testing.T) { state := live.NewState() for i := 0; i < 25; i++ { payload := json.RawMessage([]byte(fmt.Sprintf(`{ "Captures": [{"Utc": "2026-07-05T14:%02d:00Z", "RacingNumber": "%d", "Path": "TeamRadio/%02d.mp3"}] }`, i, i, i))) state.ProcessTopic("TeamRadio", payload) } radio := state.Snapshot().TeamRadio if len(radio) != 20 { t.Fatalf("radio captures = %d, want 20", len(radio)) } if radio[0].Utc != "2026-07-05T14:05:00Z" || radio[19].Utc != "2026-07-05T14:24:00Z" { t.Fatalf("radio cap kept wrong captures: first=%+v last=%+v", radio[0], radio[19]) } } func TestProcessTopicWeatherData(t *testing.T) { state := live.NewState() state.ProcessTopic("WeatherData", json.RawMessage(`{ "AirTemp": 22, "TrackTemp": 35, "Humidity": 60, "WindSpeed": 3.5, "WindDirection": 90, "Rainfall": 1 }`)) w := state.Snapshot().Weather if w.AirTemp != 22 || w.TrackTemp != 35 || w.Humidity != 60 || w.WindSpeed != 3.5 || w.WindDir != 90 || !w.Rainfall { t.Errorf("weather = %+v", w) } } func TestProcessTopicSessionInfo(t *testing.T) { state := live.NewState() state.ProcessTopic("SessionInfo", json.RawMessage(`{ "Meeting": {"Name": "British Grand Prix"}, "Name": "Qualifying", "Type": "Qualifying" }`)) s := state.Snapshot().Session if s.MeetingName != "British Grand Prix" || s.SessionName != "Qualifying" || s.SessionType != "Qualifying" { t.Errorf("session = %+v", s) } } func TestProcessTopicCurrentTyres(t *testing.T) { state := live.NewState() state.Tyres["1"] = live.LiveTyreData{Age: 7} state.ProcessTopic("CurrentTyres", json.RawMessage(`{ "1": {"Compound": "SOFT", "New": "true"}, "_kf": {"Compound": "ignore"} }`)) tyre := state.Snapshot().Tyres["1"] if tyre.Compound != "SOFT" || !tyre.New { t.Errorf("tyre = %+v", tyre) } if tyre.Age != 7 { t.Errorf("age should be preserved from prior state, got %d", tyre.Age) } } func TestProcessTopicTimingAppData(t *testing.T) { state := live.NewState() data := json.RawMessage(`{ "Lines": { "4": { "Stints": { "0": {"Compound": "MEDIUM", "New": "true", "TotalLaps": 0}, "1": {"Compound": "HARD", "New": "false", "TotalLaps": 18} } } } }`) state.ProcessTopic("TimingAppData", data) snap := state.Snapshot() stints := snap.Stints["4"] if len(stints) != 2 { t.Fatalf("expected 2 stints, got %d", len(stints)) } tyre := snap.Tyres["4"] if tyre.Compound != "HARD" || tyre.Age != 18 || tyre.New { t.Errorf("tyre synced from stint = %+v", tyre) } } // The feed sends stints as sparse deltas keyed by stint index. Replacing the // slice on each delta collapsed pit history to one entry and pinned tyre age // near zero — observed live at lap 49 of a 70-lap race, where every driver // reported a single stint of age 0 despite having pitted. func TestProcessTopicTimingAppDataMergesSparseStintDeltas(t *testing.T) { state := live.NewState() state.ProcessTopic("TimingAppData", json.RawMessage(`{ "Lines": {"4": {"Stints": {"0": {"Compound": "MEDIUM", "New": "true", "TotalLaps": 0}}}} }`)) // Stint 0 runs to 18 laps, then the driver pits onto a new hard. state.ProcessTopic("TimingAppData", json.RawMessage(`{ "Lines": {"4": {"Stints": {"0": {"TotalLaps": 18}}}} }`)) state.ProcessTopic("TimingAppData", json.RawMessage(`{ "Lines": {"4": {"Stints": {"1": {"Compound": "HARD", "New": "true", "TotalLaps": 0}}}} }`)) state.ProcessTopic("TimingAppData", json.RawMessage(`{ "Lines": {"4": {"Stints": {"1": {"TotalLaps": 12}}}} }`)) snap := state.Snapshot() stints := snap.Stints["4"] if len(stints) != 2 { t.Fatalf("expected 2 stints after a pit stop, got %d: %+v", len(stints), stints) } if stints[0].Compound != "MEDIUM" || stints[0].Laps != 18 { t.Errorf("first stint lost across deltas: %+v", stints[0]) } if stints[1].Compound != "HARD" || stints[1].Laps != 12 { t.Errorf("second stint = %+v", stints[1]) } if tyre := snap.Tyres["4"]; tyre.Compound != "HARD" || tyre.Age != 12 { t.Errorf("current tyre should track the latest stint, got %+v", tyre) } } func TestProcessTopicTimingAppDataIgnoresNonNumericStintKeys(t *testing.T) { state := live.NewState() state.ProcessTopic("TimingAppData", json.RawMessage(`{ "Lines": {"4": {"Stints": {"0": {"Compound": "SOFT", "New": "true", "TotalLaps": 9}}}} }`)) // "_kf" is a feed key-frame marker, not a stint index. Parsing it as 0 // would overwrite the real first stint. state.ProcessTopic("TimingAppData", json.RawMessage(`{ "Lines": {"4": {"Stints": {"_kf": {"Compound": "HARD", "TotalLaps": 99}}}} }`)) stints := state.Snapshot().Stints["4"] if len(stints) != 1 { t.Fatalf("expected 1 stint, got %d: %+v", len(stints), stints) } if stints[0].Compound != "SOFT" || stints[0].Laps != 9 { t.Errorf("key-frame marker corrupted stint 0: %+v", stints[0]) } } func TestProcessTopicTimingStats(t *testing.T) { state := live.NewState() state.Drivers["55"] = live.LiveDriverData{RacingNumber: "55"} state.ProcessTopic("TimingStats", json.RawMessage(`{ "Lines": {"55": {"PersonalBestLapTime": {"Value": "1:28.999"}}} }`)) if state.Snapshot().Drivers["55"].BestLapTime != "1:28.999" { t.Errorf("best lap = %q", state.Snapshot().Drivers["55"].BestLapTime) } } func TestProcessTopicUnknownIgnored(t *testing.T) { state := live.NewState() if state.ProcessTopic("Heartbeat", json.RawMessage(`{"Seq": 1}`)) { t.Error("unknown topic should not mark state updated") } if state.ProcessTopic("TotallyUnknown", json.RawMessage(`{"foo": "bar"}`)) { t.Error("unknown topic should not mark state updated") } } func TestSnapshotCopiesMapsAndSlices(t *testing.T) { state := live.NewState() state.Drivers["1"] = live.LiveDriverData{RacingNumber: "1", Position: 1, GapToLeader: "+0.000"} state.DriverInfo["1"] = live.F1DriverListEntry{Tla: "VER"} state.Tyres["1"] = live.LiveTyreData{Compound: "SOFT", Age: 5} state.Stints["1"] = []live.LiveStintData{{Compound: "SOFT", Laps: 5}} state.RCMessages = []live.LiveRCMessage{{Message: "Green flag"}} snap := state.Snapshot() snap.Drivers["1"] = live.LiveDriverData{RacingNumber: "1", Position: 99} snap.DriverInfo["1"] = live.F1DriverListEntry{Tla: "MUTATED"} snap.Tyres["1"] = live.LiveTyreData{Compound: "WET"} snap.Stints["1"][0].Compound = "WET" snap.RCMessages[0].Message = "mutated" inner := state.Snapshot() if inner.Drivers["1"].Position != 1 { t.Error("mutating snapshot drivers leaked into state") } if inner.DriverInfo["1"].Tla != "VER" { t.Error("mutating snapshot driverInfo leaked into state") } if inner.Tyres["1"].Compound != "SOFT" { t.Error("mutating snapshot tyres leaked into state") } if inner.Stints["1"][0].Compound != "SOFT" { t.Error("mutating snapshot stints leaked into state") } if inner.RCMessages[0].Message != "Green flag" { t.Error("mutating snapshot RC messages leaked into state") } } func TestProcessMessageInvalidJSON(t *testing.T) { state := live.NewState() if state.ProcessMessage([]byte(`not json`)) { t.Error("invalid JSON should not update state") } } func TestProcessMessageEmptyPayload(t *testing.T) { state := live.NewState() if state.ProcessMessage([]byte(`{}`)) { t.Error("empty envelope should not update state") } } func encodedDeflatePayload(t *testing.T, payload string) json.RawMessage { t.Helper() var buf bytes.Buffer w, err := flate.NewWriter(&buf, flate.DefaultCompression) if err != nil { t.Fatalf("flate.NewWriter() error = %v", err) } if _, err := w.Write([]byte(payload)); err != nil { t.Fatalf("flate write error = %v", err) } if err := w.Close(); err != nil { t.Fatalf("flate close error = %v", err) } raw, err := json.Marshal(base64.StdEncoding.EncodeToString(buf.Bytes())) if err != nil { t.Fatalf("marshal payload error = %v", err) } return raw }