mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
Compare commits
1 Commits
feat/issue
...
feat/front
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
888378e210 |
@@ -53,9 +53,8 @@ function StintSparkline({ seconds }: { seconds: number[] }) {
|
|||||||
|
|
||||||
export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
||||||
const isRace = isRaceSession(sessionType)
|
const isRace = isRaceSession(sessionType)
|
||||||
// In practice/qualifying deg trends are secondary — collapse by default so
|
const [collapsed, setCollapsed] = useState(true)
|
||||||
// the Timing Tower stays above the fold. Races keep it open.
|
const [readerChose, setReaderChose] = useState(false)
|
||||||
const [collapsed, setCollapsed] = useState(!isRace)
|
|
||||||
const [stints, setStints] = useState<StintHistoryMap>({})
|
const [stints, setStints] = useState<StintHistoryMap>({})
|
||||||
|
|
||||||
// One lap-history update per received snapshot (rows is rebuilt per snapshot).
|
// One lap-history update per received snapshot (rows is rebuilt per snapshot).
|
||||||
@@ -75,6 +74,30 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
|||||||
[rows, pinned],
|
[rows, pinned],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// One linear fit per driver per snapshot, shared by the readiness check and
|
||||||
|
// the rows below. degradationModel is O(laps) and this runs at feed rate.
|
||||||
|
const models = useMemo(() => {
|
||||||
|
const out: Record<string, ReturnType<typeof degradationModel>> = {}
|
||||||
|
for (const row of visible) {
|
||||||
|
out[row.RacingNumber] = degradationModel(stints[row.RacingNumber]?.samples ?? [])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}, [visible, stints])
|
||||||
|
|
||||||
|
// Before any stint has enough clean laps to fit, every row reads "warming
|
||||||
|
// up" — a full-height panel of placeholders that pushed the Timing Tower off
|
||||||
|
// the fold for the first third of a race. Stay collapsed until there is
|
||||||
|
// something to say, then open. A reader who has toggled it keeps their choice.
|
||||||
|
const hasSignal = useMemo(
|
||||||
|
() => visible.some((row) => models[row.RacingNumber] != null),
|
||||||
|
[visible, models],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (readerChose) return
|
||||||
|
setCollapsed(!(isRace && hasSignal))
|
||||||
|
}, [isRace, hasSignal, readerChose])
|
||||||
|
|
||||||
if (visible.length === 0) return null
|
if (visible.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -82,18 +105,25 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="sec-header tyredeg-toggle"
|
className="sec-header tyredeg-toggle"
|
||||||
onClick={() => setCollapsed((prev) => !prev)}
|
onClick={() => {
|
||||||
|
setReaderChose(true)
|
||||||
|
setCollapsed((prev) => !prev)
|
||||||
|
}}
|
||||||
aria-expanded={!collapsed}
|
aria-expanded={!collapsed}
|
||||||
>
|
>
|
||||||
<span className="sec-title">Tyre Deg & Pit Window</span>
|
<span className="sec-title">Tyre Deg & Pit Window</span>
|
||||||
{isRace && <span className="sec-meta">rejoin assumes ~{PIT_LOSS_SECONDS}s pit loss</span>}
|
{!hasSignal ? (
|
||||||
|
<span className="sec-meta">collecting clean laps</span>
|
||||||
|
) : (
|
||||||
|
isRace && <span className="sec-meta">rejoin assumes ~{PIT_LOSS_SECONDS}s pit loss</span>
|
||||||
|
)}
|
||||||
<span className="tyredeg-chevron" aria-hidden="true">{collapsed ? '▸' : '▾'}</span>
|
<span className="tyredeg-chevron" aria-hidden="true">{collapsed ? '▸' : '▾'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="tyredeg-rows">
|
<div className="tyredeg-rows">
|
||||||
{visible.map((row) => {
|
{visible.map((row) => {
|
||||||
const model = degradationModel(stints[row.RacingNumber]?.samples ?? [])
|
const model = models[row.RacingNumber]
|
||||||
const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null
|
const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null
|
||||||
const ageAnnotation = tyreAgeMeaning(row.Tyre?.Compound, row.Tyre?.Age)
|
const ageAnnotation = tyreAgeMeaning(row.Tyre?.Compound, row.Tyre?.Age)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -289,14 +289,16 @@ export function LiveTimingPage() {
|
|||||||
session={snapshot.Session}
|
session={snapshot.Session}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Rail runs most-synthesized to most-raw: a reader arriving
|
||||||
|
mid-session wants "what did I miss" before the regulatory log. */}
|
||||||
<div className="live-rc-col">
|
<div className="live-rc-col">
|
||||||
|
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
|
||||||
<TeamRadioTicker
|
<TeamRadioTicker
|
||||||
captures={snapshot.TeamRadio ?? []}
|
captures={snapshot.TeamRadio ?? []}
|
||||||
driverInfo={snapshot.DriverInfo}
|
driverInfo={snapshot.DriverInfo}
|
||||||
session={snapshot.Session}
|
session={snapshot.Session}
|
||||||
/>
|
/>
|
||||||
<RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} />
|
<RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} />
|
||||||
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -89,6 +89,16 @@ a { color: inherit; text-decoration: none; }
|
|||||||
}
|
}
|
||||||
.app-nav::-webkit-scrollbar { display: none; }
|
.app-nav::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
/* The nav scrolls horizontally with its scrollbar hidden. Below the width
|
||||||
|
where the links stop fitting, fade the trailing edge so the cut-off item
|
||||||
|
reads as "scroll for more" instead of as a clipping bug. */
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.app-nav {
|
||||||
|
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent 100%);
|
||||||
|
mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.nav-logo {
|
.nav-logo {
|
||||||
font-family: var(--f-mono);
|
font-family: var(--f-mono);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
|||||||
@@ -47,9 +47,20 @@ function snapshotRows(lap: number, lastLapTime: string): LiveTimingRow[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('TyreDegPanel', () => {
|
describe('TyreDegPanel', () => {
|
||||||
it('shows a warming-up placeholder until enough clean laps accumulate', () => {
|
it('stays collapsed while every stint is still warming up', () => {
|
||||||
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
|
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
|
||||||
const panel = screen.getByTestId('tyredeg-panel')
|
const panel = screen.getByTestId('tyredeg-panel')
|
||||||
|
|
||||||
|
// A panel of "warming up" placeholders carries no information and used to
|
||||||
|
// push the Timing Tower off the fold for the first third of a race.
|
||||||
|
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
|
||||||
|
expect(panel).toHaveTextContent('collecting clean laps')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a warming-up placeholder on each row once expanded', () => {
|
||||||
|
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
|
||||||
|
const panel = screen.getByTestId('tyredeg-panel')
|
||||||
expect(panel).toHaveTextContent('VER')
|
expect(panel).toHaveTextContent('VER')
|
||||||
expect(panel).toHaveTextContent('M +5')
|
expect(panel).toHaveTextContent('M +5')
|
||||||
expect(panel).toHaveTextContent('fresh')
|
expect(panel).toHaveTextContent('fresh')
|
||||||
@@ -61,6 +72,7 @@ describe('TyreDegPanel', () => {
|
|||||||
makeRow('1', 1, 'VER', { NumberOfLaps: 10, LastLapTime: '1:30.000' }, { Compound: 'MEDIUM', Age: 12 }),
|
makeRow('1', 1, 'VER', { NumberOfLaps: 10, LastLapTime: '1:30.000' }, { Compound: 'MEDIUM', Age: 12 }),
|
||||||
]
|
]
|
||||||
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={[]} />)
|
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={[]} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
|
||||||
expect(screen.getByText('mid-life')).toBeInTheDocument()
|
expect(screen.getByText('mid-life')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -92,16 +104,44 @@ describe('TyreDegPanel', () => {
|
|||||||
expect(panel).not.toHaveTextContent('~P')
|
expect(panel).not.toHaveTextContent('~P')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('starts expanded during a race', () => {
|
it('opens itself during a race as soon as a stint has signal', () => {
|
||||||
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
|
const { rerender } = render(
|
||||||
|
<TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />,
|
||||||
|
)
|
||||||
|
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
|
||||||
|
|
||||||
|
for (let lap = 2; lap <= 6; lap++) {
|
||||||
|
const time = `1:30.${String((lap - 1) * 100).padStart(3, '0')}`
|
||||||
|
rerender(<TyreDegPanel rows={snapshotRows(lap, time)} sessionType="Race" pinned={[]} />)
|
||||||
|
}
|
||||||
|
|
||||||
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
|
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps the reader\'s own collapse choice when signal arrives', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />,
|
||||||
|
)
|
||||||
|
// Reader opens it early, then closes it again — that decision must stick
|
||||||
|
// even once the panel would otherwise auto-open.
|
||||||
|
const toggle = screen.getByRole('button', { name: /tyre deg/i })
|
||||||
|
fireEvent.click(toggle)
|
||||||
|
fireEvent.click(toggle)
|
||||||
|
|
||||||
|
for (let lap = 2; lap <= 6; lap++) {
|
||||||
|
const time = `1:30.${String((lap - 1) * 100).padStart(3, '0')}`
|
||||||
|
rerender(<TyreDegPanel rows={snapshotRows(lap, time)} sessionType="Race" pinned={[]} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
it('limits rows to the top ten plus pinned drivers', () => {
|
it('limits rows to the top ten plus pinned drivers', () => {
|
||||||
const rows = Array.from({ length: 15 }, (_, index) =>
|
const rows = Array.from({ length: 15 }, (_, index) =>
|
||||||
makeRow(String(index + 1), index + 1, `D${index + 1}`),
|
makeRow(String(index + 1), index + 1, `D${index + 1}`),
|
||||||
)
|
)
|
||||||
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={['14']} />)
|
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={['14']} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
|
||||||
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(11)
|
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(11)
|
||||||
expect(screen.getByText('D14')).toBeInTheDocument()
|
expect(screen.getByText('D14')).toBeInTheDocument()
|
||||||
expect(screen.queryByText('D12')).not.toBeInTheDocument()
|
expect(screen.queryByText('D12')).not.toBeInTheDocument()
|
||||||
|
|||||||
@@ -1,435 +0,0 @@
|
|||||||
package query
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
|
||||||
"github.com/AmanTahiliani/box-box/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TemporalState describes where the fan is in the current season/weekend.
|
|
||||||
type TemporalState string
|
|
||||||
|
|
||||||
const (
|
|
||||||
TemporalNoSeason TemporalState = "no_season"
|
|
||||||
TemporalBetweenWeekends TemporalState = "between_weekends"
|
|
||||||
TemporalPreSession TemporalState = "pre_session"
|
|
||||||
TemporalSessionLive TemporalState = "session_live"
|
|
||||||
TemporalSessionSettling TemporalState = "session_settling"
|
|
||||||
TemporalBetweenSessions TemporalState = "between_sessions"
|
|
||||||
TemporalPostWeekend TemporalState = "post_weekend"
|
|
||||||
TemporalSeasonComplete TemporalState = "season_complete"
|
|
||||||
|
|
||||||
preSessionWindow = 48 * time.Hour
|
|
||||||
postWeekendWindow = 48 * time.Hour
|
|
||||||
)
|
|
||||||
|
|
||||||
// LiveEvidence is the small, transport-independent subset of FIA state needed
|
|
||||||
// by the context resolver. Active identity is authoritative over the schedule.
|
|
||||||
type LiveEvidence struct {
|
|
||||||
Active bool
|
|
||||||
Final bool
|
|
||||||
MeetingName string
|
|
||||||
CircuitName string
|
|
||||||
SessionName string
|
|
||||||
SessionType string
|
|
||||||
ObservedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContextAvailability is structured source state for a referenced session.
|
|
||||||
type ContextAvailability struct {
|
|
||||||
Schedule string `json:"schedule"`
|
|
||||||
LiveTransport string `json:"live_transport"`
|
|
||||||
LiveSession string `json:"live_session"`
|
|
||||||
Archive string `json:"archive"`
|
|
||||||
LocalAnalysis string `json:"local_analysis"`
|
|
||||||
Freshness string `json:"freshness"`
|
|
||||||
ObservedAt string `json:"observed_at,omitempty"`
|
|
||||||
Limitations []string `json:"limitations"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContextSession couples a session identity with its availability contract.
|
|
||||||
type ContextSession struct {
|
|
||||||
Session models.Session `json:"session"`
|
|
||||||
Meeting *models.Meeting `json:"meeting,omitempty"`
|
|
||||||
Availability ContextAvailability `json:"availability"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// WeekendContext is the canonical local-first previous/current/next model.
|
|
||||||
type WeekendContext struct {
|
|
||||||
Season int `json:"season,omitempty"`
|
|
||||||
TemporalState TemporalState `json:"temporal_state"`
|
|
||||||
PreviousMeeting *models.Meeting `json:"previous_meeting,omitempty"`
|
|
||||||
FocusMeeting *models.Meeting `json:"focus_meeting,omitempty"`
|
|
||||||
NextMeeting *models.Meeting `json:"next_meeting,omitempty"`
|
|
||||||
PreviousCompletedSession *ContextSession `json:"previous_completed_session,omitempty"`
|
|
||||||
ActiveSession *ContextSession `json:"active_session,omitempty"`
|
|
||||||
NextSession *ContextSession `json:"next_session,omitempty"`
|
|
||||||
DefaultAnalysisSession *ContextSession `json:"default_analysis_session,omitempty"`
|
|
||||||
ChampionshipRound int `json:"championship_round"`
|
|
||||||
TotalChampionshipRounds int `json:"total_championship_rounds"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type contextCandidate struct {
|
|
||||||
meeting store.Meeting
|
|
||||||
session store.Session
|
|
||||||
start time.Time
|
|
||||||
end time.Time
|
|
||||||
counts store.SessionDatasetCounts
|
|
||||||
complete bool
|
|
||||||
archived bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveWeekendContext computes the canonical context using only the domain
|
|
||||||
// store, the service clock, and optional in-memory FIA evidence.
|
|
||||||
func (s *Service) ResolveWeekendContext(evidence LiveEvidence) (WeekendContext, error) {
|
|
||||||
now := s.now().UTC()
|
|
||||||
out := WeekendContext{TemporalState: TemporalNoSeason}
|
|
||||||
years, err := s.store.ListYears()
|
|
||||||
if err != nil || len(years) == 0 {
|
|
||||||
return out, err
|
|
||||||
}
|
|
||||||
out.Season = currentLocalSeason(years, now.Year())
|
|
||||||
meetings, err := s.store.ListMeetingsByYear(out.Season)
|
|
||||||
if err != nil {
|
|
||||||
return WeekendContext{}, err
|
|
||||||
}
|
|
||||||
if len(meetings) == 0 {
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
byMeeting := make(map[int][]store.Session, len(meetings))
|
|
||||||
var candidates []contextCandidate
|
|
||||||
for i := range meetings {
|
|
||||||
m := meetings[i]
|
|
||||||
sessions, listErr := s.store.ListSessionsByMeeting(m.MeetingKey)
|
|
||||||
if listErr != nil {
|
|
||||||
return WeekendContext{}, listErr
|
|
||||||
}
|
|
||||||
byMeeting[m.MeetingKey] = sessions
|
|
||||||
applySessionDisplayRange(&meetings[i], sessions)
|
|
||||||
m = meetings[i]
|
|
||||||
for _, sess := range sessions {
|
|
||||||
if m.IsCancelled || sess.IsCancelled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
start, _ := parseContextTime(sess.DateStart)
|
|
||||||
end, _ := parseContextTime(sess.DateEnd)
|
|
||||||
if end.IsZero() && !start.IsZero() {
|
|
||||||
end = start.Add(3 * time.Hour)
|
|
||||||
}
|
|
||||||
counts, countErr := s.store.CountSessionDatasets(sess.SessionKey)
|
|
||||||
if countErr != nil {
|
|
||||||
return WeekendContext{}, countErr
|
|
||||||
}
|
|
||||||
archived := evidence.Final && liveMatches(evidence, m, sess)
|
|
||||||
candidates = append(candidates, contextCandidate{
|
|
||||||
meeting: m, session: sess, start: start, end: end, counts: counts,
|
|
||||||
complete: hasMeaningfulAnalysis(counts) || archived, archived: archived,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].start.Before(candidates[j].start) })
|
|
||||||
|
|
||||||
champMeetings := championshipMeetings(meetings, byMeeting)
|
|
||||||
out.TotalChampionshipRounds = len(champMeetings)
|
|
||||||
|
|
||||||
var active *contextCandidate
|
|
||||||
if evidence.Active {
|
|
||||||
for i := range candidates {
|
|
||||||
if liveMatches(evidence, candidates[i].meeting, candidates[i].session) {
|
|
||||||
active = &candidates[i]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if active == nil {
|
|
||||||
active = syntheticLiveCandidate(evidence, now)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var previous, next, defaultAnalysis *contextCandidate
|
|
||||||
for i := range candidates {
|
|
||||||
c := &candidates[i]
|
|
||||||
isActive := active != nil && active.session.SessionKey != 0 && c.session.SessionKey == active.session.SessionKey
|
|
||||||
completionEligible := c.start.IsZero() || !c.start.After(now) || c.archived
|
|
||||||
if !isActive && c.complete && completionEligible && (previous == nil || candidateTime(*c).After(candidateTime(*previous))) {
|
|
||||||
previous = c
|
|
||||||
}
|
|
||||||
if !isActive && c.complete && hasMeaningfulAnalysis(c.counts) && (c.start.IsZero() || !c.start.After(now)) && (defaultAnalysis == nil || candidateTime(*c).After(candidateTime(*defaultAnalysis))) {
|
|
||||||
defaultAnalysis = c
|
|
||||||
}
|
|
||||||
if !isActive && !c.start.IsZero() && !c.start.Before(now) && (next == nil || c.start.Before(next.start)) {
|
|
||||||
next = c
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if previous != nil {
|
|
||||||
out.PreviousCompletedSession = sessionRef(*previous, evidence, now)
|
|
||||||
out.PreviousMeeting = meetingModelByKey(meetings, previous.meeting.MeetingKey)
|
|
||||||
}
|
|
||||||
if defaultAnalysis != nil {
|
|
||||||
out.DefaultAnalysisSession = sessionRef(*defaultAnalysis, evidence, now)
|
|
||||||
}
|
|
||||||
if next != nil {
|
|
||||||
out.NextSession = sessionRef(*next, evidence, now)
|
|
||||||
out.NextMeeting = meetingModelByKey(meetings, next.meeting.MeetingKey)
|
|
||||||
}
|
|
||||||
if active != nil {
|
|
||||||
out.ActiveSession = sessionRef(*active, evidence, now)
|
|
||||||
out.FocusMeeting = out.ActiveSession.Meeting
|
|
||||||
out.TemporalState = TemporalSessionLive
|
|
||||||
} else {
|
|
||||||
out.FocusMeeting = chooseFocusMeeting(meetings, previous, next)
|
|
||||||
out.TemporalState = classifyTemporalState(now, previous, next, candidates, champMeetings, championshipScheduleUnknown(champMeetings, byMeeting))
|
|
||||||
}
|
|
||||||
if out.FocusMeeting != nil {
|
|
||||||
out.ChampionshipRound = championshipRound(champMeetings, int(out.FocusMeeting.MeetingKey))
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func currentLocalSeason(years []int, current int) int {
|
|
||||||
for _, year := range years {
|
|
||||||
if year == current {
|
|
||||||
return year
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, year := range years {
|
|
||||||
if year < current {
|
|
||||||
return year
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return years[len(years)-1]
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseContextTime(value string) (time.Time, bool) {
|
|
||||||
if value == "" {
|
|
||||||
return time.Time{}, false
|
|
||||||
}
|
|
||||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05"} {
|
|
||||||
if parsed, err := time.Parse(layout, value); err == nil {
|
|
||||||
return parsed.UTC(), true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return time.Time{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasMeaningfulAnalysis(c store.SessionDatasetCounts) bool {
|
|
||||||
return c.Results > 0 || c.Laps > 0 || c.Stints > 0 || c.Positions > 0 || c.RaceControl > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func candidateTime(c contextCandidate) time.Time {
|
|
||||||
if !c.end.IsZero() {
|
|
||||||
return c.end
|
|
||||||
}
|
|
||||||
return c.start
|
|
||||||
}
|
|
||||||
|
|
||||||
func liveMatches(e LiveEvidence, m store.Meeting, s store.Session) bool {
|
|
||||||
meetingMatch := normalizedContains(e.MeetingName, m.MeetingName) || normalizedContains(e.CircuitName, m.CircuitShortName)
|
|
||||||
sessionMatch := normalizedEqual(e.SessionName, s.SessionName)
|
|
||||||
if normalizeIdentity(e.SessionName) == "" {
|
|
||||||
sessionMatch = normalizedEqual(e.SessionType, s.SessionType)
|
|
||||||
}
|
|
||||||
return meetingMatch && sessionMatch
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizedEqual(a, b string) bool {
|
|
||||||
return normalizeIdentity(a) != "" && normalizeIdentity(a) == normalizeIdentity(b)
|
|
||||||
}
|
|
||||||
func normalizedContains(a, b string) bool {
|
|
||||||
a, b = normalizeIdentity(a), normalizeIdentity(b)
|
|
||||||
return a != "" && b != "" && (strings.Contains(a, b) || strings.Contains(b, a))
|
|
||||||
}
|
|
||||||
func normalizeIdentity(v string) string {
|
|
||||||
return strings.Map(func(r rune) rune {
|
|
||||||
if r >= 'A' && r <= 'Z' {
|
|
||||||
return r + ('a' - 'A')
|
|
||||||
}
|
|
||||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}, v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func syntheticLiveCandidate(e LiveEvidence, now time.Time) *contextCandidate {
|
|
||||||
return &contextCandidate{meeting: store.Meeting{MeetingName: e.MeetingName, CircuitShortName: e.CircuitName}, session: store.Session{SessionName: e.SessionName, SessionType: e.SessionType, DateStart: now.Format(time.RFC3339)}, start: now}
|
|
||||||
}
|
|
||||||
|
|
||||||
func applySessionDisplayRange(m *store.Meeting, sessions []store.Session) {
|
|
||||||
var first, last time.Time
|
|
||||||
for _, sess := range sessions {
|
|
||||||
if sess.IsCancelled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
start, ok := parseContextTime(sess.DateStart)
|
|
||||||
if ok && (first.IsZero() || start.Before(first)) {
|
|
||||||
first = start
|
|
||||||
}
|
|
||||||
end, ok := parseContextTime(sess.DateEnd)
|
|
||||||
if !ok {
|
|
||||||
end = start
|
|
||||||
}
|
|
||||||
if !end.IsZero() && (last.IsZero() || end.After(last)) {
|
|
||||||
last = end
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !first.IsZero() {
|
|
||||||
m.DateStart = first.Format(time.RFC3339)
|
|
||||||
}
|
|
||||||
if !last.IsZero() {
|
|
||||||
m.DateEnd = last.Format(time.RFC3339)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func meetingModelByKey(meetings []store.Meeting, key int) *models.Meeting {
|
|
||||||
for _, meeting := range meetings {
|
|
||||||
if meeting.MeetingKey == key {
|
|
||||||
model := meetingToModel(meeting)
|
|
||||||
return &model
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func sessionRef(c contextCandidate, evidence LiveEvidence, now time.Time) *ContextSession {
|
|
||||||
session := sessionToModel(c.session)
|
|
||||||
meeting := meetingToModel(c.meeting)
|
|
||||||
availability := ContextAvailability{Schedule: "available", LiveSession: "inactive", Archive: "unavailable", Freshness: "fresh", Limitations: []string{}}
|
|
||||||
availability.LiveTransport = "unknown"
|
|
||||||
if c.session.SessionKey == 0 {
|
|
||||||
availability.Schedule = "unavailable"
|
|
||||||
availability.Limitations = append(availability.Limitations, "schedule_identity_unmatched")
|
|
||||||
}
|
|
||||||
if evidence.Active && liveMatches(evidence, c.meeting, c.session) {
|
|
||||||
availability.LiveTransport = "connected"
|
|
||||||
availability.LiveSession = "active"
|
|
||||||
if !evidence.ObservedAt.IsZero() {
|
|
||||||
availability.ObservedAt = evidence.ObservedAt.Format(time.RFC3339)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if c.archived {
|
|
||||||
availability.Archive = "available"
|
|
||||||
if !evidence.ObservedAt.IsZero() {
|
|
||||||
availability.ObservedAt = evidence.ObservedAt.Format(time.RFC3339)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if c.counts.Results > 0 && (c.counts.Laps+c.counts.Stints+c.counts.Positions+c.counts.RaceControl > 0) {
|
|
||||||
availability.LocalAnalysis = "complete"
|
|
||||||
} else if hasMeaningfulAnalysis(c.counts) || c.counts.Drivers+c.counts.StartingGrid+c.counts.Weather > 0 {
|
|
||||||
availability.LocalAnalysis = "partial"
|
|
||||||
} else if !c.start.IsZero() && c.start.After(now) {
|
|
||||||
availability.LocalAnalysis = "not_applicable"
|
|
||||||
} else {
|
|
||||||
availability.LocalAnalysis = "pending"
|
|
||||||
}
|
|
||||||
return &ContextSession{Session: session, Meeting: &meeting, Availability: availability}
|
|
||||||
}
|
|
||||||
|
|
||||||
func chooseFocusMeeting(meetings []store.Meeting, previous, next *contextCandidate) *models.Meeting {
|
|
||||||
if next != nil {
|
|
||||||
return meetingModelByKey(meetings, next.meeting.MeetingKey)
|
|
||||||
}
|
|
||||||
if previous != nil {
|
|
||||||
return meetingModelByKey(meetings, previous.meeting.MeetingKey)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func classifyTemporalState(now time.Time, previous, next *contextCandidate, candidates []contextCandidate, championship []store.Meeting, scheduleUnknown bool) TemporalState {
|
|
||||||
var latestStarted *contextCandidate
|
|
||||||
for i := range candidates {
|
|
||||||
if !candidates[i].start.IsZero() && !candidates[i].start.After(now) && (latestStarted == nil || candidates[i].start.After(latestStarted.start)) {
|
|
||||||
latestStarted = &candidates[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Once a new meeting enters its preparation window, an ingest gap from an
|
|
||||||
// older meeting must not keep the product stuck in settling.
|
|
||||||
if next != nil && next.start.Sub(now) <= preSessionWindow && (latestStarted == nil || latestStarted.meeting.MeetingKey != next.meeting.MeetingKey) {
|
|
||||||
return TemporalPreSession
|
|
||||||
}
|
|
||||||
if latestStarted != nil && !latestStarted.complete && !latestStarted.end.IsZero() {
|
|
||||||
if now.Before(latestStarted.end) {
|
|
||||||
return TemporalPreSession
|
|
||||||
}
|
|
||||||
return TemporalSessionSettling
|
|
||||||
}
|
|
||||||
if previous != nil && next != nil && previous.meeting.MeetingKey == next.meeting.MeetingKey {
|
|
||||||
return TemporalBetweenSessions
|
|
||||||
}
|
|
||||||
if previous != nil && meetingFinalSession(*previous, candidates) && !candidateTime(*previous).After(now) && now.Sub(candidateTime(*previous)) <= postWeekendWindow {
|
|
||||||
return TemporalPostWeekend
|
|
||||||
}
|
|
||||||
if next != nil && next.start.Sub(now) <= preSessionWindow {
|
|
||||||
return TemporalPreSession
|
|
||||||
}
|
|
||||||
if next == nil && len(championship) > 0 && !scheduleUnknown {
|
|
||||||
return TemporalSeasonComplete
|
|
||||||
}
|
|
||||||
return TemporalBetweenWeekends
|
|
||||||
}
|
|
||||||
|
|
||||||
func championshipScheduleUnknown(meetings []store.Meeting, sessions map[int][]store.Session) bool {
|
|
||||||
for _, meeting := range meetings {
|
|
||||||
for _, session := range sessions[meeting.MeetingKey] {
|
|
||||||
if !session.IsCancelled && isChampionshipRace(session) {
|
|
||||||
if _, ok := parseContextTime(session.DateStart); !ok {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func meetingFinalSession(previous contextCandidate, candidates []contextCandidate) bool {
|
|
||||||
latest := previous.start
|
|
||||||
for _, c := range candidates {
|
|
||||||
if c.meeting.MeetingKey == previous.meeting.MeetingKey && c.start.After(latest) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func championshipMeetings(meetings []store.Meeting, sessions map[int][]store.Session) []store.Meeting {
|
|
||||||
var out []store.Meeting
|
|
||||||
for _, m := range meetings {
|
|
||||||
if m.IsCancelled || isTestMeeting(m) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, sess := range sessions[m.MeetingKey] {
|
|
||||||
if !sess.IsCancelled && isChampionshipRace(sess) {
|
|
||||||
out = append(out, m)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.SliceStable(out, func(i, j int) bool {
|
|
||||||
a, _ := parseContextTime(out[i].DateStart)
|
|
||||||
b, _ := parseContextTime(out[j].DateStart)
|
|
||||||
return a.Before(b)
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func isTestMeeting(m store.Meeting) bool {
|
|
||||||
n := strings.ToLower(m.MeetingName + " " + m.MeetingOfficialName)
|
|
||||||
return strings.Contains(n, "test")
|
|
||||||
}
|
|
||||||
func isChampionshipRace(s store.Session) bool {
|
|
||||||
n := strings.ToLower(s.SessionName)
|
|
||||||
t := strings.ToLower(s.SessionType)
|
|
||||||
return (n == "race" || t == "race") && !strings.Contains(n, "sprint") && !strings.Contains(t, "sprint")
|
|
||||||
}
|
|
||||||
func championshipRound(meetings []store.Meeting, key int) int {
|
|
||||||
for i, m := range meetings {
|
|
||||||
if m.MeetingKey == key {
|
|
||||||
return i + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
@@ -1,353 +0,0 @@
|
|||||||
package query
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/AmanTahiliani/box-box/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
func contextService(t *testing.T, now time.Time) *Service {
|
|
||||||
t.Helper()
|
|
||||||
base := openTestService(t)
|
|
||||||
return NewServiceWithClock(base.store, func() time.Time { return now })
|
|
||||||
}
|
|
||||||
|
|
||||||
func addContextMeeting(t *testing.T, svc *Service, key int, name, start, end string, cancelled bool) {
|
|
||||||
t.Helper()
|
|
||||||
if err := svc.store.UpsertMeeting(store.Meeting{MeetingKey: key, MeetingName: name, MeetingOfficialName: name, CircuitShortName: name, Year: 2026, DateStart: start, DateEnd: end, IsCancelled: cancelled}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func addContextSession(t *testing.T, svc *Service, key, meeting int, name, start, end string, cancelled bool) {
|
|
||||||
t.Helper()
|
|
||||||
if err := svc.store.UpsertSession(store.Session{SessionKey: key, MeetingKey: meeting, SessionName: name, SessionType: name, DateStart: start, DateEnd: end, IsCancelled: cancelled}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func completeContextSession(t *testing.T, svc *Service, key, meeting int) {
|
|
||||||
t.Helper()
|
|
||||||
if err := svc.store.UpsertSessionResult(store.SessionResult{SessionKey: key, MeetingKey: meeting, DriverNumber: 1, Position: 1}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveWeekendContextTemporalStates(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
now string
|
|
||||||
seed func(*testing.T, *Service)
|
|
||||||
evidence LiveEvidence
|
|
||||||
want TemporalState
|
|
||||||
}{
|
|
||||||
{name: "no season", now: "2026-06-01T12:00:00Z", want: TemporalNoSeason},
|
|
||||||
{name: "between weekends", now: "2026-06-10T12:00:00Z", seed: func(t *testing.T, s *Service) {
|
|
||||||
addContextMeeting(t, s, 1, "Monaco Grand Prix", "2026-06-01T09:00:00Z", "2026-06-02T16:00:00Z", false)
|
|
||||||
addContextSession(t, s, 11, 1, "Race", "2026-06-02T14:00:00Z", "2026-06-02T16:00:00Z", false)
|
|
||||||
completeContextSession(t, s, 11, 1)
|
|
||||||
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
|
||||||
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
|
|
||||||
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
|
||||||
}, want: TemporalBetweenWeekends},
|
|
||||||
{name: "pre session", now: "2026-06-19T12:00:00Z", seed: func(t *testing.T, s *Service) {
|
|
||||||
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
|
||||||
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
|
|
||||||
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
|
||||||
}, want: TemporalPreSession},
|
|
||||||
{name: "session live overrides schedule", now: "2026-06-20T12:00:00Z", seed: func(t *testing.T, s *Service) {
|
|
||||||
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
|
||||||
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
|
|
||||||
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
|
||||||
}, evidence: LiveEvidence{Active: true, MeetingName: "Canadian Grand Prix", CircuitName: "Canada Grand Prix", SessionName: "Practice 1", SessionType: "Practice 1"}, want: TemporalSessionLive},
|
|
||||||
{name: "session settling", now: "2026-06-20T11:00:00Z", seed: func(t *testing.T, s *Service) {
|
|
||||||
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
|
||||||
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
|
|
||||||
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
|
||||||
}, want: TemporalSessionSettling},
|
|
||||||
{name: "between sessions", now: "2026-06-20T11:00:00Z", seed: func(t *testing.T, s *Service) {
|
|
||||||
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
|
||||||
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
|
|
||||||
completeContextSession(t, s, 21, 2)
|
|
||||||
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
|
||||||
}, want: TemporalBetweenSessions},
|
|
||||||
{name: "post weekend", now: "2026-06-22T18:00:00Z", seed: func(t *testing.T, s *Service) {
|
|
||||||
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
|
||||||
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
|
||||||
completeContextSession(t, s, 22, 2)
|
|
||||||
}, want: TemporalPostWeekend},
|
|
||||||
{name: "season complete", now: "2026-06-30T12:00:00Z", seed: func(t *testing.T, s *Service) {
|
|
||||||
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
|
||||||
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
|
||||||
completeContextSession(t, s, 22, 2)
|
|
||||||
}, want: TemporalSeasonComplete},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
now, _ := time.Parse(time.RFC3339, tt.now)
|
|
||||||
svc := contextService(t, now)
|
|
||||||
if tt.seed != nil {
|
|
||||||
tt.seed(t, svc)
|
|
||||||
}
|
|
||||||
got, err := svc.ResolveWeekendContext(tt.evidence)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got.TemporalState != tt.want {
|
|
||||||
t.Fatalf("state = %s, want %s; context=%+v", got.TemporalState, tt.want, got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveWeekendContextTruthRules(t *testing.T) {
|
|
||||||
now, _ := time.Parse(time.RFC3339, "2026-07-10T12:00:00Z")
|
|
||||||
svc := contextService(t, now)
|
|
||||||
addContextMeeting(t, svc, 1, "Pre-Season Testing", "2026-02-01T00:00:00Z", "2026-02-03T00:00:00Z", false)
|
|
||||||
addContextSession(t, svc, 10, 1, "Race", "2026-02-03T10:00:00Z", "2026-02-03T12:00:00Z", false)
|
|
||||||
addContextMeeting(t, svc, 2, "Cancelled Grand Prix", "2026-03-01T00:00:00Z", "2026-03-03T00:00:00Z", true)
|
|
||||||
addContextSession(t, svc, 20, 2, "Race", "2026-03-03T10:00:00Z", "2026-03-03T12:00:00Z", false)
|
|
||||||
addContextMeeting(t, svc, 3, "British Grand Prix", "2026-07-01T00:00:00Z", "2026-07-05T00:00:00Z", false)
|
|
||||||
addContextSession(t, svc, 30, 3, "Practice 1", "2026-07-03T09:00:00Z", "2026-07-03T10:00:00Z", false)
|
|
||||||
addContextSession(t, svc, 31, 3, "Sprint", "2026-07-04T10:00:00Z", "2026-07-04T11:00:00Z", false)
|
|
||||||
addContextSession(t, svc, 32, 3, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
|
||||||
completeContextSession(t, svc, 32, 3)
|
|
||||||
addContextMeeting(t, svc, 4, "Belgian Grand Prix", "2026-07-16T00:00:00Z", "2026-07-18T00:00:00Z", false)
|
|
||||||
addContextSession(t, svc, 40, 4, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false)
|
|
||||||
addContextSession(t, svc, 41, 4, "Cancelled Practice", "2026-07-18T09:00:00Z", "2026-07-18T10:00:00Z", true)
|
|
||||||
addContextSession(t, svc, 42, 4, "Race", "2026-07-19T14:00:00Z", "2026-07-19T16:00:00Z", false)
|
|
||||||
|
|
||||||
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got.TotalChampionshipRounds != 2 || got.ChampionshipRound != 2 {
|
|
||||||
t.Fatalf("rounds = %d/%d, want 2/2", got.ChampionshipRound, got.TotalChampionshipRounds)
|
|
||||||
}
|
|
||||||
if got.DefaultAnalysisSession == nil || got.DefaultAnalysisSession.Session.SessionKey != 32 {
|
|
||||||
t.Fatalf("default analysis = %+v, want completed race 32", got.DefaultAnalysisSession)
|
|
||||||
}
|
|
||||||
if got.NextSession == nil || got.NextSession.Session.SessionKey != 40 {
|
|
||||||
t.Fatalf("next = %+v, want 40", got.NextSession)
|
|
||||||
}
|
|
||||||
if got.NextMeeting.DateStart != "2026-07-17T09:00:00Z" || got.NextMeeting.DateEnd != "2026-07-19T16:00:00Z" {
|
|
||||||
t.Fatalf("display range = %s..%s", got.NextMeeting.DateStart, got.NextMeeting.DateEnd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveWeekendContextPassedTimeDoesNotCompleteSession(t *testing.T) {
|
|
||||||
now, _ := time.Parse(time.RFC3339, "2026-07-05T18:00:00Z")
|
|
||||||
svc := contextService(t, now)
|
|
||||||
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T00:00:00Z", "2026-07-05T16:00:00Z", false)
|
|
||||||
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
|
||||||
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got.PreviousCompletedSession != nil || got.DefaultAnalysisSession != nil {
|
|
||||||
t.Fatalf("passed schedule was treated complete: %+v", got)
|
|
||||||
}
|
|
||||||
archive, err := svc.ResolveWeekendContext(LiveEvidence{Final: true, MeetingName: "British Grand Prix", CircuitName: "British Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if archive.PreviousCompletedSession == nil || archive.PreviousCompletedSession.Availability.Archive != "available" {
|
|
||||||
t.Fatalf("final archive not used: %+v", archive)
|
|
||||||
}
|
|
||||||
if archive.DefaultAnalysisSession != nil {
|
|
||||||
t.Fatal("archive without local analysis must not become default analysis")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveWeekendContextNeverUsesFutureAnalysis(t *testing.T) {
|
|
||||||
now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z")
|
|
||||||
svc := contextService(t, now)
|
|
||||||
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T00:00:00Z", "2026-07-05T16:00:00Z", false)
|
|
||||||
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
|
||||||
completeContextSession(t, svc, 11, 1) // bad/preloaded data must not make a future session canonical
|
|
||||||
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got.PreviousCompletedSession != nil || got.DefaultAnalysisSession != nil {
|
|
||||||
t.Fatalf("future analysis selected: %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regression for the v0.4.0 release blocker (#90). Production held a run
|
|
||||||
// British race (11326) and an unrun Belgian race (11334) two weekends apart.
|
|
||||||
// Analysis must resolve to the race that actually happened, even when the
|
|
||||||
// later meeting carries preloaded rows.
|
|
||||||
func TestResolveWeekendContextPrefersRunRaceOverLaterUnrunRace(t *testing.T) {
|
|
||||||
now, _ := time.Parse(time.RFC3339, "2026-07-13T12:00:00Z")
|
|
||||||
svc := contextService(t, now)
|
|
||||||
addContextMeeting(t, svc, 1289, "British Grand Prix", "2026-07-03T11:30:00Z", "2026-07-05T16:00:00Z", false)
|
|
||||||
addContextSession(t, svc, 11326, 1289, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
|
||||||
completeContextSession(t, svc, 11326, 1289)
|
|
||||||
addContextMeeting(t, svc, 1290, "Belgian Grand Prix", "2026-07-17T11:30:00Z", "2026-07-19T15:00:00Z", false)
|
|
||||||
addContextSession(t, svc, 11334, 1290, "Race", "2026-07-19T13:00:00Z", "2026-07-19T15:00:00Z", false)
|
|
||||||
completeContextSession(t, svc, 11334, 1290)
|
|
||||||
|
|
||||||
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got.DefaultAnalysisSession == nil {
|
|
||||||
t.Fatal("no analysis session resolved")
|
|
||||||
}
|
|
||||||
if key := got.DefaultAnalysisSession.Session.SessionKey; key != 11326 {
|
|
||||||
t.Fatalf("analysis session = %d, want 11326 (British); 11334 is the unrun Belgian race", key)
|
|
||||||
}
|
|
||||||
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Session.SessionKey != 11326 {
|
|
||||||
t.Fatalf("previous completed = %+v, want 11326", got.PreviousCompletedSession)
|
|
||||||
}
|
|
||||||
if got.NextSession == nil || got.NextSession.Session.SessionKey != 11334 {
|
|
||||||
t.Fatalf("next session = %+v, want 11334", got.NextSession)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveWeekendContextSprintWeekendHandoff(t *testing.T) {
|
|
||||||
now, _ := time.Parse(time.RFC3339, "2026-07-04T12:00:00Z")
|
|
||||||
svc := contextService(t, now)
|
|
||||||
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
|
||||||
addContextSession(t, svc, 11, 1, "Sprint", "2026-07-04T10:00:00Z", "2026-07-04T11:00:00Z", false)
|
|
||||||
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
|
||||||
completeContextSession(t, svc, 11, 1)
|
|
||||||
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got.TemporalState != TemporalBetweenSessions || got.PreviousCompletedSession.Session.SessionKey != 11 || got.NextSession.Session.SessionKey != 12 {
|
|
||||||
t.Fatalf("sprint handoff = %+v", got)
|
|
||||||
}
|
|
||||||
if got.TotalChampionshipRounds != 1 {
|
|
||||||
t.Fatalf("sprint created extra championship round: %d", got.TotalChampionshipRounds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveWeekendContextBoundaryTimestamps(t *testing.T) {
|
|
||||||
t.Run("pre-session window is inclusive", func(t *testing.T) {
|
|
||||||
now, _ := time.Parse(time.RFC3339, "2026-07-01T09:00:00Z")
|
|
||||||
svc := contextService(t, now)
|
|
||||||
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
|
||||||
addContextSession(t, svc, 11, 1, "Practice 1", "2026-07-03T09:00:00Z", "2026-07-03T10:00:00Z", false)
|
|
||||||
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
|
||||||
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got.TemporalState != TemporalPreSession {
|
|
||||||
t.Fatalf("state = %s", got.TemporalState)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.Run("scheduled end enters settling", func(t *testing.T) {
|
|
||||||
now, _ := time.Parse(time.RFC3339, "2026-07-03T10:00:00Z")
|
|
||||||
svc := contextService(t, now)
|
|
||||||
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
|
||||||
addContextSession(t, svc, 11, 1, "Practice 1", "2026-07-03T09:00:00Z", "2026-07-03T10:00:00Z", false)
|
|
||||||
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
|
||||||
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got.TemporalState != TemporalSessionSettling {
|
|
||||||
t.Fatalf("state = %s", got.TemporalState)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.Run("post-weekend window is inclusive", func(t *testing.T) {
|
|
||||||
now, _ := time.Parse(time.RFC3339, "2026-07-07T16:00:00Z")
|
|
||||||
svc := contextService(t, now)
|
|
||||||
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
|
||||||
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
|
||||||
completeContextSession(t, svc, 12, 1)
|
|
||||||
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got.TemporalState != TemporalPostWeekend {
|
|
||||||
t.Fatalf("state = %s", got.TemporalState)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveWeekendContextPartialFutureSchedule(t *testing.T) {
|
|
||||||
now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z")
|
|
||||||
svc := contextService(t, now)
|
|
||||||
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "", false)
|
|
||||||
addContextSession(t, svc, 11, 1, "Practice 1", "", "", false)
|
|
||||||
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "", false)
|
|
||||||
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got.NextSession == nil || got.NextSession.Session.SessionKey != 12 {
|
|
||||||
t.Fatalf("partial schedule next = %+v", got.NextSession)
|
|
||||||
}
|
|
||||||
if got.FocusMeeting == nil || got.FocusMeeting.DateStart != "2026-07-05T14:00:00Z" || got.FocusMeeting.DateEnd != "2026-07-05T14:00:00Z" {
|
|
||||||
t.Fatalf("partial display range = %+v", got.FocusMeeting)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveWeekendContextActiveSessionIsNotCompletedOrDefault(t *testing.T) {
|
|
||||||
now, _ := time.Parse(time.RFC3339, "2026-07-05T15:00:00Z")
|
|
||||||
svc := contextService(t, now)
|
|
||||||
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
|
||||||
addContextSession(t, svc, 10, 1, "Qualifying", "2026-07-04T14:00:00Z", "2026-07-04T15:00:00Z", false)
|
|
||||||
completeContextSession(t, svc, 10, 1)
|
|
||||||
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
|
||||||
completeContextSession(t, svc, 11, 1)
|
|
||||||
|
|
||||||
got, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "British Grand Prix", CircuitName: "British Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got.ActiveSession == nil || got.ActiveSession.Session.SessionKey != 11 {
|
|
||||||
t.Fatalf("active = %+v", got.ActiveSession)
|
|
||||||
}
|
|
||||||
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Session.SessionKey != 10 {
|
|
||||||
t.Fatalf("previous = %+v, want earlier completed session", got.PreviousCompletedSession)
|
|
||||||
}
|
|
||||||
if got.DefaultAnalysisSession == nil || got.DefaultAnalysisSession.Session.SessionKey != 10 {
|
|
||||||
t.Fatalf("default = %+v, want earlier completed session", got.DefaultAnalysisSession)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveWeekendContextOldIncompleteSessionDoesNotSuppressNextWeekend(t *testing.T) {
|
|
||||||
now, _ := time.Parse(time.RFC3339, "2026-07-16T12:00:00Z")
|
|
||||||
svc := contextService(t, now)
|
|
||||||
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
|
||||||
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
|
||||||
addContextMeeting(t, svc, 2, "Belgian Grand Prix", "2026-07-17T09:00:00Z", "2026-07-19T16:00:00Z", false)
|
|
||||||
addContextSession(t, svc, 21, 2, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false)
|
|
||||||
addContextSession(t, svc, 22, 2, "Race", "2026-07-19T14:00:00Z", "2026-07-19T16:00:00Z", false)
|
|
||||||
|
|
||||||
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got.TemporalState != TemporalPreSession {
|
|
||||||
t.Fatalf("state = %s, want %s; context=%+v", got.TemporalState, TemporalPreSession, got)
|
|
||||||
}
|
|
||||||
if got.FocusMeeting == nil || got.FocusMeeting.MeetingKey != 2 {
|
|
||||||
t.Fatalf("focus = %+v, want Belgian weekend", got.FocusMeeting)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveWeekendContextMissingScheduleDoesNotClaimSeasonComplete(t *testing.T) {
|
|
||||||
now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z")
|
|
||||||
svc := contextService(t, now)
|
|
||||||
addContextMeeting(t, svc, 1, "British Grand Prix", "", "", false)
|
|
||||||
addContextSession(t, svc, 11, 1, "Race", "", "", false)
|
|
||||||
|
|
||||||
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got.TemporalState != TemporalBetweenWeekends {
|
|
||||||
t.Fatalf("state = %s, want limited %s context", got.TemporalState, TemporalBetweenWeekends)
|
|
||||||
}
|
|
||||||
if got.TotalChampionshipRounds != 1 {
|
|
||||||
t.Fatalf("total rounds = %d, want scheduled round retained", got.TotalChampionshipRounds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ package query
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/AmanTahiliani/box-box/internal/chapters"
|
"github.com/AmanTahiliani/box-box/internal/chapters"
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
@@ -13,20 +12,11 @@ import (
|
|||||||
// Service assembles store-backed read models.
|
// Service assembles store-backed read models.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
store *store.Store
|
store *store.Store
|
||||||
now func() time.Time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a query service over a domain store.
|
// NewService creates a query service over a domain store.
|
||||||
func NewService(st *store.Store) *Service {
|
func NewService(st *store.Store) *Service {
|
||||||
return NewServiceWithClock(st, time.Now)
|
return &Service{store: st}
|
||||||
}
|
|
||||||
|
|
||||||
// NewServiceWithClock creates a query service with an injected clock.
|
|
||||||
func NewServiceWithClock(st *store.Store, now func() time.Time) *Service {
|
|
||||||
if now == nil {
|
|
||||||
now = time.Now
|
|
||||||
}
|
|
||||||
return &Service{store: st, now: now}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnrichedResult is a session result with driver identity fields.
|
// EnrichedResult is a session result with driver identity fields.
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/AmanTahiliani/box-box/internal/live"
|
|
||||||
"github.com/AmanTahiliani/box-box/internal/query"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *Server) handleWeekendContext(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
if !s.hasLocalQuery() {
|
|
||||||
writeJSON(w, query.WeekendContext{TemporalState: query.TemporalNoSeason})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
state := s.hub.State()
|
|
||||||
evidence := query.LiveEvidence{}
|
|
||||||
if state.IsLive && state.Data != nil {
|
|
||||||
evidence = liveEvidence(state.Data, true, false)
|
|
||||||
} else if state.LastSnapshot != nil && terminalSessionStatus(state.LastSnapshot.SessionStatus) {
|
|
||||||
evidence = liveEvidence(state.LastSnapshot, false, true)
|
|
||||||
if state.LastSnapshotAt != nil {
|
|
||||||
evidence.ObservedAt = *state.LastSnapshotAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
context, err := s.query.ResolveWeekendContext(evidence)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, err, http.StatusInternalServerError, false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, context)
|
|
||||||
}
|
|
||||||
|
|
||||||
func liveEvidence(data *live.LiveStreamData, active, final bool) query.LiveEvidence {
|
|
||||||
return query.LiveEvidence{Active: active, Final: final, MeetingName: data.Session.MeetingName, CircuitName: data.Session.CircuitName, SessionName: data.Session.SessionName, SessionType: data.Session.SessionType}
|
|
||||||
}
|
|
||||||
|
|
||||||
func terminalSessionStatus(status string) bool {
|
|
||||||
normalized := strings.ToLower(strings.Map(func(r rune) rune {
|
|
||||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}, status))
|
|
||||||
switch normalized {
|
|
||||||
case "finished", "finalised", "finalized", "ended", "aborted":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/AmanTahiliani/box-box/internal/live"
|
|
||||||
"github.com/AmanTahiliani/box-box/internal/query"
|
|
||||||
"github.com/AmanTahiliani/box-box/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
func openContextStore(t *testing.T) *store.Store {
|
|
||||||
t.Helper()
|
|
||||||
st, err := store.Open(filepath.Join(t.TempDir(), "context.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = st.Close() })
|
|
||||||
return st
|
|
||||||
}
|
|
||||||
|
|
||||||
func seedContextHandler(t *testing.T, st *store.Store) {
|
|
||||||
t.Helper()
|
|
||||||
if err := st.UpsertMeeting(store.Meeting{MeetingKey: 1, MeetingName: "British Grand Prix", CircuitShortName: "Silverstone", Year: 2026, DateStart: "2026-07-03T09:00:00Z", DateEnd: "2026-07-05T16:00:00Z"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := st.UpsertSession(store.Session{SessionKey: 11, MeetingKey: 1, SessionName: "Race", SessionType: "Race", DateStart: "2026-07-05T14:00:00Z", DateEnd: "2026-07-05T16:00:00Z"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWeekendContextHandlerWithoutStoreReturnsNoSeason(t *testing.T) {
|
|
||||||
s := NewServer(nil, 0, nil)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
|
|
||||||
if rr.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d", rr.Code)
|
|
||||||
}
|
|
||||||
var got query.WeekendContext
|
|
||||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got.TemporalState != query.TemporalNoSeason {
|
|
||||||
t.Fatalf("state = %s", got.TemporalState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWeekendContextHandlerUsesLiveHubIdentityWithoutOpenF1(t *testing.T) {
|
|
||||||
st := openContextStore(t)
|
|
||||||
seedContextHandler(t, st)
|
|
||||||
now, _ := time.Parse(time.RFC3339, "2026-07-05T13:55:00Z")
|
|
||||||
s := NewServer(nil, 0, st) // a nil OpenF1 client makes any REST dependency panic
|
|
||||||
s.query = query.NewServiceWithClock(st, func() time.Time { return now })
|
|
||||||
s.hub.applySnapshot(live.LiveStreamData{SessionStatus: "Started", Session: live.LiveSessionMeta{MeetingName: "British Grand Prix", CircuitName: "Silverstone", SessionName: "Race", SessionType: "Race"}}, now)
|
|
||||||
|
|
||||||
handler, err := s.routes()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
|
|
||||||
if rr.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d body=%s", rr.Code, rr.Body.String())
|
|
||||||
}
|
|
||||||
var got query.WeekendContext
|
|
||||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got.TemporalState != query.TemporalSessionLive || got.ActiveSession == nil || got.ActiveSession.Session.SessionKey != 11 {
|
|
||||||
t.Fatalf("live context = %+v", got)
|
|
||||||
}
|
|
||||||
if got.ActiveSession.Availability.LiveTransport != "connected" || got.ActiveSession.Availability.LiveSession != "active" {
|
|
||||||
t.Fatalf("availability = %+v", got.ActiveSession.Availability)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWeekendContextHandlerUsesTerminalArchiveAsCompletionEvidence(t *testing.T) {
|
|
||||||
st := openContextStore(t)
|
|
||||||
seedContextHandler(t, st)
|
|
||||||
now, _ := time.Parse(time.RFC3339, "2026-07-05T16:05:00Z")
|
|
||||||
s := NewServer(nil, 0, st)
|
|
||||||
s.query = query.NewServiceWithClock(st, func() time.Time { return now })
|
|
||||||
s.hub.applySnapshot(live.LiveStreamData{SessionStatus: "Finished", Session: live.LiveSessionMeta{MeetingName: "British Grand Prix", CircuitName: "Silverstone", SessionName: "Race", SessionType: "Race"}}, now)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
|
|
||||||
var got query.WeekendContext
|
|
||||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Availability.Archive != "available" {
|
|
||||||
t.Fatalf("archive context = %+v", got)
|
|
||||||
}
|
|
||||||
if got.DefaultAnalysisSession != nil {
|
|
||||||
t.Fatal("archive-only session must not become local default analysis")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTerminalSessionStatus(t *testing.T) {
|
|
||||||
for _, status := range []string{"Finished", "Finalised", "ENDED", "Aborted"} {
|
|
||||||
if !terminalSessionStatus(status) {
|
|
||||||
t.Errorf("%q should be terminal", status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, status := range []string{"Started", "Resumed", "Inactive", ""} {
|
|
||||||
if terminalSessionStatus(status) {
|
|
||||||
t.Errorf("%q should not be terminal", status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -62,7 +62,6 @@ func (s *Server) routes() (http.Handler, error) {
|
|||||||
|
|
||||||
// REST API — /api/v1/laps/comparison must be registered before /api/v1/laps
|
// REST API — /api/v1/laps/comparison must be registered before /api/v1/laps
|
||||||
// because Go's ServeMux uses longest-prefix matching.
|
// because Go's ServeMux uses longest-prefix matching.
|
||||||
mux.HandleFunc("/api/v1/weekend-context", s.handleWeekendContext)
|
|
||||||
mux.HandleFunc("/api/v1/race-hub", s.handleRaceHub)
|
mux.HandleFunc("/api/v1/race-hub", s.handleRaceHub)
|
||||||
mux.HandleFunc("/api/v1/seasons", s.handleSeasons)
|
mux.HandleFunc("/api/v1/seasons", s.handleSeasons)
|
||||||
mux.HandleFunc("/api/v1/weekend", s.handleWeekend)
|
mux.HandleFunc("/api/v1/weekend", s.handleWeekend)
|
||||||
|
|||||||
Reference in New Issue
Block a user