mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
Add weekend ingestion orchestration
This commit is contained in:
@@ -19,7 +19,7 @@ func main() {
|
||||
webMode := flag.Bool("web", false, "Start web companion server instead of TUI")
|
||||
port := flag.Int("port", 8080, "Port for web server (used with --web)")
|
||||
ingestYear := flag.Int("ingest-year", 0, "Ingest OpenF1 meetings for a season year")
|
||||
ingestMeeting := flag.Int("ingest-meeting", 0, "Ingest OpenF1 sessions for a meeting key")
|
||||
ingestMeeting := flag.Int("ingest-meeting", 0, "Ingest meeting metadata and Race Hub datasets for all sessions")
|
||||
ingestSession := flag.Int("ingest-session", 0, "Ingest Race Hub datasets for a session key")
|
||||
dryRun := flag.Bool("dry-run", false, "Preview ingestion without writing domain rows")
|
||||
dbPath := flag.String("db", "", "Domain database path (default: ~/.local/share/box-box/boxbox.db)")
|
||||
|
||||
40
documentations/refactor/20-phase-12-data-library-ui.md
Normal file
40
documentations/refactor/20-phase-12-data-library-ui.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Phase 12 Data Library UI
|
||||
|
||||
## Purpose
|
||||
|
||||
The app can now ingest full weekends and browse local seasons, meetings, and
|
||||
sessions. Phase 12 should make local data health visible in the Web UI so users
|
||||
can understand what is stored, what is partial, and what command to run next.
|
||||
|
||||
This is a frontend slice. Keep it practical and built on the APIs already
|
||||
available.
|
||||
|
||||
## Scope
|
||||
|
||||
Add a Data Library style surface that shows:
|
||||
|
||||
- local seasons and meetings;
|
||||
- sessions per meeting;
|
||||
- dataset coverage per session;
|
||||
- clear empty states;
|
||||
- suggested CLI commands for ingestion/backfill.
|
||||
|
||||
This can be a new route or a tab/section reachable from the existing Race Hub
|
||||
shell, depending on the current router structure.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Do not fetch OpenF1 from React.
|
||||
- Do not trigger ingestion from the browser.
|
||||
- Keep Race Hub direct links working.
|
||||
- Reuse existing local navigation APIs unless a small backend gap is genuinely
|
||||
blocking.
|
||||
- Keep styling dense, operational, and restrained.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- User can inspect local data coverage without opening a specific session.
|
||||
- Partial weekends/sessions are visibly distinct from complete ones.
|
||||
- Empty database state explains the relevant CLI command.
|
||||
- Existing Race Hub navigation continues to work.
|
||||
- Frontend tests/build/e2e pass.
|
||||
@@ -73,8 +73,10 @@ not implementation tickets yet.
|
||||
adding local-first season/weekend navigation around Race Hub.
|
||||
- [19 Phase 11 Weekend Ingestion](19-phase-11-weekend-ingestion.md): backend
|
||||
slice for making one command ingest a whole race weekend into the local DB.
|
||||
- [Cursor Phase 11 Prompt](cursor-phase-11-weekend-ingestion-prompt.md):
|
||||
current handoff prompt for the next backend phase.
|
||||
- [20 Phase 12 Data Library UI](20-phase-12-data-library-ui.md): frontend slice
|
||||
for showing local ingestion coverage and next CLI actions.
|
||||
- [Cursor Phase 12 Prompt](cursor-phase-12-data-library-ui-prompt.md): current
|
||||
handoff prompt for the next frontend phase.
|
||||
|
||||
## External References
|
||||
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
# Prompt For Cursor: Phase 11 Weekend Ingestion
|
||||
|
||||
You are working in the `box-box` repository on Phase 11. The Web UI can now
|
||||
browse local years, meetings, and sessions. Your task is to make the CLI able
|
||||
to populate a whole race weekend/meeting in one backend ingestion flow.
|
||||
|
||||
## Read First
|
||||
|
||||
Open these files first:
|
||||
|
||||
- `documentations/refactor/19-phase-11-weekend-ingestion.md`
|
||||
- `cmd/main.go`
|
||||
- `internal/ingest/ingest.go`
|
||||
- `internal/ingest/ingest_test.go`
|
||||
- `internal/ingest/openf1.go`
|
||||
- `internal/store/meetings.go`
|
||||
- `internal/query/navigation.go`
|
||||
|
||||
Only open older docs if you are blocked.
|
||||
|
||||
## Goal
|
||||
|
||||
Make meeting/weekend ingestion useful for the local-first Web UI. A user should
|
||||
be able to ingest a meeting and have all sessions for that meeting populated
|
||||
with Race Hub datasets where available.
|
||||
|
||||
## Current Shape
|
||||
|
||||
The project already has:
|
||||
|
||||
- `--ingest-year`
|
||||
- `--ingest-meeting`
|
||||
- `--ingest-session`
|
||||
- session-level Race Hub ingestion datasets;
|
||||
- local navigation APIs and UI that depend on ingested meeting/session data.
|
||||
|
||||
Confirm the exact current behavior before editing. If `--ingest-meeting`
|
||||
currently only stores meeting/session metadata, extend it or add a clearly named
|
||||
flag. Prefer the least surprising CLI behavior.
|
||||
|
||||
## Work To Do
|
||||
|
||||
1. Add meeting/weekend orchestration that fetches sessions for a meeting and
|
||||
ingests Race Hub datasets for each session.
|
||||
2. Preserve single-session ingestion behavior.
|
||||
3. Return/report per-session summaries clearly.
|
||||
4. Keep raw payload provenance for all fetched endpoints.
|
||||
5. Make partial failures visible without erasing successful session data.
|
||||
6. Add focused offline tests with fake sources.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Do not fetch OpenF1 from React.
|
||||
- Do not introduce background ingestion from normal page views.
|
||||
- Do not persist high-volume car telemetry in this phase.
|
||||
- Do not break existing e2e seed behavior.
|
||||
- Keep completed historical sessions as the default mental model.
|
||||
|
||||
## Verification
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/ingest/... ./internal/store/... ./internal/query/... ./internal/web/...
|
||||
go build -o /private/tmp/box-box ./cmd/main.go
|
||||
cd frontend && npm test -- --run
|
||||
cd frontend && npm run build
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
## Report Back
|
||||
|
||||
Summarize:
|
||||
|
||||
- files changed;
|
||||
- CLI behavior added or changed;
|
||||
- tests run and results;
|
||||
- follow-up risks, especially around OpenF1 rate limits or partial sessions.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Prompt For Cursor: Phase 12 Data Library UI
|
||||
|
||||
You are working in the `box-box` repository on Phase 12. Build a practical Web
|
||||
UI surface for inspecting local data coverage and ingestion status.
|
||||
|
||||
## Read First
|
||||
|
||||
Open these files first:
|
||||
|
||||
- `documentations/refactor/20-phase-12-data-library-ui.md`
|
||||
- `frontend/src/components/LocalDataNavigator.tsx`
|
||||
- `frontend/src/pages/RaceHubPage.tsx`
|
||||
- `frontend/src/api.ts`
|
||||
- `frontend/src/types.ts`
|
||||
- `frontend/src/main.tsx`
|
||||
- `frontend/src/styles/app.css`
|
||||
- `tests/race-hub.spec.ts`
|
||||
|
||||
Only open older docs if you are blocked.
|
||||
|
||||
## Goal
|
||||
|
||||
Let a user inspect what is in the local database and understand what is missing
|
||||
without needing to open every Race Hub session manually.
|
||||
|
||||
## APIs Available
|
||||
|
||||
- `GET /api/v1/seasons`
|
||||
- `GET /api/v1/meetings?year=<year>&source=local`
|
||||
- `GET /api/v1/weekend?meeting_key=<key>`
|
||||
- `GET /api/v1/race-hub?session_key=<key>`
|
||||
|
||||
Do not fetch OpenF1 from React.
|
||||
|
||||
## Work To Do
|
||||
|
||||
1. Add a Data Library route or reachable section in the React app.
|
||||
2. Show local years/meetings/sessions and dataset coverage.
|
||||
3. Make partial vs complete sessions visually clear.
|
||||
4. Include copyable/reference CLI commands, such as:
|
||||
- `box-box --ingest-year 2025`
|
||||
- `box-box --ingest-meeting <meeting_key>`
|
||||
- `box-box --ingest-session <session_key>`
|
||||
5. Preserve direct Race Hub navigation.
|
||||
6. Add focused frontend tests and update e2e if stable.
|
||||
|
||||
## Design Notes
|
||||
|
||||
- Keep it operational and table-like.
|
||||
- Avoid decorative cards and generic dashboard clutter.
|
||||
- Use the existing dataset/status visual language where possible.
|
||||
- Mobile should remain usable.
|
||||
|
||||
## Verification
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd frontend && npm test -- --run
|
||||
cd frontend && npm run build
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
## Report Back
|
||||
|
||||
Summarize:
|
||||
|
||||
- files changed;
|
||||
- Data Library behavior added;
|
||||
- tests run and results;
|
||||
- follow-up API or design gaps.
|
||||
@@ -31,26 +31,34 @@ func DefaultOptions() Options {
|
||||
}
|
||||
}
|
||||
|
||||
// SessionSummary captures the outcome of ingesting one session within a meeting run.
|
||||
type SessionSummary struct {
|
||||
SessionKey int `json:"session_key"`
|
||||
SessionName string `json:"session_name,omitempty"`
|
||||
Summary Summary `json:"summary"`
|
||||
}
|
||||
|
||||
// Summary captures the outcome of an ingestion run.
|
||||
type Summary struct {
|
||||
ScopeType string `json:"scope_type"`
|
||||
ScopeKey string `json:"scope_key"`
|
||||
Status string `json:"status"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
Meetings int `json:"meetings"`
|
||||
Sessions int `json:"sessions"`
|
||||
Drivers int `json:"drivers"`
|
||||
SessionResults int `json:"session_results"`
|
||||
StartingGrid int `json:"starting_grid"`
|
||||
Stints int `json:"stints"`
|
||||
PitStops int `json:"pit_stops"`
|
||||
Positions int `json:"positions"`
|
||||
RaceControl int `json:"race_control"`
|
||||
Weather int `json:"weather"`
|
||||
Laps int `json:"laps"`
|
||||
RawPayloads int `json:"raw_payloads"`
|
||||
RawInserted int `json:"raw_inserted"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
ScopeType string `json:"scope_type"`
|
||||
ScopeKey string `json:"scope_key"`
|
||||
Status string `json:"status"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
Meetings int `json:"meetings"`
|
||||
Sessions int `json:"sessions"`
|
||||
Drivers int `json:"drivers"`
|
||||
SessionResults int `json:"session_results"`
|
||||
StartingGrid int `json:"starting_grid"`
|
||||
Stints int `json:"stints"`
|
||||
PitStops int `json:"pit_stops"`
|
||||
Positions int `json:"positions"`
|
||||
RaceControl int `json:"race_control"`
|
||||
Weather int `json:"weather"`
|
||||
Laps int `json:"laps"`
|
||||
RawPayloads int `json:"raw_payloads"`
|
||||
RawInserted int `json:"raw_inserted"`
|
||||
SessionSummaries []SessionSummary `json:"session_summaries,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// Service orchestrates OpenF1-to-store ingestion workflows.
|
||||
@@ -130,7 +138,7 @@ func (s *Service) IngestYear(year int) (Summary, error) {
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// IngestMeeting fetches meeting metadata and all sessions for a meeting key.
|
||||
// IngestMeeting fetches meeting metadata, all sessions, and Race Hub datasets for each session.
|
||||
func (s *Service) IngestMeeting(meetingKey int) (Summary, error) {
|
||||
summary := Summary{
|
||||
ScopeType: "meeting",
|
||||
@@ -205,9 +213,37 @@ func (s *Service) IngestMeeting(meetingKey int) (Summary, error) {
|
||||
summary.Sessions++
|
||||
}
|
||||
|
||||
summary.Status = statusForDryRun(s.opts.DryRun)
|
||||
sessionFailures := 0
|
||||
for _, sess := range sessions {
|
||||
s.opts.Progress.Step("ingesting Race Hub datasets for session %d (%s)", sess.SessionKey, sess.SessionName)
|
||||
sessSummary, err := s.ingestSessionDatasets(sess.SessionKey, meetingKey)
|
||||
ss := SessionSummary{
|
||||
SessionKey: sess.SessionKey,
|
||||
SessionName: sess.SessionName,
|
||||
Summary: sessSummary,
|
||||
}
|
||||
if err != nil {
|
||||
sessionFailures++
|
||||
ss.Summary.Status = "failed"
|
||||
ss.Summary.Errors = append(ss.Summary.Errors, err.Error())
|
||||
summary.Errors = append(summary.Errors, fmt.Sprintf(
|
||||
"session %d (%s): %v", sess.SessionKey, sess.SessionName, err,
|
||||
))
|
||||
}
|
||||
summary.SessionSummaries = append(summary.SessionSummaries, ss)
|
||||
summary.mergeCounts(sessSummary)
|
||||
}
|
||||
|
||||
summary.Status = meetingStatus(sessionFailures, len(sessions), s.opts.DryRun)
|
||||
s.finishRun(runID, summary)
|
||||
s.opts.Progress.Summary(summary)
|
||||
|
||||
if sessionFailures > 0 {
|
||||
return summary, fmt.Errorf(
|
||||
"meeting %d: %d of %d session(s) failed",
|
||||
meetingKey, sessionFailures, len(sessions),
|
||||
)
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
@@ -287,28 +323,48 @@ func (s *Service) IngestSession(sessionKey int) (Summary, error) {
|
||||
summary.Sessions++
|
||||
}
|
||||
|
||||
datasetSummary, err := s.ingestSessionDatasets(sessionKey, meetingKey)
|
||||
summary.mergeCounts(datasetSummary)
|
||||
if err != nil {
|
||||
return s.finishFailed(runID, summary, err)
|
||||
}
|
||||
|
||||
summary.Status = statusForDryRun(s.opts.DryRun)
|
||||
s.finishRun(runID, summary)
|
||||
s.opts.Progress.Summary(summary)
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func (s *Service) ingestSessionDatasets(sessionKey, meetingKey int) (Summary, error) {
|
||||
summary := Summary{
|
||||
ScopeType: "session",
|
||||
ScopeKey: fmt.Sprintf("%d", sessionKey),
|
||||
DryRun: s.opts.DryRun,
|
||||
}
|
||||
sk := sessionKey
|
||||
|
||||
s.opts.Progress.Step("fetching drivers for session %d", sessionKey)
|
||||
driverFetch, drivers, err := fetchWithRetry(s, func() (FetchResult, []models.Driver, error) {
|
||||
return s.source.FetchDriversForSession(sessionKey)
|
||||
})
|
||||
if err != nil {
|
||||
return s.finishFailed(runID, summary, err)
|
||||
return summary, err
|
||||
}
|
||||
summary.RawPayloads++
|
||||
if !s.opts.DryRun {
|
||||
inserted, err := s.storeRaw(driverFetch, &meetingKey, &sk)
|
||||
if err != nil {
|
||||
return s.finishFailed(runID, summary, err)
|
||||
return summary, err
|
||||
}
|
||||
if inserted {
|
||||
summary.RawInserted++
|
||||
}
|
||||
for _, d := range drivers {
|
||||
if err := s.store.UpsertDriver(driverToStore(d)); err != nil {
|
||||
return s.finishFailed(runID, summary, err)
|
||||
return summary, err
|
||||
}
|
||||
if err := s.store.UpsertSessionDriver(sessionDriverToStore(d)); err != nil {
|
||||
return s.finishFailed(runID, summary, err)
|
||||
return summary, err
|
||||
}
|
||||
summary.Drivers++
|
||||
}
|
||||
@@ -322,20 +378,20 @@ func (s *Service) IngestSession(sessionKey int) (Summary, error) {
|
||||
return s.source.FetchSessionResult(sessionKey)
|
||||
})
|
||||
if err != nil {
|
||||
return s.finishFailed(runID, summary, err)
|
||||
return summary, err
|
||||
}
|
||||
summary.RawPayloads++
|
||||
if !s.opts.DryRun {
|
||||
inserted, err := s.storeRaw(resultFetch, &meetingKey, &sk)
|
||||
if err != nil {
|
||||
return s.finishFailed(runID, summary, err)
|
||||
return summary, err
|
||||
}
|
||||
if inserted {
|
||||
summary.RawInserted++
|
||||
}
|
||||
for _, r := range results {
|
||||
if err := s.store.UpsertSessionResult(sessionResultToStore(r)); err != nil {
|
||||
return s.finishFailed(runID, summary, err)
|
||||
return summary, err
|
||||
}
|
||||
summary.SessionResults++
|
||||
}
|
||||
@@ -349,20 +405,20 @@ func (s *Service) IngestSession(sessionKey int) (Summary, error) {
|
||||
return s.source.FetchStartingGrid(sessionKey)
|
||||
})
|
||||
if err != nil {
|
||||
return s.finishFailed(runID, summary, err)
|
||||
return summary, err
|
||||
}
|
||||
summary.RawPayloads++
|
||||
if !s.opts.DryRun {
|
||||
inserted, err := s.storeRaw(gridFetch, &meetingKey, &sk)
|
||||
if err != nil {
|
||||
return s.finishFailed(runID, summary, err)
|
||||
return summary, err
|
||||
}
|
||||
if inserted {
|
||||
summary.RawInserted++
|
||||
}
|
||||
for _, g := range grid {
|
||||
if err := s.store.UpsertStartingGridEntry(startingGridToStore(g)); err != nil {
|
||||
return s.finishFailed(runID, summary, err)
|
||||
return summary, err
|
||||
}
|
||||
summary.StartingGrid++
|
||||
}
|
||||
@@ -372,30 +428,55 @@ func (s *Service) IngestSession(sessionKey int) (Summary, error) {
|
||||
s.delay()
|
||||
|
||||
if err := s.ingestStints(&summary, meetingKey, sk); err != nil {
|
||||
return s.finishFailed(runID, summary, err)
|
||||
return summary, err
|
||||
}
|
||||
if err := s.ingestPitStops(&summary, meetingKey, sk); err != nil {
|
||||
return s.finishFailed(runID, summary, err)
|
||||
return summary, err
|
||||
}
|
||||
if err := s.ingestPositions(&summary, meetingKey, sk); err != nil {
|
||||
return s.finishFailed(runID, summary, err)
|
||||
return summary, err
|
||||
}
|
||||
if err := s.ingestRaceControl(&summary, meetingKey, sk); err != nil {
|
||||
return s.finishFailed(runID, summary, err)
|
||||
return summary, err
|
||||
}
|
||||
if err := s.ingestWeather(&summary, meetingKey, sk); err != nil {
|
||||
return s.finishFailed(runID, summary, err)
|
||||
return summary, err
|
||||
}
|
||||
if err := s.ingestLaps(&summary, meetingKey, sk); err != nil {
|
||||
return s.finishFailed(runID, summary, err)
|
||||
return summary, err
|
||||
}
|
||||
|
||||
summary.Status = statusForDryRun(s.opts.DryRun)
|
||||
s.finishRun(runID, summary)
|
||||
s.opts.Progress.Summary(summary)
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func (s *Summary) mergeCounts(other Summary) {
|
||||
s.Drivers += other.Drivers
|
||||
s.SessionResults += other.SessionResults
|
||||
s.StartingGrid += other.StartingGrid
|
||||
s.Stints += other.Stints
|
||||
s.PitStops += other.PitStops
|
||||
s.Positions += other.Positions
|
||||
s.RaceControl += other.RaceControl
|
||||
s.Weather += other.Weather
|
||||
s.Laps += other.Laps
|
||||
s.RawPayloads += other.RawPayloads
|
||||
s.RawInserted += other.RawInserted
|
||||
}
|
||||
|
||||
func meetingStatus(sessionFailures, sessionTotal int, dryRun bool) string {
|
||||
if dryRun {
|
||||
return "dry_run"
|
||||
}
|
||||
if sessionFailures == 0 {
|
||||
return "completed"
|
||||
}
|
||||
if sessionFailures == sessionTotal {
|
||||
return "failed"
|
||||
}
|
||||
return "partial"
|
||||
}
|
||||
|
||||
func (s *Service) beginRun(scopeType, scopeKey string) (int64, error) {
|
||||
if s.opts.DryRun {
|
||||
return 0, nil
|
||||
|
||||
@@ -28,6 +28,7 @@ type fakeSource struct {
|
||||
weather map[int][]models.Weather
|
||||
laps map[int][]models.Lap
|
||||
failOn string
|
||||
failSession map[int]string
|
||||
liveLockout bool
|
||||
}
|
||||
|
||||
@@ -59,6 +60,16 @@ func (f *fakeSource) maybeFail(endpoint string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSource) maybeFailSession(sessionKey int, endpoint string) error {
|
||||
if err := f.maybeFail(endpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
if f.failSession != nil && f.failSession[sessionKey] == endpoint {
|
||||
return errors.New("simulated fetch failure")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSource) wrap(endpoint, requestKey string, payload any) FetchResult {
|
||||
body, _ := json.Marshal(payload)
|
||||
return FetchResult{
|
||||
@@ -103,7 +114,7 @@ func (f *fakeSource) FetchSessionsForSessionKey(sessionKey int) (FetchResult, []
|
||||
}
|
||||
|
||||
func (f *fakeSource) FetchDriversForSession(sessionKey int) (FetchResult, []models.Driver, error) {
|
||||
if err := f.maybeFail("drivers"); err != nil {
|
||||
if err := f.maybeFailSession(sessionKey, "drivers"); err != nil {
|
||||
return FetchResult{}, nil, err
|
||||
}
|
||||
data := f.drivers[sessionKey]
|
||||
@@ -111,7 +122,7 @@ func (f *fakeSource) FetchDriversForSession(sessionKey int) (FetchResult, []mode
|
||||
}
|
||||
|
||||
func (f *fakeSource) FetchSessionResult(sessionKey int) (FetchResult, []models.SessionResult, error) {
|
||||
if err := f.maybeFail("session_result"); err != nil {
|
||||
if err := f.maybeFailSession(sessionKey, "session_result"); err != nil {
|
||||
return FetchResult{}, nil, err
|
||||
}
|
||||
data := f.results[sessionKey]
|
||||
@@ -119,7 +130,7 @@ func (f *fakeSource) FetchSessionResult(sessionKey int) (FetchResult, []models.S
|
||||
}
|
||||
|
||||
func (f *fakeSource) FetchStartingGrid(sessionKey int) (FetchResult, []models.StartingGrid, error) {
|
||||
if err := f.maybeFail("starting_grid"); err != nil {
|
||||
if err := f.maybeFailSession(sessionKey, "starting_grid"); err != nil {
|
||||
return FetchResult{}, nil, err
|
||||
}
|
||||
data := f.grid[sessionKey]
|
||||
@@ -127,7 +138,7 @@ func (f *fakeSource) FetchStartingGrid(sessionKey int) (FetchResult, []models.St
|
||||
}
|
||||
|
||||
func (f *fakeSource) FetchStintsForSession(sessionKey int) (FetchResult, []models.Stint, error) {
|
||||
if err := f.maybeFail("stints"); err != nil {
|
||||
if err := f.maybeFailSession(sessionKey, "stints"); err != nil {
|
||||
return FetchResult{}, nil, err
|
||||
}
|
||||
data := f.stints[sessionKey]
|
||||
@@ -135,7 +146,7 @@ func (f *fakeSource) FetchStintsForSession(sessionKey int) (FetchResult, []model
|
||||
}
|
||||
|
||||
func (f *fakeSource) FetchPitStopsForSession(sessionKey int) (FetchResult, []models.Pit, error) {
|
||||
if err := f.maybeFail("pit"); err != nil {
|
||||
if err := f.maybeFailSession(sessionKey, "pit"); err != nil {
|
||||
return FetchResult{}, nil, err
|
||||
}
|
||||
data := f.pitStops[sessionKey]
|
||||
@@ -143,7 +154,7 @@ func (f *fakeSource) FetchPitStopsForSession(sessionKey int) (FetchResult, []mod
|
||||
}
|
||||
|
||||
func (f *fakeSource) FetchPositionsForSession(sessionKey int) (FetchResult, []models.Position, error) {
|
||||
if err := f.maybeFail("position"); err != nil {
|
||||
if err := f.maybeFailSession(sessionKey, "position"); err != nil {
|
||||
return FetchResult{}, nil, err
|
||||
}
|
||||
data := f.positions[sessionKey]
|
||||
@@ -151,7 +162,7 @@ func (f *fakeSource) FetchPositionsForSession(sessionKey int) (FetchResult, []mo
|
||||
}
|
||||
|
||||
func (f *fakeSource) FetchRaceControlForSession(sessionKey int) (FetchResult, []models.RaceControl, error) {
|
||||
if err := f.maybeFail("race_control"); err != nil {
|
||||
if err := f.maybeFailSession(sessionKey, "race_control"); err != nil {
|
||||
return FetchResult{}, nil, err
|
||||
}
|
||||
data := f.raceControl[sessionKey]
|
||||
@@ -159,7 +170,7 @@ func (f *fakeSource) FetchRaceControlForSession(sessionKey int) (FetchResult, []
|
||||
}
|
||||
|
||||
func (f *fakeSource) FetchWeatherForSession(sessionKey int) (FetchResult, []models.Weather, error) {
|
||||
if err := f.maybeFail("weather"); err != nil {
|
||||
if err := f.maybeFailSession(sessionKey, "weather"); err != nil {
|
||||
return FetchResult{}, nil, err
|
||||
}
|
||||
data := f.weather[sessionKey]
|
||||
@@ -167,7 +178,7 @@ func (f *fakeSource) FetchWeatherForSession(sessionKey int) (FetchResult, []mode
|
||||
}
|
||||
|
||||
func (f *fakeSource) FetchLapsForSession(sessionKey int) (FetchResult, []models.Lap, error) {
|
||||
if err := f.maybeFail("laps"); err != nil {
|
||||
if err := f.maybeFailSession(sessionKey, "laps"); err != nil {
|
||||
return FetchResult{}, nil, err
|
||||
}
|
||||
data := f.laps[sessionKey]
|
||||
@@ -261,6 +272,45 @@ func testSessionFixtures() (int, int, *fakeSource) {
|
||||
return meetingKey, sessionKey, src
|
||||
}
|
||||
|
||||
func testWeekendFixtures() (int, int, int, *fakeSource) {
|
||||
meetingKey, raceKey, src := testSessionFixtures()
|
||||
const qualiKey = 9473
|
||||
|
||||
src.sessionsByMeeting[meetingKey] = []models.Session{
|
||||
{SessionKey: qualiKey, MeetingKey: meetingKey, SessionName: "Qualifying", SessionType: "Qualifying", CircuitKey: 10},
|
||||
{SessionKey: raceKey, MeetingKey: meetingKey, SessionName: "Race", SessionType: "Race", CircuitKey: 10},
|
||||
}
|
||||
src.sessionsByKey[qualiKey] = []models.Session{src.sessionsByMeeting[meetingKey][0]}
|
||||
src.drivers[qualiKey] = []models.Driver{
|
||||
{
|
||||
DriverNumber: 1,
|
||||
FullName: "Max Verstappen",
|
||||
SessionKey: qualiKey,
|
||||
MeetingKey: meetingKey,
|
||||
TeamName: "Red Bull Racing",
|
||||
TeamColour: "3671C6",
|
||||
},
|
||||
{
|
||||
DriverNumber: 44,
|
||||
FullName: "Lewis Hamilton",
|
||||
SessionKey: qualiKey,
|
||||
MeetingKey: meetingKey,
|
||||
TeamName: "Ferrari",
|
||||
TeamColour: "E8002D",
|
||||
},
|
||||
}
|
||||
src.results[qualiKey] = []models.SessionResult{
|
||||
{SessionKey: qualiKey, MeetingKey: meetingKey, DriverNumber: 1, Position: 1},
|
||||
{SessionKey: qualiKey, MeetingKey: meetingKey, DriverNumber: 44, Position: 2, GapToLeader: 0.2},
|
||||
}
|
||||
src.grid[qualiKey] = []models.StartingGrid{
|
||||
{SessionKey: qualiKey, MeetingKey: meetingKey, DriverNumber: 1, Position: 1, LapDuration: 71.1},
|
||||
{SessionKey: qualiKey, MeetingKey: meetingKey, DriverNumber: 44, Position: 2, LapDuration: 71.3},
|
||||
}
|
||||
|
||||
return meetingKey, qualiKey, raceKey, src
|
||||
}
|
||||
|
||||
func ptrFloat64(v float64) *float64 {
|
||||
return &v
|
||||
}
|
||||
@@ -476,3 +526,82 @@ func TestIngestYearAndMeeting(t *testing.T) {
|
||||
t.Fatalf("meeting sessions = %d, want 1", meetingSummary.Sessions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestMeetingWeekendIngestsAllSessions(t *testing.T) {
|
||||
meetingKey, qualiKey, raceKey, src := testWeekendFixtures()
|
||||
st := openTestStore(t)
|
||||
|
||||
opts := DefaultOptions()
|
||||
opts.RequestDelay = 0
|
||||
svc := NewService(st, src, opts)
|
||||
|
||||
summary, err := svc.IngestMeeting(meetingKey)
|
||||
if err != nil {
|
||||
t.Fatalf("IngestMeeting() error = %v", err)
|
||||
}
|
||||
if summary.Status != "completed" {
|
||||
t.Fatalf("summary.Status = %q, want completed", summary.Status)
|
||||
}
|
||||
if summary.Sessions != 2 {
|
||||
t.Fatalf("summary.Sessions = %d, want 2", summary.Sessions)
|
||||
}
|
||||
if len(summary.SessionSummaries) != 2 {
|
||||
t.Fatalf("session summaries = %d, want 2", len(summary.SessionSummaries))
|
||||
}
|
||||
if summary.Drivers != 4 {
|
||||
t.Fatalf("summary.Drivers = %d, want 4 (2 per session)", summary.Drivers)
|
||||
}
|
||||
if summary.SessionResults != 4 {
|
||||
t.Fatalf("summary.SessionResults = %d, want 4", summary.SessionResults)
|
||||
}
|
||||
|
||||
for _, sk := range []int{qualiKey, raceKey} {
|
||||
drivers, err := st.ListSessionDrivers(sk)
|
||||
if err != nil || len(drivers) != 2 {
|
||||
t.Fatalf("session %d drivers = %+v, err = %v, want 2", sk, drivers, err)
|
||||
}
|
||||
results, err := st.ListSessionResults(sk)
|
||||
if err != nil || len(results) != 2 {
|
||||
t.Fatalf("session %d results = %+v, err = %v, want 2", sk, results, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestMeetingPartialFailurePreservesSuccessfulSessions(t *testing.T) {
|
||||
meetingKey, qualiKey, raceKey, src := testWeekendFixtures()
|
||||
src.failSession = map[int]string{qualiKey: "session_result"}
|
||||
st := openTestStore(t)
|
||||
|
||||
opts := DefaultOptions()
|
||||
opts.RequestDelay = 0
|
||||
svc := NewService(st, src, opts)
|
||||
|
||||
summary, err := svc.IngestMeeting(meetingKey)
|
||||
if err == nil {
|
||||
t.Fatal("IngestMeeting() expected partial failure error, got nil")
|
||||
}
|
||||
if summary.Status != "partial" {
|
||||
t.Fatalf("summary.Status = %q, want partial", summary.Status)
|
||||
}
|
||||
if len(summary.Errors) != 1 {
|
||||
t.Fatalf("summary.Errors = %v, want 1 entry", summary.Errors)
|
||||
}
|
||||
|
||||
raceDrivers, err := st.ListSessionDrivers(raceKey)
|
||||
if err != nil || len(raceDrivers) != 2 {
|
||||
t.Fatalf("race session drivers = %+v, err = %v, want 2 preserved", raceDrivers, err)
|
||||
}
|
||||
|
||||
qualiResults, err := st.ListSessionResults(qualiKey)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSessionResults(quali) error = %v", err)
|
||||
}
|
||||
if len(qualiResults) != 0 {
|
||||
t.Fatalf("quali session_results after failure = %d, want 0", len(qualiResults))
|
||||
}
|
||||
|
||||
meetings, err := st.ListMeetingsByYear(2025)
|
||||
if err != nil || len(meetings) != 1 {
|
||||
t.Fatalf("meetings after partial failure = %+v, err = %v, want 1 meeting preserved", meetings, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,9 +40,40 @@ func (p *Progress) Summary(summary Summary) {
|
||||
if summary.StartingGrid > 0 {
|
||||
fmt.Fprintf(p.w, " starting grid: %d\n", summary.StartingGrid)
|
||||
}
|
||||
if summary.Stints > 0 {
|
||||
fmt.Fprintf(p.w, " stints: %d\n", summary.Stints)
|
||||
}
|
||||
if summary.PitStops > 0 {
|
||||
fmt.Fprintf(p.w, " pit stops: %d\n", summary.PitStops)
|
||||
}
|
||||
if summary.Positions > 0 {
|
||||
fmt.Fprintf(p.w, " positions: %d\n", summary.Positions)
|
||||
}
|
||||
if summary.RaceControl > 0 {
|
||||
fmt.Fprintf(p.w, " race control: %d\n", summary.RaceControl)
|
||||
}
|
||||
if summary.Weather > 0 {
|
||||
fmt.Fprintf(p.w, " weather: %d\n", summary.Weather)
|
||||
}
|
||||
if summary.Laps > 0 {
|
||||
fmt.Fprintf(p.w, " laps: %d\n", summary.Laps)
|
||||
}
|
||||
if summary.RawPayloads > 0 {
|
||||
fmt.Fprintf(p.w, " raw payloads fetched: %d (inserted: %d)\n", summary.RawPayloads, summary.RawInserted)
|
||||
}
|
||||
for _, ss := range summary.SessionSummaries {
|
||||
fmt.Fprintf(p.w, " session %d (%s): status=%s", ss.SessionKey, ss.SessionName, ss.Summary.Status)
|
||||
if ss.Summary.Drivers > 0 || ss.Summary.SessionResults > 0 {
|
||||
fmt.Fprintf(p.w, " drivers=%d results=%d", ss.Summary.Drivers, ss.Summary.SessionResults)
|
||||
}
|
||||
if ss.Summary.RawPayloads > 0 {
|
||||
fmt.Fprintf(p.w, " raw=%d", ss.Summary.RawPayloads)
|
||||
}
|
||||
fmt.Fprintln(p.w)
|
||||
for _, errMsg := range ss.Summary.Errors {
|
||||
fmt.Fprintf(p.w, " error: %s\n", errMsg)
|
||||
}
|
||||
}
|
||||
for _, errMsg := range summary.Errors {
|
||||
fmt.Fprintf(p.w, " error: %s\n", errMsg)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user