diff --git a/internal/live/parser_test.go b/internal/live/parser_test.go index e483e8a..11453f1 100644 --- a/internal/live/parser_test.go +++ b/internal/live/parser_test.go @@ -472,6 +472,62 @@ func TestProcessTopicTimingAppData(t *testing.T) { } } +// 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"} diff --git a/internal/live/state.go b/internal/live/state.go index ab7ba49..8388cb0 100644 --- a/internal/live/state.go +++ b/internal/live/state.go @@ -10,6 +10,7 @@ import ( "io" "log" "sort" + "strconv" "strings" "time" ) @@ -404,22 +405,42 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool { Stints json.RawMessage `json:"Stints"` } if json.Unmarshal(lineRaw, &line) == nil && line.Stints != nil { - var driverStints []LiveStintData + // The feed sends stints as sparse deltas keyed by stint index: + // a mid-stint update is just {"1": {"TotalLaps": 14}}. Merge + // each entry into the stint it addresses. Replacing the slice + // wholesale discarded every earlier stint, so pit history + // collapsed to one entry and tyre age stuck near zero for the + // whole race. + driverStints := append([]LiveStintData(nil), s.Stints[num]...) + changed := false for _, sRaw := range indexedRawValues(line.Stints) { var st struct { - Compound string `json:"Compound"` - New string `json:"New"` - TotalLaps int `json:"TotalLaps"` + Compound *string `json:"Compound"` + New *string `json:"New"` + TotalLaps *int `json:"TotalLaps"` } - if json.Unmarshal(sRaw.Raw, &st) == nil && st.Compound != "" { - driverStints = append(driverStints, LiveStintData{ - Compound: st.Compound, - New: st.New == "true" || st.New == "True", - Laps: st.TotalLaps, - }) + if json.Unmarshal(sRaw.Raw, &st) != nil { + continue } + if st.Compound == nil && st.New == nil && st.TotalLaps == nil { + continue + } + for len(driverStints) <= sRaw.Index { + driverStints = append(driverStints, LiveStintData{}) + } + entry := &driverStints[sRaw.Index] + if st.Compound != nil && *st.Compound != "" { + entry.Compound = *st.Compound + } + if st.New != nil { + entry.New = *st.New == "true" || *st.New == "True" + } + if st.TotalLaps != nil { + entry.Laps = *st.TotalLaps + } + changed = true } - if len(driverStints) > 0 { + if changed { s.Stints[num] = driverStints lastStint := driverStints[len(driverStints)-1] t := s.Tyres[num] @@ -874,8 +895,13 @@ func indexedRawValues(raw json.RawMessage) []indexedRaw { if err := json.Unmarshal(raw, &obj); err == nil { values := make([]indexedRaw, 0, len(obj)) for k, v := range obj { - i := 0 - fmt.Sscanf(k, "%d", &i) + // Keys are array indices in the feed's delta form. Non-numeric keys + // are feed metadata — "_kf" (key frame) is the common one — and must + // not be folded in as index 0, which would clobber the first entry. + i, err := strconv.Atoi(k) + if err != nil || i < 0 { + continue + } values = append(values, indexedRaw{Index: i, Raw: v}) } sort.Slice(values, func(i, j int) bool {