From 9c0e5103ac071fa0c9035c2aff2a85d39629d689 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 19:22:10 +0000 Subject: [PATCH 1/3] fix(polish): final-freeze pass gate, skew-safe rendering, tone round-trip, orphan pruning + zoom tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release-polish batch over the deferred audit items: - FINAL FREEZE (audit round-2 §4 deferral, the top remaining integrity item): a pass landing at/after a heat's Finalized transition never joins its window (heat, class, and live folds) — a delayed RotorHazard catch-up pass, tagged or untagged, could silently change an official result with no command and no audit entry. Rulings are unaffected: Revert re-opens marshaling, not the observation record. - Version-skew rendering: an unmodeled timer kind now tags/labels/tones as unknown with an update nudge (it mislabeled as RotorHazard and field access CRASHED the Timers page); an unmodeled result metric labels 'unsupported' instead of a DNF-reading em dash. - Start-procedure round-trip: the rounds form models only the min/max delay but the server replaces the round wholesale — editing any round ERASED a stored start tone (and would flatten a future non-randomized mode). The stored procedure now round-trips verbatim under the form's fields, the same preserved-seeding pattern that saved bracket chains. - Housekeeping: bridge + presence tasks EXIT when their event is deleted (the log Arc kept orphans polling forever) and the spawners prune their attached sets; the presence reconciler dedups CompetitorSeen within a poll batch. - Test debt: the RSSI zoom/pan surface gets its suite — wheel/buttons/Fit, and the deferred-capture regression (a plain marker click selects while zoomed; a real drag captures and pans). Pan/zoom coordinates are NaN-guarded against coordinate-less synthetic pointer events. Co-Authored-By: Claude Fable 5 --- crates/app/src/source.rs | 24 +++++ crates/server/src/app.rs | 78 +++++++++++++++-- crates/server/src/live_state.rs | 31 ++++++- .../apps/rd-console/src/lib/RssiGraph.svelte | 5 +- frontend/apps/rd-console/src/lib/timers.ts | 25 ++++-- .../rd-console/src/screens/EventRounds.svelte | 25 +++++- .../apps/rd-console/tests/EventRounds.test.ts | 39 +++++++++ .../apps/rd-console/tests/RssiGraph.test.ts | 87 +++++++++++++++++++ frontend/apps/rd-console/tests/timers.test.ts | 19 +++- frontend/packages/components/src/format.ts | 5 +- .../packages/components/tests/format.test.ts | 5 ++ 11 files changed, 322 insertions(+), 21 deletions(-) diff --git a/crates/app/src/source.rs b/crates/app/src/source.rs index 217d635..c27eec7 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -511,6 +511,11 @@ pub fn spawn_registry_bridge( let mut attached: HashSet = HashSet::new(); let mut ticker = tokio::time::interval(REGISTRY_POLL_INTERVAL); loop { + // Prune ids that left the registry: their per-event tasks exit on their own (they + // watch for the event's disappearance), and dropping them here keeps this set from + // growing forever across create/delete cycles. + let live: HashSet = registry.list().into_iter().map(|m| m.id).collect(); + attached.retain(|id| live.contains(id)); for meta in registry.list() { if attached.contains(&meta.id) { continue; @@ -601,6 +606,12 @@ pub(crate) async fn run_bridge( loop { ticker.tick().await; + // A DELETED event ends its bridge: the AppState Arc outlives the registry entry, so + // without this check the orphaned task would poll a dead log forever. + if registry.resolve(&event_id).is_none() { + return; + } + // Reap a finished/cancelled source task so a heat that ran to the end clears the // slot (without it, a re-Start of the same heat would be ignored). A heat with an armed // RotorHazard connection is NOT reaped on the Mock tasks finishing — the live connection @@ -699,6 +710,8 @@ pub fn spawn_presence_reconciler(registry: EventRegistry) -> JoinHandle<()> { let mut attached: HashSet = HashSet::new(); let mut ticker = tokio::time::interval(REGISTRY_POLL_INTERVAL); loop { + let live: HashSet = registry.list().into_iter().map(|m| m.id).collect(); + attached.retain(|id| live.contains(id)); for meta in registry.list() { if attached.contains(&meta.id) { continue; @@ -734,6 +747,10 @@ pub(crate) async fn run_presence_reconciler( let mut ticker = tokio::time::interval(POLL_INTERVAL); loop { ticker.tick().await; + // A DELETED event ends its reconciler (the log Arc alone would keep it alive forever). + if registry.resolve(&event_id).is_none() { + return; + } let new_events = match read_tail(&state, cursor) { Ok(batch) => batch, // A poisoned lock (or a dropped log at shutdown) ends the reconciler cleanly. @@ -748,6 +765,10 @@ pub(crate) async fn run_presence_reconciler( Ok(events) => registrations(events.iter()), Err(_) => continue, }; + // Dedup WITHIN the batch: `bindings` was folded once before the loop, so two + // CompetitorSeen for the same key in one poll batch both read "unbound" and appended + // twice (benign — same key/value, last-wins fold — but noise in the log). + let mut seen_this_batch: HashSet<(AdapterId, CompetitorRef)> = HashSet::new(); for (offset, event) in new_events { cursor = offset + 1; if let Event::CompetitorSeen { @@ -755,6 +776,9 @@ pub(crate) async fn run_presence_reconciler( competitor, } = event { + if !seen_this_batch.insert((adapter.clone(), competitor.clone())) { + continue; + } reconcile_seen( &state, ®istry, &pilots, &event_id, &bindings, adapter, competitor, ); diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 8f25454..fee80cc 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -1710,9 +1710,14 @@ pub(crate) fn class_window_offsets(events: &[Event], class: &ClassId) -> Vec<(u6 } } // A tagged pass belongs to its stamped heat — in or out by class membership, - // independent of the positional cursor (same rule as `heat_window_offsets`). + // independent of the positional cursor (same rule as `heat_window_offsets`), + // and frozen out once that heat's run is official (the Final freeze). Event::Pass(p) if p.heat.is_some() => { - if p.heat.as_ref().is_some_and(|h| class_heats.contains(h)) { + if p.heat.as_ref().is_some_and(|h| { + class_heats.contains(h) + && offset + < crate::live_state::current_run_pass_ceiling(events, h) as u64 + }) { window.push((offset, event.clone())); } } @@ -1921,6 +1926,7 @@ fn now_micros() -> i64 { /// they carry the lineup and the FSM lineage the folds need. pub(crate) fn heat_window_offsets(events: &[Event], heat: &HeatId) -> Vec<(u64, Event)> { let run_start = crate::live_state::current_run_start(events, heat) as u64; + let pass_ceiling = crate::live_state::current_run_pass_ceiling(events, heat) as u64; let mut window = Vec::new(); // The offsets already claimed by this window — a target-carrying ruling joins iff its // target is one of them (targets always point backwards, so one forward scan suffices). @@ -1952,11 +1958,17 @@ pub(crate) fn heat_window_offsets(events: &[Event], heat: &HeatId) -> Vec<(u64, } // Passes: by their bridge-stamped heat TAG when present (robust against a // heat-span event closing the positional span mid-race — the scheduling-eats-laps - // bug); an untagged (legacy) pass keeps the positional rule. - Event::Pass(p) => match &p.heat { - Some(h) => h == heat && offset >= run_start, - None => active && offset >= run_start, - }, + // bug); an untagged (legacy) pass keeps the positional rule. Either way, a pass + // landing at/after the run's `Finalized` is FROZEN OUT: once a result is official + // it cannot shift under a delayed catch-up pass with no command and no audit + // entry (rulings are unaffected — a Revert re-opens marshaling, not the record). + Event::Pass(p) => { + let before_official = offset < pass_ceiling; + match &p.heat { + Some(h) => h == heat && offset >= run_start && before_official, + None => active && offset >= run_start && before_official, + } + } // Untagged (legacy insertions, registrations, …): positional. _ => active && offset >= run_start, }; @@ -2096,6 +2108,58 @@ mod tests { ] } + /// The FINAL FREEZE: a pass landing AFTER a heat's run went official never joins its + /// window — a delayed RotorHazard catch-up pass (tagged or untagged) used to silently + /// change a Final result with no command, no ruling, and no audit entry. + #[test] + fn a_pass_landing_after_finalized_never_joins_the_official_record() { + let heat = HeatId("q-1".into()); + let mut events = recorded_heat(); + let window_before = heat_window_offsets(&events, &heat); + let passes_before = window_before + .iter() + .filter(|(_, e)| matches!(e, Event::Pass(_))) + .count(); + assert_eq!(passes_before, 5, "the run's real passes all count"); + + // A late catch-up pass TAGGED with the (now Final) heat… + let mut late = Pass { + adapter: AdapterId("vd".into()), + competitor: CompetitorRef("A".into()), + at: SourceTime::from_micros(9_000_000), + sequence: Some(9), + gate: GateIndex::LAP, + signal: None, + heat: Some(heat.clone()), + }; + events.push(Event::Pass(late.clone())); + // …and an UNTAGGED one (legacy positional attribution would claim it too). + late.heat = None; + late.sequence = Some(10); + events.push(Event::Pass(late)); + + let window_after = heat_window_offsets(&events, &heat); + let passes_after = window_after + .iter() + .filter(|(_, e)| matches!(e, Event::Pass(_))) + .count(); + assert_eq!( + passes_after, passes_before, + "the official record is frozen — late passes stay out" + ); + + // Rulings are NOT frozen (Revert re-opens marshaling): a void targeting a real run + // pass still joins the window. + events.push(Event::DetectionVoided { target: LogRef(6) }); + let with_ruling = heat_window_offsets(&events, &heat); + assert!( + with_ruling + .iter() + .any(|(_, e)| matches!(e, Event::DetectionVoided { .. })), + "rulings keep flowing into the window" + ); + } + /// A registry whose Practice event carries a round with `win_condition` and a heat `q-1` /// tagged with that round, driven Scheduled → Final over the given lap-gate `passes`. Used to /// prove the result projection scores under the heat's round win condition (#45). diff --git a/crates/server/src/live_state.rs b/crates/server/src/live_state.rs index c1759f2..a4263c7 100644 --- a/crates/server/src/live_state.rs +++ b/crates/server/src/live_state.rs @@ -265,6 +265,7 @@ fn live_state_core(events: &[Event], window: &[(u64, &Event)]) -> LiveRaceState // marshaling adjudication still addresses the right pass. A heat that never reset has no // boundary, so everything counts (a normally-finalized heat is unaffected). let run_start = current_run_start(events, ¤t_heat); + let pass_ceiling = current_run_pass_ceiling(events, ¤t_heat); let laps = lap_list_marshaled( window .iter() @@ -276,9 +277,12 @@ fn live_state_core(events: &[Event], window: &[(u64, &Event)]) -> LiveRaceState // Tag-aware pass attribution: a pass stamped for ANOTHER heat never counts // toward this one — selecting an older heat as current used to absorb every // later heat's passes (they all sit after its run_start). An untagged - // (legacy) pass keeps the positional rule. + // (legacy) pass keeps the positional rule. Either way a pass landing AFTER + // the run went official is frozen out (the Final freeze). match e { - Event::Pass(p) => p.heat.as_ref().is_none_or(|h| h == ¤t_heat), + Event::Pass(p) => { + *i < pass_ceiling && p.heat.as_ref().is_none_or(|h| h == ¤t_heat) + } _ => true, } }) @@ -604,6 +608,29 @@ pub(crate) fn current_run_start(events: &[Event], heat: &HeatId) -> usize { start } +/// The log index where the current run's **official record closes**: the first `Finalized` +/// transition for `heat` at/after [`current_run_start`], or `usize::MAX` while the run is not +/// (yet) official. Passes at/after this offset NEVER join the heat's window — once a result is +/// official it is frozen against late arrivals (a delayed RotorHazard catch-up pass tagged with +/// the heat used to silently change a Final result with no command, no ruling, and no audit +/// entry). A `Revert` re-opens marshaling (rulings are not passes and stay unaffected), but the +/// late passes stay out: the race's observation record ended with its run. +pub(crate) fn current_run_pass_ceiling(events: &[Event], heat: &HeatId) -> usize { + let run_start = current_run_start(events, heat); + events + .iter() + .enumerate() + .skip(run_start) + .find_map(|(i, e)| match e { + Event::HeatStateChanged { + heat: h, + transition: HeatTransition::Finalized, + } if h == heat => Some(i), + _ => None, + }) + .unwrap_or(usize::MAX) +} + /// The lineup of a heat: the competitors from its most recent `HeatScheduled`. fn lineup_of(events: &[Event], heat: &HeatId) -> Vec { let mut lineup = Vec::new(); diff --git a/frontend/apps/rd-console/src/lib/RssiGraph.svelte b/frontend/apps/rd-console/src/lib/RssiGraph.svelte index bfec842..7bdce66 100644 --- a/frontend/apps/rd-console/src/lib/RssiGraph.svelte +++ b/frontend/apps/rd-console/src/lib/RssiGraph.svelte @@ -409,7 +409,9 @@ function startPan(e: PointerEvent, ref: CompetitorRef): void { if (!zoom || zoom.ref !== ref) return; - panning = { ref, lastX: pointerX(e, e.currentTarget as SVGSVGElement), engaged: false }; + const x = pointerX(e, e.currentTarget as SVGSVGElement); + if (!Number.isFinite(x)) return; // ditto — never seed a drag from a coordinate-less event + panning = { ref, lastX: x, engaged: false }; } function movePan(e: PointerEvent, full: { from: number; to: number }): void { @@ -417,6 +419,7 @@ const svg = e.currentTarget as SVGSVGElement; const x = pointerX(e, svg); const dx = x - panning.lastX; + if (!Number.isFinite(dx)) return; // a coordinate-less synthetic event must not corrupt the view if (!panning.engaged) { if (Math.abs(dx) < PAN_START_UNITS) return; // a click in progress, not a pan // A real drag: NOW capture (safe — any click this gesture could produce is a drag end). diff --git a/frontend/apps/rd-console/src/lib/timers.ts b/frontend/apps/rd-console/src/lib/timers.ts index 2b27e25..eeb9287 100644 --- a/frontend/apps/rd-console/src/lib/timers.ts +++ b/frontend/apps/rd-console/src/lib/timers.ts @@ -8,8 +8,11 @@ */ import type { Timer, TimerKind } from '@gridfpv/types'; -/** The two selectable kinds in the add/edit dialog (the discriminant tag). */ -export type TimerKindTag = 'Mock' | 'Rotorhazard'; +/** The two selectable kinds in the add/edit dialog (the discriminant tag). `'Unknown'` is the + * version-skew fallback: a NEWER Director may send a kind this console build doesn't model + * yet, and it must render labeled (not mislabeled as RotorHazard, and never crash on a field + * access) — the timer is still real and still selectable. */ +export type TimerKindTag = 'Mock' | 'Rotorhazard' | 'Unknown'; /** Sensible defaults for a fresh **Mock** timer: a handful of laps at a one-minute-ish pace. */ export const DEFAULT_MOCK_LAPS = 3; @@ -17,17 +20,24 @@ export const DEFAULT_MOCK_LAP_MS = 30_000; /** The discriminant tag of a kind (`'Mock'` | `'Rotorhazard'`). */ export function kindTag(kind: TimerKind): TimerKindTag { - return 'Mock' in kind ? 'Mock' : 'Rotorhazard'; + if ('Mock' in kind) return 'Mock'; + if ('Rotorhazard' in kind) return 'Rotorhazard'; + return 'Unknown'; } /** The short display label for the kind **badge** (RotorHazard is the brand spelling). */ export function kindLabel(kind: TimerKind): string { - return 'Mock' in kind ? 'Mock' : 'RotorHazard'; + if ('Mock' in kind) return 'Mock'; + if ('Rotorhazard' in kind) return 'RotorHazard'; + // A newer Director's kind: show its discriminant verbatim rather than a wrong brand. + return Object.keys(kind)[0] ?? 'Unknown'; } /** The Badge `tone` for a kind: Mock is the brand accent; RotorHazard reads as informational. */ -export function kindTone(kind: TimerKind): 'accent' | 'info' { - return 'Mock' in kind ? 'accent' : 'info'; +export function kindTone(kind: TimerKind): 'accent' | 'info' | 'neutral' { + if ('Mock' in kind) return 'accent'; + if ('Rotorhazard' in kind) return 'info'; + return 'neutral'; } /** A one-line summary of a kind's config for the timer row (the sim pace, or the RH url). */ @@ -37,7 +47,8 @@ export function kindSummary(kind: TimerKind): string { const lapName = laps === 1 ? 'lap' : 'laps'; return `${laps} ${lapName} · ${(lap_ms / 1000).toFixed(1)}s pace`; } - return kind.Rotorhazard.url || 'No URL set'; + if ('Rotorhazard' in kind) return kind.Rotorhazard.url || 'No URL set'; + return 'Unsupported by this console build — update the console'; } /** Whether a timer is the undeletable built-in Mock (its reserved id). */ diff --git a/frontend/apps/rd-console/src/screens/EventRounds.svelte b/frontend/apps/rd-console/src/screens/EventRounds.svelte index 1b57920..2e30efe 100644 --- a/frontend/apps/rd-console/src/screens/EventRounds.svelte +++ b/frontend/apps/rd-console/src/screens/EventRounds.svelte @@ -523,6 +523,12 @@ // bracket level's "winners of Semifinal" seeding to FromRoster — a grace-window tweak destroyed // the bracket chain. When set, the seeding controls lock and submit round-trips it unchanged. let editPreservedSeeding = $state(undefined); + // The edited round's stored start procedure, verbatim — same wholesale-replace trap as the + // seeding above: the form models only min/max delay, so rebuilding the procedure from the + // two inputs silently ERASED a configured start `tone` (and would flatten any future + // non-randomized mode back to randomized-delay). Submit spreads this under the form's + // fields, so everything the form doesn't model survives the round trip. + let editPreservedStart = $state(undefined); // The chosen format's params, as a `key → value` map (Rounds form redesign item 4): every param // the format declares is shown inline as a proper labeled field, seeded from its schema default // (or the edited round's stored value). On a format switch the map is re-seeded to the new @@ -698,6 +704,7 @@ seedSources = new Set(); seedTopN = 8; editPreservedSeeding = undefined; + editPreservedStart = undefined; selectedNodes = new Set(); paramValues = {}; pointsTable = [...DEFAULT_POINTS_TABLE]; @@ -783,6 +790,7 @@ const stagingTotal = round.staging_timer_secs ?? 300; stagingMinutes = Math.floor(stagingTotal / 60); stagingSeconds = stagingTotal % 60; + editPreservedStart = round.start_procedure ?? undefined; startMinSeconds = msToSeconds(round.start_procedure?.min_delay_ms ?? 2000); startMaxSeconds = msToSeconds(round.start_procedure?.max_delay_ms ?? 5000); const grace = round.grace_window; @@ -926,9 +934,24 @@ * applies). */ function buildStartProcedure(): StartProcedure { + // A mode this form can't model (a future fixed-countdown / external trigger): round-trip + // it VERBATIM — the delay inputs simply don't apply to it. + if ( + editPreservedStart && + (editPreservedStart as { mode: string }).mode !== 'randomized-delay' + ) { + return editPreservedStart; + } const min = secondsToMs(startMinSeconds); const max = Math.max(min, secondsToMs(startMaxSeconds)); - return { mode: 'randomized-delay', min_delay_ms: min, max_delay_ms: max }; + // Spread the stored procedure UNDER the form's fields: the `tone` (and any additive future + // field) survives; only what the form actually edits is rewritten. + return { + ...editPreservedStart, + mode: 'randomized-delay', + min_delay_ms: min, + max_delay_ms: max + }; } /** The completion grace window as a bounded `Duration` (seconds → micros). */ diff --git a/frontend/apps/rd-console/tests/EventRounds.test.ts b/frontend/apps/rd-console/tests/EventRounds.test.ts index f4f9852..7428846 100644 --- a/frontend/apps/rd-console/tests/EventRounds.test.ts +++ b/frontend/apps/rd-console/tests/EventRounds.test.ts @@ -451,6 +451,45 @@ describe('EventRounds (define rounds — classes, format, seeding)', () => { expect(req.grace_window).toEqual({ Duration: { micros: 5_000_000 } }); }); + it('a configured start TONE survives an edit the form never touched (verbatim round-trip)', async () => { + // The form models only the min/max delay; the server replaces the round WHOLESALE on + // update — so rebuilding start_procedure from the two inputs silently ERASED a stored + // tone. The stored procedure must round-trip under the form's fields. + const impls = baseImpls(); + const toned: RoundDef = { + ...QUAL, + start_procedure: { + mode: 'randomized-delay', + min_delay_ms: 2000, + max_delay_ms: 5000, + tone: { pattern: 'triple-beep', volume: 0.8 } + } as RoundDef['start_procedure'] + }; + const updateRoundImpl = vi.fn(async (_b, _e, _id, req) => ({ ...toned, ...req })); + const { session } = makeTestSession({ + ...impls, + updateRoundImpl, + event: { ...EVENT, rounds: [toned] } + }); + render(EventRounds, { session }); + + await fireEvent.click(await screen.findByRole('button', { name: 'Edit' })); + // Touch something unrelated (a grace tweak — the exact trap that destroyed seedings). + await fireEvent.input(screen.getByLabelText('Grace window seconds'), { + target: { value: '10' } + }); + await fireEvent.click(screen.getByRole('button', { name: 'Save round' })); + + await waitFor(() => expect(updateRoundImpl).toHaveBeenCalledTimes(1)); + const [, , , req] = updateRoundImpl.mock.calls[0]; + expect(req.start_procedure).toEqual({ + mode: 'randomized-delay', + min_delay_ms: 2000, + max_delay_ms: 5000, + tone: { pattern: 'triple-beep', volume: 0.8 } + }); + }); + it('surfaces the chosen format’s params inline as labeled fields, seeded from their defaults', async () => { const impls = baseImpls(); const createRoundImpl = vi.fn(async (_b, _e, _req) => ({ ...QUAL, id: 'r2' })); diff --git a/frontend/apps/rd-console/tests/RssiGraph.test.ts b/frontend/apps/rd-console/tests/RssiGraph.test.ts index 5a8c81c..49cbe5c 100644 --- a/frontend/apps/rd-console/tests/RssiGraph.test.ts +++ b/frontend/apps/rd-console/tests/RssiGraph.test.ts @@ -438,3 +438,90 @@ describe('RssiGraph threshold tuning + preview', () => { expect(within(graph).getByText(/Preview pass/)).toBeInTheDocument(); }); }); + +/** + * Zoom & pan (the marshaling deep-dive batch): wheel-at-cursor narrows the drawn span, the + * caption's Fit resets it, and — the regression that matters — pointer capture is DEFERRED + * until a real drag, so a plain click on a lap MARKER still selects the lap while zoomed + * (capturing on pointerdown retargeted the browser's synthesized click to the svg). + */ +describe('zoom & pan', () => { + function renderGraph(onselect = () => {}) { + render(RssiGraph, { + trace: signalTrace, + laps: lapList, + selected: null, + onselect + }); + const svg = screen.getByLabelText(/RSSI trace for ALICE/); + pinSvgBox(svg); + return svg; + } + + it('wheel-in narrows the view (zoom note + Fit appear) and Fit restores the full span', async () => { + const svg = renderGraph(); + // No zoom yet: Fit and zoom-out are disabled, no zoom note. + expect(screen.getByRole('button', { name: 'Reset zoom' })).toBeDisabled(); + expect(screen.queryByText(/drag to pan/)).toBeNull(); + + // One wheel notch IN at mid-plot (45s). + await fireEvent.wheel(svg, { deltaY: -120, clientX: xForTime(45_000_000) }); + expect(screen.getByText(/drag to pan/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Reset zoom' })).toBeEnabled(); + + await fireEvent.click(screen.getByRole('button', { name: 'Reset zoom' })); + expect(screen.queryByText(/drag to pan/)).toBeNull(); + expect(screen.getByRole('button', { name: 'Reset zoom' })).toBeDisabled(); + }); + + it('the caption +/− buttons zoom without a wheel', async () => { + renderGraph(); + await fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })); + expect(screen.getByText(/drag to pan/)).toBeInTheDocument(); + await fireEvent.click(screen.getByRole('button', { name: 'Zoom out' })); + // One notch out from one notch in: back to the full span. + expect(screen.queryByText(/drag to pan/)).toBeNull(); + }); + + it('a plain CLICK on a lap marker still selects the lap while zoomed (deferred capture)', async () => { + const onselect = vi.fn(); + const svg = renderGraph(onselect); + await fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })); + + // A full click gesture on a marker: pointerdown lands on the svg (startPan) but must NOT + // capture — the click on the marker then selects the lap as normal. + const marker = screen.getByRole('button', { name: /Lap 2 at .* — select/ }); + const capture = vi.fn(); + (svg as unknown as SVGSVGElement).setPointerCapture = capture; + await fireEvent( + svg, + new MouseEvent('pointerdown', { bubbles: true, clientX: xForTime(80_000_000) }) + ); + await fireEvent(svg, new MouseEvent('pointerup', { bubbles: true })); + await fireEvent.click(marker); + expect(capture).not.toHaveBeenCalled(); + expect(onselect).toHaveBeenCalledTimes(1); + }); + + it('a real horizontal drag pans (captures) instead of clicking', async () => { + const svg = renderGraph(); + await fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })); + const noteBefore = screen.getByText(/drag to pan/).textContent; + + const capture = vi.fn(); + (svg as unknown as SVGSVGElement).setPointerCapture = capture; + const at = (x: number) => new MouseEvent('pointermove', { bubbles: true, clientX: x }); + await fireEvent( + svg, + new MouseEvent('pointerdown', { bubbles: true, clientX: xForTime(45_000_000) }) + ); + // Move well past the engage threshold, then further to pan. + await fireEvent(svg, at(xForTime(45_000_000) + 40)); + await fireEvent(svg, at(xForTime(45_000_000) + 140)); + await fireEvent(svg, new MouseEvent('pointerup', { bubbles: true })); + + expect(capture).toHaveBeenCalled(); + const noteAfter = screen.getByText(/drag to pan/).textContent; + expect(noteAfter).not.toBe(noteBefore); // the window actually moved + }); +}); diff --git a/frontend/apps/rd-console/tests/timers.test.ts b/frontend/apps/rd-console/tests/timers.test.ts index 9e28676..f7da6ca 100644 --- a/frontend/apps/rd-console/tests/timers.test.ts +++ b/frontend/apps/rd-console/tests/timers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { Timer, TimerStatus } from '@gridfpv/types'; -import { isTimerConnected } from '../src/lib/timers.js'; +import type { Timer, TimerKind, TimerStatus } from '@gridfpv/types'; +import { isTimerConnected, kindLabel, kindSummary, kindTag, kindTone } from '../src/lib/timers.js'; /** Build a Timer with the given status (the only field `isTimerConnected` reads). */ function timerWith(status: TimerStatus): Timer { @@ -34,3 +34,18 @@ describe('isTimerConnected', () => { expect(isTimerConnected(timerWith('Error'))).toBe(false); }); }); + +describe('version skew: an unmodeled timer kind renders labeled, never crashes', () => { + // A NEWER Director may ship a kind this console build doesn't know (the RH-plugin pivot + // makes this likely). It must not mislabel as RotorHazard — and field access on + // `kind.Rotorhazard` must never throw. + const future = { RhPlugin: { url: 'http://rig:5055' } } as unknown as TimerKind; + it('tags, labels and tones it as unknown', () => { + expect(kindTag(future)).toBe('Unknown'); + expect(kindLabel(future)).toBe('RhPlugin'); + expect(kindTone(future)).toBe('neutral'); + }); + it('summarizes it with an update nudge instead of crashing', () => { + expect(kindSummary(future)).toContain('update the console'); + }); +}); diff --git a/frontend/packages/components/src/format.ts b/frontend/packages/components/src/format.ts index 5497265..32ad038 100644 --- a/frontend/packages/components/src/format.ts +++ b/frontend/packages/components/src/format.ts @@ -51,7 +51,10 @@ export function formatMetric(metric: Metric): string { if ('BestConsecutiveMicros' in metric) return formatMicros(metric.BestConsecutiveMicros); if ('LastLapAt' in metric) return metric.LastLapAt === null ? '—' : 'banked'; if ('ReachedAt' in metric) return metric.ReachedAt === null ? '—' : 'reached'; - return '—'; + // Version skew: a NEWER Director may score under a metric variant this console build + // doesn't model. The pilot HAS a value — an em dash would read as DNF/no-time, so label + // the gap instead. + return 'unsupported'; } /** The medal token name for a 1-based finishing position, or `null` past 3rd. */ diff --git a/frontend/packages/components/tests/format.test.ts b/frontend/packages/components/tests/format.test.ts index 2805f7e..1c29b4d 100644 --- a/frontend/packages/components/tests/format.test.ts +++ b/frontend/packages/components/tests/format.test.ts @@ -37,6 +37,11 @@ describe('formatMetric', () => { expect(formatMetric({ LastLapAt: 5 } as Metric)).toBe('banked'); expect(formatMetric({ ReachedAt: null } as Metric)).toBe('—'); }); + it('labels a metric variant this build does not model (version skew), not a DNF dash', () => { + expect(formatMetric({ FastestThreeMicros: 99_000_000 } as unknown as Metric)).toBe( + 'unsupported' + ); + }); }); describe('medalFor', () => { From a7039b69a5df09332891e1fa19a04a89fb0b7425 Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Sat, 4 Jul 2026 19:23:15 +0000 Subject: [PATCH 2/3] docs(audit): reconcile the deferred list with the release-polish batch Co-Authored-By: Claude Fable 5 --- docs/release-audit-2026-07.html | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/release-audit-2026-07.html b/docs/release-audit-2026-07.html index de2da9d..9a44400 100644 --- a/docs/release-audit-2026-07.html +++ b/docs/release-audit-2026-07.html @@ -213,6 +213,14 @@

4.4 Fuzz verdict

5. Deferred — recorded, deliberately not built

+

+ Closed by the release-polish batch (2026-07-04, late): the Final freeze + (a delayed catch-up pass can no longer shift an official result), + version-skew rendering (unknown timer kinds/metrics render labeled instead of a crash / + a DNF-reading dash), the start-tone round-trip (editing any round silently erased + it), orphan bridge/presence tasks (they exit when their event is deleted), and the + presence-batch duplicate bindings. What follows is what genuinely remains. +

  • Failover completeness (dual-timer): crossings that land between the primary's real death and the gate flip are dropped (a standby's passes are discarded, never @@ -220,17 +228,10 @@

    5. Deferred — recorded, deliberately not built

    Real but rare (multi-timer setups), and buffering needs a design of its own — replay semantics, dedup against the primary's already-appended passes. The cheap status-stomp fix (§2) did land.
  • -
  • Orphan bridge pruning: a deleted event's bridge task holds a strong log - handle for the process lifetime — a resource leak, no correctness impact.
  • -
  • Presence-reconciler duplicate bindings within one poll batch — benign - (same key/value, last-wins fold).
  • -
  • Version-skew rendering: an additive server enum variant renders as a - blank metric cell / mislabeled timer kind on a stale console build. Mitigated by the - both-halves deploy rule and the new contract pins; a graceful-unknown rendering pass is - future polish.
  • -
  • StartProcedure form round-trip: the round form always writes - randomized-delay — lossless today (one mode exists), lossy the day a second - mode ships. The wire shape is now contract-pinned so that day is loud, not silent.
  • + + + +