2026-05-25 00:49:13 -04:00
|
|
|
package query
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"database/sql"
|
2026-05-25 02:25:06 -04:00
|
|
|
"errors"
|
2026-05-25 00:49:13 -04:00
|
|
|
"path/filepath"
|
|
|
|
|
"testing"
|
feat(race-hub): trustworthy defaults and fan-facing analysis hierarchy (#75)
Make bare /race-hub resolve to a completed session and never open empty
post-session analysis for a future race.
- Backend: add `default_analysis_session` to the Weekend context. It never
resolves to a future session (picks the richest completed session, ties
toward the later one; 0 when everything is upcoming). Existing
`default_session_key` and deep links are unchanged.
- Frontend default resolution prefers the most recently completed weekend
(`pickAnalysisFocusMeeting`) and consumes `default_analysis_session`, falling
back to the switcher when only upcoming sessions exist.
- New `sessionState` lib maps timing + coverage to user language
(upcoming/live/preparing/partial/ready/cancelled); the session rail, active
sub-bar, and WeekendSwitcher now label states instead of raw x/11 counts.
- Future sessions render a purpose-built PreSessionView (expected availability +
countdown) instead of empty Winner/Podium/Pole/Strategy/Compare cards.
- Analysis navigation regrouped into Story / Analysis / Data & Context; every
existing tab is preserved. Diagnostics (renamed from Data Status) is now a
secondary action and the raw dataset strip is hidden behind an explicit
toggle, so operational coverage no longer precedes fan content.
- Loading/error states offer Retry and a path back to Weekend.
Tests: Go query tests for future-exclusion; Vitest for default selection,
future pre-session, partial state, error/retry, grouped nav, and sessionState;
hermetic Playwright for bare/completed/future/return-to-Weekend; new
race-hub-future visual snapshots. Seed adds a far-future session inside the
Monaco meeting (kept in-meeting so Command Center focus is unaffected).
Note: `default_analysis_session` is an additive field on the existing
`/weekend` contract (no new endpoint), per the spec's "context contract" scope.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 18:29:34 -04:00
|
|
|
"time"
|
2026-05-25 00:49:13 -04:00
|
|
|
|
2026-05-25 02:04:16 -04:00
|
|
|
"github.com/AmanTahiliani/box-box/internal/models"
|
2026-05-25 00:49:13 -04:00
|
|
|
"github.com/AmanTahiliani/box-box/internal/store"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func openTestService(t *testing.T) *Service {
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
path := filepath.Join(dir, "test.db")
|
|
|
|
|
|
|
|
|
|
st, err := store.Open(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("store.Open() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
t.Cleanup(func() { _ = st.Close() })
|
|
|
|
|
return NewService(st)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func seedRaceHubData(t *testing.T, st *store.Store) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
|
|
|
|
meetingKey := 1229
|
|
|
|
|
sessionKey := 9472
|
|
|
|
|
|
|
|
|
|
if err := st.UpsertMeeting(store.Meeting{
|
|
|
|
|
MeetingKey: meetingKey,
|
|
|
|
|
MeetingName: "Monaco",
|
|
|
|
|
MeetingOfficialName: "FORMULA 1 GRAND PRIX DE MONACO 2025",
|
|
|
|
|
Location: "Monaco",
|
|
|
|
|
CountryCode: "MON",
|
|
|
|
|
CountryName: "Monaco",
|
|
|
|
|
CircuitKey: 10,
|
|
|
|
|
CircuitShortName: "Monaco",
|
|
|
|
|
Year: 2025,
|
|
|
|
|
DateStart: "2025-05-23T00:00:00+00:00",
|
|
|
|
|
DateEnd: "2025-05-25T00:00:00+00:00",
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("UpsertMeeting() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := st.UpsertSession(store.Session{
|
|
|
|
|
SessionKey: sessionKey,
|
|
|
|
|
MeetingKey: meetingKey,
|
|
|
|
|
SessionName: "Race",
|
|
|
|
|
SessionType: "Race",
|
|
|
|
|
CircuitKey: 10,
|
|
|
|
|
DateStart: "2025-05-25T13:00:00+00:00",
|
|
|
|
|
DateEnd: "2025-05-25T15:00:00+00:00",
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("UpsertSession() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := st.UpsertDriver(store.Driver{
|
|
|
|
|
DriverNumber: 1,
|
|
|
|
|
FullName: "Max Verstappen",
|
|
|
|
|
NameAcronym: "VER",
|
|
|
|
|
TeamName: "Red Bull Racing",
|
|
|
|
|
TeamColour: "3671C6",
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("UpsertDriver() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := st.UpsertSessionDriver(store.SessionDriver{
|
|
|
|
|
SessionKey: sessionKey,
|
|
|
|
|
DriverNumber: 1,
|
|
|
|
|
MeetingKey: meetingKey,
|
|
|
|
|
TeamName: "Red Bull Racing",
|
|
|
|
|
TeamColour: "3671C6",
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("UpsertSessionDriver() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := st.UpsertStartingGridEntry(store.StartingGridEntry{
|
|
|
|
|
SessionKey: sessionKey,
|
|
|
|
|
DriverNumber: 1,
|
|
|
|
|
MeetingKey: meetingKey,
|
|
|
|
|
Position: 1,
|
|
|
|
|
LapDuration: 71.234,
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("UpsertStartingGridEntry() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGetRaceHubMissingSession(t *testing.T) {
|
|
|
|
|
svc := openTestService(t)
|
|
|
|
|
|
|
|
|
|
hub, err := svc.GetRaceHub(9472)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetRaceHub() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if hub.Source != ResponseSourceNone {
|
|
|
|
|
t.Fatalf("Source = %q, want %q", hub.Source, ResponseSourceNone)
|
|
|
|
|
}
|
|
|
|
|
if hub.Datasets["session"].Status != DatasetStatusMissing {
|
|
|
|
|
t.Fatalf("session status = %q, want %q", hub.Datasets["session"].Status, DatasetStatusMissing)
|
|
|
|
|
}
|
|
|
|
|
if hub.Meeting != nil || hub.Session != nil {
|
|
|
|
|
t.Fatal("expected no meeting/session for missing session")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGetRaceHubPartialData(t *testing.T) {
|
|
|
|
|
svc := openTestService(t)
|
|
|
|
|
seedRaceHubData(t, svc.store)
|
|
|
|
|
|
|
|
|
|
hub, err := svc.GetRaceHub(9472)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetRaceHub() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if hub.Source != ResponseSourcePartial {
|
|
|
|
|
t.Fatalf("Source = %q, want %q", hub.Source, ResponseSourcePartial)
|
|
|
|
|
}
|
|
|
|
|
if hub.Session == nil || hub.Meeting == nil {
|
|
|
|
|
t.Fatal("expected meeting and session")
|
|
|
|
|
}
|
|
|
|
|
if len(hub.Drivers) != 1 || hub.Drivers[0].NameAcronym != "VER" {
|
|
|
|
|
t.Fatalf("Drivers = %+v, want one VER entry", hub.Drivers)
|
|
|
|
|
}
|
|
|
|
|
if hub.Datasets["results"].Status != DatasetStatusMissing {
|
|
|
|
|
t.Fatalf("results status = %q, want %q", hub.Datasets["results"].Status, DatasetStatusMissing)
|
|
|
|
|
}
|
|
|
|
|
if len(hub.StartingGrid) != 1 {
|
|
|
|
|
t.Fatalf("StartingGrid len = %d, want 1", len(hub.StartingGrid))
|
|
|
|
|
}
|
|
|
|
|
if hub.StartingGrid[0].NameAcronym != "VER" {
|
|
|
|
|
t.Fatalf("StartingGrid driver = %+v, want enriched VER", hub.StartingGrid[0])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGetRaceHubCompleteData(t *testing.T) {
|
|
|
|
|
svc := openTestService(t)
|
|
|
|
|
seedRaceHubData(t, svc.store)
|
|
|
|
|
|
|
|
|
|
if err := svc.store.UpsertSessionResult(store.SessionResult{
|
|
|
|
|
SessionKey: 9472,
|
|
|
|
|
DriverNumber: 1,
|
|
|
|
|
MeetingKey: 1229,
|
|
|
|
|
Position: 1,
|
|
|
|
|
Points: 25,
|
|
|
|
|
NumberOfLaps: 78,
|
|
|
|
|
DurationJSON: "5234.567",
|
|
|
|
|
GapToLeaderJSON: "0",
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("UpsertSessionResult() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
hub, err := svc.GetRaceHub(9472)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetRaceHub() error = %v", err)
|
|
|
|
|
}
|
2026-05-25 02:04:16 -04:00
|
|
|
if hub.Source != ResponseSourcePartial {
|
|
|
|
|
t.Fatalf("Source = %q, want %q (core datasets complete, analytics missing)", hub.Source, ResponseSourcePartial)
|
2026-05-25 00:49:13 -04:00
|
|
|
}
|
|
|
|
|
if len(hub.Results) != 1 {
|
|
|
|
|
t.Fatalf("Results len = %d, want 1", len(hub.Results))
|
|
|
|
|
}
|
|
|
|
|
if hub.Results[0].FullName != "Max Verstappen" {
|
|
|
|
|
t.Fatalf("Results[0].FullName = %q, want Max Verstappen", hub.Results[0].FullName)
|
|
|
|
|
}
|
|
|
|
|
if hub.Datasets["results"].Count != 1 {
|
|
|
|
|
t.Fatalf("results count = %d, want 1", hub.Datasets["results"].Count)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 01:11:31 -04:00
|
|
|
func TestGetChampionshipInputsDerivesLocalStandings(t *testing.T) {
|
|
|
|
|
svc := openTestService(t)
|
|
|
|
|
seedRaceHubData(t, svc.store)
|
|
|
|
|
|
|
|
|
|
if err := svc.store.UpsertDriver(store.Driver{
|
|
|
|
|
DriverNumber: 44,
|
|
|
|
|
FullName: "Lewis Hamilton",
|
|
|
|
|
NameAcronym: "HAM",
|
|
|
|
|
TeamName: "Ferrari",
|
|
|
|
|
TeamColour: "E8002D",
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("UpsertDriver() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := svc.store.UpsertSessionDriver(store.SessionDriver{
|
|
|
|
|
SessionKey: 9472,
|
|
|
|
|
DriverNumber: 44,
|
|
|
|
|
MeetingKey: 1229,
|
|
|
|
|
TeamName: "Ferrari",
|
|
|
|
|
TeamColour: "E8002D",
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("UpsertSessionDriver() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := svc.store.UpsertSessionResult(store.SessionResult{
|
|
|
|
|
SessionKey: 9472,
|
|
|
|
|
DriverNumber: 1,
|
|
|
|
|
MeetingKey: 1229,
|
|
|
|
|
Position: 1,
|
|
|
|
|
Points: 25,
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("UpsertSessionResult() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := svc.store.UpsertSessionResult(store.SessionResult{
|
|
|
|
|
SessionKey: 9472,
|
|
|
|
|
DriverNumber: 44,
|
|
|
|
|
MeetingKey: 1229,
|
|
|
|
|
Position: 2,
|
|
|
|
|
Points: 18,
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("UpsertSessionResult() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
inputs, err := svc.GetChampionshipInputs(2025)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetChampionshipInputs() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(inputs.Races) != 1 || inputs.Races[0].RaceSessionKey != 9472 {
|
|
|
|
|
t.Fatalf("Races = %+v, want Monaco race", inputs.Races)
|
|
|
|
|
}
|
|
|
|
|
if len(inputs.Champ) != 2 {
|
|
|
|
|
t.Fatalf("Champ len = %d, want 2", len(inputs.Champ))
|
|
|
|
|
}
|
|
|
|
|
if inputs.Champ[0].DriverNumber != 1 || inputs.Champ[0].PointsCurrent != 25 || inputs.Champ[0].PositionCurrent != 1 {
|
|
|
|
|
t.Fatalf("leader = %+v, want VER 25 P1", inputs.Champ[0])
|
|
|
|
|
}
|
|
|
|
|
if got := inputs.DriverMap[44]; got.NameAcronym != "HAM" || got.TeamName != "Ferrari" {
|
|
|
|
|
t.Fatalf("DriverMap[44] = %+v, want HAM Ferrari", got)
|
|
|
|
|
}
|
|
|
|
|
if len(inputs.Teams) != 2 || inputs.Teams[0].TeamName != "Red Bull Racing" || inputs.Teams[0].PointsCurrent != 25 {
|
|
|
|
|
t.Fatalf("Teams = %+v, want Red Bull Racing leading with 25", inputs.Teams)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 00:49:13 -04:00
|
|
|
func TestListMeetingsByYear(t *testing.T) {
|
|
|
|
|
svc := openTestService(t)
|
|
|
|
|
seedRaceHubData(t, svc.store)
|
|
|
|
|
|
|
|
|
|
meetings, err := svc.ListMeetingsByYear(2025)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("ListMeetingsByYear() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(meetings) != 1 || meetings[0].MeetingName != "Monaco" {
|
|
|
|
|
t.Fatalf("ListMeetingsByYear() = %+v, want Monaco meeting", meetings)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
empty, err := svc.ListMeetingsByYear(2024)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("ListMeetingsByYear(2024) error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(empty) != 0 {
|
|
|
|
|
t.Fatalf("ListMeetingsByYear(2024) len = %d, want 0", len(empty))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 02:04:16 -04:00
|
|
|
func TestGetRaceHubAnalyticsDatasets(t *testing.T) {
|
|
|
|
|
svc := openTestService(t)
|
|
|
|
|
seedRaceHubData(t, svc.store)
|
|
|
|
|
|
|
|
|
|
sessionKey := 9472
|
|
|
|
|
meetingKey := 1229
|
|
|
|
|
|
|
|
|
|
if err := svc.store.UpsertStint(store.Stint{
|
|
|
|
|
SessionKey: sessionKey, DriverNumber: 1, MeetingKey: meetingKey,
|
|
|
|
|
StintNumber: 1, Compound: "MEDIUM", LapStart: 1, LapEnd: 30,
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("UpsertStint() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := svc.store.UpsertPositionSample(store.PositionSample{
|
|
|
|
|
SessionKey: sessionKey, DriverNumber: 1, MeetingKey: meetingKey,
|
|
|
|
|
Date: "2025-05-25T13:05:00+00:00", Position: 1,
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("UpsertPositionSample() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
hub, err := svc.GetRaceHub(sessionKey)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetRaceHub() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if hub.Datasets["stints"].Status != DatasetStatusAvailable {
|
|
|
|
|
t.Fatalf("stints status = %+v, want available", hub.Datasets["stints"])
|
|
|
|
|
}
|
|
|
|
|
if hub.Datasets["positions"].Status != DatasetStatusAvailable {
|
|
|
|
|
t.Fatalf("positions status = %+v, want available", hub.Datasets["positions"])
|
|
|
|
|
}
|
|
|
|
|
if len(hub.Stints) != 1 || hub.Stints[0].Compound != models.CompoundMedium {
|
|
|
|
|
t.Fatalf("Stints = %+v, want one MEDIUM stint", hub.Stints)
|
|
|
|
|
}
|
|
|
|
|
if len(hub.Positions) != 1 {
|
|
|
|
|
t.Fatalf("Positions len = %d, want 1", len(hub.Positions))
|
|
|
|
|
}
|
|
|
|
|
if hub.Datasets["pit_stops"].Status != DatasetStatusMissing {
|
|
|
|
|
t.Fatalf("pit_stops status = %+v, want missing", hub.Datasets["pit_stops"])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 00:49:13 -04:00
|
|
|
func TestListDriversRequiresSession(t *testing.T) {
|
|
|
|
|
svc := openTestService(t)
|
|
|
|
|
|
|
|
|
|
_, err := svc.ListDrivers(9472)
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Fatal("ListDrivers() error = nil, want sql.ErrNoRows")
|
|
|
|
|
}
|
|
|
|
|
if err != sql.ErrNoRows {
|
|
|
|
|
t.Fatalf("ListDrivers() error = %v, want sql.ErrNoRows", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-25 02:25:06 -04:00
|
|
|
|
|
|
|
|
func TestListSeasonsEmpty(t *testing.T) {
|
|
|
|
|
svc := openTestService(t)
|
|
|
|
|
|
|
|
|
|
years, err := svc.ListSeasons()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("ListSeasons() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(years) != 0 {
|
|
|
|
|
t.Fatalf("ListSeasons() = %v, want empty", years)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestListSeasonsWithData(t *testing.T) {
|
|
|
|
|
svc := openTestService(t)
|
|
|
|
|
seedRaceHubData(t, svc.store)
|
|
|
|
|
|
|
|
|
|
years, err := svc.ListSeasons()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("ListSeasons() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(years) != 1 || years[0] != 2025 {
|
|
|
|
|
t.Fatalf("ListSeasons() = %v, want [2025]", years)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGetWeekendMissingMeeting(t *testing.T) {
|
|
|
|
|
svc := openTestService(t)
|
|
|
|
|
|
|
|
|
|
_, err := svc.GetWeekend(1229)
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Fatal("GetWeekend() error = nil, want ErrMeetingNotFound")
|
|
|
|
|
}
|
|
|
|
|
if !errors.Is(err, ErrMeetingNotFound) {
|
|
|
|
|
t.Fatalf("GetWeekend() error = %v, want ErrMeetingNotFound", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGetWeekendWithSessions(t *testing.T) {
|
|
|
|
|
svc := openTestService(t)
|
|
|
|
|
seedRaceHubData(t, svc.store)
|
|
|
|
|
|
|
|
|
|
if err := svc.store.UpsertSession(store.Session{
|
|
|
|
|
SessionKey: 9000,
|
|
|
|
|
MeetingKey: 1229,
|
|
|
|
|
SessionName: "Core Only",
|
|
|
|
|
SessionType: "Race",
|
|
|
|
|
DateStart: "2025-05-24T13:00:00+00:00",
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("UpsertSession() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := svc.store.UpsertSessionDriver(store.SessionDriver{
|
|
|
|
|
SessionKey: 9000, DriverNumber: 1, MeetingKey: 1229,
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("UpsertSessionDriver() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
weekend, err := svc.GetWeekend(1229)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetWeekend() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if weekend.Meeting.MeetingName != "Monaco" {
|
|
|
|
|
t.Fatalf("MeetingName = %q, want Monaco", weekend.Meeting.MeetingName)
|
|
|
|
|
}
|
|
|
|
|
if len(weekend.Sessions) != 2 {
|
|
|
|
|
t.Fatalf("Sessions len = %d, want 2", len(weekend.Sessions))
|
|
|
|
|
}
|
|
|
|
|
if weekend.DefaultSessionKey != 9472 {
|
|
|
|
|
t.Fatalf("DefaultSessionKey = %d, want 9472 (full data session)", weekend.DefaultSessionKey)
|
|
|
|
|
}
|
|
|
|
|
if weekend.Sessions[0].Datasets["drivers"].Status != DatasetStatusAvailable {
|
|
|
|
|
t.Fatalf("first session drivers = %+v, want available", weekend.Sessions[0].Datasets["drivers"])
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-10 23:32:12 -04:00
|
|
|
|
feat(race-hub): trustworthy defaults and fan-facing analysis hierarchy (#75)
Make bare /race-hub resolve to a completed session and never open empty
post-session analysis for a future race.
- Backend: add `default_analysis_session` to the Weekend context. It never
resolves to a future session (picks the richest completed session, ties
toward the later one; 0 when everything is upcoming). Existing
`default_session_key` and deep links are unchanged.
- Frontend default resolution prefers the most recently completed weekend
(`pickAnalysisFocusMeeting`) and consumes `default_analysis_session`, falling
back to the switcher when only upcoming sessions exist.
- New `sessionState` lib maps timing + coverage to user language
(upcoming/live/preparing/partial/ready/cancelled); the session rail, active
sub-bar, and WeekendSwitcher now label states instead of raw x/11 counts.
- Future sessions render a purpose-built PreSessionView (expected availability +
countdown) instead of empty Winner/Podium/Pole/Strategy/Compare cards.
- Analysis navigation regrouped into Story / Analysis / Data & Context; every
existing tab is preserved. Diagnostics (renamed from Data Status) is now a
secondary action and the raw dataset strip is hidden behind an explicit
toggle, so operational coverage no longer precedes fan content.
- Loading/error states offer Retry and a path back to Weekend.
Tests: Go query tests for future-exclusion; Vitest for default selection,
future pre-session, partial state, error/retry, grouped nav, and sessionState;
hermetic Playwright for bare/completed/future/return-to-Weekend; new
race-hub-future visual snapshots. Seed adds a far-future session inside the
Monaco meeting (kept in-meeting so Command Center focus is unaffected).
Note: `default_analysis_session` is an additive field on the existing
`/weekend` contract (no new endpoint), per the spec's "context contract" scope.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 18:29:34 -04:00
|
|
|
func TestPickDefaultAnalysisSessionSkipsFuture(t *testing.T) {
|
|
|
|
|
now := mustTime(t, "2025-05-24T18:00:00Z")
|
|
|
|
|
sessions := []WeekendSession{
|
|
|
|
|
{ // completed qualifying, partial coverage
|
|
|
|
|
Session: models.Session{SessionKey: 100, DateStart: "2025-05-24T14:00:00+00:00"},
|
|
|
|
|
Datasets: map[string]DatasetInfo{"results": availableLocal(1)},
|
|
|
|
|
},
|
|
|
|
|
{ // future race with the richest coverage — must NOT be selected
|
|
|
|
|
Session: models.Session{SessionKey: 200, DateStart: "2025-05-25T13:00:00+00:00"},
|
|
|
|
|
Datasets: map[string]DatasetInfo{
|
|
|
|
|
"results": availableLocal(1),
|
|
|
|
|
"laps": availableLocal(1),
|
|
|
|
|
"stints": availableLocal(1),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
got := pickDefaultAnalysisSession(sessions, now)
|
|
|
|
|
if got != 100 {
|
|
|
|
|
t.Fatalf("pickDefaultAnalysisSession() = %d, want 100 (never a future session)", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestPickDefaultAnalysisSessionAllFuture(t *testing.T) {
|
|
|
|
|
now := mustTime(t, "2025-05-20T00:00:00Z")
|
|
|
|
|
sessions := []WeekendSession{
|
|
|
|
|
{Session: models.Session{SessionKey: 100, DateStart: "2025-05-24T14:00:00+00:00"}},
|
|
|
|
|
{Session: models.Session{SessionKey: 200, DateStart: "2025-05-25T13:00:00+00:00"}},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if got := pickDefaultAnalysisSession(sessions, now); got != 0 {
|
|
|
|
|
t.Fatalf("pickDefaultAnalysisSession() = %d, want 0 (everything upcoming)", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestPickDefaultAnalysisSessionPrefersRichestCompleted(t *testing.T) {
|
|
|
|
|
now := mustTime(t, "2025-05-26T00:00:00Z")
|
|
|
|
|
sessions := []WeekendSession{
|
|
|
|
|
{
|
|
|
|
|
Session: models.Session{SessionKey: 100, DateStart: "2025-05-24T14:00:00+00:00"},
|
|
|
|
|
Datasets: map[string]DatasetInfo{"results": availableLocal(1)},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
Session: models.Session{SessionKey: 200, DateStart: "2025-05-25T13:00:00+00:00"},
|
|
|
|
|
Datasets: map[string]DatasetInfo{
|
|
|
|
|
"results": availableLocal(1),
|
|
|
|
|
"laps": availableLocal(1),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if got := pickDefaultAnalysisSession(sessions, now); got != 200 {
|
|
|
|
|
t.Fatalf("pickDefaultAnalysisSession() = %d, want 200 (richest completed)", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGetWeekendSetsDefaultAnalysisSession(t *testing.T) {
|
|
|
|
|
prev := weekendNow
|
|
|
|
|
weekendNow = func() time.Time { return mustTime(t, "2025-05-26T00:00:00Z") }
|
|
|
|
|
t.Cleanup(func() { weekendNow = prev })
|
|
|
|
|
|
|
|
|
|
svc := openTestService(t)
|
|
|
|
|
seedRaceHubData(t, svc.store)
|
|
|
|
|
|
|
|
|
|
weekend, err := svc.GetWeekend(1229)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetWeekend() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if weekend.DefaultAnalysisSession != 9472 {
|
|
|
|
|
t.Fatalf("DefaultAnalysisSession = %d, want 9472", weekend.DefaultAnalysisSession)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func mustTime(t *testing.T, value string) time.Time {
|
|
|
|
|
t.Helper()
|
|
|
|
|
parsed, err := time.Parse(time.RFC3339, value)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("parse time %q: %v", value, err)
|
|
|
|
|
}
|
|
|
|
|
return parsed
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-10 23:32:12 -04:00
|
|
|
func TestGetChampionshipInputsIncludesSprintPoints(t *testing.T) {
|
|
|
|
|
// Regression for #57: Race-only aggregation dropped Sprint points.
|
|
|
|
|
// Setup: same meeting 1229 has Race (9472) 25pts + Sprint (9473) 8pts => total 33.
|
|
|
|
|
svc := openTestService(t)
|
|
|
|
|
seedRaceHubData(t, svc.store)
|
|
|
|
|
|
|
|
|
|
// Add sprint session for same meeting
|
|
|
|
|
if err := svc.store.UpsertSession(store.Session{
|
|
|
|
|
SessionKey: 9473,
|
|
|
|
|
MeetingKey: 1229,
|
|
|
|
|
SessionName: "Sprint",
|
|
|
|
|
SessionType: "Race", // OpenF1 uses SessionType Race even for Sprint
|
|
|
|
|
DateStart: "2025-05-24T13:00:00+00:00",
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("UpsertSession sprint error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := svc.store.UpsertSessionDriver(store.SessionDriver{
|
|
|
|
|
SessionKey: 9473, DriverNumber: 1, MeetingKey: 1229, TeamName: "Red Bull Racing",
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("UpsertSessionDriver sprint error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Race points 25
|
|
|
|
|
if err := svc.store.UpsertSessionResult(store.SessionResult{
|
|
|
|
|
SessionKey: 9472, DriverNumber: 1, MeetingKey: 1229, Position: 1, Points: 25,
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("UpsertSessionResult race error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
// Sprint points 8
|
|
|
|
|
if err := svc.store.UpsertSessionResult(store.SessionResult{
|
|
|
|
|
SessionKey: 9473, DriverNumber: 1, MeetingKey: 1229, Position: 1, Points: 8,
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("UpsertSessionResult sprint error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
inputs, err := svc.GetChampionshipInputs(2025)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetChampionshipInputs() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(inputs.Races) != 1 {
|
|
|
|
|
t.Fatalf("Races len = %d, want 1 race entry", len(inputs.Races))
|
|
|
|
|
}
|
|
|
|
|
if len(inputs.Champ) != 1 {
|
|
|
|
|
t.Fatalf("Champ len = %d, want 1", len(inputs.Champ))
|
|
|
|
|
}
|
|
|
|
|
if inputs.Champ[0].PointsCurrent != 33 {
|
|
|
|
|
t.Fatalf("PointsCurrent = %v, want 33 (25 race + 8 sprint)", inputs.Champ[0].PointsCurrent)
|
|
|
|
|
}
|
|
|
|
|
if len(inputs.Teams) != 1 || inputs.Teams[0].PointsCurrent != 33 {
|
|
|
|
|
t.Fatalf("Teams = %+v, want Red Bull 33", inputs.Teams)
|
|
|
|
|
}
|
|
|
|
|
}
|