From e39345a4cecb0aa509e277fe66d00d2e4d54b306 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 26 Sep 2026 19:18:59 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat(server,console,docs):=20RaceGOW=20at?= =?UTF-8?q?=20home=20=E2=80=94=20a=20solo=20track-run=20preset,=20and=20th?= =?UTF-8?q?e=20first=20overlay=20(#527)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RaceGOW is an at-home whoop competition: fly the season's track, submit your fastest 3 consecutive laps with a video that shows a timer. GridFPV already ran that event shape, six RD screens deep. This is a start button over it. - `EventMeta.preset` (+ `CreateEventRequest`): the console preset that built an event, `"racegow"` here. Persisted like any meta; nothing server-side consults it. - `#/racegow`: the RaceGOW page. Track, pilot (or a new callsign inline), timer, channel → `createRaceGowRun` builds the event through the wizard's own commands: Whoop class, one-pilot roster with a fixed channel, one time-trial round, `rounds=0`, `BestConsecutive{n:3}`, a 10-minute ceiling (a ranking-only round with no race time is refused), a fixed 1 s hold. - `#/racegow/run`: one button (FillRound → SetCurrentHeat → Stage → Start; Stop = ForceEnd → Finalize), the live clock, this run's laps off the served lap list with the best-3 window highlighted, the served round-wide best as the headline, a copy-and-paste submission line, the runs table, the overlay URL, and a link into the full console. - `#/overlay/racegow`: the first overlay of the streaming design — a bare, transparent (`html[data-overlay]`), read-only route following the active event: run clock, recent laps, best 3 this run and today. - Hub card; design-doc notes (streaming §5, roadmap); unit tests for the helpers and all three screens; `e2e/racegow.spec.ts` drives the whole loop on the Simulator, including the overlay and reopening a run. No engine change: every run is an ordinary heat of an ordinary event, with marshaling, audit and export behind it. Co-Authored-By: Claude Fable 5.1 --- bindings/CreateEventRequest.ts | 6 +- bindings/EventMeta.ts | 9 + crates/server/src/app.rs | 2 + crates/server/src/control_handler.rs | 7 + crates/server/src/events.rs | 21 + crates/server/src/open_practice.rs | 1 + crates/server/src/round_engine.rs | 1 + .../server/tests/min_lap_floor_conformance.rs | 1 + crates/server/tests/multi_round_event_live.rs | 1 + docs/roadmap.html | 16 + docs/streaming.html | 17 + frontend/apps/rd-console/src/App.svelte | 42 + frontend/apps/rd-console/src/app.css | 9 + frontend/apps/rd-console/src/lib/racegow.ts | 236 ++++++ frontend/apps/rd-console/src/lib/route.ts | 51 +- .../apps/rd-console/src/lib/session.svelte.ts | 11 + .../rd-console/src/screens/HomeHub.svelte | 45 +- .../src/screens/RaceGowOverlay.svelte | 269 +++++++ .../rd-console/src/screens/RaceGowPage.svelte | 442 ++++++++++ .../rd-console/src/screens/RaceGowRun.svelte | 761 ++++++++++++++++++ .../apps/rd-console/tests/HomeHub.test.ts | 16 +- .../rd-console/tests/RaceGowOverlay.test.ts | 96 +++ .../apps/rd-console/tests/RaceGowPage.test.ts | 168 ++++ .../apps/rd-console/tests/RaceGowRun.test.ts | 217 +++++ .../apps/rd-console/tests/racegow.test.ts | 217 +++++ frontend/apps/rd-console/tests/route.test.ts | 39 + frontend/e2e/racegow.spec.ts | 151 ++++ 27 files changed, 2843 insertions(+), 9 deletions(-) create mode 100644 frontend/apps/rd-console/src/lib/racegow.ts create mode 100644 frontend/apps/rd-console/src/screens/RaceGowOverlay.svelte create mode 100644 frontend/apps/rd-console/src/screens/RaceGowPage.svelte create mode 100644 frontend/apps/rd-console/src/screens/RaceGowRun.svelte create mode 100644 frontend/apps/rd-console/tests/RaceGowOverlay.test.ts create mode 100644 frontend/apps/rd-console/tests/RaceGowPage.test.ts create mode 100644 frontend/apps/rd-console/tests/RaceGowRun.test.ts create mode 100644 frontend/apps/rd-console/tests/racegow.test.ts create mode 100644 frontend/e2e/racegow.spec.ts diff --git a/bindings/CreateEventRequest.ts b/bindings/CreateEventRequest.ts index ccb648b1..0163711d 100644 --- a/bindings/CreateEventRequest.ts +++ b/bindings/CreateEventRequest.ts @@ -29,4 +29,8 @@ description?: string, /** * Optional organizer name, stored on [`EventMeta::organizer`]. */ -organizer?: string, }; +organizer?: string, +/** + * Optional **preset marker**, stored on [`EventMeta::preset`] (e.g. `"racegow"`). + */ +preset?: string, }; diff --git a/bindings/EventMeta.ts b/bindings/EventMeta.ts index 4c333dd1..5510e94b 100644 --- a/bindings/EventMeta.ts +++ b/bindings/EventMeta.ts @@ -55,6 +55,15 @@ description?: string, * Optional organizer name (the running club / person). Omitted from the wire when unset. */ organizer?: string, +/** + * The **console preset** that created this event, when one did — `"racegow"` for a solo + * RaceGOW track run. A preset is a ready-made event shape (timer, roster, one round, its win + * condition) built by the console through the ordinary create/configure commands; the marker + * only records *which* preset shaped it, so the console can open the event on the preset's + * own surface (the solo run screen) instead of the full workspace. Free-text on the wire and + * absent for an ordinary event; nothing server-side consults it. Omitted when unset. + */ +preset?: string, /** * The application-level timers this event **selects** (issue #73) — the per-event reference * into the app-level [`TimerRegistry`](crate::timers::TimerRegistry). Additive diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 4e5cc67c..a0b94d4b 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -4381,6 +4381,7 @@ mod tests { location: None, description: None, organizer: None, + preset: None, }) .unwrap(); assert!(registry.resolve(&created.id).is_some()); @@ -4420,6 +4421,7 @@ mod tests { location: None, description: None, organizer: None, + preset: None, }) .unwrap(); let (status, _) = delete_event_req(registry.clone(), &created.id.0, None).await; diff --git a/crates/server/src/control_handler.rs b/crates/server/src/control_handler.rs index 1c6fdfb4..9f603422 100644 --- a/crates/server/src/control_handler.rs +++ b/crates/server/src/control_handler.rs @@ -4281,6 +4281,7 @@ mod tests { location: None, description: None, organizer: None, + preset: None, }) .unwrap() .id; @@ -4430,6 +4431,7 @@ mod tests { location: None, description: None, organizer: None, + preset: None, }) .unwrap() .id; @@ -5197,6 +5199,7 @@ mod tests { location: None, description: None, organizer: None, + preset: None, }) .unwrap() .id; @@ -5511,6 +5514,7 @@ mod tests { location: None, description: None, organizer: None, + preset: None, }) .unwrap() .id; @@ -5978,6 +5982,7 @@ mod tests { location: None, description: None, organizer: None, + preset: None, }) .unwrap() .id; @@ -6151,6 +6156,7 @@ mod tests { location: None, description: None, organizer: None, + preset: None, }) .unwrap() .id; @@ -6234,6 +6240,7 @@ mod tests { location: None, description: None, organizer: None, + preset: None, }) .unwrap() .id; diff --git a/crates/server/src/events.rs b/crates/server/src/events.rs index 7feb812d..cc5c0ef4 100644 --- a/crates/server/src/events.rs +++ b/crates/server/src/events.rs @@ -105,6 +105,15 @@ pub struct EventMeta { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub organizer: Option, + /// The **console preset** that created this event, when one did — `"racegow"` for a solo + /// RaceGOW track run. A preset is a ready-made event shape (timer, roster, one round, its win + /// condition) built by the console through the ordinary create/configure commands; the marker + /// only records *which* preset shaped it, so the console can open the event on the preset's + /// own surface (the solo run screen) instead of the full workspace. Free-text on the wire and + /// absent for an ordinary event; nothing server-side consults it. Omitted when unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub preset: Option, /// The application-level timers this event **selects** (issue #73) — the per-event reference /// into the app-level [`TimerRegistry`](crate::timers::TimerRegistry). Additive /// (`#[serde(default)]`) so an event persisted before #73 reads back with an empty list; new @@ -1627,6 +1636,10 @@ pub struct CreateEventRequest { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub organizer: Option, + /// Optional **preset marker**, stored on [`EventMeta::preset`] (e.g. `"racegow"`). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub preset: Option, } impl CreateEventRequest { @@ -1640,6 +1653,7 @@ impl CreateEventRequest { location: None, description: None, organizer: None, + preset: None, } } } @@ -3030,6 +3044,7 @@ impl EventRegistry { location: normalize_optional(&request.location), description: normalize_optional(&request.description), organizer: normalize_optional(&request.organizer), + preset: normalize_optional(&request.preset), timers: default_timer_selection(), primary_timer: None, roster: Vec::new(), @@ -4421,6 +4436,7 @@ mod tests { location: None, description: None, organizer: None, + preset: None, }) .unwrap(); event_id = created.id.clone(); @@ -4463,6 +4479,7 @@ mod tests { location: Some("Main field".to_string()), description: None, organizer: Some("GridFPV Club".to_string()), + preset: Some("racegow".to_string()), }) .unwrap(); created_id = created.id.clone(); @@ -4503,6 +4520,10 @@ mod tests { assert_eq!(restored.date.as_deref(), Some("2026-06-20")); assert_eq!(restored.location.as_deref(), Some("Main field")); assert_eq!(restored.organizer.as_deref(), Some("GridFPV Club")); + // The preset marker is ordinary meta: stored on create, restored on reload (the console + // opens a `racegow` event on the solo run screen, so losing it would demote a track run + // to a plain event after a restart). + assert_eq!(restored.preset.as_deref(), Some("racegow")); assert_eq!( restored.timers, vec![TimerId("rh-1".into()), TimerId(MOCK_TIMER_ID.into())] diff --git a/crates/server/src/open_practice.rs b/crates/server/src/open_practice.rs index fcc35c9e..afe4899b 100644 --- a/crates/server/src/open_practice.rs +++ b/crates/server/src/open_practice.rs @@ -127,6 +127,7 @@ mod tests { location: None, description: None, organizer: None, + preset: None, timers: vec![], primary_timer: None, roster: vec![], diff --git a/crates/server/src/round_engine.rs b/crates/server/src/round_engine.rs index 19888d8a..d975959e 100644 --- a/crates/server/src/round_engine.rs +++ b/crates/server/src/round_engine.rs @@ -3066,6 +3066,7 @@ mod tests { location: None, description: None, organizer: None, + preset: None, timers: vec![], primary_timer: None, roster: vec![], diff --git a/crates/server/tests/min_lap_floor_conformance.rs b/crates/server/tests/min_lap_floor_conformance.rs index e5096779..cea43803 100644 --- a/crates/server/tests/min_lap_floor_conformance.rs +++ b/crates/server/tests/min_lap_floor_conformance.rs @@ -135,6 +135,7 @@ fn rig() -> Rig { location: None, description: None, organizer: None, + preset: None, }) .expect("event created"); let event = meta.id.clone(); diff --git a/crates/server/tests/multi_round_event_live.rs b/crates/server/tests/multi_round_event_live.rs index 3193d0e4..f202f1d2 100644 --- a/crates/server/tests/multi_round_event_live.rs +++ b/crates/server/tests/multi_round_event_live.rs @@ -607,6 +607,7 @@ async fn build_event( location: None, description: None, organizer: None, + preset: None, }, ) .await; diff --git a/docs/roadmap.html b/docs/roadmap.html index db2798b0..b8cdb5b4 100644 --- a/docs/roadmap.html +++ b/docs/roadmap.html @@ -125,6 +125,22 @@

2. The phases

GET /about + the console build-stamp; CONTRACT_VERSION stays the independent wire integer).

+

+ Status (2026-09-26): v0.4.0 is released (the non-prerelease + v0.4.0, 2026-09-07), + and the first work after it is a preset, not a phase: + RaceGOW at home — a solo track-run surface over the existing + engine. An event marked EventMeta.preset = "racegow" is built by the + console through the wizard's own commands (Whoop class, one pilot with a fixed channel, + an open-ended Time Trials round scored by best 3 consecutive laps, a 10-minute + ceiling), then driven from one screen (#/racegow/run: fill → stage → start, + stop → finalize) with its result as a copy-and-paste submission line. It also ships the + first overlay of the streaming design, + #/overlay/racegow, because the competition requires a timer in the video. + The point of building it as a preset: it proves the primitives carry a real + competition's rules with zero engine change, and every RaceGOW run remains an + ordinary event with marshaling, audit and results behind it. +

v0.1 — Walking skeleton

diff --git a/docs/streaming.html b/docs/streaming.html index a2a160ca..02e88796 100644 --- a/docs/streaming.html +++ b/docs/streaming.html @@ -308,6 +308,23 @@

5. Overlay catalogue & theming

Each gets a fuller spec as it is built; the list confirms the protocol exposes what they need.

+
+

+ As built (2026-09-26): the first overlay is the RaceGOW timer, + #/overlay/racegow. It is a route of the RD console SPA rather than a + separate app — the "one codebase, profiled three ways" frontend — rendered bare, on a + transparent document (html[data-overlay] lifts the console canvas), in the + .gridfpv-overlay token context. It is exactly the §2 shape: a read-only + client on the same session as the console, following the program (§2.1) — the + Director's active event — with no binding of its own, because a solo pilot has one + run open. It shows the run clock, the last few laps off the served lap-list + projection, and the best 3-consecutive window this run and today (the served round + standings). It exists because RaceGOW's rules require a timer in view in the + submitted video; the pilot adds it to OBS as a browser source over their DVR. Nothing + in §3 (broadcast control) is built. +

+
+

5.1 Theming & chapter branding

Theming is presentation, never data. Overlays render from diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte index 9826cc27..783556f1 100644 --- a/frontend/apps/rd-console/src/App.svelte +++ b/frontend/apps/rd-console/src/App.svelte @@ -45,6 +45,10 @@ import Marshaling from './screens/Marshaling.svelte'; import Results from './screens/Results.svelte'; import EventAudit from './screens/EventAudit.svelte'; + import RaceGowPage from './screens/RaceGowPage.svelte'; + import RaceGowRun from './screens/RaceGowRun.svelte'; + import RaceGowOverlay from './screens/RaceGowOverlay.svelte'; + import { isRaceGowEvent } from './lib/racegow.js'; import { openAudit } from './lib/auditFilter.svelte.js'; import type { Timer } from '@gridfpv/types'; import { @@ -115,6 +119,11 @@ }; /** Back out of an event-scoped tune: the event workspace's Timers tab — where the RD came from. */ const goEventTimersTab = () => navigate({ kind: 'workspace', tab: 'timers' }); + // The RaceGOW preset's two surfaces: the page that starts a run, and the solo run screen for + // the active one. Both are app-level routes (no workspace shell) — a pilot alone in a garage + // wants a start button, not a race director's console; the workspace stays one click away. + const goRaceGow = () => navigate({ kind: 'racegow' }); + const goRaceGowRun = () => navigate({ kind: 'racegow-run' }); // The workspace's app-route concept is "Events" (where event entry/switch happens). const route$page = $derived(route.kind === 'page' ? route.page : 'home'); @@ -346,6 +355,38 @@

{/if} +{:else if route.kind === 'overlay'} + +
+ +
+{:else if route.kind === 'racegow'} +
+ goPage('timers')} /> +
+{:else if route.kind === 'racegow-run'} + +
+ {#if isRaceGowEvent(session.currentEvent)} + navigate({ kind: 'workspace', tab: 'live' })} + ontimers={() => goPage('timers')} + /> + {:else} + goPage('timers')} + /> + {/if} +
{:else if route.kind === 'page'} +
+ +
+
+
+ + {/if} + + +
+ + + + + + + + {#if pilotId === NEW_PILOT} + + + + {/if} + + + + + {#if timerHasNoChannels} +

+ Pick the channels it may use on the + , then come + back. +

+ {/if} + + {#if channels.length > 0} + + + + {/if} + + {#if formError} + + {/if} + {#if loadError} + + {/if} + +
+ +
+
+
+ + + {#if runs === undefined} +

Loading…

+ {:else if runs.length === 0} +

No runs yet. Your first one shows up here.

+ {:else} +
    + {#each runs as r (r.id)} +
  • +
    + {r.name} + {whenLabel(r)} +
    + +
  • + {/each} +
+

+ A run is an ordinary event: rename or delete it on the Events page. +

+ {/if} +
+ + + + diff --git a/frontend/apps/rd-console/src/screens/RaceGowRun.svelte b/frontend/apps/rd-console/src/screens/RaceGowRun.svelte new file mode 100644 index 00000000..d9cc3b03 --- /dev/null +++ b/frontend/apps/rd-console/src/screens/RaceGowRun.svelte @@ -0,0 +1,761 @@ + + +
+
+
+ + +
+
+

{event?.name ?? 'RaceGOW run'}

+

+ {callsign} on {timerLabel} + {#if timer} + · + {timerDown ? 'timer not connected' : 'timer connected'} + + {/if} +

+
+
+ +
+
+ + {#if !round} + + The event was changed in the full console. Add a Time Trials round scored by best 3 laps, or + start a new run. + + {/if} + {#if timerDown} + + Laps cannot arrive until it is. + {#snippet actions()} + + {/snippet} + + {/if} + session.clearCommandError()} /> + {#if actionError} + (actionError = undefined)}> + {actionError} + + {/if} + + + +
+
+
+ + {#if view === 'idle'} + {runRows.length === 0 + ? 'Ready when you are' + : `${runRows.length} ${runRows.length === 1 ? 'run' : 'runs'} flown`} + {:else if view === 'ready'} + Run {runRows.length} is drawn + {:else if view === 'staged'} + Run {runRows.length} · staged + {:else if view === 'arming'} + Run {runRows.length} · timer going live… + {:else if view === 'running'} + Run {runRows.length} · flying + {:else} + Run {runRows.length} · stopped + {/if} + + +
+
+ {#if view === 'idle'} + + {:else if view === 'ready' || view === 'staged'} + + {:else if view === 'arming'} + + {:else if view === 'running'} + + {:else} + + {/if} +
+
+ +
+
+ This run · best {RACEGOW_LAPS} laps + + {currentBest ? formatMicros(currentBest.totalMicros) : '—'} + + {#if currentBest} + {currentBest.laps.map((l) => formatMicros(l)).join(' · ')} + {:else if view === 'running' || view === 'arming'} + Fly {RACEGOW_LAPS} laps in a row to get a time. + {/if} +
+ +
    + {#each currentLaps as lap, i (lap.end_ref)} +
  1. + {lapLabel(i)} + {formatMicros(lap.duration_micros)} +
  2. + {/each} + {#if currentLaps.length === 0 && heat} +
  3. No laps yet.
  4. + {/if} +
+
+
+
+ + +
+ +
+ + {headlineMicros !== undefined ? formatMicros(headlineMicros) : '—'} + + {#if bestRun?.best} + + Run {bestRun.number}, laps {bestRun.best.start + 1}–{bestRun.best.start + + bestRun.best.laps.length}: {bestRun.best.laps + .map((l) => formatMicros(l)) + .join(' · ')} + + {:else} + Save a run with {RACEGOW_LAPS} laps in a row and it shows here. + {/if} + {#if submission} + + +
+ +
+ {/if} +
+
+ + +
+

+ RaceGOW wants a timer in view. Add this page to OBS as a Browser source + over your goggles’ DVR or a camera on the track, and record. It shows the clock, the laps + and the best 3 so far, on a transparent background. +

+ {overlay} +
+ + Open overlay +
+
+
+
+ + + {#if runRows.length === 0} +

Every run you fly on this track is listed here with its best 3 laps.

+ {:else} + + + + + + + + + + + {#each runRows as row (row.heat)} + + + + + + + {/each} + +
RunStateLapsBest {RACEGOW_LAPS}
Run {row.number}{phaseLabel(row.phase)}{row.laps}{row.best ? formatMicros(row.best.totalMicros) : '—'}
+ {/if} +
+
+
+ + diff --git a/frontend/apps/rd-console/tests/HomeHub.test.ts b/frontend/apps/rd-console/tests/HomeHub.test.ts index 34898f55..38679b5e 100644 --- a/frontend/apps/rd-console/tests/HomeHub.test.ts +++ b/frontend/apps/rd-console/tests/HomeHub.test.ts @@ -99,13 +99,15 @@ describe('HomeHub (app-level landing, #118)', () => { const onclasses = vi.fn(); const onevents = vi.fn(); const ontimers = vi.fn(); - render(HomeHub, { session, onpilots, onclasses, onevents, ontimers }); + const onracegow = vi.fn(); + render(HomeHub, { session, onpilots, onclasses, onevents, ontimers, onracegow }); - // Four navigable cards. + // Five navigable cards: the four pages, plus RaceGOW (the solo track-run preset). expect(screen.getByRole('heading', { name: 'Pilots' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Classes' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Events' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Timers' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'RaceGOW' })).toBeInTheDocument(); // Summaries settle per card (count + unit are separate spans): 2 pilots, 3 classes, 2 events, // 3 timers · 2 connected (Mock is Ready + the live RH is Connected; the Configured RH is excluded). @@ -119,6 +121,10 @@ describe('HomeHub (app-level landing, #118)', () => { expect(within(classesCard).getByText('classes')).toBeInTheDocument(); await waitFor(() => expect(within(eventsCard).getByText('2')).toBeInTheDocument()); expect(within(eventsCard).getByText('events')).toBeInTheDocument(); + // RaceGOW counts only the events the preset made: neither of these two carries the marker. + const raceGowCard = screen.getByRole('heading', { name: 'RaceGOW' }).closest('button')!; + await waitFor(() => expect(within(raceGowCard).getByText('0')).toBeInTheDocument()); + expect(within(raceGowCard).getByText('track runs')).toBeInTheDocument(); await waitFor(() => expect(within(timersCard).getByText('timers')).toBeInTheDocument()); expect(within(timersCard).getByText(/2 connected/)).toBeInTheDocument(); }); @@ -129,7 +135,8 @@ describe('HomeHub (app-level landing, #118)', () => { const onclasses = vi.fn(); const onevents = vi.fn(); const ontimers = vi.fn(); - render(HomeHub, { session, onpilots, onclasses, onevents, ontimers }); + const onracegow = vi.fn(); + render(HomeHub, { session, onpilots, onclasses, onevents, ontimers, onracegow }); await fireEvent.click(screen.getByRole('heading', { name: 'Pilots' }).closest('button')!); await fireEvent.click(screen.getByRole('heading', { name: 'Classes' }).closest('button')!); @@ -155,7 +162,8 @@ describe('HomeHub (app-level landing, #118)', () => { onpilots: vi.fn(), onclasses: vi.fn(), onevents: vi.fn(), - ontimers: vi.fn() + ontimers: vi.fn(), + onracegow: vi.fn() }); const timersCard = screen.getByRole('heading', { name: 'Timers' }).closest('button')!; diff --git a/frontend/apps/rd-console/tests/RaceGowOverlay.test.ts b/frontend/apps/rd-console/tests/RaceGowOverlay.test.ts new file mode 100644 index 00000000..55c48a69 --- /dev/null +++ b/frontend/apps/rd-console/tests/RaceGowOverlay.test.ts @@ -0,0 +1,96 @@ +/** + * RaceGowOverlay — the OBS browser source: idle with no RaceGOW run active, and the clock, recent + * laps, this run's best window and the served session best once one is flying. + */ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, within } from '@testing-library/svelte'; +import { waitFor } from '@testing-library/dom'; +import type { EventMeta, LapList, LiveRaceState } from '@gridfpv/types'; +import RaceGowOverlay from '../src/screens/RaceGowOverlay.svelte'; +import { makeTestSession } from './support.js'; + +const RUN: EventMeta = { + id: 'track1-ab12', + name: 'RaceGOW6 Track 1', + created_at: 0, + persistent: true, + preset: 'racegow', + timers: ['rh-1'], + roster: ['p1'], + classes: ['mgp-whoop'], + rounds: [ + { + id: 'racegow-x1', + label: 'RaceGOW', + classes: ['mgp-whoop'], + format: 'timed_qual', + params: { rounds: '0' }, + win_condition: { BestConsecutive: { n: 3 } }, + seeding: 'FromRoster' + } + ] +} as unknown as EventMeta; + +const LAPS: LapList = { + competitors: [ + { + competitor: { adapter: 'rotorhazard', competitor: 'p1' }, + laps: [9, 7, 6.8, 6.8, 7.5, 7.1, 7.0, 6.9].map((s, i) => ({ + number: i + 1, + duration_micros: Math.round(s * 1_000_000), + at: (i + 1) * 10_000_000, + start_ref: i, + end_ref: i + 1 + })) + } + ] +}; + +describe('RaceGowOverlay', () => { + it('renders idle with no run active, and marks the document as an overlay', () => { + const { session } = makeTestSession({ noEnter: true }); + render(RaceGowOverlay, { session }); + expect(screen.getByText('waiting for a run…')).toBeInTheDocument(); + expect(document.documentElement.dataset.overlay).toBe('racegow'); + }); + + it('shows the track, the pilot, LIVE, the recent laps and both bests while flying', async () => { + const live: LiveRaceState = { + phase: 'Running', + current_heat: 'racegow-x1-3', + active_pilots: ['p1'], + race_started_at: 1_000_000 + }; + const { session } = makeTestSession({ + event: RUN, + live, + listPilotsImpl: vi.fn(async () => [{ id: 'p1', callsign: 'Recon', vtx_types: [] }]), + roundStandingsImpl: vi.fn(async () => [ + { + competitor: 'p1', + position: 1, + best_lap_micros: 6_800_000, + laps: 8, + metric: { BestConsecutive: { n: 3, micros: 20_500_000 } } + } + ]), + heatFetches: { 'racegow-x1-3': { laps: LAPS } } + }); + render(RaceGowOverlay, { session }); + + expect(await screen.findByText('RaceGOW6 Track 1 · Recon')).toBeInTheDocument(); + expect(screen.getByText('LIVE')).toBeInTheDocument(); + expect(screen.getByRole('timer', { name: /Run time/ })).toBeInTheDocument(); + // Only the last six of eight laps fit the frame; they keep their real lap numbers. + const laps = await screen.findByRole('list', { name: 'Recent laps' }); + await waitFor(() => expect(within(laps).getAllByRole('listitem')).toHaveLength(6)); + expect(within(laps).getByText('L3')).toBeInTheDocument(); + expect(within(laps).getByText('L8')).toBeInTheDocument(); + expect(within(laps).queryByText('L2')).not.toBeInTheDocument(); + // This run's best window (laps 2–4 = 20.600) and the served session best. + expect(screen.getByLabelText("This run's best 3 consecutive laps")).toHaveTextContent('20.600'); + await waitFor(() => + expect(screen.getByLabelText('Best 3 consecutive laps, all runs')).toHaveTextContent('20.500') + ); + }); +}); diff --git a/frontend/apps/rd-console/tests/RaceGowPage.test.ts b/frontend/apps/rd-console/tests/RaceGowPage.test.ts new file mode 100644 index 00000000..4b62ef7f --- /dev/null +++ b/frontend/apps/rd-console/tests/RaceGowPage.test.ts @@ -0,0 +1,168 @@ +/** + * RaceGowPage — the RaceGOW landing: the form defaults sensibly off the directories, refuses a + * timer with no channels (pointing at the Timers page), builds the run through the session's + * ordinary commands, and lists earlier runs to reopen. + */ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, within } from '@testing-library/svelte'; +import { fireEvent, waitFor } from '@testing-library/dom'; +import type { EventMeta, Pilot, Timer } from '@gridfpv/types'; +import RaceGowPage from '../src/screens/RaceGowPage.svelte'; +import { makeTestSession } from './support.js'; + +const RECON: Pilot = { id: 'p1', callsign: 'Recon', vtx_types: [] }; +const RH: Timer = { + id: 'rh-1', + name: 'Garage RH', + kind: { Rotorhazard: { url: 'http://rh.local:5000' } }, + status: 'Connected', + channel_capability: 'Flexible', + node_count: 1, + available_channels: [5658, 5880], + manual_connect: false, + calibration: [], + disabled_nodes: [] +}; +const BARE_RH: Timer = { ...RH, id: 'rh-2', name: 'Bare RH', available_channels: [] }; +const MOCK: Timer = { + id: 'mock', + name: 'Mock', + kind: { Mock: { laps: 3, lap_ms: 30000 } }, + status: 'Ready', + channel_capability: 'Flexible', + node_count: 8, + available_channels: [5658], + manual_connect: false, + calibration: [], + disabled_nodes: [] +}; +const OLD_RUN: EventMeta = { + id: 'track1-old1', + name: 'RaceGOW6 Track 1', + created_at: 1000, + persistent: true, + preset: 'racegow', + timers: ['rh-1'], + roster: ['p1'], + classes: ['mgp-whoop'] +}; +const CLUB: EventMeta = { + id: 'club-1', + name: 'Club night', + created_at: 2000, + persistent: true, + timers: ['rh-1'], + roster: [], + classes: [] +}; + +function setup(opts: { pilots?: Pilot[]; timers?: Timer[]; events?: EventMeta[] } = {}) { + const { session } = makeTestSession({ + noEnter: true, + listPilotsImpl: vi.fn(async () => opts.pilots ?? [RECON]), + listTimersImpl: vi.fn(async () => opts.timers ?? [MOCK, RH]), + listChannelsImpl: vi.fn(async () => [ + { band: 'Raceband', channel: 'R1', mhz: 5658 }, + { band: 'Raceband', channel: 'R7', mhz: 5880 } + ]) + }); + vi.spyOn(session, 'listEvents').mockResolvedValue(opts.events ?? [OLD_RUN, CLUB]); + const props = { onhome: vi.fn(), onrun: vi.fn(), ontimers: vi.fn() }; + return { session, props }; +} + +describe('RaceGowPage', () => { + it('defaults to the only pilot and the connected RotorHazard, with its first channel', async () => { + const { session, props } = setup(); + render(RaceGowPage, { session, ...props }); + const pilot = (await screen.findByLabelText('Pilot')) as HTMLSelectElement; + await waitFor(() => expect(pilot.value).toBe('p1')); + const timer = screen.getByLabelText('Timer') as HTMLSelectElement; + expect(timer.value).toBe('rh-1'); + const channel = screen.getByLabelText('Your channel') as HTMLSelectElement; + expect(channel.value).toBe('5658'); + // Channel options carry the friendly band + channel, with the MHz beside it. + expect(within(channel).getByRole('option', { name: /Raceband R7/ })).toBeInTheDocument(); + expect(screen.getByLabelText('Track')).toHaveValue('RaceGOW6 Track 1'); + }); + + it('builds the run through the ordinary commands and hands off to the run screen', async () => { + const { session, props } = setup(); + const created: EventMeta = { ...OLD_RUN, id: 'track1-new1' }; + const create = vi.spyOn(session, 'createEventAndEnter').mockResolvedValue(created); + const timers = vi.spyOn(session, 'setEventTimers').mockResolvedValue(created); + const primary = vi.spyOn(session, 'setPrimaryTimer').mockResolvedValue(created); + const classes = vi.spyOn(session, 'setEventClasses').mockResolvedValue(created); + const roster = vi.spyOn(session, 'setEventRoster').mockResolvedValue(created); + const members = vi.spyOn(session, 'setClassMembership').mockResolvedValue(created); + const round = vi.spyOn(session, 'createRound').mockResolvedValue({ id: 'r1' } as never); + render(RaceGowPage, { session, ...props }); + + const pilot = (await screen.findByLabelText('Pilot')) as HTMLSelectElement; + await waitFor(() => expect(pilot.value).toBe('p1')); + await fireEvent.change(screen.getByLabelText('Your channel'), { target: { value: '5880' } }); + await fireEvent.input(screen.getByLabelText('Track'), { + target: { value: 'RaceGOW6 Track 2' } + }); + await fireEvent.click(screen.getByRole('button', { name: 'Set up run' })); + + await waitFor(() => expect(props.onrun).toHaveBeenCalledTimes(1)); + expect(create).toHaveBeenCalledWith('RaceGOW6 Track 2', { preset: 'racegow' }); + expect(timers).toHaveBeenCalledWith(['rh-1']); + expect(primary).toHaveBeenCalledWith('rh-1'); + expect(classes).toHaveBeenCalledWith(['mgp-whoop']); + expect(roster).toHaveBeenCalledWith(['p1']); + expect(members).toHaveBeenCalledWith('mgp-whoop', [{ pilot: 'p1', channel: 5880 }]); + expect(round).toHaveBeenCalledTimes(1); + }); + + it('adds a new pilot inline when there is none, then builds the run for them', async () => { + const { session, props } = setup({ pilots: [] }); + const createPilot = vi + .spyOn(session, 'createPilot') + .mockResolvedValue({ id: 'p9', callsign: 'Newbie', vtx_types: [] }); + const created: EventMeta = { ...OLD_RUN, id: 'x', roster: ['p9'] }; + vi.spyOn(session, 'createEventAndEnter').mockResolvedValue(created); + vi.spyOn(session, 'setEventTimers').mockResolvedValue(created); + vi.spyOn(session, 'setPrimaryTimer').mockResolvedValue(created); + vi.spyOn(session, 'setEventClasses').mockResolvedValue(created); + const roster = vi.spyOn(session, 'setEventRoster').mockResolvedValue(created); + vi.spyOn(session, 'setClassMembership').mockResolvedValue(created); + vi.spyOn(session, 'createRound').mockResolvedValue({ id: 'r1' } as never); + render(RaceGowPage, { session, ...props }); + + // No pilots → the picker sits on "New pilot" and the callsign field is up. + const callsign = await screen.findByLabelText('Callsign'); + await fireEvent.input(callsign, { target: { value: 'Newbie' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Set up run' })); + + await waitFor(() => expect(props.onrun).toHaveBeenCalledTimes(1)); + expect(createPilot).toHaveBeenCalledWith({ callsign: 'Newbie', vtx_types: [] }); + expect(roster).toHaveBeenCalledWith(['p9']); + }); + + it('refuses a timer with no channels and points at the Timers page', async () => { + const { session, props } = setup({ timers: [BARE_RH] }); + const create = vi.spyOn(session, 'createEventAndEnter'); + render(RaceGowPage, { session, ...props }); + await screen.findByText('This timer has no channels set yet.'); + expect(screen.getByRole('button', { name: 'Set up run' })).toBeDisabled(); + expect(screen.queryByLabelText('Your channel')).not.toBeInTheDocument(); + await fireEvent.click(screen.getByRole('button', { name: 'Timers page' })); + expect(props.ontimers).toHaveBeenCalledTimes(1); + expect(create).not.toHaveBeenCalled(); + }); + + it('lists only RaceGOW runs, and reopens one through chooseEvent', async () => { + const { session, props } = setup(); + const choose = vi.spyOn(session, 'chooseEvent').mockResolvedValue(OLD_RUN); + render(RaceGowPage, { session, ...props }); + const runs = await screen.findByRole('list', { name: 'Your runs' }); + expect(within(runs).getAllByRole('listitem')).toHaveLength(1); + expect(within(runs).getByText('RaceGOW6 Track 1')).toBeInTheDocument(); + expect(within(runs).queryByText('Club night')).not.toBeInTheDocument(); + await fireEvent.click(within(runs).getByRole('button', { name: 'Open RaceGOW6 Track 1' })); + await waitFor(() => expect(props.onrun).toHaveBeenCalledTimes(1)); + expect(choose).toHaveBeenCalledWith(OLD_RUN); + }); +}); diff --git a/frontend/apps/rd-console/tests/RaceGowRun.test.ts b/frontend/apps/rd-console/tests/RaceGowRun.test.ts new file mode 100644 index 00000000..8e84ccda --- /dev/null +++ b/frontend/apps/rd-console/tests/RaceGowRun.test.ts @@ -0,0 +1,217 @@ +/** + * RaceGowRun — the solo run screen: the one button issues the right command sequence for each + * phase, the laps and best-3 window render off the served lap list, and the round-wide best comes + * from the served standings. The e2e (`e2e/racegow.spec.ts`) drives the real loop; this pins what + * the screen sends and shows. + */ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, within } from '@testing-library/svelte'; +import { fireEvent, waitFor } from '@testing-library/dom'; +import type { EventMeta, HeatSummary, LapList, LiveRaceState, RoundStanding } from '@gridfpv/types'; +import RaceGowRun from '../src/screens/RaceGowRun.svelte'; +import { makeTestSession } from './support.js'; + +const EVENT: EventMeta = { + id: 'track1-ab12', + name: 'RaceGOW6 Track 1', + created_at: 0, + persistent: true, + preset: 'racegow', + timers: ['rh-1'], + primary_timer: 'rh-1', + roster: ['p1'], + classes: ['mgp-whoop'], + classes_membership: [{ class: 'mgp-whoop', pilots: [{ pilot: 'p1', channel: 5880 }] }], + rounds: [ + { + id: 'racegow-x1', + label: 'RaceGOW', + classes: ['mgp-whoop'], + format: 'timed_qual', + params: { rounds: '0' }, + win_condition: { BestConsecutive: { n: 3 } }, + seeding: 'FromRoster', + channel_mode: 'Static' + } + ] +} as unknown as EventMeta; + +function lapList(durations: number[]): LapList { + return { + competitors: [ + { + competitor: { adapter: 'rotorhazard', competitor: 'p1' }, + laps: durations.map((d, i) => ({ + number: i + 1, + duration_micros: d, + at: (i + 1) * 10_000_000, + start_ref: i, + end_ref: i + 1 + })) + } + ] + }; +} + +function heat(id: string, phase: HeatSummary['phase'], current = false): HeatSummary { + return { + heat: id, + name: `RaceGOW Heat ${id.slice(-1)}`, + lineup: ['p1'], + round: 'racegow-x1', + class: 'mgp-whoop', + phase, + is_current: current + }; +} + +const idle: LiveRaceState = { phase: 'Scheduled' }; + +function setup(opts: { + live?: LiveRaceState; + heats?: HeatSummary[]; + laps?: Record; + standings?: RoundStanding[]; +}) { + const heats = opts.heats ?? []; + const listHeatsImpl = vi.fn(async () => heats); + const roundStandingsImpl = vi.fn(async () => opts.standings ?? []); + const heatFetches = Object.fromEntries( + Object.entries(opts.laps ?? {}).map(([id, d]) => [id, { laps: lapList(d) }]) + ); + const t = makeTestSession({ + event: EVENT, + live: opts.live ?? idle, + listHeatsImpl, + roundStandingsImpl, + listPilotsImpl: vi.fn(async () => [{ id: 'p1', callsign: 'Recon', vtx_types: [] }]), + heatFetches + }); + return { ...t, listHeatsImpl, roundStandingsImpl }; +} + +const props = { onhome: vi.fn(), onback: vi.fn(), onconsole: vi.fn(), ontimers: vi.fn() }; + +describe('RaceGowRun', () => { + it('idle: "Start run" draws the next heat, focuses it, stages and starts it', async () => { + // The fill lands a Scheduled heat the screen must then find in the heats list. + const drawn = heat('racegow-x1-1', 'Scheduled'); + const { session, sendSpy, listHeatsImpl } = setup({ heats: [] }); + listHeatsImpl.mockResolvedValue([drawn]); + render(RaceGowRun, { session, ...props }); + + await fireEvent.click(await screen.findByRole('button', { name: 'Start run' })); + + await waitFor(() => expect(sendSpy).toHaveBeenCalledTimes(4)); + expect(sendSpy.mock.calls.map(([c]) => c)).toEqual([ + { FillRound: { round: 'racegow-x1', mode: 'Next' } }, + { SetCurrentHeat: { heat: 'racegow-x1-1' } }, + { Stage: { heat: 'racegow-x1-1' } }, + { Start: { heat: 'racegow-x1-1' } } + ]); + }); + + it('a drawn-but-unstarted heat is started without drawing another', async () => { + const { session, sendSpy } = setup({ + live: { phase: 'Scheduled', current_heat: 'racegow-x1-1' }, + heats: [heat('racegow-x1-1', 'Scheduled', true)] + }); + render(RaceGowRun, { session, ...props }); + await fireEvent.click(await screen.findByRole('button', { name: 'Start run' })); + await waitFor(() => expect(sendSpy).toHaveBeenCalledTimes(2)); + expect(sendSpy.mock.calls.map(([c]) => c)).toEqual([ + { Stage: { heat: 'racegow-x1-1' } }, + { Start: { heat: 'racegow-x1-1' } } + ]); + }); + + it('running: shows the laps and this run’s best 3 window; "Stop run" ends and finalizes', async () => { + const { session, sendSpy } = setup({ + live: { + phase: 'Running', + current_heat: 'racegow-x1-1', + active_pilots: ['p1'], + race_started_at: 1_000_000 + }, + heats: [heat('racegow-x1-1', 'Running', true)], + laps: { 'racegow-x1-1': [9_000_000, 7_000_000, 6_800_000, 6_800_000, 7_500_000] } + }); + render(RaceGowRun, { session, ...props }); + + // The served laps render, and the best window (laps 2–4 = 20.600) is called out. + const laps = await screen.findByRole('list', { name: "This run's laps" }); + await waitFor(() => expect(within(laps).getAllByRole('listitem')).toHaveLength(5)); + expect(screen.getByLabelText("This run's best 3 consecutive laps")).toHaveTextContent('20.600'); + const rows = within(laps).getAllByRole('listitem'); + expect(rows[1]).toHaveClass('in-window'); + expect(rows[3]).toHaveClass('in-window'); + expect(rows[0]).not.toHaveClass('in-window'); + expect(rows[4]).not.toHaveClass('in-window'); + + await fireEvent.click(screen.getByRole('button', { name: 'Stop run' })); + await waitFor(() => expect(sendSpy).toHaveBeenCalledTimes(2)); + expect(sendSpy.mock.calls.map(([c]) => c)).toEqual([ + { ForceEnd: { heat: 'racegow-x1-1' } }, + { Finalize: { heat: 'racegow-x1-1' } } + ]); + }); + + it('stopped but unsaved: "Save run" only finalizes', async () => { + const { session, sendSpy } = setup({ + live: { phase: 'Unofficial', current_heat: 'racegow-x1-1' }, + heats: [heat('racegow-x1-1', 'Unofficial', true)] + }); + render(RaceGowRun, { session, ...props }); + await fireEvent.click(await screen.findByRole('button', { name: 'Save run' })); + await waitFor(() => expect(sendSpy).toHaveBeenCalledTimes(1)); + expect(sendSpy.mock.calls[0][0]).toEqual({ Finalize: { heat: 'racegow-x1-1' } }); + }); + + it('after saved runs: the served best headlines, the runs table names the best run, and the submission line is ready', async () => { + const { session } = setup({ + live: { phase: 'Final', current_heat: 'racegow-x1-2' }, + heats: [heat('racegow-x1-1', 'Final'), heat('racegow-x1-2', 'Final', true)], + laps: { + 'racegow-x1-1': [8_000_000, 8_000_000, 8_000_000], + 'racegow-x1-2': [7_000_000, 7_000_000, 7_000_000, 9_000_000] + }, + standings: [ + { + competitor: 'p1', + position: 1, + best_lap_micros: 7_000_000, + laps: 4, + metric: { BestConsecutive: { n: 3, micros: 21_000_000 } } + } + ] + }); + render(RaceGowRun, { session, ...props }); + + // The next action is another run. + expect(await screen.findByRole('button', { name: 'Fly again' })).toBeInTheDocument(); + // The headline is the SERVED round-wide best. + await waitFor(() => + expect(screen.getByLabelText('Best 3 consecutive laps, all runs')).toHaveTextContent('21.000') + ); + // The runs table: two saved runs, run 2 the best. + const table = await screen.findByRole('table', { name: 'Runs' }); + await waitFor(() => expect(within(table).getAllByRole('row')).toHaveLength(3)); + const [, run1, run2] = within(table).getAllByRole('row'); + expect(run1).toHaveTextContent('Run 1'); + expect(run1).toHaveTextContent('24.000'); + expect(run2).toHaveTextContent('Run 2'); + expect(run2).toHaveTextContent('21.000'); + expect(run2).toHaveClass('best'); + // The submission line names the track, the pilot, the time and the run. + const submission = screen.getByLabelText('Submission text') as HTMLTextAreaElement; + expect(submission.value).toContain('RaceGOW6 Track 1 — Recon'); + expect(submission.value).toContain('Best 3 consecutive laps: 21.000 s (7.000 / 7.000 / 7.000)'); + expect(submission.value).toContain('Run 2 of 2, laps 1–3'); + }); + + it('offers the overlay URL for OBS', async () => { + const { session } = setup({}); + render(RaceGowRun, { session, ...props }); + expect(await screen.findByLabelText('Overlay URL')).toHaveTextContent('#/overlay/racegow'); + }); +}); diff --git a/frontend/apps/rd-console/tests/racegow.test.ts b/frontend/apps/rd-console/tests/racegow.test.ts new file mode 100644 index 00000000..18b1b214 --- /dev/null +++ b/frontend/apps/rd-console/tests/racegow.test.ts @@ -0,0 +1,217 @@ +/** + * Unit tests for the RaceGOW helpers (`lib/racegow.ts`): the best-consecutive window the screens + * slide over served laps, the preset's round shape, the create orchestration's command order, and + * the submission line. The browser e2e (`e2e/racegow.spec.ts`) proves the whole loop against a + * real Director; this pins the arithmetic and the composition it relies on. + */ +import { describe, expect, it, vi } from 'vitest'; +import type { EventMeta, LapList, RoundStanding } from '@gridfpv/types'; +import { + bestConsecutive, + createRaceGowRun, + isRaceGowEvent, + overlayUrl, + raceGowRound, + raceGowRoundRequest, + runLaps, + servedBestMicros, + submissionText, + RACEGOW_CLASS_ID, + RACEGOW_PRESET +} from '../src/lib/racegow.js'; +import type { Session } from '../src/lib/session.svelte.js'; + +describe('bestConsecutive', () => { + it('is undefined with fewer laps than the window', () => { + expect(bestConsecutive([], 3)).toBeUndefined(); + expect(bestConsecutive([7_000_000, 7_100_000], 3)).toBeUndefined(); + }); + + it('finds the fastest 3-lap window and where it starts', () => { + // Laps 2–4 (indices 1–3) sum to 20.6 s; every other window is slower. + const laps = [9_000_000, 7_000_000, 6_800_000, 6_800_000, 7_500_000, 7_100_000]; + expect(bestConsecutive(laps)).toEqual({ + start: 1, + laps: [7_000_000, 6_800_000, 6_800_000], + totalMicros: 20_600_000 + }); + }); + + it('gives a tie to the earliest window (the engine breaks ties by the earlier completion)', () => { + const laps = [5_000_000, 5_000_000, 5_000_000, 5_000_000]; + expect(bestConsecutive(laps)?.start).toBe(0); + }); + + it('honours a different window size, and refuses a zero one', () => { + expect(bestConsecutive([3_000_000, 1_000_000, 2_000_000], 1)).toEqual({ + start: 1, + laps: [1_000_000], + totalMicros: 1_000_000 + }); + expect(bestConsecutive([1, 2, 3], 0)).toBeUndefined(); + }); +}); + +describe('runLaps / servedBestMicros', () => { + it('flattens the lap list in competitor order (a solo run has one)', () => { + const list: LapList = { + competitors: [ + { + competitor: { adapter: 'rotorhazard', competitor: 'p1' }, + laps: [ + { number: 1, duration_micros: 7_000_000, at: 1, start_ref: 0, end_ref: 1 }, + { number: 2, duration_micros: 6_900_000, at: 2, start_ref: 1, end_ref: 2 } + ] + } + ] + }; + expect(runLaps(list).map((l) => l.duration_micros)).toEqual([7_000_000, 6_900_000]); + expect(runLaps(undefined)).toEqual([]); + }); + + it('reads the served best-consecutive window off the standings, or nothing', () => { + const rows: RoundStanding[] = [ + { + competitor: 'p1', + position: 1, + best_lap_micros: 6_800_000, + laps: 6, + metric: { BestConsecutive: { n: 3, micros: 20_600_000 } } + } + ]; + expect(servedBestMicros(rows)).toBe(20_600_000); + expect( + servedBestMicros([{ ...rows[0], metric: { BestConsecutive: { n: 3, micros: null } } }]) + ).toBeUndefined(); + expect(servedBestMicros([{ ...rows[0], metric: { BestLap: { micros: 1 } } }])).toBeUndefined(); + expect(servedBestMicros([])).toBeUndefined(); + }); +}); + +describe('the preset shape', () => { + it('marks and finds a RaceGOW event by its preset', () => { + const plain = { id: 'e1', name: 'Club night', preset: undefined } as unknown as EventMeta; + const run = { id: 'e2', name: 'Track 1', preset: RACEGOW_PRESET } as unknown as EventMeta; + expect(isRaceGowEvent(plain)).toBe(false); + expect(isRaceGowEvent(run)).toBe(true); + expect(isRaceGowEvent(undefined)).toBe(false); + }); + + it('scores best 3 consecutive laps, open-ended, on the Whoop class, under a 10-minute ceiling', () => { + const req = raceGowRoundRequest(); + expect(req.format).toBe('timed_qual'); + expect(req.params).toEqual({ rounds: '0' }); + expect(req.win_condition).toEqual({ BestConsecutive: { n: 3 } }); + expect(req.classes).toEqual([RACEGOW_CLASS_ID]); + expect(req.seeding).toBe('FromRoster'); + expect(req.channel_mode).toBe('Static'); + // Ranking-only conditions never end a heat, so the Director requires a race time; this one is + // a ceiling far past a whoop pack, not a race window. + expect(req.time_limit_secs).toBe(600); + // A fixed one-second hold: no field of pilots is waiting on a random tone. + expect(req.start_procedure).toEqual({ + mode: 'randomized-delay', + min_delay_ms: 1000, + max_delay_ms: 1000 + }); + }); + + it('finds the scoring round by format, not by label', () => { + const meta = { + rounds: [ + { id: 'r0', label: 'Warm-up', format: 'open_practice' }, + { id: 'r1', label: 'Renamed by hand', format: 'timed_qual' } + ] + } as unknown as EventMeta; + expect(raceGowRound(meta)?.id).toBe('r1'); + expect(raceGowRound(undefined)).toBeUndefined(); + }); +}); + +describe('createRaceGowRun', () => { + function fakeSession() { + const calls: string[] = []; + const meta = { id: 'e1', name: 'RaceGOW6 Track 1', preset: RACEGOW_PRESET } as EventMeta; + const session = { + currentEvent: meta, + createEventAndEnter: vi.fn(async (name: string, fields?: unknown) => { + calls.push(`create:${name}:${JSON.stringify(fields)}`); + return meta; + }), + setEventTimers: vi.fn(async (ids: string[]) => (calls.push(`timers:${ids}`), meta)), + setPrimaryTimer: vi.fn(async (id: string) => (calls.push(`primary:${id}`), meta)), + setEventClasses: vi.fn(async (ids: string[]) => (calls.push(`classes:${ids}`), meta)), + setEventRoster: vi.fn(async (ids: string[]) => (calls.push(`roster:${ids}`), meta)), + setClassMembership: vi.fn(async (cls: string, slots: unknown[]) => { + calls.push(`members:${cls}:${JSON.stringify(slots)}`); + return meta; + }), + createRound: vi.fn(async (req: { format: string }) => { + calls.push(`round:${req.format}`); + return { id: 'r1' }; + }) + }; + return { calls, session: session as unknown as Session, meta }; + } + + it('creates, then configures timer → primary → class → roster → channel slot → round', async () => { + const { calls, session, meta } = fakeSession(); + const result = await createRaceGowRun(session, { + track: ' RaceGOW6 Track 1 ', + pilot: 'p1', + timer: 'rh-1', + channel: 5880 + }); + expect(result).toBe(meta); + expect(calls).toEqual([ + 'create:RaceGOW6 Track 1:{"preset":"racegow"}', + 'timers:rh-1', + 'primary:rh-1', + `classes:${RACEGOW_CLASS_ID}`, + 'roster:p1', + `members:${RACEGOW_CLASS_ID}:[{"pilot":"p1","channel":5880}]`, + 'round:timed_qual' + ]); + }); + + it('seats the pilot without a channel when the timer offers none', async () => { + const { calls, session } = fakeSession(); + await createRaceGowRun(session, { track: 'T', pilot: 'p1', timer: 'mock' }); + expect(calls).toContain(`members:${RACEGOW_CLASS_ID}:[{"pilot":"p1"}]`); + }); + + it('stops at a cancelled token prompt and reports it as undefined', async () => { + const { calls, session } = fakeSession(); + (session.setEventRoster as unknown as ReturnType).mockResolvedValueOnce( + undefined + ); + const result = await createRaceGowRun(session, { track: 'T', pilot: 'p1', timer: 'rh-1' }); + expect(result).toBeUndefined(); + expect(calls.some((c) => c.startsWith('members:'))).toBe(false); + expect(calls.some((c) => c.startsWith('round:'))).toBe(false); + }); +}); + +describe('submissionText / overlayUrl', () => { + it('writes the time, the three laps, which run and when, and what timed it', () => { + const text = submissionText({ + track: 'RaceGOW6 Track 1', + callsign: 'Recon', + best: { start: 2, laps: [7_012_000, 7_201_000, 7_224_000], totalMicros: 21_437_000 }, + run: 3, + runs: 5, + flownAt: new Date(2026, 8, 26, 14, 3), + timedBy: 'Garage RH (RotorHazard)' + }); + expect(text).toContain('RaceGOW6 Track 1 — Recon'); + expect(text).toContain('Best 3 consecutive laps: 21.437 s (7.012 / 7.201 / 7.224)'); + expect(text).toContain('Run 3 of 5, laps 3–5'); + expect(text).toContain('Timed by GridFPV on Garage RH (RotorHazard)'); + }); + + it('points the overlay at the console origin + path, on the overlay hash', () => { + expect(overlayUrl({ origin: 'http://192.168.1.20:8080', pathname: '/' })).toBe( + 'http://192.168.1.20:8080/#/overlay/racegow' + ); + }); +}); diff --git a/frontend/apps/rd-console/tests/route.test.ts b/frontend/apps/rd-console/tests/route.test.ts index a8b11da5..91aa7748 100644 --- a/frontend/apps/rd-console/tests/route.test.ts +++ b/frontend/apps/rd-console/tests/route.test.ts @@ -333,3 +333,42 @@ describe('resolveInitialRoute (hash is authoritative; #118)', () => { } }); }); + +describe('RaceGOW + overlay routes', () => { + it('parses the RaceGOW page, its run screen, and the overlay', () => { + expect(parseHash('#/racegow')).toEqual({ kind: 'racegow' }); + expect(parseHash('#/racegow/run')).toEqual({ kind: 'racegow-run' }); + expect(parseHash('#/RaceGOW/Run')).toEqual({ kind: 'racegow-run' }); + expect(parseHash('#/overlay/racegow')).toEqual({ kind: 'overlay', overlay: 'racegow' }); + }); + + it('degrades a malformed RaceGOW sub-route to the page, and an unknown overlay to the hub', () => { + expect(parseHash('#/racegow/whatever')).toEqual({ kind: 'racegow' }); + expect(parseHash('#/overlay')).toEqual(DEFAULT_ROUTE); + expect(parseHash('#/overlay/leaderboard')).toEqual(DEFAULT_ROUTE); + }); + + it('round-trips each through formatHash', () => { + const routes: Route[] = [ + { kind: 'racegow' }, + { kind: 'racegow-run' }, + { kind: 'overlay', overlay: 'racegow' } + ]; + for (const r of routes) expect(parseHash(formatHash(r))).toEqual(r); + expect(formatHash({ kind: 'racegow' })).toBe('#/racegow'); + expect(formatHash({ kind: 'racegow-run' })).toBe('#/racegow/run'); + expect(formatHash({ kind: 'overlay', overlay: 'racegow' })).toBe('#/overlay/racegow'); + }); + + it('reconciles the run screen to the RaceGOW page with no active event, and keeps the rest', () => { + expect(reconcileRoute({ kind: 'racegow-run' }, false)).toEqual({ kind: 'racegow' }); + expect(reconcileRoute({ kind: 'racegow-run' }, true)).toEqual({ kind: 'racegow-run' }); + expect(reconcileRoute({ kind: 'racegow' }, false)).toEqual({ kind: 'racegow' }); + // An overlay sits in an OBS scene whether or not a run is on: never bounced. + expect(reconcileRoute({ kind: 'overlay', overlay: 'racegow' }, false)).toEqual({ + kind: 'overlay', + overlay: 'racegow' + }); + expect(resolveInitialRoute('#/racegow/run', false)).toEqual({ kind: 'racegow' }); + }); +}); diff --git a/frontend/e2e/racegow.spec.ts b/frontend/e2e/racegow.spec.ts new file mode 100644 index 00000000..929c7153 --- /dev/null +++ b/frontend/e2e/racegow.spec.ts @@ -0,0 +1,151 @@ +/** + * RaceGOW — the solo track-run loop, end to end, in real headless chromium against a real + * Director: hub → RaceGOW page → set up a run on the Simulator → Start run → laps climb → Stop + * run → the best 3-lap window and the submission line → Fly again → two runs in the table. Then + * the OBS overlay page renders the same run, read-only, on a transparent document. + * + * This spec boots its **own** Director (a fresh registry: no pilots, no events — the first-run + * state a pilot installing GridFPV for RaceGOW is in) with the built-in Simulator emitting six + * laps a run, and drives real clicks. Nothing is mocked. + */ +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { expect, test } from '@playwright/test'; + +import { type Director, startDirector } from '../test-harness/director.js'; + +const here = fileURLToPath(new URL('.', import.meta.url)); +const dist = resolve(here, '..', 'apps', 'rd-console', 'dist'); + +const TRACK = 'RaceGOW6 Track 1'; +const PILOT = 'Recon'; +/** Six sim laps per run: enough for a 3-lap window with room either side. */ +const SIM_LAPS = 6; + +let director: Director; + +test.beforeAll(async () => { + director = await startDirector({ token: false, assets: dist, simLaps: SIM_LAPS, simLapMs: 250 }); +}); + +test.afterAll(async () => { + await director?.stop(); +}); + +test('a pilot sets up a track run, flies two runs, and gets a submission line', async ({ + page +}) => { + await page.goto(director.baseUrl); + + // ── Hub → RaceGOW ──────────────────────────────────────────────────────────────────────── + await expect(page.getByRole('heading', { name: 'RaceGOW' })).toBeVisible({ timeout: 15_000 }); + await page.getByRole('heading', { name: 'RaceGOW' }).click(); + await expect(page).toHaveURL(/#\/racegow$/); + const form = page.getByRole('form', { name: 'New track run' }); + await expect(form).toBeVisible(); + + // ── The form: a fresh Director has no pilots, so the callsign field is up; the built-in + // Simulator is the only timer, and it carries Raceband, so a channel is offered. ───────── + await expect(form.getByLabel('Track')).toHaveValue(TRACK); + await form.getByLabel('Callsign').fill(PILOT); + await expect(form.getByLabel('Timer')).toHaveValue('mock'); + await expect(form.getByLabel('Your channel')).toBeVisible(); + await form.getByRole('button', { name: 'Set up run' }).click(); + + // ── The run screen, on the event the preset built ─────────────────────────────────────── + await expect(page).toHaveURL(/#\/racegow\/run$/, { timeout: 15_000 }); + await expect(page.getByRole('heading', { name: TRACK })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText(`${PILOT} on Mock (Simulator)`)).toBeVisible(); + + // The event really exists server-side, shaped by the preset: one Whoop-class round, scored by + // best 3 consecutive laps, open-ended, with the pilot on the roster. + const events = (await (await fetch(`${director.baseUrl}/events`)).json()) as { + id: string; + name: string; + preset?: string; + roster: string[]; + classes: string[]; + rounds?: { format: string; params: Record; win_condition: unknown }[]; + }[]; + expect(events).toHaveLength(1); + const event = events[0]; + expect(event.name).toBe(TRACK); + expect(event.preset).toBe('racegow'); + expect(event.roster).toHaveLength(1); + expect(event.classes).toEqual(['mgp-whoop']); + expect(event.rounds).toHaveLength(1); + expect(event.rounds![0].format).toBe('timed_qual'); + expect(event.rounds![0].params).toEqual({ rounds: '0' }); + expect(event.rounds![0].win_condition).toEqual({ BestConsecutive: { n: 3 } }); + + // ── Run 1: Start run → the sim flies its laps → Stop run ───────────────────────────────── + const run = page.getByRole('region', { name: 'Current run' }); + await run.getByRole('button', { name: 'Start run' }).click(); + // The Simulator emits laps once the heat is Running (after the fixed 1 s hold). + const laps = run.getByRole('list', { name: "This run's laps" }); + await expect(laps.getByRole('listitem').filter({ hasText: 'Lap' })).toHaveCount(SIM_LAPS, { + timeout: 20_000 + }); + // With ≥ 3 laps the live best-3 readout has a time. + await expect(run.getByLabel("This run's best 3 consecutive laps")).not.toHaveText('—'); + await run.getByRole('button', { name: 'Stop run' }).click(); + + // Stopped + saved: the headline is the served round-wide best, the table lists run 1 as saved, + // and the submission line is ready with the track and the pilot. + await expect(run.getByRole('button', { name: 'Fly again' })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByLabel('Best 3 consecutive laps, all runs')).not.toHaveText('—', { + timeout: 15_000 + }); + const table = page.getByRole('table', { name: 'Runs' }); + await expect(table.getByRole('row').filter({ hasText: 'Run 1' })).toContainText('saved'); + const submission = page.getByLabel('Submission text'); + await expect(submission).toHaveValue(new RegExp(`${TRACK} — ${PILOT}`)); + await expect(submission).toHaveValue(/Best 3 consecutive laps: \d+\.\d{3} s/); + await expect(submission).toHaveValue(/Run 1 of 1/); + + // ── Run 2: Fly again draws the next heat and runs it ───────────────────────────────────── + await run.getByRole('button', { name: 'Fly again' }).click(); + await expect(laps.getByRole('listitem').filter({ hasText: 'Lap' })).toHaveCount(SIM_LAPS, { + timeout: 20_000 + }); + await run.getByRole('button', { name: 'Stop run' }).click(); + await expect(run.getByRole('button', { name: 'Fly again' })).toBeVisible({ timeout: 15_000 }); + await expect(table.getByRole('row').filter({ hasText: 'Run 2' })).toContainText('saved'); + await expect(submission).toHaveValue(/of 2/); + + // The server agrees: two Final heats on the round, and the standings carry a best-3 window. + const heats = (await (await fetch(`${director.baseUrl}/events/${event.id}/heats`)).json()) as { + phase: string; + }[]; + expect(heats.map((h) => h.phase)).toEqual(['Final', 'Final']); + const roundId = ( + (await (await fetch(`${director.baseUrl}/events`)).json()) as { rounds: { id: string }[] }[] + )[0].rounds[0].id; + const standings = (await ( + await fetch(`${director.baseUrl}/events/${event.id}/rounds/${roundId}/standings`) + ).json()) as { metric: { BestConsecutive?: { n: number; micros: number | null } } }[]; + expect(standings).toHaveLength(1); + expect(standings[0].metric.BestConsecutive?.n).toBe(3); + expect(standings[0].metric.BestConsecutive?.micros).not.toBeNull(); + + // ── The overlay: the OBS browser source follows the active run, read-only ─────────────── + await page.goto(`${director.baseUrl}/#/overlay/racegow`); + await expect(page.getByLabel('RaceGOW overlay')).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText(`${TRACK} · ${PILOT}`)).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('timer', { name: /Run time/ })).toBeVisible(); + await expect(page.getByLabel('Best 3 consecutive laps, all runs')).not.toHaveText('—', { + timeout: 15_000 + }); + // The document is flagged transparent for the keyer. + await expect(page.locator('html')).toHaveAttribute('data-overlay', 'racegow'); + await expect(page.locator('body')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); + + // ── Back on the RaceGOW page, the run is listed and reopens ───────────────────────────── + await page.goto(`${director.baseUrl}/#/racegow`); + const runs = page.getByRole('list', { name: 'Your runs' }); + await expect(runs.getByRole('listitem')).toHaveCount(1, { timeout: 15_000 }); + await runs.getByRole('button', { name: `Open ${TRACK}` }).click(); + await expect(page).toHaveURL(/#\/racegow\/run$/, { timeout: 15_000 }); + await expect(page.getByRole('heading', { name: TRACK })).toBeVisible(); +}); From 14919feac4f7391f60c9155bcde134f36b4e9713 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 26 Sep 2026 19:24:07 +0000 Subject: [PATCH 2/5] =?UTF-8?q?chore(deps):=20rustls=200.23.45=20=E2=80=94?= =?UTF-8?q?=20RUSTSEC-2026-0285=20(TLS=201.3=20handshake=20across=20encryp?= =?UTF-8?q?tion=20levels)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new advisory (published 2026-09-14, medium) against rustls 0.23.40, which turned the cargo-audit gate red on the first push after it. Bumped in both lockfiles (root + src-tauri) so the tauri-lock gate stays in step. No manifest change; rustls is a transitive dependency. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 16 ++++++++-------- src-tauri/Cargo.lock | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 257db550..0a9e5a43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -268,7 +268,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1305,14 +1305,14 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "once_cell", "rustls-pki-types", @@ -1332,9 +1332,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -1606,7 +1606,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2102,7 +2102,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2846031e..1e208098 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3107,9 +3107,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "once_cell", "rustls-pki-types", From 7555eeccf548201282b8b9d0382bfc5777e282b6 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sun, 4 Oct 2026 00:31:30 +0000 Subject: [PATCH 3/5] =?UTF-8?q?feat(console):=20RaceGOW=20overlay=20in=20e?= =?UTF-8?q?ither=20contrast=20=E2=80=94=20light=20text=20on=20dark,=20or?= =?UTF-8?q?=20dark=20text=20on=20light?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OBS overlay (`#/overlay/racegow`) now takes a theme: the default dark glass panel for goggle DVR, or `#/overlay/racegow/light` — dark text on a light panel — for a bright room camera. The run screen's "Timer in your video" card grows a two-way picker above the URL, so the copied URL carries the choice. The light variant is the component library's `[data-theme='light']` token set scoped to the overlay, plus the page's own glass values flipped. A mistyped theme segment still renders the dark overlay rather than bouncing the scene to the hub. Co-Authored-By: Claude Fable 5.1 --- frontend/apps/rd-console/src/App.svelte | 2 +- frontend/apps/rd-console/src/lib/racegow.ts | 14 +++-- frontend/apps/rd-console/src/lib/route.ts | 26 +++++++-- .../src/screens/RaceGowOverlay.svelte | 35 +++++++++++- .../rd-console/src/screens/RaceGowRun.svelte | 53 ++++++++++++++++++- .../apps/rd-console/tests/RaceGowRun.test.ts | 9 +++- .../apps/rd-console/tests/racegow.test.ts | 6 +++ frontend/apps/rd-console/tests/route.test.ts | 25 ++++++++- frontend/e2e/racegow.spec.ts | 6 +++ 9 files changed, 161 insertions(+), 15 deletions(-) diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte index 783556f1..e31c9e55 100644 --- a/frontend/apps/rd-console/src/App.svelte +++ b/frontend/apps/rd-console/src/App.svelte @@ -359,7 +359,7 @@
- +
{:else if route.kind === 'racegow'}
diff --git a/frontend/apps/rd-console/src/lib/racegow.ts b/frontend/apps/rd-console/src/lib/racegow.ts index 6853579f..b857f9b1 100644 --- a/frontend/apps/rd-console/src/lib/racegow.ts +++ b/frontend/apps/rd-console/src/lib/racegow.ts @@ -35,6 +35,7 @@ import type { TimerId } from '@gridfpv/types'; import type { Session } from './session.svelte.js'; +import { formatHash, type OverlayTheme } from './route.js'; /** The `EventMeta.preset` marker a RaceGOW run carries. */ export const RACEGOW_PRESET = 'racegow'; @@ -193,9 +194,16 @@ export function servedBestMicros(standings: readonly RoundStanding[]): number | return undefined; } -/** The OBS browser-source URL for the RaceGOW overlay, from the console's own location. */ -export function overlayUrl(loc: { origin: string; pathname: string }): string { - return `${loc.origin}${loc.pathname}#/overlay/racegow`; +/** + * The OBS browser-source URL for the RaceGOW overlay, from the console's own location. `theme` + * picks the panel's contrast for the footage underneath — light text on a dark panel (default) + * or dark text on a light one (`#/overlay/racegow/light`, see `route.ts`). + */ +export function overlayUrl( + loc: { origin: string; pathname: string }, + theme: OverlayTheme = 'dark' +): string { + return `${loc.origin}${loc.pathname}${formatHash({ kind: 'overlay', overlay: 'racegow', theme })}`; } /** What the submission line needs — everything a RaceGOW form asks beside the clip. */ diff --git a/frontend/apps/rd-console/src/lib/route.ts b/frontend/apps/rd-console/src/lib/route.ts index c8212116..7b54b827 100644 --- a/frontend/apps/rd-console/src/lib/route.ts +++ b/frontend/apps/rd-console/src/lib/route.ts @@ -24,6 +24,7 @@ * - `#/racegow` → the RaceGOW page (your track runs; start a new one) * - `#/racegow/run` → the solo run screen for the active RaceGOW event * - `#/overlay/racegow` → the RaceGOW OBS browser-source overlay (transparent, read-only) + * - `#/overlay/racegow/light` → the same overlay, dark text on a light panel * * **The route IS the tuning scope (#411).** Tuning is layered — a timer has its own baseline * calibration, and an event will eventually carry its own tune over the top — and an RD dragging a @@ -101,12 +102,18 @@ export type Route = * follows the Director's active event (streaming.html §2.1, "follow the program"). Never * reconciled away — with nothing on the timer it renders idle, which is what a browser source * sitting in a scene should do. + * + * `theme` picks the panel's contrast for the footage it sits over: light text on a dark panel + * (the default, `#/overlay/`) or dark text on a light one (`#/overlay//light`). + * Absent means dark; it is only ever present as `'light'`, so a route compares structurally. */ - | { kind: 'overlay'; overlay: OverlayName }; + | { kind: 'overlay'; overlay: OverlayName; theme?: OverlayTheme }; /** The overlay pages the console serves (streaming.html §2). */ export type OverlayName = 'racegow'; const OVERLAYS: readonly OverlayName[] = ['racegow']; +/** An overlay's contrast: light text over a dark panel, or dark text over a light one. */ +export type OverlayTheme = 'dark' | 'light'; export const WORKSPACE_TABS: readonly WorkspaceTab[] = [ 'classes-roster', @@ -197,12 +204,17 @@ export function parseHash(hash: string): Route { return segments[1]?.toLowerCase() === 'run' ? { kind: 'racegow-run' } : { kind: 'racegow' }; } - // `#/overlay/` — an OBS browser source. An unknown overlay name is the hub, not a blank - // page: there is nothing overlay-shaped to land on. + // `#/overlay/[/light]` — an OBS browser source. An unknown overlay name is the hub, not a + // blank page: there is nothing overlay-shaped to land on. The optional third segment picks the + // light panel; anything else (including an explicit `dark`) is the default dark panel, so a + // mistyped theme still renders an overlay rather than bouncing the scene to the hub. if (head === 'overlay') { const name = segments[1]?.toLowerCase(); if (name && (OVERLAYS as readonly string[]).includes(name)) { - return { kind: 'overlay', overlay: name as OverlayName }; + const overlay = name as OverlayName; + return segments[2]?.toLowerCase() === 'light' + ? { kind: 'overlay', overlay, theme: 'light' } + : { kind: 'overlay', overlay }; } return DEFAULT_ROUTE; } @@ -223,7 +235,11 @@ export function formatHash(route: Route): string { if (route.kind === 'workspace') return `#/event/${route.tab}`; if (route.kind === 'racegow') return '#/racegow'; if (route.kind === 'racegow-run') return '#/racegow/run'; - if (route.kind === 'overlay') return `#/overlay/${route.overlay}`; + if (route.kind === 'overlay') { + return route.theme === 'light' + ? `#/overlay/${route.overlay}/light` + : `#/overlay/${route.overlay}`; + } if (route.kind === 'tune') { const timer = encodeURIComponent(route.timer); // The scope is the route (#411): an event-scoped tune nests under its event, so the trail back diff --git a/frontend/apps/rd-console/src/screens/RaceGowOverlay.svelte b/frontend/apps/rd-console/src/screens/RaceGowOverlay.svelte index 6fcbe0e6..92521e4a 100644 --- a/frontend/apps/rd-console/src/screens/RaceGowOverlay.svelte +++ b/frontend/apps/rd-console/src/screens/RaceGowOverlay.svelte @@ -11,6 +11,11 @@ * * It renders idle — the brand and "waiting for a run" — with no RaceGOW event active, which is * what a browser source sitting in a scene should do rather than an error. + * + * `theme` is the panel's contrast for the footage it sits over: light text on a dark glass + * panel (the default — reads over most DVR), or dark text on a light one for a bright room + * camera. The light variant is the component library's `data-theme="light"` token set scoped to + * this page, plus this page's own glass values flipped. */ import { RaceClock, formatMicros } from '@gridfpv/components'; import type { HeatId, Lap, LiveRaceState, Pilot, RoundStanding } from '@gridfpv/types'; @@ -26,7 +31,9 @@ type ConsecutiveWindow } from '../lib/racegow.js'; - let { session }: { session: Session } = $props(); + import type { OverlayTheme } from '../lib/route.js'; + + let { session, theme = 'dark' }: { session: Session; theme?: OverlayTheme } = $props(); // The page keys on transparency: flag the document while mounted (app.css lifts the canvas). $effect(() => { @@ -101,7 +108,12 @@ } -
+
GridFPV @@ -266,4 +278,23 @@ color: #fff; font-weight: 700; } + + /* Dark text on a light panel (`#/overlay/racegow/light`). The semantic tokens flip through the + * library's `[data-theme='light']` set; only this page's own glass values need restating. */ + .overlay[data-theme='light'] .panel { + background: rgba(255, 255, 255, 0.82); + border-color: rgba(0, 0, 0, 0.14); + text-shadow: none; + } + .overlay[data-theme='light'] .clock-row :global(.gridfpv-race-clock) { + color: var(--gf-text); + } + .overlay[data-theme='light'] .fpv, + .overlay[data-theme='light'][data-flying='true'] .phase { + color: var(--gf-brand-600); + } + .overlay[data-theme='light'] .lap.in-window { + background: rgba(0, 0, 0, 0.08); + color: var(--gf-text); + } diff --git a/frontend/apps/rd-console/src/screens/RaceGowRun.svelte b/frontend/apps/rd-console/src/screens/RaceGowRun.svelte index d9cc3b03..a8bec3ab 100644 --- a/frontend/apps/rd-console/src/screens/RaceGowRun.svelte +++ b/frontend/apps/rd-console/src/screens/RaceGowRun.svelte @@ -41,6 +41,7 @@ RACEGOW_LAPS, type ConsecutiveWindow } from '../lib/racegow.js'; + import { formatHash, type OverlayTheme } from '../lib/route.js'; let { session, @@ -287,8 +288,13 @@ }); }); + // The overlay's contrast, chosen here so the copied URL carries it: light text on a dark panel + // for goggle DVR, dark text on a light panel for a bright room camera (`route.ts`). + let overlayTheme = $state('dark'); const overlay = $derived( - typeof location !== 'undefined' ? overlayUrl(location) : '#/overlay/racegow' + typeof location !== 'undefined' + ? overlayUrl(location, overlayTheme) + : formatHash({ kind: 'overlay', overlay: 'racegow', theme: overlayTheme }) ); async function copy(text: string, what: string): Promise { @@ -495,6 +501,26 @@ over your goggles’ DVR or a camera on the track, and record. It shows the clock, the laps and the best 3 so far, on a transparent background.

+
+ + +
{overlay}
{:else if route.kind === 'racegow'}
- goPage('timers')} /> + goPage('timers')} + />
{:else if route.kind === 'racegow-run'} + RaceGOW + {/if} {#if ev.id === activeEventId} Active {/if} @@ -304,65 +334,109 @@ (newError = undefined)}>
- - - - -
- Add details (optional) -
- - - - - - - - - - - - -
-
+ +
+ What are you setting up? + + +
+ + {#if newKind === 'racegow'} +

+ The RaceGOW page asks only which track, who is flying, and on what timer and channel — it + builds the event for you and opens the run screen. +

+ {:else} + + + + +
+ Add details (optional) +
+ + + + + + + + + + + + +
+
- - + + {/if}
{#snippet footer()} - + {#if newKind === 'racegow'} + + {:else} + + {/if} {/snippet}
@@ -602,8 +676,10 @@ color: var(--gf-text-faint); font-size: var(--gf-font-size-xs); } - /* The "Active" pill (#91): sits between the name and the arrow, never shrinking. */ - .active-pill { + /* The "Active" pill (#91) and the RaceGOW kind pill: between the name and the arrow, never + shrinking. */ + .active-pill, + .kind-pill { flex-shrink: 0; } .event-main { @@ -698,6 +774,72 @@ flex-direction: column; gap: var(--gf-space-3); } + /* The kind chooser: two radio cards side by side (stacked when narrow), the chosen one with + the accent border. The native radio stays visible — it is the control, not decoration. */ + .kind { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); + gap: var(--gf-space-3); + margin: 0; + padding: 0; + border: none; + min-width: 0; + } + .kind-legend { + grid-column: 1 / -1; + padding: 0; + margin-bottom: var(--gf-space-1); + font-size: var(--gf-font-size-sm); + font-weight: var(--gf-font-weight-medium); + color: var(--gf-text-secondary); + } + .kind-opt { + display: flex; + align-items: flex-start; + gap: var(--gf-space-3); + padding: var(--gf-space-3); + border: 1px solid var(--gf-border); + border-radius: var(--gf-radius-md); + background: var(--gf-surface-alt); + cursor: pointer; + transition: + border-color var(--gf-motion-fast) var(--gf-ease-out), + background var(--gf-motion-fast) var(--gf-ease-out); + } + .kind-opt:hover { + border-color: var(--gf-accent); + } + .kind-opt.selected { + border-color: var(--gf-accent); + background: var(--gf-accent-soft); + } + .kind-opt input { + width: 1.1rem; + height: 1.1rem; + margin-top: 0.15rem; + accent-color: var(--gf-accent); + flex-shrink: 0; + cursor: pointer; + } + .kind-text { + display: flex; + flex-direction: column; + gap: 2px; + } + .kind-title { + font-size: var(--gf-font-size-sm); + font-weight: var(--gf-font-weight-semibold); + color: var(--gf-text); + } + .kind-sub { + font-size: var(--gf-font-size-xs); + color: var(--gf-text-muted); + } + .kind-hint { + margin: 0; + font-size: var(--gf-font-size-sm); + color: var(--gf-text-muted); + } .details { border-top: 1px solid var(--gf-border-subtle); padding-top: var(--gf-space-3); diff --git a/frontend/apps/rd-console/src/screens/HomeHub.svelte b/frontend/apps/rd-console/src/screens/HomeHub.svelte index 3c2b7fe2..6ecb9b95 100644 --- a/frontend/apps/rd-console/src/screens/HomeHub.svelte +++ b/frontend/apps/rd-console/src/screens/HomeHub.svelte @@ -10,6 +10,10 @@ * count (`GET /events`), and timer count + how many are connected (`GET /timers`). A failed read * just shows a dash — the hub never blocks on a summary. * + * There is deliberately no RaceGOW card here: a RaceGOW track run is an ordinary event, so it + * starts from the Events page ("+ New event" → RaceGOW track run) and its runs sit in the Events + * list with a badge, rather than the hub growing a card per preset. + * * Big text + dark surfaces by design: this hub is glanced at on a laptop outdoors in the sun, so * the cards are large, high-contrast tap targets ("readable in sunlight?" is the gate). */ @@ -17,23 +21,19 @@ import type { Timer } from '@gridfpv/types'; import type { Session } from '../lib/session.svelte.js'; import { isTimerConnected } from '../lib/timers.js'; - import { isRaceGowEvent } from '../lib/racegow.js'; let { session, onpilots, onclasses, onevents, - ontimers, - onracegow + ontimers }: { session: Session; onpilots: () => void; onclasses: () => void; onevents: () => void; ontimers: () => void; - /** The RaceGOW page — the solo track-run preset (`lib/racegow.ts`). */ - onracegow: () => void; } = $props(); // Each summary loads independently and best-effort; `undefined` while loading, `null` on a @@ -41,7 +41,6 @@ let pilotCount = $state(undefined); let classCount = $state(undefined); let eventCount = $state(undefined); - let raceGowCount = $state(undefined); let timerCount = $state(undefined); let connectedTimers = $state(undefined); @@ -63,14 +62,8 @@ .catch(() => (classCount = null)); void session .listEvents() - .then((events) => { - eventCount = events.length; - raceGowCount = events.filter(isRaceGowEvent).length; - }) - .catch(() => { - eventCount = null; - raceGowCount = null; - }); + .then((events) => (eventCount = events.length)) + .catch(() => (eventCount = null)); void session .listTimers() .then((timers) => { @@ -138,34 +131,6 @@
- -
- -
-
+ + {/each} +
+ + diff --git a/frontend/apps/rd-console/src/screens/EventPicker.svelte b/frontend/apps/rd-console/src/screens/EventPicker.svelte index c645aa33..01f634d2 100644 --- a/frontend/apps/rd-console/src/screens/EventPicker.svelte +++ b/frontend/apps/rd-console/src/screens/EventPicker.svelte @@ -5,12 +5,8 @@ * In the two-level IA the home hub is the landing and this is the **Events page** (Home › * Events) — the select/create surface. On load it reads the open event list (`listEvents()`, * no token) and renders the events the RD has created. Selecting one enters its workspace. - * "+ New event" opens a `Dialog` that first asks **what kind** of event: a **club race** (a name - * → `createEvent(name)`, which obtains the RD token lazily → enters it, then hands off to the - * guided setup wizard, #97), or a **RaceGOW track run** (the solo preset, `lib/racegow.ts`), - * which hands off to the RaceGOW page's own short form. A RaceGOW run is an ordinary event, so - * it lists here like any other — with a badge — and entering one opens the solo run screen - * (the shell routes on the preset marker) rather than the race director's workspace. + * "+ New event" opens a name `Dialog` → `createEvent(name)` (which obtains the RD token + * lazily) → enters it, then hands off to the guided setup wizard (#97). * * **The list can be empty, and that is a first run, not an error** (#414): the built-in * in-memory Practice event is gone, so a brand-new Director genuinely has zero events. The @@ -32,18 +28,14 @@ import type { Session, CreateEventFields } from '../lib/session.svelte.js'; import Brand from '../Brand.svelte'; import Breadcrumbs from '../Breadcrumbs.svelte'; - import { isRaceGowEvent } from '../lib/racegow.js'; let { session, onhome, - onracegow, onsetup = undefined }: { session: Session; onhome: () => void; - /** The RaceGOW page — where a track run (the solo preset) is set up and started. */ - onracegow: () => void; /** Called after a brand-new event is created + entered, when the RD opted to set it up (the * guided wizard, race redesign Slice 7). The shell opens the wizard once the workspace mounts. */ onsetup?: () => void; @@ -116,13 +108,6 @@ // The "new event" dialog. Name-only is the one-click default (#72, Slice 1b C); the // optional descriptive fields live behind a collapsible "Add details" section. let newOpen = $state(false); - /** - * What the dialog is making: a club **race** (the default — the name form and the wizard), or - * a RaceGOW **track run**, which the RaceGOW page's own form sets up. The choice sits at the - * top of the one dialog rather than behind a second button, so the club path stays one click. - */ - type NewKind = 'race' | 'racegow'; - let newKind = $state('race'); let newName = $state(''); let newDate = $state(''); let newLocation = $state(''); @@ -194,7 +179,6 @@ } function openNew() { - newKind = 'race'; newName = ''; newDate = ''; newLocation = ''; @@ -216,18 +200,8 @@ return Object.keys(fields).length > 0 ? fields : undefined; } - /** The RaceGOW choice: close the dialog and hand off to the RaceGOW page's form. */ - function startRaceGow() { - newOpen = false; - onracegow(); - } - async function submitNew(e: Event) { e.preventDefault(); - if (newKind === 'racegow') { - startRaceGow(); - return; - } const name = newName.trim(); if (!name || creating) return; creating = true; @@ -305,10 +279,6 @@ {ev.name} {rowSubtitle(ev)} - {#if isRaceGowEvent(ev)} - - RaceGOW - {/if} {#if ev.id === activeEventId} Active {/if} @@ -334,109 +304,65 @@ (newError = undefined)}>
- -
- What are you setting up? - - -
- - {#if newKind === 'racegow'} -

- The RaceGOW page asks only which track, who is flying, and on what timer and channel — it - builds the event for you and opens the run screen. -

- {:else} - - - - -
- Add details (optional) -
- - - - - - - - - - - - -
-
+ + + + +
+ Add details (optional) +
+ + + + + + + + + + + + +
+
- - - {/if} +
{#snippet footer()} - {#if newKind === 'racegow'} - - {:else} - - {/if} + {/snippet}
@@ -676,10 +602,8 @@ color: var(--gf-text-faint); font-size: var(--gf-font-size-xs); } - /* The "Active" pill (#91) and the RaceGOW kind pill: between the name and the arrow, never - shrinking. */ - .active-pill, - .kind-pill { + /* The "Active" pill (#91): sits between the name and the arrow, never shrinking. */ + .active-pill { flex-shrink: 0; } .event-main { @@ -774,72 +698,6 @@ flex-direction: column; gap: var(--gf-space-3); } - /* The kind chooser: two radio cards side by side (stacked when narrow), the chosen one with - the accent border. The native radio stays visible — it is the control, not decoration. */ - .kind { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); - gap: var(--gf-space-3); - margin: 0; - padding: 0; - border: none; - min-width: 0; - } - .kind-legend { - grid-column: 1 / -1; - padding: 0; - margin-bottom: var(--gf-space-1); - font-size: var(--gf-font-size-sm); - font-weight: var(--gf-font-weight-medium); - color: var(--gf-text-secondary); - } - .kind-opt { - display: flex; - align-items: flex-start; - gap: var(--gf-space-3); - padding: var(--gf-space-3); - border: 1px solid var(--gf-border); - border-radius: var(--gf-radius-md); - background: var(--gf-surface-alt); - cursor: pointer; - transition: - border-color var(--gf-motion-fast) var(--gf-ease-out), - background var(--gf-motion-fast) var(--gf-ease-out); - } - .kind-opt:hover { - border-color: var(--gf-accent); - } - .kind-opt.selected { - border-color: var(--gf-accent); - background: var(--gf-accent-soft); - } - .kind-opt input { - width: 1.1rem; - height: 1.1rem; - margin-top: 0.15rem; - accent-color: var(--gf-accent); - flex-shrink: 0; - cursor: pointer; - } - .kind-text { - display: flex; - flex-direction: column; - gap: 2px; - } - .kind-title { - font-size: var(--gf-font-size-sm); - font-weight: var(--gf-font-weight-semibold); - color: var(--gf-text); - } - .kind-sub { - font-size: var(--gf-font-size-xs); - color: var(--gf-text-muted); - } - .kind-hint { - margin: 0; - font-size: var(--gf-font-size-sm); - color: var(--gf-text-muted); - } .details { border-top: 1px solid var(--gf-border-subtle); padding-top: var(--gf-space-3); diff --git a/frontend/apps/rd-console/src/screens/HomeHub.svelte b/frontend/apps/rd-console/src/screens/HomeHub.svelte index 6ecb9b95..a7edbb4b 100644 --- a/frontend/apps/rd-console/src/screens/HomeHub.svelte +++ b/frontend/apps/rd-console/src/screens/HomeHub.svelte @@ -10,10 +10,6 @@ * count (`GET /events`), and timer count + how many are connected (`GET /timers`). A failed read * just shows a dash — the hub never blocks on a summary. * - * There is deliberately no RaceGOW card here: a RaceGOW track run is an ordinary event, so it - * starts from the Events page ("+ New event" → RaceGOW track run) and its runs sit in the Events - * list with a badge, rather than the hub growing a card per preset. - * * Big text + dark surfaces by design: this hub is glanced at on a laptop outdoors in the sun, so * the cards are large, high-contrast tap targets ("readable in sunlight?" is the gate). */ diff --git a/frontend/apps/rd-console/src/screens/RaceGowOverlay.svelte b/frontend/apps/rd-console/src/screens/RaceGowOverlay.svelte deleted file mode 100644 index 92521e4a..00000000 --- a/frontend/apps/rd-console/src/screens/RaceGowOverlay.svelte +++ /dev/null @@ -1,300 +0,0 @@ - - -
-
-
- GridFPV - {#if active && event} - {event.name}{callsign ? ` · ${callsign}` : ''} - {:else} - waiting for a run… - {/if} -
- - {#if active} -
- - - {#if phase === 'Running'} - LIVE - {:else if phase === 'Armed'} - READY - {:else if phase === 'Unofficial' || phase === 'Final'} - STOPPED - {:else} - — - {/if} - -
- -
-
- Best {RACEGOW_LAPS} this run - - {best ? formatMicros(best.totalMicros) : '—'} - -
-
- Best {RACEGOW_LAPS} today - - {sessionBest !== undefined ? formatMicros(sessionBest) : '—'} - -
-
- -
    - {#each recent as lap, i (lap.end_ref)} -
  1. - L{recentOffset + i + 1} - {formatMicros(lap.duration_micros)} -
  2. - {/each} -
- {/if} -
-
- - diff --git a/frontend/apps/rd-console/src/screens/RaceGowPage.svelte b/frontend/apps/rd-console/src/screens/RaceGowPage.svelte deleted file mode 100644 index 7dcee487..00000000 --- a/frontend/apps/rd-console/src/screens/RaceGowPage.svelte +++ /dev/null @@ -1,452 +0,0 @@ - - -
-
-
- - -
-
-

RaceGOW

-

- Time your track runs at home. Fly as many packs as you like — GridFPV keeps every lap and - finds your fastest 3 laps in a row, the time RaceGOW asks for. -

-
-
- - {#if isRaceGowEvent(session.currentEvent)} - - -
-
- Open now - {session.currentEvent.name} -
- -
-
- {/if} - - -
- - - - - - - - {#if pilotId === NEW_PILOT} - - - - {/if} - - - - - {#if timerHasNoChannels} -

- Pick the channels it may use on the - , then come - back. -

- {/if} - - {#if channels.length > 0} - - - - {/if} - - {#if formError} - - {/if} - {#if loadError} - - {/if} - -
- -
-
-
- - - {#if runs === undefined} -

Loading…

- {:else if runs.length === 0} -

No runs yet. Your first one shows up here.

- {:else} -
    - {#each runs as r (r.id)} -
  • -
    - {r.name} - {whenLabel(r)} -
    - -
  • - {/each} -
-

- A run is an ordinary event: rename or delete it on the Events page. -

- {/if} -
-
-
- - diff --git a/frontend/apps/rd-console/src/screens/RaceGowRun.svelte b/frontend/apps/rd-console/src/screens/RaceGowRun.svelte deleted file mode 100644 index 30830801..00000000 --- a/frontend/apps/rd-console/src/screens/RaceGowRun.svelte +++ /dev/null @@ -1,816 +0,0 @@ - - -
-
-
- - -
-
-

{event?.name ?? 'RaceGOW run'}

-

- {callsign} on {timerLabel} - {#if timer} - · - {timerDown ? 'timer not connected' : 'timer connected'} - - {/if} -

-
-
- -
-
- - {#if !round} - - The event was changed in the full console. Add a Time Trials round scored by best 3 laps, or - start a new run. - - {/if} - {#if timerDown} - - Laps cannot arrive until it is. - {#snippet actions()} - - {/snippet} - - {/if} - session.clearCommandError()} /> - {#if actionError} - (actionError = undefined)}> - {actionError} - - {/if} - - - -
-
-
- - {#if view === 'idle'} - {runRows.length === 0 - ? 'Ready when you are' - : `${runRows.length} ${runRows.length === 1 ? 'run' : 'runs'} flown`} - {:else if view === 'ready'} - Run {runRows.length} is drawn - {:else if view === 'staged'} - Run {runRows.length} · staged - {:else if view === 'arming'} - Run {runRows.length} · timer going live… - {:else if view === 'running'} - Run {runRows.length} · flying - {:else} - Run {runRows.length} · stopped - {/if} - - -
-
- {#if view === 'idle'} - - {:else if view === 'ready' || view === 'staged'} - - {:else if view === 'arming'} - - {:else if view === 'running'} - - {:else} - - {/if} -
-
- -
-
- This run · best {RACEGOW_LAPS} laps - - {currentBest ? formatMicros(currentBest.totalMicros) : '—'} - - {#if currentBest} - {currentBest.laps.map((l) => formatMicros(l)).join(' · ')} - {:else if view === 'running' || view === 'arming'} - Fly {RACEGOW_LAPS} laps in a row to get a time. - {/if} -
- -
    - {#each currentLaps as lap, i (lap.end_ref)} -
  1. - {lapLabel(i)} - {formatMicros(lap.duration_micros)} -
  2. - {/each} - {#if currentLaps.length === 0 && heat} -
  3. No laps yet.
  4. - {/if} -
-
-
-
- - -
- -
- - {headlineMicros !== undefined ? formatMicros(headlineMicros) : '—'} - - {#if bestRun?.best} - - Run {bestRun.number}, laps {bestRun.best.start + 1}–{bestRun.best.start + - bestRun.best.laps.length}: {bestRun.best.laps - .map((l) => formatMicros(l)) - .join(' · ')} - - {:else} - Save a run with {RACEGOW_LAPS} laps in a row and it shows here. - {/if} - {#if submission} - - -
- -
- {/if} -
-
- - -
-

- RaceGOW wants a timer in view. Add this page to OBS as a Browser source - over your goggles’ DVR or a camera on the track, and record. It shows the clock, the laps - and the best 3 so far, on a transparent background. -

-
- - -
- {overlay} -
- - Open overlay -
-
-
-
- - - {#if runRows.length === 0} -

Every run you fly on this track is listed here with its best 3 laps.

- {:else} - - - - - - - - - - - {#each runRows as row (row.heat)} - - - - - - - {/each} - -
RunStateLapsBest {RACEGOW_LAPS}
Run {row.number}{phaseLabel(row.phase)}{row.laps}{row.best ? formatMicros(row.best.totalMicros) : '—'}
- {/if} -
-
-
- - diff --git a/frontend/apps/rd-console/src/screens/RaceOverlay.svelte b/frontend/apps/rd-console/src/screens/RaceOverlay.svelte new file mode 100644 index 00000000..dd112268 --- /dev/null +++ b/frontend/apps/rd-console/src/screens/RaceOverlay.svelte @@ -0,0 +1,548 @@ + + +
+
+
+ GridFPV + {#if active && event} + {event.name}{heatName ? ` · ${heatName}` : ''} + {:else if event} + {event.name} · waiting for a race… + {:else} + waiting for a race… + {/if} +
+ + {#if active} +
+ {#if remainingMs !== undefined} + + {formatMicros(clock.elapsedMs * 1000)} + {:else} + + {/if} + {phaseLabel} +
+ + {#if solo} +
{solo.name}
+
+ {#if windowN !== undefined} +
+ Best {windowN} this heat + + {micros(solo.window?.totalMicros)} + +
+
+ Best {windowN} today + + {micros(solo.roundBest)} + +
+ {:else} +
+ Laps + {solo.laps} +
+
+ Best lap + {micros(solo.best)} +
+ {/if} +
+ +
    + {#each recent as lap, i (recentOffset + i)} +
  1. + L{recentOffset + i + 1} + {formatMicros(lap)} +
  2. + {/each} +
+ {:else if rows.length > 0} + + + + + + + + + + + + {#each rows as row, i (row.ref)} + + + + + + + + {/each} + +
PilotLapsLast{windowN === undefined ? 'Best' : `Best ${windowN}`}
{i + 1}{row.name}{row.laps}{micros(row.last)} + {windowN === undefined ? micros(row.best) : micros(row.window?.totalMicros)} +
+ {:else} +

No pilots in this heat yet.

+ {/if} + {/if} +
+
+ + diff --git a/frontend/apps/rd-console/tests/EventOverlays.test.ts b/frontend/apps/rd-console/tests/EventOverlays.test.ts new file mode 100644 index 00000000..730fd12f --- /dev/null +++ b/frontend/apps/rd-console/tests/EventOverlays.test.ts @@ -0,0 +1,45 @@ +/** + * EventOverlays — the in-event Overlays tab: lists the race overlay with its URL for OBS, swaps the + * contrast into the URL, copies it, and frames a live preview of the same route. + */ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/svelte'; +import EventOverlays from '../src/screens/EventOverlays.svelte'; +import { makeTestSession } from './support.js'; + +describe('EventOverlays', () => { + it('lists the race overlay with its OBS URL on the console origin, in either contrast', async () => { + const { session } = makeTestSession({}); + render(EventOverlays, { session }); + expect(screen.getByText('Race timer')).toBeInTheDocument(); + const url = screen.getByLabelText('Race timer URL'); + expect(url).toHaveTextContent( + new RegExp(`^${location.origin}${location.pathname}#/overlay/race$`) + ); + await fireEvent.click(screen.getByRole('radio', { name: 'Dark text on light' })); + expect(url).toHaveTextContent(/#\/overlay\/race\/light$/); + await fireEvent.click(screen.getByRole('radio', { name: 'Light text on dark' })); + expect(url).toHaveTextContent(/#\/overlay\/race$/); + }); + + it('frames the same route as a preview and links it to a new tab', async () => { + const { session } = makeTestSession({}); + render(EventOverlays, { session }); + const frame = screen.getByTitle('Race timer preview'); + expect(frame).toHaveAttribute('src', expect.stringMatching(/#\/overlay\/race$/)); + const link = screen.getByRole('link', { name: 'Open in a new tab' }); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('href', expect.stringMatching(/#\/overlay\/race$/)); + await fireEvent.click(screen.getByRole('radio', { name: 'Dark text on light' })); + expect(frame).toHaveAttribute('src', expect.stringMatching(/#\/overlay\/race\/light$/)); + }); + + it('copies the URL to the clipboard', async () => { + const writeText = vi.fn(async () => {}); + Object.assign(navigator, { clipboard: { writeText } }); + const { session } = makeTestSession({}); + render(EventOverlays, { session }); + await fireEvent.click(screen.getByRole('button', { name: 'Copy URL' })); + expect(writeText).toHaveBeenCalledWith(expect.stringMatching(/#\/overlay\/race$/)); + }); +}); diff --git a/frontend/apps/rd-console/tests/EventPicker.test.ts b/frontend/apps/rd-console/tests/EventPicker.test.ts index 87d654a6..d277641f 100644 --- a/frontend/apps/rd-console/tests/EventPicker.test.ts +++ b/frontend/apps/rd-console/tests/EventPicker.test.ts @@ -38,7 +38,7 @@ function renderPicker() { getActiveEventImpl: vi.fn(async () => ({ event: null })), deleteEventImpl: vi.fn(async () => undefined) }); - render(EventPicker, { session, onhome: noop, onracegow: noop }); + render(EventPicker, { session, onhome: noop }); } /** Open the hard-delete dialog for EVENT and return its scope. */ @@ -63,7 +63,7 @@ describe('EventPicker — the brand home root (#118)', () => { listEventsImpl: vi.fn(async () => [EVENT]), getActiveEventImpl: vi.fn(async () => ({ event: null })) }); - render(EventPicker, { session, onhome, onracegow: noop }); + render(EventPicker, { session, onhome }); // The button's accessible name comes from its wordmark content; the title is the tooltip. const brand = await screen.findByTitle('Home — GridFPV hub'); await fireEvent.click(brand); @@ -119,7 +119,7 @@ describe('EventPicker — the empty picker is a first run, not an empty list (#4 listEventsImpl: vi.fn(async () => []), getActiveEventImpl: vi.fn(async () => ({ event: null })) }); - render(EventPicker, { session, onhome: noop, onracegow: noop }); + render(EventPicker, { session, onhome: noop }); } it('offers a create-your-first-event call to action when no events exist', async () => { @@ -150,64 +150,3 @@ describe('EventPicker — the empty picker is a first run, not an empty list (#4 expect(screen.queryByRole('button', { name: /Practice/ })).toBeNull(); }); }); - -/** - * RaceGOW lives on the Events page, not the hub: "+ New event" asks club race vs track run, and a - * run the preset built lists here with a badge. The club path must stay one click — the kind - * chooser defaults to a race, with the name form already up. - */ -describe('EventPicker — a RaceGOW track run is a kind of new event', () => { - const RUN: EventMeta = { - id: 'track1-run', - name: 'RaceGOW6 Track 1', - created_at: 1000, - persistent: true, - preset: 'racegow', - timers: ['rh-1'], - roster: ['p1'], - classes: ['mgp-whoop'] - }; - - function renderWithRun() { - const onracegow = vi.fn(); - const { session } = makeTestSession({ - noEnter: true, - listEventsImpl: vi.fn(async () => [EVENT, RUN]), - getActiveEventImpl: vi.fn(async () => ({ event: null })) - }); - render(EventPicker, { session, onhome: noop, onracegow }); - return { onracegow }; - } - - it('badges the run the preset built, and only that one', async () => { - renderWithRun(); - // Anchored: the delete button's name is "Delete ", and a loose match would hit both. - const runRow = await screen.findByRole('button', { name: /^RaceGOW6 Track 1/ }); - expect(within(runRow).getByText('RaceGOW')).toBeInTheDocument(); - const clubRow = screen.getByRole('button', { name: /^Friday Night Series/ }); - expect(within(clubRow).queryByText('RaceGOW')).toBeNull(); - }); - - it('the new-event dialog defaults to a club race, with the name form up', async () => { - renderWithRun(); - await fireEvent.click(await screen.findByRole('button', { name: '+ New event' })); - const form = await screen.findByRole('form', { name: 'New event' }); - expect(within(form).getByRole('radio', { name: 'Club race' })).toBeChecked(); - expect(within(form).getByRole('radio', { name: 'RaceGOW track run' })).not.toBeChecked(); - expect(within(form).getByLabelText('Event name')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Create & enter' })).toBeInTheDocument(); - }); - - it('choosing a track run swaps the form for a hand-off to the RaceGOW page', async () => { - const { onracegow } = renderWithRun(); - await fireEvent.click(await screen.findByRole('button', { name: '+ New event' })); - const form = await screen.findByRole('form', { name: 'New event' }); - await fireEvent.click(within(form).getByRole('radio', { name: 'RaceGOW track run' })); - // The name form steps aside: the RaceGOW page's own form asks what a run needs. - await waitFor(() => expect(within(form).queryByLabelText('Event name')).toBeNull()); - expect(screen.queryByRole('button', { name: 'Create & enter' })).toBeNull(); - await fireEvent.click(screen.getByRole('button', { name: 'Set up track run' })); - expect(onracegow).toHaveBeenCalledTimes(1); - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); - }); -}); diff --git a/frontend/apps/rd-console/tests/HomeHub.test.ts b/frontend/apps/rd-console/tests/HomeHub.test.ts index 61338cc9..34898f55 100644 --- a/frontend/apps/rd-console/tests/HomeHub.test.ts +++ b/frontend/apps/rd-console/tests/HomeHub.test.ts @@ -101,13 +101,11 @@ describe('HomeHub (app-level landing, #118)', () => { const ontimers = vi.fn(); render(HomeHub, { session, onpilots, onclasses, onevents, ontimers }); - // Four navigable cards — the four pages. RaceGOW (the solo track-run preset) is NOT a card: - // a track run is an event, so it starts from the Events page and lists there with a badge. + // Four navigable cards. expect(screen.getByRole('heading', { name: 'Pilots' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Classes' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Events' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Timers' })).toBeInTheDocument(); - expect(screen.queryByRole('heading', { name: 'RaceGOW' })).toBeNull(); // Summaries settle per card (count + unit are separate spans): 2 pilots, 3 classes, 2 events, // 3 timers · 2 connected (Mock is Ready + the live RH is Connected; the Configured RH is excluded). diff --git a/frontend/apps/rd-console/tests/RaceGowOverlay.test.ts b/frontend/apps/rd-console/tests/RaceGowOverlay.test.ts deleted file mode 100644 index 55c48a69..00000000 --- a/frontend/apps/rd-console/tests/RaceGowOverlay.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * RaceGowOverlay — the OBS browser source: idle with no RaceGOW run active, and the clock, recent - * laps, this run's best window and the served session best once one is flying. - */ -import { describe, expect, it, vi } from 'vitest'; -import { render, screen, within } from '@testing-library/svelte'; -import { waitFor } from '@testing-library/dom'; -import type { EventMeta, LapList, LiveRaceState } from '@gridfpv/types'; -import RaceGowOverlay from '../src/screens/RaceGowOverlay.svelte'; -import { makeTestSession } from './support.js'; - -const RUN: EventMeta = { - id: 'track1-ab12', - name: 'RaceGOW6 Track 1', - created_at: 0, - persistent: true, - preset: 'racegow', - timers: ['rh-1'], - roster: ['p1'], - classes: ['mgp-whoop'], - rounds: [ - { - id: 'racegow-x1', - label: 'RaceGOW', - classes: ['mgp-whoop'], - format: 'timed_qual', - params: { rounds: '0' }, - win_condition: { BestConsecutive: { n: 3 } }, - seeding: 'FromRoster' - } - ] -} as unknown as EventMeta; - -const LAPS: LapList = { - competitors: [ - { - competitor: { adapter: 'rotorhazard', competitor: 'p1' }, - laps: [9, 7, 6.8, 6.8, 7.5, 7.1, 7.0, 6.9].map((s, i) => ({ - number: i + 1, - duration_micros: Math.round(s * 1_000_000), - at: (i + 1) * 10_000_000, - start_ref: i, - end_ref: i + 1 - })) - } - ] -}; - -describe('RaceGowOverlay', () => { - it('renders idle with no run active, and marks the document as an overlay', () => { - const { session } = makeTestSession({ noEnter: true }); - render(RaceGowOverlay, { session }); - expect(screen.getByText('waiting for a run…')).toBeInTheDocument(); - expect(document.documentElement.dataset.overlay).toBe('racegow'); - }); - - it('shows the track, the pilot, LIVE, the recent laps and both bests while flying', async () => { - const live: LiveRaceState = { - phase: 'Running', - current_heat: 'racegow-x1-3', - active_pilots: ['p1'], - race_started_at: 1_000_000 - }; - const { session } = makeTestSession({ - event: RUN, - live, - listPilotsImpl: vi.fn(async () => [{ id: 'p1', callsign: 'Recon', vtx_types: [] }]), - roundStandingsImpl: vi.fn(async () => [ - { - competitor: 'p1', - position: 1, - best_lap_micros: 6_800_000, - laps: 8, - metric: { BestConsecutive: { n: 3, micros: 20_500_000 } } - } - ]), - heatFetches: { 'racegow-x1-3': { laps: LAPS } } - }); - render(RaceGowOverlay, { session }); - - expect(await screen.findByText('RaceGOW6 Track 1 · Recon')).toBeInTheDocument(); - expect(screen.getByText('LIVE')).toBeInTheDocument(); - expect(screen.getByRole('timer', { name: /Run time/ })).toBeInTheDocument(); - // Only the last six of eight laps fit the frame; they keep their real lap numbers. - const laps = await screen.findByRole('list', { name: 'Recent laps' }); - await waitFor(() => expect(within(laps).getAllByRole('listitem')).toHaveLength(6)); - expect(within(laps).getByText('L3')).toBeInTheDocument(); - expect(within(laps).getByText('L8')).toBeInTheDocument(); - expect(within(laps).queryByText('L2')).not.toBeInTheDocument(); - // This run's best window (laps 2–4 = 20.600) and the served session best. - expect(screen.getByLabelText("This run's best 3 consecutive laps")).toHaveTextContent('20.600'); - await waitFor(() => - expect(screen.getByLabelText('Best 3 consecutive laps, all runs')).toHaveTextContent('20.500') - ); - }); -}); diff --git a/frontend/apps/rd-console/tests/RaceGowPage.test.ts b/frontend/apps/rd-console/tests/RaceGowPage.test.ts deleted file mode 100644 index 8c6fa381..00000000 --- a/frontend/apps/rd-console/tests/RaceGowPage.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * RaceGowPage — the RaceGOW landing: the form defaults sensibly off the directories, refuses a - * timer with no channels (pointing at the Timers page), builds the run through the session's - * ordinary commands, and lists earlier runs to reopen. - */ -import { describe, expect, it, vi } from 'vitest'; -import { render, screen, within } from '@testing-library/svelte'; -import { fireEvent, waitFor } from '@testing-library/dom'; -import type { EventMeta, Pilot, Timer } from '@gridfpv/types'; -import RaceGowPage from '../src/screens/RaceGowPage.svelte'; -import { makeTestSession } from './support.js'; - -const RECON: Pilot = { id: 'p1', callsign: 'Recon', vtx_types: [] }; -const RH: Timer = { - id: 'rh-1', - name: 'Garage RH', - kind: { Rotorhazard: { url: 'http://rh.local:5000' } }, - status: 'Connected', - channel_capability: 'Flexible', - node_count: 1, - available_channels: [5658, 5880], - manual_connect: false, - calibration: [], - disabled_nodes: [] -}; -const BARE_RH: Timer = { ...RH, id: 'rh-2', name: 'Bare RH', available_channels: [] }; -const MOCK: Timer = { - id: 'mock', - name: 'Mock', - kind: { Mock: { laps: 3, lap_ms: 30000 } }, - status: 'Ready', - channel_capability: 'Flexible', - node_count: 8, - available_channels: [5658], - manual_connect: false, - calibration: [], - disabled_nodes: [] -}; -const OLD_RUN: EventMeta = { - id: 'track1-old1', - name: 'RaceGOW6 Track 1', - created_at: 1000, - persistent: true, - preset: 'racegow', - timers: ['rh-1'], - roster: ['p1'], - classes: ['mgp-whoop'] -}; -const CLUB: EventMeta = { - id: 'club-1', - name: 'Club night', - created_at: 2000, - persistent: true, - timers: ['rh-1'], - roster: [], - classes: [] -}; - -function setup(opts: { pilots?: Pilot[]; timers?: Timer[]; events?: EventMeta[] } = {}) { - const { session } = makeTestSession({ - noEnter: true, - listPilotsImpl: vi.fn(async () => opts.pilots ?? [RECON]), - listTimersImpl: vi.fn(async () => opts.timers ?? [MOCK, RH]), - listChannelsImpl: vi.fn(async () => [ - { band: 'Raceband', channel: 'R1', mhz: 5658 }, - { band: 'Raceband', channel: 'R7', mhz: 5880 } - ]) - }); - vi.spyOn(session, 'listEvents').mockResolvedValue(opts.events ?? [OLD_RUN, CLUB]); - const props = { onhome: vi.fn(), onevents: vi.fn(), onrun: vi.fn(), ontimers: vi.fn() }; - return { session, props }; -} - -describe('RaceGowPage', () => { - it('defaults to the only pilot and the connected RotorHazard, with its first channel', async () => { - const { session, props } = setup(); - render(RaceGowPage, { session, ...props }); - const pilot = (await screen.findByLabelText('Pilot')) as HTMLSelectElement; - await waitFor(() => expect(pilot.value).toBe('p1')); - const timer = screen.getByLabelText('Timer') as HTMLSelectElement; - expect(timer.value).toBe('rh-1'); - const channel = screen.getByLabelText('Your channel') as HTMLSelectElement; - expect(channel.value).toBe('5658'); - // Channel options carry the friendly band + channel, with the MHz beside it. - expect(within(channel).getByRole('option', { name: /Raceband R7/ })).toBeInTheDocument(); - expect(screen.getByLabelText('Track')).toHaveValue('RaceGOW6 Track 1'); - }); - - it('builds the run through the ordinary commands and hands off to the run screen', async () => { - const { session, props } = setup(); - const created: EventMeta = { ...OLD_RUN, id: 'track1-new1' }; - const create = vi.spyOn(session, 'createEventAndEnter').mockResolvedValue(created); - const timers = vi.spyOn(session, 'setEventTimers').mockResolvedValue(created); - const primary = vi.spyOn(session, 'setPrimaryTimer').mockResolvedValue(created); - const classes = vi.spyOn(session, 'setEventClasses').mockResolvedValue(created); - const roster = vi.spyOn(session, 'setEventRoster').mockResolvedValue(created); - const members = vi.spyOn(session, 'setClassMembership').mockResolvedValue(created); - const round = vi.spyOn(session, 'createRound').mockResolvedValue({ id: 'r1' } as never); - render(RaceGowPage, { session, ...props }); - - const pilot = (await screen.findByLabelText('Pilot')) as HTMLSelectElement; - await waitFor(() => expect(pilot.value).toBe('p1')); - await fireEvent.change(screen.getByLabelText('Your channel'), { target: { value: '5880' } }); - await fireEvent.input(screen.getByLabelText('Track'), { - target: { value: 'RaceGOW6 Track 2' } - }); - await fireEvent.click(screen.getByRole('button', { name: 'Set up run' })); - - await waitFor(() => expect(props.onrun).toHaveBeenCalledTimes(1)); - expect(create).toHaveBeenCalledWith('RaceGOW6 Track 2', { preset: 'racegow' }); - expect(timers).toHaveBeenCalledWith(['rh-1']); - expect(primary).toHaveBeenCalledWith('rh-1'); - expect(classes).toHaveBeenCalledWith(['mgp-whoop']); - expect(roster).toHaveBeenCalledWith(['p1']); - expect(members).toHaveBeenCalledWith('mgp-whoop', [{ pilot: 'p1', channel: 5880 }]); - expect(round).toHaveBeenCalledTimes(1); - }); - - it('adds a new pilot inline when there is none, then builds the run for them', async () => { - const { session, props } = setup({ pilots: [] }); - const createPilot = vi - .spyOn(session, 'createPilot') - .mockResolvedValue({ id: 'p9', callsign: 'Newbie', vtx_types: [] }); - const created: EventMeta = { ...OLD_RUN, id: 'x', roster: ['p9'] }; - vi.spyOn(session, 'createEventAndEnter').mockResolvedValue(created); - vi.spyOn(session, 'setEventTimers').mockResolvedValue(created); - vi.spyOn(session, 'setPrimaryTimer').mockResolvedValue(created); - vi.spyOn(session, 'setEventClasses').mockResolvedValue(created); - const roster = vi.spyOn(session, 'setEventRoster').mockResolvedValue(created); - vi.spyOn(session, 'setClassMembership').mockResolvedValue(created); - vi.spyOn(session, 'createRound').mockResolvedValue({ id: 'r1' } as never); - render(RaceGowPage, { session, ...props }); - - // No pilots → the picker sits on "New pilot" and the callsign field is up. - const callsign = await screen.findByLabelText('Callsign'); - await fireEvent.input(callsign, { target: { value: 'Newbie' } }); - await fireEvent.click(screen.getByRole('button', { name: 'Set up run' })); - - await waitFor(() => expect(props.onrun).toHaveBeenCalledTimes(1)); - expect(createPilot).toHaveBeenCalledWith({ callsign: 'Newbie', vtx_types: [] }); - expect(roster).toHaveBeenCalledWith(['p9']); - }); - - it('refuses a timer with no channels and points at the Timers page', async () => { - const { session, props } = setup({ timers: [BARE_RH] }); - const create = vi.spyOn(session, 'createEventAndEnter'); - render(RaceGowPage, { session, ...props }); - await screen.findByText('This timer has no channels set yet.'); - expect(screen.getByRole('button', { name: 'Set up run' })).toBeDisabled(); - expect(screen.queryByLabelText('Your channel')).not.toBeInTheDocument(); - await fireEvent.click(screen.getByRole('button', { name: 'Timers page' })); - expect(props.ontimers).toHaveBeenCalledTimes(1); - expect(create).not.toHaveBeenCalled(); - }); - - it('lists only RaceGOW runs, and reopens one through chooseEvent', async () => { - const { session, props } = setup(); - const choose = vi.spyOn(session, 'chooseEvent').mockResolvedValue(OLD_RUN); - render(RaceGowPage, { session, ...props }); - const runs = await screen.findByRole('list', { name: 'Your runs' }); - expect(within(runs).getAllByRole('listitem')).toHaveLength(1); - expect(within(runs).getByText('RaceGOW6 Track 1')).toBeInTheDocument(); - expect(within(runs).queryByText('Club night')).not.toBeInTheDocument(); - await fireEvent.click(within(runs).getByRole('button', { name: 'Open RaceGOW6 Track 1' })); - await waitFor(() => expect(props.onrun).toHaveBeenCalledTimes(1)); - expect(choose).toHaveBeenCalledWith(OLD_RUN); - }); -}); diff --git a/frontend/apps/rd-console/tests/RaceGowRun.test.ts b/frontend/apps/rd-console/tests/RaceGowRun.test.ts deleted file mode 100644 index 2506190b..00000000 --- a/frontend/apps/rd-console/tests/RaceGowRun.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -/** - * RaceGowRun — the solo run screen: the one button issues the right command sequence for each - * phase, the laps and best-3 window render off the served lap list, and the round-wide best comes - * from the served standings. The e2e (`e2e/racegow.spec.ts`) drives the real loop; this pins what - * the screen sends and shows. - */ -import { describe, expect, it, vi } from 'vitest'; -import { render, screen, within } from '@testing-library/svelte'; -import { fireEvent, waitFor } from '@testing-library/dom'; -import type { EventMeta, HeatSummary, LapList, LiveRaceState, RoundStanding } from '@gridfpv/types'; -import RaceGowRun from '../src/screens/RaceGowRun.svelte'; -import { makeTestSession } from './support.js'; - -const EVENT: EventMeta = { - id: 'track1-ab12', - name: 'RaceGOW6 Track 1', - created_at: 0, - persistent: true, - preset: 'racegow', - timers: ['rh-1'], - primary_timer: 'rh-1', - roster: ['p1'], - classes: ['mgp-whoop'], - classes_membership: [{ class: 'mgp-whoop', pilots: [{ pilot: 'p1', channel: 5880 }] }], - rounds: [ - { - id: 'racegow-x1', - label: 'RaceGOW', - classes: ['mgp-whoop'], - format: 'timed_qual', - params: { rounds: '0' }, - win_condition: { BestConsecutive: { n: 3 } }, - seeding: 'FromRoster', - channel_mode: 'Static' - } - ] -} as unknown as EventMeta; - -function lapList(durations: number[]): LapList { - return { - competitors: [ - { - competitor: { adapter: 'rotorhazard', competitor: 'p1' }, - laps: durations.map((d, i) => ({ - number: i + 1, - duration_micros: d, - at: (i + 1) * 10_000_000, - start_ref: i, - end_ref: i + 1 - })) - } - ] - }; -} - -function heat(id: string, phase: HeatSummary['phase'], current = false): HeatSummary { - return { - heat: id, - name: `RaceGOW Heat ${id.slice(-1)}`, - lineup: ['p1'], - round: 'racegow-x1', - class: 'mgp-whoop', - phase, - is_current: current - }; -} - -const idle: LiveRaceState = { phase: 'Scheduled' }; - -function setup(opts: { - live?: LiveRaceState; - heats?: HeatSummary[]; - laps?: Record; - standings?: RoundStanding[]; -}) { - const heats = opts.heats ?? []; - const listHeatsImpl = vi.fn(async () => heats); - const roundStandingsImpl = vi.fn(async () => opts.standings ?? []); - const heatFetches = Object.fromEntries( - Object.entries(opts.laps ?? {}).map(([id, d]) => [id, { laps: lapList(d) }]) - ); - const t = makeTestSession({ - event: EVENT, - live: opts.live ?? idle, - listHeatsImpl, - roundStandingsImpl, - listPilotsImpl: vi.fn(async () => [{ id: 'p1', callsign: 'Recon', vtx_types: [] }]), - heatFetches - }); - return { ...t, listHeatsImpl, roundStandingsImpl }; -} - -const props = { - onhome: vi.fn(), - onevents: vi.fn(), - onback: vi.fn(), - onconsole: vi.fn(), - ontimers: vi.fn() -}; - -describe('RaceGowRun', () => { - it('idle: "Start run" draws the next heat, focuses it, stages and starts it', async () => { - // The fill lands a Scheduled heat the screen must then find in the heats list. - const drawn = heat('racegow-x1-1', 'Scheduled'); - const { session, sendSpy, listHeatsImpl } = setup({ heats: [] }); - listHeatsImpl.mockResolvedValue([drawn]); - render(RaceGowRun, { session, ...props }); - - await fireEvent.click(await screen.findByRole('button', { name: 'Start run' })); - - await waitFor(() => expect(sendSpy).toHaveBeenCalledTimes(4)); - expect(sendSpy.mock.calls.map(([c]) => c)).toEqual([ - { FillRound: { round: 'racegow-x1', mode: 'Next' } }, - { SetCurrentHeat: { heat: 'racegow-x1-1' } }, - { Stage: { heat: 'racegow-x1-1' } }, - { Start: { heat: 'racegow-x1-1' } } - ]); - }); - - it('a drawn-but-unstarted heat is started without drawing another', async () => { - const { session, sendSpy } = setup({ - live: { phase: 'Scheduled', current_heat: 'racegow-x1-1' }, - heats: [heat('racegow-x1-1', 'Scheduled', true)] - }); - render(RaceGowRun, { session, ...props }); - await fireEvent.click(await screen.findByRole('button', { name: 'Start run' })); - await waitFor(() => expect(sendSpy).toHaveBeenCalledTimes(2)); - expect(sendSpy.mock.calls.map(([c]) => c)).toEqual([ - { Stage: { heat: 'racegow-x1-1' } }, - { Start: { heat: 'racegow-x1-1' } } - ]); - }); - - it('running: shows the laps and this run’s best 3 window; "Stop run" ends and finalizes', async () => { - const { session, sendSpy } = setup({ - live: { - phase: 'Running', - current_heat: 'racegow-x1-1', - active_pilots: ['p1'], - race_started_at: 1_000_000 - }, - heats: [heat('racegow-x1-1', 'Running', true)], - laps: { 'racegow-x1-1': [9_000_000, 7_000_000, 6_800_000, 6_800_000, 7_500_000] } - }); - render(RaceGowRun, { session, ...props }); - - // The served laps render, and the best window (laps 2–4 = 20.600) is called out. - const laps = await screen.findByRole('list', { name: "This run's laps" }); - await waitFor(() => expect(within(laps).getAllByRole('listitem')).toHaveLength(5)); - expect(screen.getByLabelText("This run's best 3 consecutive laps")).toHaveTextContent('20.600'); - const rows = within(laps).getAllByRole('listitem'); - expect(rows[1]).toHaveClass('in-window'); - expect(rows[3]).toHaveClass('in-window'); - expect(rows[0]).not.toHaveClass('in-window'); - expect(rows[4]).not.toHaveClass('in-window'); - - await fireEvent.click(screen.getByRole('button', { name: 'Stop run' })); - await waitFor(() => expect(sendSpy).toHaveBeenCalledTimes(2)); - expect(sendSpy.mock.calls.map(([c]) => c)).toEqual([ - { ForceEnd: { heat: 'racegow-x1-1' } }, - { Finalize: { heat: 'racegow-x1-1' } } - ]); - }); - - it('stopped but unsaved: "Save run" only finalizes', async () => { - const { session, sendSpy } = setup({ - live: { phase: 'Unofficial', current_heat: 'racegow-x1-1' }, - heats: [heat('racegow-x1-1', 'Unofficial', true)] - }); - render(RaceGowRun, { session, ...props }); - await fireEvent.click(await screen.findByRole('button', { name: 'Save run' })); - await waitFor(() => expect(sendSpy).toHaveBeenCalledTimes(1)); - expect(sendSpy.mock.calls[0][0]).toEqual({ Finalize: { heat: 'racegow-x1-1' } }); - }); - - it('after saved runs: the served best headlines, the runs table names the best run, and the submission line is ready', async () => { - const { session } = setup({ - live: { phase: 'Final', current_heat: 'racegow-x1-2' }, - heats: [heat('racegow-x1-1', 'Final'), heat('racegow-x1-2', 'Final', true)], - laps: { - 'racegow-x1-1': [8_000_000, 8_000_000, 8_000_000], - 'racegow-x1-2': [7_000_000, 7_000_000, 7_000_000, 9_000_000] - }, - standings: [ - { - competitor: 'p1', - position: 1, - best_lap_micros: 7_000_000, - laps: 4, - metric: { BestConsecutive: { n: 3, micros: 21_000_000 } } - } - ] - }); - render(RaceGowRun, { session, ...props }); - - // The next action is another run. - expect(await screen.findByRole('button', { name: 'Fly again' })).toBeInTheDocument(); - // The headline is the SERVED round-wide best. - await waitFor(() => - expect(screen.getByLabelText('Best 3 consecutive laps, all runs')).toHaveTextContent('21.000') - ); - // The runs table: two saved runs, run 2 the best. - const table = await screen.findByRole('table', { name: 'Runs' }); - await waitFor(() => expect(within(table).getAllByRole('row')).toHaveLength(3)); - const [, run1, run2] = within(table).getAllByRole('row'); - expect(run1).toHaveTextContent('Run 1'); - expect(run1).toHaveTextContent('24.000'); - expect(run2).toHaveTextContent('Run 2'); - expect(run2).toHaveTextContent('21.000'); - expect(run2).toHaveClass('best'); - // The submission line names the track, the pilot, the time and the run. - const submission = screen.getByLabelText('Submission text') as HTMLTextAreaElement; - expect(submission.value).toContain('RaceGOW6 Track 1 — Recon'); - expect(submission.value).toContain('Best 3 consecutive laps: 21.000 s (7.000 / 7.000 / 7.000)'); - expect(submission.value).toContain('Run 2 of 2, laps 1–3'); - }); - - it('offers the overlay URL for OBS, in either contrast', async () => { - const { session } = setup({}); - render(RaceGowRun, { session, ...props }); - const url = await screen.findByLabelText('Overlay URL'); - expect(url).toHaveTextContent(/#\/overlay\/racegow$/); - await fireEvent.click(screen.getByRole('radio', { name: 'Dark text on light' })); - expect(url).toHaveTextContent(/#\/overlay\/racegow\/light$/); - await fireEvent.click(screen.getByRole('radio', { name: 'Light text on dark' })); - expect(url).toHaveTextContent(/#\/overlay\/racegow$/); - }); -}); diff --git a/frontend/apps/rd-console/tests/RaceOverlay.test.ts b/frontend/apps/rd-console/tests/RaceOverlay.test.ts new file mode 100644 index 00000000..5481aa49 --- /dev/null +++ b/frontend/apps/rd-console/tests/RaceOverlay.test.ts @@ -0,0 +1,187 @@ +/** + * RaceOverlay — the OBS browser source: idle with no heat on the timer; the long form (clock, + * recent laps, this heat's best window, the served round best) for a one-pilot heat of a + * best-consecutive round; and a row per pilot for a full heat of a race round. + */ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, within } from '@testing-library/svelte'; +import { waitFor } from '@testing-library/dom'; +import type { EventMeta, HeatSummary, LapList, LiveRaceState, Pilot } from '@gridfpv/types'; +import RaceOverlay from '../src/screens/RaceOverlay.svelte'; +import { makeTestSession } from './support.js'; + +const EVENT: EventMeta = { + id: 'club-ab12', + name: 'Tuesday Club Night', + created_at: 0, + persistent: true, + timers: ['rh-1'], + roster: ['p1', 'p2', 'p3'], + classes: ['mgp-whoop'], + rounds: [ + { + id: 'tt-1', + label: 'Time trial', + classes: ['mgp-whoop'], + format: 'timed_qual', + params: { rounds: '0' }, + win_condition: { BestConsecutive: { n: 3 } }, + seeding: 'FromRoster' + }, + { + id: 'race-1', + label: 'Mains', + classes: ['mgp-whoop'], + format: 'race', + params: {}, + win_condition: { FirstToLaps: { n: 4 } }, + seeding: 'FromRoster' + } + ] +} as unknown as EventMeta; + +const HEATS: HeatSummary[] = [ + { + heat: 'tt-1-3', + name: 'Time trial Heat 3', + lineup: ['p1'], + round: 'tt-1', + phase: 'Running', + is_current: true + }, + { + heat: 'race-1-1', + name: 'Mains Heat 1', + lineup: ['p1', 'p2', 'p3'], + round: 'race-1', + phase: 'Running', + is_current: false + } +]; + +const PILOTS: Pilot[] = [ + { id: 'p1', callsign: 'Recon', vtx_types: [] }, + { id: 'p2', callsign: 'Bee', vtx_types: [] }, + { id: 'p3', callsign: 'Cee', vtx_types: [] } +] as unknown as Pilot[]; + +function lapsOf(ref: string, seconds: number[]) { + return { + competitor: { adapter: 'rotorhazard', competitor: ref }, + laps: seconds.map((s, i) => ({ + number: i + 1, + duration_micros: Math.round(s * 1_000_000), + at: (i + 1) * 10_000_000, + start_ref: i, + end_ref: i + 1 + })) + }; +} + +describe('RaceOverlay', () => { + it('renders idle with no event, and marks the document as an overlay', () => { + const { session } = makeTestSession({ noEnter: true }); + render(RaceOverlay, { session }); + expect(screen.getByText('waiting for a race…')).toBeInTheDocument(); + expect(document.documentElement.dataset.overlay).toBe('race'); + expect(screen.queryByRole('timer')).toBeNull(); + }); + + it('names the event while it waits for a heat', () => { + const { session } = makeTestSession({ event: EVENT, live: { phase: 'Scheduled' } }); + render(RaceOverlay, { session }); + expect(screen.getByText('Tuesday Club Night · waiting for a race…')).toBeInTheDocument(); + expect(screen.queryByRole('timer')).toBeNull(); + }); + + it('shows a one-pilot best-consecutive heat in long form: name, LIVE, recent laps, both bests', async () => { + const SOLO: LapList = { competitors: [lapsOf('p1', [9, 7, 6.8, 6.8, 7.5, 7.1, 7.0, 6.9])] }; + const live: LiveRaceState = { + phase: 'Running', + current_heat: 'tt-1-3', + active_pilots: ['p1'], + race_started_at: 1_000_000 + }; + const { session } = makeTestSession({ + event: EVENT, + live, + listPilotsImpl: vi.fn(async () => PILOTS), + listHeatsImpl: vi.fn(async () => HEATS), + roundStandingsImpl: vi.fn(async () => [ + { + competitor: 'p1', + position: 1, + best_lap_micros: 6_800_000, + laps: 8, + metric: { BestConsecutive: { n: 3, micros: 20_500_000 } } + } + ]), + heatFetches: { 'tt-1-3': { laps: SOLO } } + }); + render(RaceOverlay, { session }); + + // The heat is named by the shared resolver's friendly name, never its id. + expect(await screen.findByText('Tuesday Club Night · Time trial Heat 3')).toBeInTheDocument(); + expect(screen.getByText('LIVE')).toBeInTheDocument(); + // No fixed end on this round: the clock counts up. + expect(screen.getByRole('timer', { name: /Race time/ })).toBeInTheDocument(); + expect(await screen.findByText('Recon')).toBeInTheDocument(); + // Only the last six of eight laps fit the frame; they keep their real lap numbers. + const laps = await screen.findByRole('list', { name: 'Recent laps' }); + await waitFor(() => expect(within(laps).getAllByRole('listitem')).toHaveLength(6)); + expect(within(laps).getByText('L3')).toBeInTheDocument(); + expect(within(laps).getByText('L8')).toBeInTheDocument(); + expect(within(laps).queryByText('L2')).not.toBeInTheDocument(); + // This heat's best window (laps 2–4 = 20.600) and the served round best. + expect(screen.getByLabelText("This heat's best 3 consecutive laps")).toHaveTextContent( + '20.600' + ); + await waitFor(() => + expect(screen.getByLabelText('Best 3 consecutive laps, all heats')).toHaveTextContent( + '20.500' + ) + ); + expect(screen.queryByRole('table')).toBeNull(); + }); + + it('shows a full heat as a row per pilot in running order, with laps, last and best lap', async () => { + const FIELD: LapList = { + competitors: [lapsOf('p1', [7, 6.5]), lapsOf('p2', [8]), lapsOf('p3', [7.2, 7.1, 7.0])] + }; + const live: LiveRaceState = { + phase: 'Running', + current_heat: 'race-1-1', + active_pilots: ['p1', 'p2', 'p3'], + running_order: ['p3', 'p1', 'p2'], + race_started_at: 1_000_000 + }; + const { session } = makeTestSession({ + event: EVENT, + live, + listPilotsImpl: vi.fn(async () => PILOTS), + listHeatsImpl: vi.fn(async () => HEATS), + heatFetches: { 'race-1-1': { laps: FIELD } } + }); + render(RaceOverlay, { session }); + + expect(await screen.findByText('Tuesday Club Night · Mains Heat 1')).toBeInTheDocument(); + const table = await screen.findByRole('table', { name: 'Pilots in this heat' }); + const rows = within(table).getAllByRole('row').slice(1); // minus the header + await waitFor(() => expect(within(rows[0]).getByText('Cee')).toBeInTheDocument()); + expect(within(rows[1]).getByText('Recon')).toBeInTheDocument(); + expect(within(rows[2]).getByText('Bee')).toBeInTheDocument(); + // Cee: 3 laps, last 7.000, best 7.000. Recon: 2 laps, last 6.500, best 6.500. + await waitFor(() => expect(rows[0]).toHaveTextContent(/3.*7\.000.*7\.000/)); + expect(rows[1]).toHaveTextContent(/2.*6\.500.*6\.500/); + expect(rows[2]).toHaveTextContent(/1.*8\.000.*8\.000/); + // A race round scores no consecutive window: the column is the best lap. + expect(within(table).getByRole('columnheader', { name: 'Best' })).toBeInTheDocument(); + expect(screen.queryByRole('list', { name: 'Recent laps' })).toBeNull(); + }); + + it('takes the light contrast from its theme prop', () => { + const { session } = makeTestSession({ noEnter: true }); + render(RaceOverlay, { session, theme: 'light' }); + expect(screen.getByLabelText('Race overlay')).toHaveAttribute('data-theme', 'light'); + }); +}); diff --git a/frontend/apps/rd-console/tests/overlay.test.ts b/frontend/apps/rd-console/tests/overlay.test.ts new file mode 100644 index 00000000..92ac263c --- /dev/null +++ b/frontend/apps/rd-console/tests/overlay.test.ts @@ -0,0 +1,208 @@ +/** + * Unit tests for the overlay helpers (`lib/overlay.ts`): the best-consecutive window the race + * overlay slides over served laps, how it finds a heat's round and scoring window, the per-pilot + * served figures, the frame order, and the OBS URL. The browser e2e (`e2e/overlay.spec.ts`) proves + * the page against a real Director; this pins the arithmetic it relies on. + */ +import { describe, expect, it } from 'vitest'; +import type { EventMeta, HeatSummary, LapList, RoundStanding } from '@gridfpv/types'; +import { + bestConsecutive, + competitorLaps, + consecutiveWindow, + heatRound, + overlayOrder, + overlayUrl, + servedRoundBestMicros, + OVERLAY_CATALOG +} from '../src/lib/overlay.js'; + +describe('bestConsecutive', () => { + it('is undefined with fewer laps than the window', () => { + expect(bestConsecutive([], 3)).toBeUndefined(); + expect(bestConsecutive([7_000_000, 7_100_000], 3)).toBeUndefined(); + }); + + it('finds the fastest 3-lap window and where it starts', () => { + // Laps 2–4 (indices 1–3) sum to 20.6 s; every other window is slower. + const laps = [9_000_000, 7_000_000, 6_800_000, 6_800_000, 7_500_000, 7_100_000]; + expect(bestConsecutive(laps, 3)).toEqual({ + start: 1, + laps: [7_000_000, 6_800_000, 6_800_000], + totalMicros: 20_600_000 + }); + }); + + it('gives a tie to the earliest window (the engine breaks ties by the earlier completion)', () => { + const laps = [7_000_000, 7_000_000, 7_000_000, 7_000_000]; + expect(bestConsecutive(laps, 3)?.start).toBe(0); + }); + + it('honours a different window size, and refuses a zero one', () => { + const laps = [8_000_000, 6_000_000, 7_000_000]; + expect(bestConsecutive(laps, 1)).toEqual({ + start: 1, + laps: [6_000_000], + totalMicros: 6_000_000 + }); + expect(bestConsecutive(laps, 2)).toEqual({ + start: 1, + laps: [6_000_000, 7_000_000], + totalMicros: 13_000_000 + }); + expect(bestConsecutive(laps, 0)).toBeUndefined(); + }); +}); + +describe('consecutiveWindow / heatRound', () => { + const EVENT = { + id: 'e', + name: 'Club night', + created_at: 0, + persistent: true, + timers: [], + roster: [], + classes: [], + rounds: [ + { + id: 'r-tt', + label: 'Time trial', + format: 'timed_qual', + win_condition: { BestConsecutive: { n: 3 } } + }, + { id: 'r-race', label: 'Race', format: 'race', win_condition: { FirstToLaps: { n: 4 } } } + ] + } as unknown as EventMeta; + const HEATS = [ + { + heat: 'h-1', + name: 'Time trial Heat 1', + lineup: ['p1'], + round: 'r-tt', + phase: 'Final', + is_current: false + }, + { + heat: 'h-2', + name: 'Race Heat 1', + lineup: ['p1', 'p2'], + round: 'r-race', + phase: 'Running', + is_current: true + }, + { heat: 'free', name: 'free', lineup: ['p1'], phase: 'Scheduled', is_current: false } + ] as HeatSummary[]; + + it('reads the window only off a BestConsecutive condition', () => { + expect(consecutiveWindow({ BestConsecutive: { n: 3 } })).toBe(3); + expect(consecutiveWindow('BestLap')).toBeUndefined(); + expect(consecutiveWindow({ Timed: { window_micros: 120_000_000 } })).toBeUndefined(); + expect(consecutiveWindow({ FirstToLaps: { n: 4 } })).toBeUndefined(); + expect(consecutiveWindow(undefined)).toBeUndefined(); + }); + + it('finds a heat’s round through its round tag, and nothing for an untagged heat', () => { + expect(heatRound(EVENT, HEATS, 'h-1')?.id).toBe('r-tt'); + expect(heatRound(EVENT, HEATS, 'h-2')?.id).toBe('r-race'); + expect(heatRound(EVENT, HEATS, 'free')).toBeUndefined(); + expect(heatRound(EVENT, HEATS, 'gone')).toBeUndefined(); + expect(heatRound(undefined, HEATS, 'h-1')).toBeUndefined(); + }); +}); + +describe('competitorLaps / servedRoundBestMicros / overlayOrder', () => { + const LAPS: LapList = { + competitors: [ + { + competitor: { adapter: 'sim', competitor: 'p2' }, + laps: [{ number: 1, duration_micros: 8_000_000, at: 1, start_ref: 0, end_ref: 1 }] + }, + { + competitor: { adapter: 'sim', competitor: 'p1' }, + laps: [ + { number: 1, duration_micros: 7_000_000, at: 1, start_ref: 0, end_ref: 1 }, + { number: 2, duration_micros: 6_500_000, at: 2, start_ref: 1, end_ref: 2 } + ] + } + ] + }; + + it('picks one competitor’s served laps, in order, and nothing for a stranger', () => { + expect(competitorLaps(LAPS, 'p1').map((l) => l.duration_micros)).toEqual([ + 7_000_000, 6_500_000 + ]); + expect(competitorLaps(LAPS, 'p2')).toHaveLength(1); + expect(competitorLaps(LAPS, 'p9')).toEqual([]); + expect(competitorLaps(undefined, 'p1')).toEqual([]); + }); + + it('reads the served round figure per pilot: a best window, a best lap, or nothing for laps', () => { + const standings: RoundStanding[] = [ + { + competitor: 'p1', + position: 1, + best_lap_micros: 6_500_000, + laps: 8, + metric: { BestConsecutive: { n: 3, micros: 20_500_000 } } + }, + { + competitor: 'p2', + position: 2, + best_lap_micros: null, + laps: 1, + metric: { BestConsecutive: { n: 3, micros: null } } + }, + { + competitor: 'p3', + position: 3, + best_lap_micros: 7_100_000, + laps: 3, + metric: { BestLap: { micros: 7_100_000 } } + }, + { + competitor: 'p4', + position: 4, + best_lap_micros: null, + laps: 5, + metric: { MostLaps: { laps: 5 } } + } + ]; + expect(servedRoundBestMicros(standings, 'p1')).toBe(20_500_000); + expect(servedRoundBestMicros(standings, 'p2')).toBeUndefined(); + expect(servedRoundBestMicros(standings, 'p3')).toBe(7_100_000); + expect(servedRoundBestMicros(standings, 'p4')).toBeUndefined(); + expect(servedRoundBestMicros(standings, 'p9')).toBeUndefined(); + }); + + it('orders by the first source that names a pilot, appending the rest, without duplicates', () => { + expect(overlayOrder([['p2', 'p1'], ['p1', 'p3'], undefined, ['p4', 'p2']])).toEqual([ + 'p2', + 'p1', + 'p3', + 'p4' + ]); + expect(overlayOrder([undefined, undefined])).toEqual([]); + }); +}); + +describe('overlayUrl / the catalogue', () => { + it('points the overlay at the console origin + path, on the overlay hash', () => { + expect(overlayUrl({ origin: 'http://192.168.1.50:8080', pathname: '/' }, 'race')).toBe( + 'http://192.168.1.50:8080/#/overlay/race' + ); + expect(overlayUrl({ origin: 'http://127.0.0.1:8080', pathname: '/console/' }, 'race')).toBe( + 'http://127.0.0.1:8080/console/#/overlay/race' + ); + }); + + it('carries the light theme in the URL, and leaves the dark default bare', () => { + const loc = { origin: 'http://127.0.0.1:8080', pathname: '/' }; + expect(overlayUrl(loc, 'race', 'light')).toBe('http://127.0.0.1:8080/#/overlay/race/light'); + expect(overlayUrl(loc, 'race', 'dark')).toBe('http://127.0.0.1:8080/#/overlay/race'); + }); + + it('lists the race timer as the one overlay built so far', () => { + expect(OVERLAY_CATALOG.map((o) => o.name)).toEqual(['race']); + expect(OVERLAY_CATALOG[0].title).toBe('Race timer'); + }); +}); diff --git a/frontend/apps/rd-console/tests/racegow.test.ts b/frontend/apps/rd-console/tests/racegow.test.ts deleted file mode 100644 index fb9158a9..00000000 --- a/frontend/apps/rd-console/tests/racegow.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -/** - * Unit tests for the RaceGOW helpers (`lib/racegow.ts`): the best-consecutive window the screens - * slide over served laps, the preset's round shape, the create orchestration's command order, and - * the submission line. The browser e2e (`e2e/racegow.spec.ts`) proves the whole loop against a - * real Director; this pins the arithmetic and the composition it relies on. - */ -import { describe, expect, it, vi } from 'vitest'; -import type { EventMeta, LapList, RoundStanding } from '@gridfpv/types'; -import { - bestConsecutive, - createRaceGowRun, - isRaceGowEvent, - overlayUrl, - raceGowRound, - raceGowRoundRequest, - runLaps, - servedBestMicros, - submissionText, - RACEGOW_CLASS_ID, - RACEGOW_PRESET -} from '../src/lib/racegow.js'; -import type { Session } from '../src/lib/session.svelte.js'; - -describe('bestConsecutive', () => { - it('is undefined with fewer laps than the window', () => { - expect(bestConsecutive([], 3)).toBeUndefined(); - expect(bestConsecutive([7_000_000, 7_100_000], 3)).toBeUndefined(); - }); - - it('finds the fastest 3-lap window and where it starts', () => { - // Laps 2–4 (indices 1–3) sum to 20.6 s; every other window is slower. - const laps = [9_000_000, 7_000_000, 6_800_000, 6_800_000, 7_500_000, 7_100_000]; - expect(bestConsecutive(laps)).toEqual({ - start: 1, - laps: [7_000_000, 6_800_000, 6_800_000], - totalMicros: 20_600_000 - }); - }); - - it('gives a tie to the earliest window (the engine breaks ties by the earlier completion)', () => { - const laps = [5_000_000, 5_000_000, 5_000_000, 5_000_000]; - expect(bestConsecutive(laps)?.start).toBe(0); - }); - - it('honours a different window size, and refuses a zero one', () => { - expect(bestConsecutive([3_000_000, 1_000_000, 2_000_000], 1)).toEqual({ - start: 1, - laps: [1_000_000], - totalMicros: 1_000_000 - }); - expect(bestConsecutive([1, 2, 3], 0)).toBeUndefined(); - }); -}); - -describe('runLaps / servedBestMicros', () => { - it('flattens the lap list in competitor order (a solo run has one)', () => { - const list: LapList = { - competitors: [ - { - competitor: { adapter: 'rotorhazard', competitor: 'p1' }, - laps: [ - { number: 1, duration_micros: 7_000_000, at: 1, start_ref: 0, end_ref: 1 }, - { number: 2, duration_micros: 6_900_000, at: 2, start_ref: 1, end_ref: 2 } - ] - } - ] - }; - expect(runLaps(list).map((l) => l.duration_micros)).toEqual([7_000_000, 6_900_000]); - expect(runLaps(undefined)).toEqual([]); - }); - - it('reads the served best-consecutive window off the standings, or nothing', () => { - const rows: RoundStanding[] = [ - { - competitor: 'p1', - position: 1, - best_lap_micros: 6_800_000, - laps: 6, - metric: { BestConsecutive: { n: 3, micros: 20_600_000 } } - } - ]; - expect(servedBestMicros(rows)).toBe(20_600_000); - expect( - servedBestMicros([{ ...rows[0], metric: { BestConsecutive: { n: 3, micros: null } } }]) - ).toBeUndefined(); - expect(servedBestMicros([{ ...rows[0], metric: { BestLap: { micros: 1 } } }])).toBeUndefined(); - expect(servedBestMicros([])).toBeUndefined(); - }); -}); - -describe('the preset shape', () => { - it('marks and finds a RaceGOW event by its preset', () => { - const plain = { id: 'e1', name: 'Club night', preset: undefined } as unknown as EventMeta; - const run = { id: 'e2', name: 'Track 1', preset: RACEGOW_PRESET } as unknown as EventMeta; - expect(isRaceGowEvent(plain)).toBe(false); - expect(isRaceGowEvent(run)).toBe(true); - expect(isRaceGowEvent(undefined)).toBe(false); - }); - - it('scores best 3 consecutive laps, open-ended, on the Whoop class, under a 10-minute ceiling', () => { - const req = raceGowRoundRequest(); - expect(req.format).toBe('timed_qual'); - expect(req.params).toEqual({ rounds: '0' }); - expect(req.win_condition).toEqual({ BestConsecutive: { n: 3 } }); - expect(req.classes).toEqual([RACEGOW_CLASS_ID]); - expect(req.seeding).toBe('FromRoster'); - expect(req.channel_mode).toBe('Static'); - // Ranking-only conditions never end a heat, so the Director requires a race time; this one is - // a ceiling far past a whoop pack, not a race window. - expect(req.time_limit_secs).toBe(600); - // A fixed one-second hold: no field of pilots is waiting on a random tone. - expect(req.start_procedure).toEqual({ - mode: 'randomized-delay', - min_delay_ms: 1000, - max_delay_ms: 1000 - }); - }); - - it('finds the scoring round by format, not by label', () => { - const meta = { - rounds: [ - { id: 'r0', label: 'Warm-up', format: 'open_practice' }, - { id: 'r1', label: 'Renamed by hand', format: 'timed_qual' } - ] - } as unknown as EventMeta; - expect(raceGowRound(meta)?.id).toBe('r1'); - expect(raceGowRound(undefined)).toBeUndefined(); - }); -}); - -describe('createRaceGowRun', () => { - function fakeSession() { - const calls: string[] = []; - const meta = { id: 'e1', name: 'RaceGOW6 Track 1', preset: RACEGOW_PRESET } as EventMeta; - const session = { - currentEvent: meta, - createEventAndEnter: vi.fn(async (name: string, fields?: unknown) => { - calls.push(`create:${name}:${JSON.stringify(fields)}`); - return meta; - }), - setEventTimers: vi.fn(async (ids: string[]) => (calls.push(`timers:${ids}`), meta)), - setPrimaryTimer: vi.fn(async (id: string) => (calls.push(`primary:${id}`), meta)), - setEventClasses: vi.fn(async (ids: string[]) => (calls.push(`classes:${ids}`), meta)), - setEventRoster: vi.fn(async (ids: string[]) => (calls.push(`roster:${ids}`), meta)), - setClassMembership: vi.fn(async (cls: string, slots: unknown[]) => { - calls.push(`members:${cls}:${JSON.stringify(slots)}`); - return meta; - }), - createRound: vi.fn(async (req: { format: string }) => { - calls.push(`round:${req.format}`); - return { id: 'r1' }; - }) - }; - return { calls, session: session as unknown as Session, meta }; - } - - it('creates, then configures timer → primary → class → roster → channel slot → round', async () => { - const { calls, session, meta } = fakeSession(); - const result = await createRaceGowRun(session, { - track: ' RaceGOW6 Track 1 ', - pilot: 'p1', - timer: 'rh-1', - channel: 5880 - }); - expect(result).toBe(meta); - expect(calls).toEqual([ - 'create:RaceGOW6 Track 1:{"preset":"racegow"}', - 'timers:rh-1', - 'primary:rh-1', - `classes:${RACEGOW_CLASS_ID}`, - 'roster:p1', - `members:${RACEGOW_CLASS_ID}:[{"pilot":"p1","channel":5880}]`, - 'round:timed_qual' - ]); - }); - - it('seats the pilot without a channel when the timer offers none', async () => { - const { calls, session } = fakeSession(); - await createRaceGowRun(session, { track: 'T', pilot: 'p1', timer: 'mock' }); - expect(calls).toContain(`members:${RACEGOW_CLASS_ID}:[{"pilot":"p1"}]`); - }); - - it('stops at a cancelled token prompt and reports it as undefined', async () => { - const { calls, session } = fakeSession(); - (session.setEventRoster as unknown as ReturnType).mockResolvedValueOnce( - undefined - ); - const result = await createRaceGowRun(session, { track: 'T', pilot: 'p1', timer: 'rh-1' }); - expect(result).toBeUndefined(); - expect(calls.some((c) => c.startsWith('members:'))).toBe(false); - expect(calls.some((c) => c.startsWith('round:'))).toBe(false); - }); -}); - -describe('submissionText / overlayUrl', () => { - it('writes the time, the three laps, which run and when, and what timed it', () => { - const text = submissionText({ - track: 'RaceGOW6 Track 1', - callsign: 'Recon', - best: { start: 2, laps: [7_012_000, 7_201_000, 7_224_000], totalMicros: 21_437_000 }, - run: 3, - runs: 5, - flownAt: new Date(2026, 8, 26, 14, 3), - timedBy: 'Garage RH (RotorHazard)' - }); - expect(text).toContain('RaceGOW6 Track 1 — Recon'); - expect(text).toContain('Best 3 consecutive laps: 21.437 s (7.012 / 7.201 / 7.224)'); - expect(text).toContain('Run 3 of 5, laps 3–5'); - expect(text).toContain('Timed by GridFPV on Garage RH (RotorHazard)'); - }); - - it('points the overlay at the console origin + path, on the overlay hash', () => { - expect(overlayUrl({ origin: 'http://192.168.1.20:8080', pathname: '/' })).toBe( - 'http://192.168.1.20:8080/#/overlay/racegow' - ); - }); - - it('carries the light theme in the overlay URL, and leaves the dark default bare', () => { - const loc = { origin: 'http://192.168.1.20:8080', pathname: '/' }; - expect(overlayUrl(loc, 'light')).toBe('http://192.168.1.20:8080/#/overlay/racegow/light'); - expect(overlayUrl(loc, 'dark')).toBe('http://192.168.1.20:8080/#/overlay/racegow'); - }); -}); diff --git a/frontend/apps/rd-console/tests/route.test.ts b/frontend/apps/rd-console/tests/route.test.ts index 4787e57d..e043a350 100644 --- a/frontend/apps/rd-console/tests/route.test.ts +++ b/frontend/apps/rd-console/tests/route.test.ts @@ -334,64 +334,65 @@ describe('resolveInitialRoute (hash is authoritative; #118)', () => { }); }); -describe('RaceGOW + overlay routes', () => { - it('parses the RaceGOW page, its run screen, and the overlay', () => { - expect(parseHash('#/racegow')).toEqual({ kind: 'racegow' }); - expect(parseHash('#/racegow/run')).toEqual({ kind: 'racegow-run' }); - expect(parseHash('#/RaceGOW/Run')).toEqual({ kind: 'racegow-run' }); - expect(parseHash('#/overlay/racegow')).toEqual({ kind: 'overlay', overlay: 'racegow' }); +describe('overlay routes', () => { + it('parses the race overlay, case-insensitively', () => { + expect(parseHash('#/overlay/race')).toEqual({ kind: 'overlay', overlay: 'race' }); + expect(parseHash('#/Overlay/RACE')).toEqual({ kind: 'overlay', overlay: 'race' }); }); it('parses the overlay theme: /light is the light panel, anything else is the dark default', () => { - expect(parseHash('#/overlay/racegow/light')).toEqual({ + expect(parseHash('#/overlay/race/light')).toEqual({ kind: 'overlay', - overlay: 'racegow', + overlay: 'race', theme: 'light' }); - expect(parseHash('#/overlay/racegow/LIGHT')).toEqual({ + expect(parseHash('#/overlay/race/LIGHT')).toEqual({ kind: 'overlay', - overlay: 'racegow', + overlay: 'race', theme: 'light' }); // An explicit `dark`, or a typo, still renders the overlay — never bounces the scene to the hub. - expect(parseHash('#/overlay/racegow/dark')).toEqual({ kind: 'overlay', overlay: 'racegow' }); - expect(parseHash('#/overlay/racegow/lite')).toEqual({ kind: 'overlay', overlay: 'racegow' }); + expect(parseHash('#/overlay/race/dark')).toEqual({ kind: 'overlay', overlay: 'race' }); + expect(parseHash('#/overlay/race/lite')).toEqual({ kind: 'overlay', overlay: 'race' }); }); - it('degrades a malformed RaceGOW sub-route to the page, and an unknown overlay to the hub', () => { - expect(parseHash('#/racegow/whatever')).toEqual({ kind: 'racegow' }); + it('degrades a bare or unknown overlay to the hub', () => { expect(parseHash('#/overlay')).toEqual(DEFAULT_ROUTE); expect(parseHash('#/overlay/leaderboard')).toEqual(DEFAULT_ROUTE); }); it('round-trips each through formatHash', () => { const routes: Route[] = [ - { kind: 'racegow' }, - { kind: 'racegow-run' }, - { kind: 'overlay', overlay: 'racegow' }, - { kind: 'overlay', overlay: 'racegow', theme: 'light' } + { kind: 'overlay', overlay: 'race' }, + { kind: 'overlay', overlay: 'race', theme: 'light' } ]; for (const r of routes) expect(parseHash(formatHash(r))).toEqual(r); - expect(formatHash({ kind: 'overlay', overlay: 'racegow', theme: 'light' })).toBe( - '#/overlay/racegow/light' + expect(formatHash({ kind: 'overlay', overlay: 'race', theme: 'light' })).toBe( + '#/overlay/race/light' ); - expect(formatHash({ kind: 'overlay', overlay: 'racegow', theme: 'dark' })).toBe( - '#/overlay/racegow' - ); - expect(formatHash({ kind: 'racegow' })).toBe('#/racegow'); - expect(formatHash({ kind: 'racegow-run' })).toBe('#/racegow/run'); - expect(formatHash({ kind: 'overlay', overlay: 'racegow' })).toBe('#/overlay/racegow'); + expect(formatHash({ kind: 'overlay', overlay: 'race', theme: 'dark' })).toBe('#/overlay/race'); + expect(formatHash({ kind: 'overlay', overlay: 'race' })).toBe('#/overlay/race'); }); - it('reconciles the run screen to the RaceGOW page with no active event, and keeps the rest', () => { - expect(reconcileRoute({ kind: 'racegow-run' }, false)).toEqual({ kind: 'racegow' }); - expect(reconcileRoute({ kind: 'racegow-run' }, true)).toEqual({ kind: 'racegow-run' }); - expect(reconcileRoute({ kind: 'racegow' }, false)).toEqual({ kind: 'racegow' }); - // An overlay sits in an OBS scene whether or not a run is on: never bounced. - expect(reconcileRoute({ kind: 'overlay', overlay: 'racegow' }, false)).toEqual({ + it('never reconciles an overlay away — it sits in an OBS scene whether or not a race is on', () => { + expect(reconcileRoute({ kind: 'overlay', overlay: 'race' }, false)).toEqual({ + kind: 'overlay', + overlay: 'race' + }); + expect(resolveInitialRoute('#/overlay/race/light', false)).toEqual({ kind: 'overlay', - overlay: 'racegow' + overlay: 'race', + theme: 'light' + }); + }); + + it('has an Overlays workspace tab, reached like any other tab', () => { + expect(parseHash('#/event/overlays')).toEqual({ kind: 'workspace', tab: 'overlays' }); + expect(formatHash({ kind: 'workspace', tab: 'overlays' })).toBe('#/event/overlays'); + // With no active event the tab reconciles to Events like every workspace route. + expect(reconcileRoute({ kind: 'workspace', tab: 'overlays' }, false)).toEqual({ + kind: 'page', + page: 'events' }); - expect(resolveInitialRoute('#/racegow/run', false)).toEqual({ kind: 'racegow' }); }); }); diff --git a/frontend/e2e/overlay.spec.ts b/frontend/e2e/overlay.spec.ts new file mode 100644 index 00000000..7cb1ab54 --- /dev/null +++ b/frontend/e2e/overlay.spec.ts @@ -0,0 +1,124 @@ +/** + * The race overlay, end to end, in real headless chromium against the worker's real Director: an + * ordinary event's heat is scheduled and started over the control path, then the OBS browser-source + * page (`#/overlay/race`) renders it — the event and heat names, the clock, a row per pilot with + * climbing laps — read-only, on a transparent document, in both contrasts. Then the workspace's + * **Overlays** tab offers that page's URL and frames a live preview of it. + * + * Nothing about the event is special: the overlay follows the Director's active event, whatever it + * is. Importing `test`/`expect` from `./observability.js` means a failure carries the full-stack + * dump (browser console, page errors, the Director's log). + */ +import { expect, test } from './observability.js'; + +const PILOTS = ['Ace', 'Bee', 'Cee']; +const HEAT_ID = 'ov-1'; +const ROUND_LABEL = 'E2E-Overlay'; + +test('the race overlay follows an ordinary event’s heat, and the Overlays tab hands out its URL', async ({ + page, + director +}) => { + // ── Make the event active, the way an RD does: open it from the Events page ──────────────── + await page.goto('/'); + await page.getByRole('button', { name: /Events/ }).click(); + const liveNav = page.getByRole('button', { name: /Race control/ }); + await expect( + page.getByRole('heading', { name: 'Choose an event' }).or(liveNav).first() + ).toBeVisible({ timeout: 15_000 }); + if ( + await page + .getByRole('heading', { name: 'Choose an event' }) + .isVisible() + .catch(() => false) + ) { + await page + .getByRole('button', { name: /Practice/ }) + .first() + .click(); + } + await expect(liveNav).toBeVisible(); + + // ── A round and a heat of three, focused and started over the open control path ──────────── + const roundRes = await page.request.post(`${director.eventRoot}/rounds`, { + headers: { 'Content-Type': 'application/json' }, + data: { + label: ROUND_LABEL, + classes: [], + format: 'timed_qual', + params: { rounds: '1' }, + win_condition: 'BestLap', + time_limit_secs: 60, + seeding: 'FromRoster', + channel_mode: 'PerHeat' + } + }); + expect(roundRes.ok()).toBeTruthy(); + const roundId = ((await roundRes.json()) as { id: string }).id; + for (const command of [ + { ScheduleHeat: { heat: HEAT_ID, lineup: PILOTS, round: roundId } }, + { SetCurrentHeat: { heat: HEAT_ID } }, + { Stage: { heat: HEAT_ID } }, + { Start: { heat: HEAT_ID } } + ]) { + const ack = await page.request.post(`${director.eventRoot}/control`, { + headers: { 'Content-Type': 'application/json' }, + data: command + }); + expect(ack.ok(), JSON.stringify(command)).toBeTruthy(); + } + + // ── The overlay: the OBS browser source follows the active event's heat, read-only ───────── + await page.goto(`/#/overlay/race`); + const overlay = page.getByLabel('Race overlay'); + await expect(overlay).toBeVisible({ timeout: 15_000 }); + // The event and the heat, by their friendly names — never a raw id. + await expect(page.getByText(`Practice · ${ROUND_LABEL} Heat 1`)).toBeVisible({ timeout: 15_000 }); + // A 60 s race time: the primary clock counts down, as the console's does. + await expect(page.getByRole('timer', { name: /Time remaining/ })).toBeVisible({ + timeout: 15_000 + }); + await expect(page.getByText('LIVE')).toBeVisible({ timeout: 15_000 }); + // One row per pilot, named; their laps climb as the sim flies. + const table = page.getByRole('table', { name: 'Pilots in this heat' }); + for (const pilot of PILOTS) { + await expect(table.getByText(pilot, { exact: true })).toBeVisible(); + } + const lapCells = table.locator('tbody tr td:nth-child(3)'); + const totalLaps = async () => { + const texts = await lapCells.allTextContents(); + return texts.reduce((sum, t) => sum + (parseInt(t.trim(), 10) || 0), 0); + }; + await expect + .poll(totalLaps, { timeout: 30_000, message: 'laps should appear on the overlay' }) + .toBeGreaterThan(0); + // The document is flagged transparent for the keyer. + await expect(page.locator('html')).toHaveAttribute('data-overlay', 'race'); + await expect(page.locator('body')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); + + // The light variant: same overlay, dark text on a light panel, still transparent underneath. + await page.goto(`/#/overlay/race/light`); + await expect(page.getByLabel('Race overlay')).toHaveAttribute('data-theme', 'light'); + await expect(page.getByText(`Practice · ${ROUND_LABEL} Heat 1`)).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('body')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); + + // ── The Overlays tab inside the event: the URL for OBS, and a live preview of the page ───── + await page.goto(`/#/event/overlays`); + await expect(page.getByRole('heading', { name: 'Overlays' })).toBeVisible({ timeout: 15_000 }); + const url = page.getByLabel('Race timer URL'); + await expect(url).toHaveText(new RegExp(`^${director.baseUrl}/#/overlay/race$`)); + await page.getByRole('radio', { name: 'Dark text on light' }).click(); + await expect(url).toHaveText(/#\/overlay\/race\/light$/); + // The preview frames the real route; inside it, the same heat is rendering. + const preview = page.frameLocator('iframe[title="Race timer preview"]'); + await expect(preview.getByLabel('Race overlay')).toBeVisible({ timeout: 15_000 }); + await expect(preview.getByText(`Practice · ${ROUND_LABEL} Heat 1`)).toBeVisible({ + timeout: 15_000 + }); + + // ── Tidy: end the heat so the worker's Director is quiet for the next spec ───────────────── + await page.request.post(`${director.eventRoot}/control`, { + headers: { 'Content-Type': 'application/json' }, + data: { ForceEnd: { heat: HEAT_ID } } + }); +}); diff --git a/frontend/e2e/racegow.spec.ts b/frontend/e2e/racegow.spec.ts deleted file mode 100644 index 43b4ffc7..00000000 --- a/frontend/e2e/racegow.spec.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * RaceGOW — the solo track-run loop, end to end, in real headless chromium against a real - * Director: hub → Events → "+ New event" → RaceGOW track run → set up a run on the Simulator → - * Start run → laps climb → Stop - * run → the best 3-lap window and the submission line → Fly again → two runs in the table. Then - * the OBS overlay page renders the same run, read-only, on a transparent document. - * - * This spec boots its **own** Director (a fresh registry: no pilots, no events — the first-run - * state a pilot installing GridFPV for RaceGOW is in) with the built-in Simulator emitting six - * laps a run, and drives real clicks. Nothing is mocked. - */ -import { resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { expect, test } from '@playwright/test'; - -import { type Director, startDirector } from '../test-harness/director.js'; - -const here = fileURLToPath(new URL('.', import.meta.url)); -const dist = resolve(here, '..', 'apps', 'rd-console', 'dist'); - -const TRACK = 'RaceGOW6 Track 1'; -const PILOT = 'Recon'; -/** Six sim laps per run: enough for a 3-lap window with room either side. */ -const SIM_LAPS = 6; - -let director: Director; - -test.beforeAll(async () => { - director = await startDirector({ token: false, assets: dist, simLaps: SIM_LAPS, simLapMs: 250 }); -}); - -test.afterAll(async () => { - await director?.stop(); -}); - -test('a pilot sets up a track run, flies two runs, and gets a submission line', async ({ - page -}) => { - await page.goto(director.baseUrl); - - // ── Hub → Events → "+ New event" → RaceGOW track run ─────────────────────────────────── - // There is no RaceGOW card on the hub: a track run is an event, so it starts where events - // do. A fresh Director has none, so the first-run call to action is the way in. - await expect(page.getByRole('heading', { name: 'Events' })).toBeVisible({ timeout: 15_000 }); - await expect(page.getByRole('heading', { name: 'RaceGOW' })).toHaveCount(0); - await page.getByRole('heading', { name: 'Events' }).click(); - await page.getByRole('button', { name: 'Create your first event' }).click(); - const newEvent = page.getByRole('form', { name: 'New event' }); - await newEvent.getByRole('radio', { name: 'RaceGOW track run' }).check(); - await page.getByRole('button', { name: 'Set up track run' }).click(); - await expect(page).toHaveURL(/#\/racegow$/); - const form = page.getByRole('form', { name: 'New track run' }); - await expect(form).toBeVisible(); - - // ── The form: a fresh Director has no pilots, so the callsign field is up; the built-in - // Simulator is the only timer, and it carries Raceband, so a channel is offered. ───────── - await expect(form.getByLabel('Track')).toHaveValue(TRACK); - await form.getByLabel('Callsign').fill(PILOT); - await expect(form.getByLabel('Timer')).toHaveValue('mock'); - await expect(form.getByLabel('Your channel')).toBeVisible(); - await form.getByRole('button', { name: 'Set up run' }).click(); - - // ── The run screen, on the event the preset built ─────────────────────────────────────── - await expect(page).toHaveURL(/#\/racegow\/run$/, { timeout: 15_000 }); - await expect(page.getByRole('heading', { name: TRACK })).toBeVisible({ timeout: 15_000 }); - await expect(page.getByText(`${PILOT} on Mock (Simulator)`)).toBeVisible(); - - // The event really exists server-side, shaped by the preset: one Whoop-class round, scored by - // best 3 consecutive laps, open-ended, with the pilot on the roster. - const events = (await (await fetch(`${director.baseUrl}/events`)).json()) as { - id: string; - name: string; - preset?: string; - roster: string[]; - classes: string[]; - rounds?: { format: string; params: Record; win_condition: unknown }[]; - }[]; - expect(events).toHaveLength(1); - const event = events[0]; - expect(event.name).toBe(TRACK); - expect(event.preset).toBe('racegow'); - expect(event.roster).toHaveLength(1); - expect(event.classes).toEqual(['mgp-whoop']); - expect(event.rounds).toHaveLength(1); - expect(event.rounds![0].format).toBe('timed_qual'); - expect(event.rounds![0].params).toEqual({ rounds: '0' }); - expect(event.rounds![0].win_condition).toEqual({ BestConsecutive: { n: 3 } }); - - // ── Run 1: Start run → the sim flies its laps → Stop run ───────────────────────────────── - const run = page.getByRole('region', { name: 'Current run' }); - await run.getByRole('button', { name: 'Start run' }).click(); - // The Simulator emits laps once the heat is Running (after the fixed 1 s hold). - const laps = run.getByRole('list', { name: "This run's laps" }); - await expect(laps.getByRole('listitem').filter({ hasText: 'Lap' })).toHaveCount(SIM_LAPS, { - timeout: 20_000 - }); - // With ≥ 3 laps the live best-3 readout has a time. - await expect(run.getByLabel("This run's best 3 consecutive laps")).not.toHaveText('—'); - await run.getByRole('button', { name: 'Stop run' }).click(); - - // Stopped + saved: the headline is the served round-wide best, the table lists run 1 as saved, - // and the submission line is ready with the track and the pilot. - await expect(run.getByRole('button', { name: 'Fly again' })).toBeVisible({ timeout: 15_000 }); - await expect(page.getByLabel('Best 3 consecutive laps, all runs')).not.toHaveText('—', { - timeout: 15_000 - }); - const table = page.getByRole('table', { name: 'Runs' }); - await expect(table.getByRole('row').filter({ hasText: 'Run 1' })).toContainText('saved'); - const submission = page.getByLabel('Submission text'); - await expect(submission).toHaveValue(new RegExp(`${TRACK} — ${PILOT}`)); - await expect(submission).toHaveValue(/Best 3 consecutive laps: \d+\.\d{3} s/); - await expect(submission).toHaveValue(/Run 1 of 1/); - - // ── Run 2: Fly again draws the next heat and runs it ───────────────────────────────────── - await run.getByRole('button', { name: 'Fly again' }).click(); - await expect(laps.getByRole('listitem').filter({ hasText: 'Lap' })).toHaveCount(SIM_LAPS, { - timeout: 20_000 - }); - await run.getByRole('button', { name: 'Stop run' }).click(); - await expect(run.getByRole('button', { name: 'Fly again' })).toBeVisible({ timeout: 15_000 }); - await expect(table.getByRole('row').filter({ hasText: 'Run 2' })).toContainText('saved'); - await expect(submission).toHaveValue(/of 2/); - - // The server agrees: two Final heats on the round, and the standings carry a best-3 window. - const heats = (await (await fetch(`${director.baseUrl}/events/${event.id}/heats`)).json()) as { - phase: string; - }[]; - expect(heats.map((h) => h.phase)).toEqual(['Final', 'Final']); - const roundId = ( - (await (await fetch(`${director.baseUrl}/events`)).json()) as { rounds: { id: string }[] }[] - )[0].rounds[0].id; - const standings = (await ( - await fetch(`${director.baseUrl}/events/${event.id}/rounds/${roundId}/standings`) - ).json()) as { metric: { BestConsecutive?: { n: number; micros: number | null } } }[]; - expect(standings).toHaveLength(1); - expect(standings[0].metric.BestConsecutive?.n).toBe(3); - expect(standings[0].metric.BestConsecutive?.micros).not.toBeNull(); - - // ── The overlay: the OBS browser source follows the active run, read-only ─────────────── - await page.goto(`${director.baseUrl}/#/overlay/racegow`); - await expect(page.getByLabel('RaceGOW overlay')).toBeVisible({ timeout: 15_000 }); - await expect(page.getByText(`${TRACK} · ${PILOT}`)).toBeVisible({ timeout: 15_000 }); - await expect(page.getByRole('timer', { name: /Run time/ })).toBeVisible(); - await expect(page.getByLabel('Best 3 consecutive laps, all runs')).not.toHaveText('—', { - timeout: 15_000 - }); - // The document is flagged transparent for the keyer. - await expect(page.locator('html')).toHaveAttribute('data-overlay', 'racegow'); - await expect(page.locator('body')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); - - // The light variant: same overlay, dark text on a light panel, still transparent underneath. - await page.goto(`${director.baseUrl}/#/overlay/racegow/light`); - await expect(page.getByLabel('RaceGOW overlay')).toHaveAttribute('data-theme', 'light'); - await expect(page.getByText(`${TRACK} · ${PILOT}`)).toBeVisible({ timeout: 15_000 }); - await expect(page.locator('body')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); - - // ── The run's Events crumb leaves the run and lands on the Events page, where the run lists - // with its badge; entering it there opens the run screen again (not the workspace): the - // preset marker routes it. ────────────────────────────────────────────────────────────── - await page.goto(`${director.baseUrl}/#/racegow/run`); - await expect(page.getByRole('heading', { name: TRACK })).toBeVisible({ timeout: 15_000 }); - await page - .getByRole('navigation', { name: 'Breadcrumb' }) - .getByRole('button', { name: 'Events' }) - .click(); - await expect(page.getByRole('heading', { name: 'Choose an event' })).toBeVisible({ - timeout: 15_000 - }); - // Anchored: the row's delete button is named "Delete " and would match a loose regex. - const row = page.getByRole('button', { name: new RegExp(`^${TRACK}`) }); - await expect(row).toBeVisible(); - await expect(row.getByText('RaceGOW', { exact: true })).toBeVisible(); - await row.click(); - await expect(page).toHaveURL(/#\/racegow\/run$/, { timeout: 15_000 }); - await expect(page.getByRole('heading', { name: TRACK })).toBeVisible(); - - // ── The RaceGOW page still lists every run and reopens one ────────────────────────────── - await page.goto(`${director.baseUrl}/#/racegow`); - const runs = page.getByRole('list', { name: 'Your runs' }); - await expect(runs.getByRole('listitem')).toHaveCount(1, { timeout: 15_000 }); - await runs.getByRole('button', { name: `Open ${TRACK}` }).click(); - await expect(page).toHaveURL(/#\/racegow\/run$/, { timeout: 15_000 }); - await expect(page.getByRole('heading', { name: TRACK })).toBeVisible(); -}); diff --git a/src-tauri/README.md b/src-tauri/README.md index cfd75e44..0020030e 100644 --- a/src-tauri/README.md +++ b/src-tauri/README.md @@ -21,11 +21,20 @@ On launch the app: 1. Builds a multi-thread tokio runtime and spawns the **same** Director the hosted `gridfpv` binary runs — via the shared `gridfpv_app::director::run_director` — as a background task. - It binds **loopback on an ephemeral free port** (`127.0.0.1:0`), uses a **true-portable - data dir** (`gridfpv-data/` next to the executable, with a per-user app-data fallback) for - created events' SQLite files, and serves the **bundled** `rd-console` dist as the SPA. -2. Waits for the Director to report its OS-assigned port, then opens the main window pointed - at `http://127.0.0.1:/`. + It binds **loopback on a fixed port** (`127.0.0.1:8080` by default; `GRIDFPV_ADDR` + overrides it), uses a **true-portable data dir** (`gridfpv-data/` next to the executable, + with a per-user app-data fallback) for created events' SQLite files, and serves the + **bundled** `rd-console` dist as the SPA. +2. Waits for the Director to report the port it bound, then opens the main window pointed at + `http://127.0.0.1:/`. + +**Why a fixed port.** The app used to bind `127.0.0.1:0` and take whatever port the OS gave +it, which changed on every restart — and an **OBS browser source** holding an overlay URL +(`http://127.0.0.1:/#/overlay/race`) broke every time. The port is now the same one the +hosted binary defaults to, and the same after every launch. If it is taken, the app falls back +to an ephemeral port **and says so in the log** rather than refusing to start; set +`GRIDFPV_ADDR=127.0.0.1:` to pick a different fixed port (or a LAN address for OBS on +another machine — off loopback, set `GRIDFPV_RD_TOKEN` to gate control). Because the window loads the Director's HTTP origin directly (rather than the bundled assets over `tauri://`), the SPA's **same-origin** API calls (`/snapshot/...`, `/control/...`) and @@ -43,8 +52,8 @@ The Director's serve entry point was extracted into one reusable function, - the hosted `gridfpv` binary (`crates/app/src/main.rs`) — env-resolved address, Ctrl-C shutdown, prints the startup banner; behavior unchanged; and -- this native app (`src-tauri/src/lib.rs`) — `127.0.0.1:0`, per-user data dir, bundled - assets, and an `on_ready` that reads the ephemeral port. +- this native app (`src-tauri/src/lib.rs`) — `127.0.0.1:8080` (or `GRIDFPV_ADDR`), the + portable data dir, bundled assets, and an `on_ready` that reads the bound port. So the two share identical Director wiring (event registry, optional RD token, sim lap-source bridge, presence reconciler, router, CORS). @@ -120,16 +129,16 @@ chmod +x src-tauri/target/release/gridfpv-desktop ./src-tauri/target/release/gridfpv-desktop ``` -A native window opens with the RD console, backed by the in-process Director on a private -loopback port. The maintainer runs this over RDP (which provides a display). A `gridfpv-data/` +A native window opens with the RD console, backed by the in-process Director on loopback +port 8080 (`http://127.0.0.1:8080/`, the address OBS browser sources and other tabs can hold). The maintainer runs this over RDP (which provides a display). A `gridfpv-data/` folder appears next to the binary, holding created events' SQLite files. > **Headless note:** the GUI window needs a display, so on a headless VM the window itself > can't render. The **server half** (the embedded Director) needs no display and is covered > by an integration test — `crates/app/tests/director.rs :: > run_director_serves_health_on_loopback_ephemeral_port` — which drives the exact -> `run_director` entry point the app uses, binds a loopback ephemeral port, and asserts -> `/health` answers over a real socket. The full app can also be smoke-launched headless with +> `run_director` entry point the app uses (on an ephemeral port, so the test never collides +> with a running Director) and asserts `/health` answers over a real socket. The full app can also be smoke-launched headless with > `xvfb-run` (set `WEBKIT_DISABLE_DMABUF_RENDERER=1 LIBGL_ALWAYS_SOFTWARE=1` to avoid the > WebKit GPU path hanging under Xvfb). diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7a383a3f..9cf9c7b6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,16 +9,32 @@ //! //! 1. Builds a multi-thread tokio runtime and spawns the **same** Director the hosted //! `gridfpv` binary runs — via the shared [`gridfpv_app::director::run_director`] — as a -//! background task. It binds **loopback on an ephemeral free port** (`127.0.0.1:0`), uses -//! a **true-portable data dir** (`gridfpv-data/` beside the running executable, with a +//! background task. It binds **loopback on a fixed, well-known port** +//! ([`DEFAULT_ADDR`], `127.0.0.1:8080`, overridable with `GRIDFPV_ADDR`), uses a +//! **true-portable data dir** (`gridfpv-data/` beside the running executable, with a //! per-user app-data fallback when the exe dir isn't writable) for created events, and //! serves the `rd-console` SPA **embedded into the binary** (`gridfpv-app`'s `embed-assets` //! feature) — so this executable is self-contained, with no external dist folder beside it. //! Loopback ⇒ the control path is open (no auth), per the GridFPV auth model (loopback = //! trusted). -//! 2. Waits (briefly) for the Director to report the OS-assigned port, then opens the main +//! 2. Waits (briefly) for the Director to report the port it bound, then opens the main //! window pointed at `http://127.0.0.1:/`. //! +//! # Why a fixed port, not an ephemeral one +//! +//! The app used to bind `127.0.0.1:0` and let the OS pick. That is fine for the window — the +//! shell learns the port and points the webview at it — and wrong for everything else that +//! holds the Director's address: an **OBS browser source** carrying an overlay URL +//! (`#/overlay/race`), a bookmark, a second browser tab. Every restart handed out a new port +//! and silently broke each of them, so the overlay in the pilot's recording went blank until +//! they re-pasted a URL. The address is now stable across restarts, like the hosted binary's. +//! +//! If the fixed port is **taken** (another Director, a dev server), the app still comes up: it +//! falls back to an ephemeral port and says so in the log, loudly, rather than refusing to +//! start — a window with a one-off port beats no window. `GRIDFPV_ADDR` picks a different +//! fixed port (or a LAN bind, for OBS on another machine — mind the auth model: off loopback, +//! set `GRIDFPV_RD_TOKEN` to gate control). +//! //! Pointing the webview at the Director's HTTP origin (rather than loading the bundled //! assets over the `tauri://` protocol) is deliberate: the SPA makes **same-origin** //! relative calls to the protocol API (`/snapshot/...`, `/control/...`) and opens the @@ -36,6 +52,7 @@ use std::net::{Ipv4Addr, SocketAddr}; use std::path::PathBuf; +use std::sync::{Arc, Mutex}; use tauri::{Manager, WebviewUrl, WebviewWindowBuilder}; use tokio::sync::oneshot; @@ -51,6 +68,35 @@ macro_rules! eprintln { ($($arg:tt)*) => { gridfpv_app::logging::record(::std::format_args!($($arg)*)) }; } +/// The address the embedded Director binds by default: **loopback, port 8080** — the same +/// port the hosted `gridfpv` binary defaults to, so the console's address is the one every +/// guide already prints, and it is the **same after every restart** (see the module docs for +/// why that matters: OBS browser sources hold the overlay URL). +pub const DEFAULT_ADDR: SocketAddr = + SocketAddr::new(std::net::IpAddr::V4(Ipv4Addr::LOCALHOST), 8080); + +/// The env var that overrides [`DEFAULT_ADDR`] — the same name the hosted binary honours. +pub const ADDR_ENV: &str = "GRIDFPV_ADDR"; + +/// Resolve the address to bind: `GRIDFPV_ADDR` when set and valid, else [`DEFAULT_ADDR`]. An +/// unparsable value is reported and ignored rather than aborting the launch — the fixed +/// default is always a usable answer. +fn requested_addr() -> SocketAddr { + match std::env::var(ADDR_ENV) { + Ok(value) => match value.trim().parse::() { + Ok(addr) => addr, + Err(err) => { + eprintln!( + "gridfpv-desktop: {ADDR_ENV}={value:?} is not a valid socket address ({err}) — \ + using {DEFAULT_ADDR}" + ); + DEFAULT_ADDR + } + }, + Err(_) => DEFAULT_ADDR, + } +} + /// The env var that opts into the Linux WebKitGTK rendering workarounds (#476). /// /// Accepts (case-insensitive, comma-separated): @@ -187,33 +233,63 @@ pub fn run() { .build() .expect("failed to build the Director tokio runtime"); - // `oneshot` carries the OS-assigned ephemeral port from the Director's - // `on_ready` (inside the runtime) back out to the setup thread so we can open the - // window at the right URL. + // `oneshot` carries the port the Director actually bound from its `on_ready` + // (inside the runtime) back out to the setup thread so we can open the window at + // the right URL. Shared between the first attempt and the fallback below. let (port_tx, port_rx) = oneshot::channel::(); + let port_tx = Arc::new(Mutex::new(Some(port_tx))); - // Spawn the Director on the runtime. Bind `127.0.0.1:0` ⇒ loopback + ephemeral - // free port (no auth, per the auth model). It runs until the process exits; the - // app quitting tears the runtime (and the server) down. + // Spawn the Director on the runtime, on the FIXED loopback address (no auth, per + // the auth model) — the same one after every restart, so an OBS browser source + // holding an overlay URL keeps working. It runs until the process exits; the app + // quitting tears the runtime (and the server) down. + let requested = requested_addr(); runtime.spawn(async move { - let mut port_tx = Some(port_tx); - let result = gridfpv_app::director::run_director( - SocketAddr::from((Ipv4Addr::LOCALHOST, 0)), - Some(data_dir), - assets_dir, - move |ready| { - // Hand the bound ephemeral port back to the setup thread. - if let Some(tx) = port_tx.take() { - let _ = tx.send(ready.bound.port()); + let serve = |addr: SocketAddr, data_dir: PathBuf, assets_dir: PathBuf| { + let port_tx = port_tx.clone(); + gridfpv_app::director::run_director( + addr, + Some(data_dir), + assets_dir, + move |ready| { + // Hand the bound port back to the setup thread. + if let Some(tx) = port_tx.lock().unwrap().take() { + let _ = tx.send(ready.bound.port()); + } + }, + // No graceful-shutdown trigger: the server lives for the app's + // lifetime and is torn down when the process exits. + std::future::pending::<()>(), + ) + }; + let bound_fixed = port_tx.clone(); + // The error is flattened to a `String` before the next `.await`: `Box` + // is not `Send`, and holding it across the fallback's await would make this + // future unspawnable. + let result = serve(requested, data_dir.clone(), assets_dir.clone()) + .await + .map_err(|err| err.to_string()); + // `run_director` returns early only on a failure. If it never reported a port, + // the bind itself failed (the port is taken, or the address is not local): + // fall back to an ephemeral port so the window still opens — and say so, since + // every URL anyone pasted elsewhere is now wrong until the port is freed. + let never_bound = bound_fixed.lock().unwrap().is_some(); + match result { + Err(err) if never_bound => { + eprintln!( + "gridfpv-desktop: could not bind {requested} ({err}). Falling back to \ + an EPHEMERAL loopback port — the console's address will differ from \ + the usual one until whatever holds {requested} is stopped (or set \ + {ADDR_ENV} to another fixed port). OBS browser sources pointing at \ + the usual address will not connect in this session." + ); + let fallback = SocketAddr::from((Ipv4Addr::LOCALHOST, 0)); + if let Err(err) = serve(fallback, data_dir, assets_dir).await { + eprintln!("gridfpv-desktop: Director exited with error: {err}"); } - }, - // No graceful-shutdown trigger: the server lives for the app's lifetime - // and is torn down when the process exits. - std::future::pending::<()>(), - ) - .await; - if let Err(err) = result { - eprintln!("gridfpv-desktop: Director exited with error: {err}"); + } + Err(err) => eprintln!("gridfpv-desktop: Director exited with error: {err}"), + Ok(()) => {} } }); @@ -222,6 +298,12 @@ pub fn run() { let port = port_rx .blocking_recv() .expect("the embedded Director failed to report its bound port"); + if port != requested.port() { + eprintln!( + "gridfpv-desktop: NOTE — serving on port {port}, not the configured \ + {requested}; see the bind failure above" + ); + } let url = format!("http://{}:{port}/", Ipv4Addr::LOCALHOST); eprintln!("gridfpv-desktop: embedded Director ready — opening {url}");