mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
The F1 feed sends TimingAppData stints as sparse deltas keyed by stint
index — a mid-stint update is just {"1": {"TotalLaps": 14}}. The parser
replaced the whole stint slice with whatever a delta carried, so pit
history collapsed to a single entry and tyre age was pinned near zero for
the entire race. A partial delta was also dropped outright, because the
parser required a Compound field that mid-stint updates do not send.
Observed live at lap 49 of the 70-lap 2026 Hungarian GP: all 22 drivers
reported exactly one stint with age 0 or 3, after most had pitted twice.
With the fix, the same feed at lap 51 yields 3 stints for 15 drivers and
2 for 6, ages spread 1-30 — e.g. car 1 as MEDIUM 17 / HARD 22 / HARD 11.
Stints now merge by index, and Compound, New and TotalLaps each apply only
when the delta actually carries them.
Also stop folding non-numeric keys into index 0 in indexedRawValues. The
feed's "_kf" key-frame marker parsed as 0 and overwrote the first entry;
only the CurrentTyres path guarded against it, leaving the other five
callers exposed.
This feeds the tyre column, the deg model, stint history and the pit
window, all of which were reading near-zero tyre age all race.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
619 lines
20 KiB
Go
619 lines
20 KiB
Go
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
|
|
}
|