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.
+
+
+
+