diff --git a/bindings/EventAuditEntry.ts b/bindings/EventAuditEntry.ts new file mode 100644 index 0000000..be4bafc --- /dev/null +++ b/bindings/EventAuditEntry.ts @@ -0,0 +1,46 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AuditKind } from "./AuditKind"; +import type { CompetitorRef } from "./CompetitorRef"; +import type { HeatId } from "./HeatId"; +import type { LogRef } from "./LogRef"; + +/** + * One row of the **event-wide** audit trail (`GET /events/{event_id}/audit`): a per-heat + * marshaling [`AuditEntry`] plus the heat it belongs to. + * + * The per-heat [`AuditEntry`] deliberately carries no heat id — it is served from a heat-scoped + * route where the heat is implicit. The event-wide read merges every heat's trail into one list, + * so each entry must say *which* heat it rules on; the console's Audit page renders (and filters + * by) that tag. The entry's own fields are flattened onto the row, so on the wire this is "an + * `AuditEntry` plus `heat`" — additive, no re-modelling. + */ +export type EventAuditEntry = { +/** + * The heat this ruling belongs to — attributed by the same heat-window rules Marshaling's + * per-heat audit uses ([`heat_window_offsets`]), so the two views can never disagree. + */ +heat: HeatId, +/** + * What kind of marshaling action this was — derived from the event type. + */ +kind: AuditKind, +/** + * When the log received this fact (microseconds since the Unix epoch), if recorded. + * `None` when the append carried no arrival timestamp (e.g. a replay with none supplied). + */ +at: number | null, +/** + * The global append offset of this fact — a stable identity for the entry (and what a + * later "reverse this" would target). Lets the UI key the list deterministically. + */ +at_ref: LogRef, +/** + * The competitor this action targeted, as a **structured ref** the client resolves to a + * callsign and composes into the displayed line. `None` for actions that name no competitor. + */ +competitor: CompetitorRef | null, +/** + * A short human-readable description of the change — **without** the raw competitor ref (that + * is carried structured in `competitor` so the client can show the resolved callsign instead). + */ +summary: string, }; diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index e6a0df5..5ace603 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -84,7 +84,7 @@ use gridfpv_engine::format::{FormatRegistry, FormatSchema}; use gridfpv_engine::scoring::{HeatResult, WinCondition, score_corrected_with_global_offsets}; use gridfpv_events::{CompetitorRef, Event, HeatId, SourceTime}; use gridfpv_projection::{ - LapList, lap_list_marshaled, marshaling_log, registrations, signal_trace, + AuditEntry, LapList, lap_list_marshaled, marshaling_log, registrations, signal_trace, }; use gridfpv_storage::{EventLog, Offset, Result as StorageResult, StoredEvent}; use serde::Deserialize; @@ -439,6 +439,10 @@ pub fn router(registry: EventRegistry) -> Router { // Per-event scheduled **heats** (race redesign Slice 3b): the round-tagged heats list the // Heats UI reads — open, no token (a read), like the snapshot routes. .route("/events/{event_id}/heats", get(list_heats)) + // The event-wide **audit trail** (the "defensible results" review surface): every heat's + // marshaling audit fold, heat-tagged and merged newest-first — what the console's Audit + // page reads. Open, no token (a read, like the heats list and the snapshot routes). + .route("/events/{event_id}/audit", get(event_audit)) // A round's **ranking** (race redesign Slice 5/6a): the ordered per-pilot ranking the // engine seeds `FromRanking` from — what the bracket-carry UI displays. Open, no token. .route( @@ -1344,6 +1348,85 @@ async fn class_standings( Ok(Json(standings)) } +/// One row of the **event-wide** audit trail (`GET /events/{event_id}/audit`): a per-heat +/// marshaling [`AuditEntry`] plus the heat it belongs to. +/// +/// The per-heat [`AuditEntry`] deliberately carries no heat id — it is served from a heat-scoped +/// route where the heat is implicit. The event-wide read merges every heat's trail into one list, +/// so each entry must say *which* heat it rules on; the console's Audit page renders (and filters +/// by) that tag. The entry's own fields are flattened onto the row, so on the wire this is "an +/// `AuditEntry` plus `heat`" — additive, no re-modelling. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, Deserialize, ts_rs::TS)] +#[ts(export, export_to = "bindings/")] +pub struct EventAuditEntry { + /// The heat this ruling belongs to — attributed by the same heat-window rules Marshaling's + /// per-heat audit uses ([`heat_window_offsets`]), so the two views can never disagree. + pub heat: HeatId, + /// The per-heat audit fact itself (kind / when / offset / competitor / summary), flattened. + #[serde(flatten)] + pub entry: AuditEntry, +} + +/// Fold the whole event log into the **event-wide audit trail**, newest first. +/// +/// For each heat the log ever scheduled (the distinct `HeatScheduled` ids, in order), this runs +/// the *existing* per-heat audit fold — [`marshaling_log`] over that heat's +/// [`heat_window_offsets`], exactly the pipeline the heat-scoped `?projection=audit` snapshot +/// uses — tags each entry with the heat id, merges all heats, and sorts by the entry's global +/// append offset **descending** (append order is time order, so newest first). Reusing the +/// shipped window attribution is the point: a ruling filed about a *finished* heat while a later +/// heat is live lands under the marshaled heat here too, and a **Restarted** heat's pre-restart +/// rulings are absent (the window folds from the heat's current run by design — an abandoned +/// run's rulings are not part of the heat's result, so they are not part of its audit either). +pub(crate) fn event_audit_log(stored: &[StoredEvent]) -> Vec { + let events: Vec = stored.iter().map(|s| s.event.clone()).collect(); + // The distinct heats ever scheduled, in first-scheduled order (a re-schedule of the same id + // must not fold — and double-report — the same window twice). + let mut heats: Vec = Vec::new(); + for event in &events { + if let Event::HeatScheduled { heat, .. } = event { + if !heats.contains(heat) { + heats.push(heat.clone()); + } + } + } + let mut entries: Vec = Vec::new(); + for heat in &heats { + let offsets = heat_window_offsets(&events, heat); + let trail = marshaling_log( + offsets + .iter() + .map(|(o, e)| (stored.get(*o as usize).and_then(|s| s.recorded_at), *o, e)), + heat, + ); + entries.extend(trail.into_iter().map(|entry| EventAuditEntry { + heat: heat.clone(), + entry, + })); + } + // Newest first across the whole event: the global append offset is the one total order every + // heat's entries share (recorded_at can be absent), so descending offset is descending time. + entries.sort_by_key(|e| std::cmp::Reverse(e.entry.at_ref)); + entries +} + +/// `GET /events/{event_id}/audit` — the **event-wide** audit trail (the "defensible results" +/// review surface). +/// +/// Serves every heat's marshaling audit fold, heat-tagged and merged newest-first (see +/// [`event_audit_log`]). This is what the console's Audit page reads: the full searchable ruling +/// history for the event, while Marshaling keeps only the marshaled heat's recent entries. An +/// open read (no token), like the heats list and the snapshot routes; an unknown event is a +/// typed **404**. +async fn event_audit( + State(registry): State, + Path(event_id): Path, +) -> Result>, ProtocolError> { + let state = resolve_event(®istry, &event_id)?; + let (stored, _cursor) = state.read_stored()?; + Ok(Json(event_audit_log(&stored))) +} + /// `POST /events/{event_id}/auth/join-token` — mint a fresh **read-only** join token /// (protocol.html §5, §9.4) — issue #63, now event-rooted. /// @@ -2368,6 +2451,161 @@ mod tests { } } + // --- The event-wide audit read (`GET /events/{event_id}/audit`) ----------------------------- + + /// `GET /events/practice/audit`, deserialized. The route serves plain `Vec` + /// (no snapshot envelope — it is a directory-style read like `/heats`). + async fn get_event_audit(registry: EventRegistry) -> (StatusCode, Vec) { + let response = router(registry) + .oneshot( + Request::builder() + .uri("/events/practice/audit") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let entries = serde_json::from_slice::>(&bytes).unwrap_or_default(); + (status, entries) + } + + /// The heat-loop events for a second heat `q-2` (C and D), appended after `recorded_heat`'s + /// `q-1`. With `recorded_heat` first (offsets 0..=10), these land at offsets 11..=18 — the + /// two passes at 15 and 16. + fn second_heat() -> Vec { + let changed = |t| Event::HeatStateChanged { + heat: HeatId("q-2".into()), + transition: t, + }; + vec![ + Event::HeatScheduled { + heat: HeatId("q-2".into()), + lineup: vec![CompetitorRef("C".into()), CompetitorRef("D".into())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + changed(HeatTransition::Staged), + changed(HeatTransition::Armed), + changed(HeatTransition::Running), + pass("C", 1_000_000, 1), + pass("C", 4_000_000, 2), + changed(HeatTransition::Finished), + changed(HeatTransition::Finalized), + ] + } + + #[tokio::test] + async fn event_audit_merges_heats_newest_first_with_correct_heat_tags() { + // Two heats run back-to-back, then two rulings appended AFTER both have run: first a void + // targeting q-2's second pass (offset 16 → window-attributed to q-2), then a penalty + // heat-tagged to q-1 — the FIRST heat, filed while it is long finished. The tag (not the + // position in the log) must decide the heat it lands under. + let mut events = recorded_heat(); // q-1 at offsets 0..=10 + events.extend(second_heat()); // q-2 at offsets 11..=18 + events.push(Event::DetectionVoided { + target: LogRef(16), // q-2's second pass → belongs to q-2 + }); + events.push(Event::PenaltyApplied { + heat: HeatId("q-1".into()), + competitor: CompetitorRef("A".into()), + penalty: gridfpv_events::Penalty::Disqualify { reason: None }, + }); + let (registry, _state, _) = state_with(events); + + let (status, entries) = get_event_audit(registry).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(entries.len(), 2, "two rulings, no passes"); + // Newest first: the penalty (offset 20) precedes the void (offset 19)… + assert_eq!(entries[0].entry.at_ref, LogRef(20)); + assert_eq!( + entries[0].entry.kind, + gridfpv_projection::AuditKind::PenaltyApplied + ); + // …and it is tagged to q-1 (the heat it names), NOT q-2 (the heat last active in the log). + assert_eq!(entries[0].heat, HeatId("q-1".into())); + assert_eq!(entries[1].entry.at_ref, LogRef(19)); + assert_eq!(entries[1].entry.kind, gridfpv_projection::AuditKind::Voided); + assert_eq!(entries[1].heat, HeatId("q-2".into())); + } + + #[tokio::test] + async fn event_audit_omits_a_restarted_heats_pre_restart_rulings() { + // A ruling made during q-1's FIRST run, then the heat is Restarted and re-raced. The heat + // window folds from the current run only (a reset abandons the prior run and everything + // ruled about it), so the pre-restart penalty must NOT appear in the event audit — while a + // post-restart ruling does. + let heat = || HeatId("q-1".into()); + let changed = |t| Event::HeatStateChanged { + heat: heat(), + transition: t, + }; + let events = vec![ + Event::HeatScheduled { + heat: heat(), + lineup: vec![CompetitorRef("A".into()), CompetitorRef("B".into())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + changed(HeatTransition::Staged), + changed(HeatTransition::Armed), + changed(HeatTransition::Running), + pass("A", 1_000_000, 1), + // The abandoned run's ruling (offset 5): a penalty filed mid-first-run. + Event::PenaltyApplied { + heat: heat(), + competitor: CompetitorRef("A".into()), + penalty: gridfpv_events::Penalty::Disqualify { reason: None }, + }, + changed(HeatTransition::Restarted), // offset 6 — abandons the run above + changed(HeatTransition::Staged), + changed(HeatTransition::Armed), + changed(HeatTransition::Running), + pass("A", 1_000_000, 2), + pass("A", 4_000_000, 3), + changed(HeatTransition::Finished), + changed(HeatTransition::Finalized), + // The current run's ruling (offset 14): survives. + Event::PenaltyApplied { + heat: heat(), + competitor: CompetitorRef("B".into()), + penalty: gridfpv_events::Penalty::TimeAdded { micros: 2_000_000 }, + }, + ]; + let (registry, _state, _) = state_with(events); + + let (status, entries) = get_event_audit(registry).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + entries.len(), + 1, + "only the current run's ruling — the pre-restart DQ is abandoned with its run" + ); + assert_eq!(entries[0].entry.at_ref, LogRef(14)); + assert_eq!(entries[0].heat, heat()); + assert_eq!(entries[0].entry.competitor, Some(CompetitorRef("B".into()))); + } + + #[tokio::test] + async fn event_audit_on_unknown_event_is_not_found() { + let (registry, _state, _) = state_with(recorded_heat()); + let response = router(registry) + .oneshot( + Request::builder() + .uri("/events/no-such-event/audit") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + #[tokio::test] async fn heat_scope_signal_projection_returns_captured_trace() { // Seed a heat with two signal chunks for node A plus thresholds; the signal projection diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte index 4528d67..969895f 100644 --- a/frontend/apps/rd-console/src/App.svelte +++ b/frontend/apps/rd-console/src/App.svelte @@ -42,6 +42,8 @@ import LiveRaceControl from './screens/LiveRaceControl.svelte'; import Marshaling from './screens/Marshaling.svelte'; import Results from './screens/Results.svelte'; + import EventAudit from './screens/EventAudit.svelte'; + import { openAudit } from './lib/auditFilter.svelte.js'; import { parseHash, formatHash, @@ -134,7 +136,14 @@ } }); - type ScreenId = 'timers' | 'classes-roster' | 'rounds' | 'live' | 'marshaling' | 'results'; + type ScreenId = + | 'timers' + | 'classes-roster' + | 'rounds' + | 'live' + | 'marshaling' + | 'results' + | 'audit'; const SCREENS: { id: ScreenId; label: string; key: string; icon: string }[] = [ { id: 'classes-roster', @@ -151,6 +160,14 @@ { id: 'live', label: 'Race control', key: '3', icon: 'M5 3l14 9-14 9V3z' }, { id: 'marshaling', label: 'Marshaling', key: '4', icon: 'M9 11l3 3L22 4M21 12v7H3V5h12' }, { id: 'results', label: 'Results', key: '5', icon: 'M4 19V10M10 19V4M16 19v-7M22 19H2' }, + // The event-wide audit review page (a magnifier: search the ruling history). Sits with the + // review surfaces (after Results); key '7' — the unused digit — so Timers keeps its Alt+6. + { + id: 'audit', + label: 'Audit', + key: '7', + icon: 'M10.5 4a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13zM15.5 15.5L21 21' + }, { id: 'timers', label: 'Timers', @@ -367,10 +384,13 @@ {:else if active === 'live'} {:else if active === 'marshaling'} - + + openAudit(setTab, prefilter)} /> {:else if active === 'results'} openAudit(setTab, prefilter)} heatResult={session.heatResult ?? (session.protocolState?.body && 'HeatResult' in session.protocolState.body ? session.protocolState.body.HeatResult @@ -379,6 +399,8 @@ ? session.protocolState.body.Ranking : undefined} /> + {:else if active === 'audit'} + {/if} diff --git a/frontend/apps/rd-console/src/lib/auditFilter.svelte.ts b/frontend/apps/rd-console/src/lib/auditFilter.svelte.ts new file mode 100644 index 0000000..f5025b3 --- /dev/null +++ b/frontend/apps/rd-console/src/lib/auditFilter.svelte.ts @@ -0,0 +1,49 @@ +/** + * The cross-screen **Audit prefilter seam**: how another screen jumps to the Audit tab already + * filtered to a heat or a pilot (Marshaling's "View full audit →" on the marshaled heat, a + * Results row's per-pilot audit link). + * + * Navigation itself stays the shell's job (`setTab` writes the `#/event/audit` hash, route.ts) — + * but a hash carries no payload, and threading a filter through the shell's route state would + * grow the URL scheme for what is a one-shot handoff. So the prefilter is a tiny module-level + * `$state` mailbox instead: {@link openAudit} deposits it and switches the tab; the Audit screen + * {@link consumeAuditPrefilter consumes and clears} it on mount. One-shot by design — a later + * manual visit to the Audit tab (or a refresh, which drops the in-memory mailbox) starts + * unfiltered, exactly like every other tab. + */ +import type { CompetitorRef, HeatId } from '@gridfpv/types'; + +import type { WorkspaceTab } from './route.js'; + +/** What the Audit page should be pre-filtered to on arrival. Both fields optional. */ +export interface AuditPrefilter { + /** Pre-select this heat in the Audit page's heat filter. */ + heat?: HeatId; + /** Pre-select this pilot (competitor ref) in the Audit page's pilot filter. */ + pilot?: CompetitorRef; +} + +// The one-shot mailbox. Module-level `$state` so the depositing screen and the consuming screen +// share it without threading it through the shell; exported only via the functions below (a +// module cannot export reassigned `$state` directly). +let pending = $state(undefined); + +/** + * Jump to the Audit tab pre-filtered: deposit `prefilter`, then switch the tab. `setTab` is the + * shell's tab navigation (App.svelte threads it in via the screen's callback prop, the same way + * sibling screens receive `ongolive` etc.). + */ +export function openAudit(setTab: (tab: WorkspaceTab) => void, prefilter: AuditPrefilter): void { + pending = prefilter; + setTab('audit'); +} + +/** + * Take the pending prefilter, clearing it — the Audit page calls this once on mount. Returns + * `undefined` when nothing was deposited (a direct visit to the tab). + */ +export function consumeAuditPrefilter(): AuditPrefilter | undefined { + const prefilter = pending; + pending = undefined; + return prefilter; +} diff --git a/frontend/apps/rd-console/src/lib/auditRender.ts b/frontend/apps/rd-console/src/lib/auditRender.ts new file mode 100644 index 0000000..8d02046 --- /dev/null +++ b/frontend/apps/rd-console/src/lib/auditRender.ts @@ -0,0 +1,179 @@ +/** + * Shared audit-entry rendering for the RD console — the #337 recomposition helpers, extracted + * from Marshaling so the event-wide **Audit page** and Marshaling's **Recent rulings** strip + * render an entry the *same* way (and never drift, the friendly-names rule in CLAUDE.md). + * + * The server bakes raw LOG OFFSETS into some summaries ("Lap thrown out (ref 14)") because it + * cannot resolve anything friendlier, and it names no competitor structurally for the + * lap-/target-addressed rulings. The client can do better: a `ProtestResolved`/`RulingReversed` + * targets another audit entry (whose own `competitor` is structured — chase the chain), and a + * lap-addressed ruling targets a pass the lap list still carries (where a lap list is in hand). + * So the rendered line is re-composed from the **structured** fields — resolved callsign first, + * the target offset only as a trailing "· #N" detail — instead of printing the server's raw-ref + * string. Every helper takes plain data, no Svelte reactivity: the screens own the reactive + * assembly of the inputs (the entry list, the name resolver) and call these per render. + */ +import type { AuditEntry, AuditKind, CompetitorRef } from '@gridfpv/types'; + +/** + * The subset of {@link AuditEntry} these helpers read. The event-wide `EventAuditEntry` rows are + * the same fields plus `heat` (flattened on the wire), so both entry shapes satisfy this. + */ +export type AuditEntryLike = Pick; + +/** + * The inputs an audit line resolves against. `byRef` indexes the trail by each entry's global + * `at_ref` so a target-addressed ruling (protest resolution, reversal) chases the entry it acted + * on; `competitorName` is the screen's shared resolver ({@link createCompetitorNameResolver}); + * `competitorForPassRef` resolves a pass offset to the competitor whose lap it bounds — only + * Marshaling (which holds the heat's lap list) can supply it, the event-wide page omits it and + * those lines simply render without a name (never with a raw ref). + */ +export interface AuditRenderInputs { + byRef: Map; + competitorName: (ref: CompetitorRef) => string; + competitorForPassRef?: (target: number) => CompetitorRef | undefined; +} + +/** The compact kind label Marshaling's strip badges an entry with (one word per kind). */ +export function auditKindLabel(kind: AuditKind): string { + switch (kind) { + case 'Voided': + return 'Voided'; + case 'Inserted': + return 'Inserted'; + case 'Adjusted': + return 'Re-timed'; + case 'Split': + return 'Split'; + case 'PenaltyApplied': + return 'Penalty'; + case 'LapThrownOut': + return 'Thrown out'; + case 'ProtestFiled': + return 'Protest filed'; + case 'ProtestResolved': + return 'Protest resolved'; + case 'RulingReversed': + return 'Reversed'; + case 'HeatVoided': + return 'Heat voided'; + case 'Pass': + return 'Detection'; + default: + return kind; + } +} + +/** + * The **badge** text for an entry — the user-facing "what happened" the Audit page filters by. + * Finer-grained than {@link auditKindLabel} for `PenaltyApplied`, which covers three distinct + * rulings the RD thinks of separately (DQ / time penalty / points); the split is derived from the + * server-baked summary prefix ("DQ applied…" / "+2.000s penalty" / "-4 points"), the one place + * that distinction reaches the wire. + */ +export function auditBadge(entry: Pick): string { + switch (entry.kind) { + case 'PenaltyApplied': + if (entry.summary.startsWith('DQ')) return 'DQ'; + if (entry.summary.includes('points')) return 'Points'; + return 'Time penalty'; + case 'Inserted': + return 'Lap added'; + case 'Adjusted': + return 'Lap re-timed'; + case 'Split': + return 'Lap split'; + case 'Voided': + return 'Detection voided'; + case 'LapThrownOut': + return 'Lap thrown out'; + case 'HeatVoided': + return 'Heat voided'; + case 'ProtestFiled': + return 'Protest filed'; + case 'ProtestResolved': + return 'Protest resolved'; + case 'RulingReversed': + return 'Ruling reversed'; + case 'Pass': + return 'Detection'; + default: + return entry.kind; + } +} + +/** A local wall-clock time for an entry's `recorded_at` (µs since the Unix epoch), or ''. */ +export function auditTime(at: number | null): string { + if (at == null) return ''; + // `at` is microseconds since the Unix epoch (server recorded_at). + return new Date(at / 1000).toLocaleTimeString(); +} + +/** The target log offset a server summary interpolates ("(ref 42)"), if any. */ +export function summaryTargetRef(summary: string): number | undefined { + const m = /\(ref (\d+)\)/.exec(summary); + return m ? Number(m[1]) : undefined; +} + +/** The summary with the raw "(ref N)" clause removed (the offset renders as a trailing detail). */ +export function stripRefClause(summary: string): string { + return summary.replace(/\s*\(ref \d+\)/, ''); +} + +/** + * The competitor an audit entry concerns: its own structured ref when the action named one, else + * chased through the entry's target — a `ProtestResolved` / `RulingReversed` targets another + * audit entry (follow the chain), a lap-addressed ruling targets a pass in the lap list (when the + * caller supplied `competitorForPassRef`). `undefined` when nothing resolves (a heat-void, or a + * voided pass with no lap list in hand) — the line then renders without a name rather than with a + * raw ref. + */ +export function auditCompetitor( + entry: AuditEntryLike, + inputs: AuditRenderInputs, + depth = 0 +): CompetitorRef | undefined { + if (entry.competitor != null) return entry.competitor; + const target = summaryTargetRef(entry.summary); + if (target === undefined) return undefined; + const chained = inputs.byRef.get(target); + if (chained && chained !== entry && depth < 4) return auditCompetitor(chained, inputs, depth + 1); + return inputs.competitorForPassRef?.(target); +} + +/** + * The displayed audit line: the **resolved callsign** (from the structured `competitor` ref, or + * chased through the entry's target) joined to the summary with any server-baked raw "(ref N)" + * stripped; the target offset renders only as a trailing "· #N" detail. A baked raw-id string + * couldn't be re-resolved, so the composition happens here, client-side (#337). + */ +export function auditSummaryLine(entry: AuditEntryLike, inputs: AuditRenderInputs): string { + const ref = auditCompetitor(entry, inputs); + const target = summaryTargetRef(entry.summary); + const text = stripRefClause(entry.summary); + const line = ref != null ? `${inputs.competitorName(ref)} · ${text}` : text; + return target !== undefined ? `${line} · #${target}` : line; +} + +/** + * The summary line **without** the competitor name — for surfaces that render the pilot as its + * own element (the Audit page's clickable pilot chip) and must not repeat it in the text. + */ +export function auditActionText(entry: AuditEntryLike): string { + const target = summaryTargetRef(entry.summary); + const text = stripRefClause(entry.summary); + return target !== undefined ? `${text} · #${target}` : text; +} + +/** + * A ruling/protest picker option: "‹what› — ‹callsign› · #‹offset›". The action + callsign is the + * primary label; the entry's own log offset (what the reverse/resolve command targets) is only a + * trailing detail, never the label itself (#337). + */ +export function rulingOptionLabel(entry: AuditEntryLike, inputs: AuditRenderInputs): string { + const ref = auditCompetitor(entry, inputs); + const text = stripRefClause(entry.summary); + const who = ref != null ? ` — ${inputs.competitorName(ref)}` : ''; + return `${text}${who} · #${entry.at_ref}`; +} diff --git a/frontend/apps/rd-console/src/lib/route.ts b/frontend/apps/rd-console/src/lib/route.ts index 93efdb0..cc39617 100644 --- a/frontend/apps/rd-console/src/lib/route.ts +++ b/frontend/apps/rd-console/src/lib/route.ts @@ -17,7 +17,7 @@ * - `#/events` → the Events page (the picker) * - `#/timers` → the Timers page * - `#/event/` → the in-event workspace on `` - * (tab ∈ classes-roster | rounds | live | marshaling | results | timers) + * (tab ∈ classes-roster | rounds | live | marshaling | results | audit | timers) * * The active event itself is **app-wide server state** (the Director's active event, #90), so the * hash only restores *which tab* of the workspace — not *which* event. The workspace always shows @@ -35,6 +35,7 @@ export type WorkspaceTab = | 'live' | 'marshaling' | 'results' + | 'audit' | 'timers'; /** @@ -50,6 +51,7 @@ export const WORKSPACE_TABS: readonly WorkspaceTab[] = [ 'live', 'marshaling', 'results', + 'audit', 'timers' ]; diff --git a/frontend/apps/rd-console/src/lib/session.svelte.ts b/frontend/apps/rd-console/src/lib/session.svelte.ts index 731d7b6..0f2335f 100644 --- a/frontend/apps/rd-console/src/lib/session.svelte.ts +++ b/frontend/apps/rd-console/src/lib/session.svelte.ts @@ -70,6 +70,7 @@ import { updateRound, deleteRound, listHeats, + eventAudit, roundRanking, roundStandings, classStandings, @@ -93,6 +94,7 @@ import type { CreateEventRequest, CreatePilotRequest, CreateTimerRequest, + EventAuditEntry, EventMeta, FillMode, FormatSchema, @@ -340,6 +342,7 @@ export class Session { #updateRoundImpl: typeof updateRound; #deleteRoundImpl: typeof deleteRound; #listHeatsImpl: typeof listHeats; + #eventAuditImpl: typeof eventAudit; #roundRankingImpl: typeof roundRanking; #roundStandingsImpl: typeof roundStandings; #classStandingsImpl: typeof classStandings; @@ -379,6 +382,7 @@ export class Session { updateRoundImpl?: typeof updateRound; deleteRoundImpl?: typeof deleteRound; listHeatsImpl?: typeof listHeats; + eventAuditImpl?: typeof eventAudit; roundRankingImpl?: typeof roundRanking; roundStandingsImpl?: typeof roundStandings; classStandingsImpl?: typeof classStandings; @@ -419,6 +423,7 @@ export class Session { this.#updateRoundImpl = opts?.updateRoundImpl ?? updateRound; this.#deleteRoundImpl = opts?.deleteRoundImpl ?? deleteRound; this.#listHeatsImpl = opts?.listHeatsImpl ?? listHeats; + this.#eventAuditImpl = opts?.eventAuditImpl ?? eventAudit; this.#roundRankingImpl = opts?.roundRankingImpl ?? roundRanking; this.#roundStandingsImpl = opts?.roundStandingsImpl ?? roundStandings; this.#classStandingsImpl = opts?.classStandingsImpl ?? classStandings; @@ -904,6 +909,21 @@ export class Session { return this.#listHeatsImpl(this.baseUrl, event.id, { token: this.#token }); } + /** + * Read the current event's **event-wide audit trail** (`GET /events/{id}/audit`) — every heat's + * marshaling audit fold, heat-tagged ({@link EventAuditEntry}) and merged newest first. This is + * what the Audit page renders and filters; Marshaling's per-heat trail keeps reading the + * heat-scoped `?projection=audit` snapshot ({@link refreshMarshaling}), and both derive from the + * same server-side fold so they can never disagree. Open to read (no token, role-agnostic). + * No-op (resolves `[]`) when no event is selected; rejects on a transport/HTTP failure (the + * screen surfaces it — no silent empty list, #340). + */ + eventAudit(): Promise { + const event = this.currentEvent; + if (!event) return Promise.resolve([]); + return this.#eventAuditImpl(this.baseUrl, event.id, { token: this.#token }); + } + // --- Rankings & standings (race redesign Slice 5/6a + 5/6b) ----------------------------------- // The season-join reads the Results screen + the Rounds stage's per-round standings render, and // the source the bracket seeds `FromRanking` from. Both are open reads (no token); they resolve diff --git a/frontend/apps/rd-console/src/screens/EventAudit.svelte b/frontend/apps/rd-console/src/screens/EventAudit.svelte new file mode 100644 index 0000000..f42a0c9 --- /dev/null +++ b/frontend/apps/rd-console/src/screens/EventAudit.svelte @@ -0,0 +1,510 @@ + + +
+
+

Audit

+

+ Every ruling across the event, newest first — each one a recorded, reversible fact. Click a + pilot or heat to filter to it. +

+
+ + {#if loadError} + + + {/if} + +
+ + + + + {#if anyFilter} + + {/if} +
+ + {#if filtered.length > 0} +
    + {#each filtered as entry (entry.at_ref)} + {@const pilot = rowPilot(entry)} +
  1. + {auditBadge(entry)} + + {#if pilot !== undefined} + + {/if} + + {auditActionText(entry)} + + + {#if entry.at != null} + {auditTime(entry.at)} + {/if} + #{entry.at_ref} + +
  2. + {/each} +
+ {:else if !entriesLoaded && !auditError} +

Loading the audit trail…

+ {:else if entries.length > 0} +

No entries match the current filters.

+ {:else} +

+ No rulings in this event yet — the raw timer output stands everywhere. +

+ {/if} +
+ + diff --git a/frontend/apps/rd-console/src/screens/Marshaling.svelte b/frontend/apps/rd-console/src/screens/Marshaling.svelte index df2475a..661dc02 100644 --- a/frontend/apps/rd-console/src/screens/Marshaling.svelte +++ b/frontend/apps/rd-console/src/screens/Marshaling.svelte @@ -11,10 +11,14 @@ * * Every correction *appends* a marshaling event; the projection re-folds and the corrected lap list, * the audit trail, AND the standings (the live stream) all refresh by the same append→re-fold→re-read - * path — nothing reconciled locally (architecture.html §3). The **audit panel** renders that history, - * reverse-chronological, derived purely from the event type — the "defensible results" theme made - * visible. Mutating controls are **role-gated**: a read-only-pilot session sees the laps + audit but - * every action is hidden (the Director enforces the boundary; this mirrors it). + * path — nothing reconciled locally (architecture.html §3). Marshaling is the **do the work** page: + * the full reverse-chronological audit history lives on the event-wide **Audit page** now; this + * screen keeps a compact **Recent rulings** strip (the marshaled heat's latest entries, same shared + * rendering) plus a "View full audit →" jump pre-filtered to the marshaled heat (the auditFilter + * seam). The heat-scoped audit read stays — the Reverse-ruling / Resolve-protest pickers and the + * open-protest Finalize gate all derive from it. Mutating controls are **role-gated**: a + * read-only-pilot session sees the laps + strip but every action is hidden (the Director enforces + * the boundary; this mirrors it). */ import type { AuditEntry, @@ -33,6 +37,15 @@ SignalTraceView } from '@gridfpv/types'; import { formatMicros, Select, toast } from '@gridfpv/components'; + import type { AuditPrefilter } from '../lib/auditFilter.svelte.js'; + import { + auditKindLabel, + auditSummaryLine, + auditTime, + rulingOptionLabel, + summaryTargetRef, + type AuditRenderInputs + } from '../lib/auditRender.js'; import { channelLabel } from '../lib/channels.js'; import { createCompetitorNameResolver } from '../lib/competitorName.js'; import { heatNameById } from '../lib/heats.js'; @@ -67,7 +80,20 @@ import ErrorBanner from '../lib/ErrorBanner.svelte'; import RssiGraph from '../lib/RssiGraph.svelte'; - let { session, adapter = 'rh-1' }: { session: Session; adapter?: string } = $props(); + let { + session, + adapter = 'rh-1', + onviewaudit = undefined + }: { + session: Session; + adapter?: string; + /** + * Jump to the event-wide Audit page pre-filtered (the "View full audit →" strip action). + * The shell wires this to the auditFilter seam (`openAudit(setTab, prefilter)`), the same way + * sibling screens receive navigation callbacks. + */ + onviewaudit?: (prefilter: AuditPrefilter) => void; + } = $props(); // Which heat to marshal. Defaults to — and tracks — Race Control's current heat, but the RD can // pin ANY heat to marshal it. Marshaling issues no `SetCurrentHeat`, so switching the marshaled @@ -614,61 +640,11 @@ } } - // ── Audit rendering helpers ── - function auditLabel(kind: AuditKind): string { - switch (kind) { - case 'Voided': - return 'Voided'; - case 'Inserted': - return 'Inserted'; - case 'Adjusted': - return 'Re-timed'; - case 'Split': - return 'Split'; - case 'PenaltyApplied': - return 'Penalty'; - case 'LapThrownOut': - return 'Thrown out'; - case 'ProtestFiled': - return 'Protest filed'; - case 'ProtestResolved': - return 'Protest resolved'; - case 'RulingReversed': - return 'Reversed'; - case 'HeatVoided': - return 'Heat voided'; - case 'Pass': - return 'Detection'; - default: - return kind; - } - } - - function auditTime(at: number | null): string { - if (at == null) return ''; - // `at` is microseconds since the Unix epoch (server recorded_at). - return new Date(at / 1000).toLocaleTimeString(); - } - - // ── Recomposing the server-baked summaries (#337) ── - // The server interpolates raw LOG OFFSETS into some summaries ("Lap thrown out (ref 14)") because - // it can't resolve anything friendlier. The client can: a lap-addressed ruling targets a pass ref - // the lap list still carries, and a protest-resolution / reversal targets another audit entry - // (whose own `competitor` is structured). So the rendered line re-composes from the structured - // fields — resolved callsign first, the offset only as a trailing "· #N" detail — instead of - // printing the server's raw-ref string. - - /** The target log offset a server summary interpolates ("(ref 42)"), if any. */ - function summaryTargetRef(summary: string): number | undefined { - const m = /\(ref (\d+)\)/.exec(summary); - return m ? Number(m[1]) : undefined; - } - /** The summary with the raw "(ref N)" clause removed (the offset renders as a trailing detail). */ - function stripRefClause(summary: string): string { - return summary.replace(/\s*\(ref \d+\)/, ''); - } - - const auditByRef = $derived(new Map((audit ?? []).map((e) => [e.at_ref, e]))); + // ── Audit rendering (the SHARED #337 recomposition — `lib/auditRender.ts`) ── + // The line composition (resolved callsign first, the server-baked "(ref N)" stripped, the target + // offset only trailing) is shared with the event-wide Audit page so the two never drift. This + // screen supplies the one input only it has: the marshaled heat's LAP LIST, which resolves a + // lap-addressed ruling's pass ref to the competitor whose lap it bounds. /** The competitor whose lap a pass offset (a lap's start/end ref) bounds, from the lap list. */ function competitorForPassRef(target: number): CompetitorRef | undefined { @@ -678,43 +654,17 @@ return undefined; } - /** - * The competitor an audit entry concerns: its own structured ref when the action named one, else - * chased through the entry's target — a `ProtestResolved` / `RulingReversed` targets another - * audit entry (follow the chain), a lap-addressed ruling targets a pass in the lap list. - * `undefined` when nothing resolves (a heat-void, or a voided pass no longer in the lap list) — - * the line then renders without a name rather than with a raw ref. - */ - function auditCompetitor(entry: AuditEntry, depth = 0): CompetitorRef | undefined { - if (entry.competitor != null) return entry.competitor; - const target = summaryTargetRef(entry.summary); - if (target === undefined) return undefined; - const chained = auditByRef.get(target); - if (chained && depth < 4) return auditCompetitor(chained, depth + 1); - return competitorForPassRef(target); - } - - // The displayed audit line: the **resolved callsign** (from the structured `competitor` ref, or - // chased through the entry's target) joined to the summary with any server-baked raw "(ref N)" - // stripped; the target offset renders only as a trailing "· #N" detail. A baked raw-id string - // couldn't be re-resolved, so the composition happens here, client-side. - function auditSummary(entry: AuditEntry): string { - const ref = auditCompetitor(entry); - const target = summaryTargetRef(entry.summary); - const text = stripRefClause(entry.summary); - const line = ref != null ? `${competitorName(ref)} · ${text}` : text; - return target !== undefined ? `${line} · #${target}` : line; - } + const renderInputs = $derived({ + byRef: new Map((audit ?? []).map((e) => [e.at_ref, e])), + competitorName, + competitorForPassRef + }); - // A ruling/protest picker option: "‹what› — ‹callsign› · #‹offset›". The action + callsign is the - // primary label; the entry's own log offset (what the reverse/resolve command targets) is only a - // trailing detail, never the label itself (#337). - function rulingOptionLabel(entry: AuditEntry): string { - const ref = auditCompetitor(entry); - const text = stripRefClause(entry.summary); - const who = ref != null ? ` — ${competitorName(ref)}` : ''; - return `${text}${who} · #${entry.at_ref}`; - } + // The Recent-rulings strip: the marshaled heat's latest few entries (the trail arrives newest + // first). The FULL history — searchable, event-wide — lives on the Audit page; the strip only + // answers "what just changed here?" at a glance. + const RECENT_RULINGS = 3; + const recentRulings = $derived((audit ?? []).slice(0, RECENT_RULINGS));
@@ -1064,7 +1014,7 @@ @@ -1109,7 +1059,7 @@ @@ -1170,15 +1120,17 @@ {/if} - -
@@ -1557,6 +1517,17 @@ font-size: var(--gf-font-size-2xs); font-family: var(--gf-font-mono); } + /* The jump to the event-wide Audit page (pre-filtered to the marshaled heat). */ + .view-audit { + margin-top: var(--gf-space-3); + width: 100%; + border-color: color-mix(in srgb, var(--gf-accent) 35%, var(--gf-border)); + } + .view-audit:hover:not(:disabled) { + border-color: var(--gf-accent); + color: var(--gf-accent); + background: var(--gf-elevated); + } @media (max-width: 70rem) { .layout { grid-template-columns: 1fr; diff --git a/frontend/apps/rd-console/src/screens/Results.svelte b/frontend/apps/rd-console/src/screens/Results.svelte index 8e693dd..0946e4a 100644 --- a/frontend/apps/rd-console/src/screens/Results.svelte +++ b/frontend/apps/rd-console/src/screens/Results.svelte @@ -37,16 +37,24 @@ import { isTimedQualFormat } from '../lib/formats.js'; import { createCompetitorNameResolver } from '../lib/competitorName.js'; import { channelLabel, nodeIndexOf } from '../lib/channels.js'; + import type { AuditPrefilter } from '../lib/auditFilter.svelte.js'; import type { Session } from '../lib/session.svelte.js'; let { session, heatResult = undefined, - standings = undefined + standings = undefined, + onviewaudit = undefined }: { session?: Session; heatResult?: HeatResult; standings?: RankEntry[]; + /** + * Jump to the event-wide Audit page pre-filtered to a pilot (each ROUND-standings row's + * "audit" affordance — the defensible-results answer to "why is this pilot placed here?"). + * The shell wires this to the auditFilter seam (`openAudit(setTab, prefilter)`). + */ + onviewaudit?: (prefilter: AuditPrefilter) => void; } = $props(); const hasEventProjection = $derived(!!heatResult || !!(standings && standings.length)); @@ -437,6 +445,22 @@ {/snippet} +{#snippet auditLink(competitor: CompetitorRef)} + + {#if onviewaudit} + + {/if} +{/snippet} + {#snippet rankTable(caption: string, rows: RankEntry[])} @@ -449,7 +473,7 @@ {#each rows as row (row.competitor)} - + {/each} @@ -472,7 +496,7 @@ {#each rows as row (row.competitor)} - + {#if ttMetricHeader} @@ -554,6 +578,30 @@ font-weight: var(--gf-font-weight-semibold); letter-spacing: var(--gf-tracking-tight); } + /* The per-pilot Audit-page jump: a quiet pill after the callsign (round views). */ + .audit-link { + margin-left: var(--gf-space-2); + padding: 0.05em 0.55em; + border: 1px solid var(--gf-border); + border-radius: var(--gf-radius-pill); + background: transparent; + color: var(--gf-text-muted); + font-family: inherit; + font-size: var(--gf-font-size-2xs); + font-weight: var(--gf-font-weight-semibold); + text-transform: uppercase; + letter-spacing: var(--gf-tracking-caps); + cursor: pointer; + vertical-align: middle; + } + .audit-link:hover { + border-color: var(--gf-accent); + color: var(--gf-accent); + } + .audit-link:focus-visible { + outline: none; + box-shadow: var(--gf-focus-ring); + } .standings .pos { width: 2.75em; } diff --git a/frontend/apps/rd-console/tests/EventAudit.test.ts b/frontend/apps/rd-console/tests/EventAudit.test.ts new file mode 100644 index 0000000..cde3d76 --- /dev/null +++ b/frontend/apps/rd-console/tests/EventAudit.test.ts @@ -0,0 +1,248 @@ +/** + * The event-wide Audit page: renders the Director's heat-tagged, newest-first audit trail + * (`GET /events/{id}/audit`) with friendly names everywhere, client-side filters (pilot / heat / + * kind / free text), clickable pilot+heat chips that set those filters, the #340 error+retry + * treatment, and the cross-screen prefilter seam (auditFilter) consumed on mount. + */ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor, within } from '@testing-library/svelte'; +import { fireEvent } from '@testing-library/dom'; +import type { EventAuditEntry, EventMeta, HeatSummary, RoundDef } from '@gridfpv/types'; +import EventAudit from '../src/screens/EventAudit.svelte'; +import { consumeAuditPrefilter, openAudit } from '../src/lib/auditFilter.svelte.js'; +import { makeTestSession } from './support.js'; + +// A roster-seeded event: the competitor refs ARE directory pilot ids, so callsigns must resolve +// from the pilots directory alone (the common FromRoster case), never render raw. +const ROUND = { + id: 'r1', + label: 'Qualifying R1', + classes: ['c1'], + format: 'timed_qual', + params: {}, + win_condition: { Timed: { window_micros: 120_000_000 } }, + seeding: 'FromRoster', + channel_mode: 'Static', + protest_window: 'Off' +} as unknown as RoundDef; +const EVENT: EventMeta = { + id: 'e1', + name: 'Friday', + created_at: 0, + persistent: true, + timers: ['mock'], + roster: [], + classes: ['c1'], + rounds: [ROUND] +}; +const PILOTS = [ + { id: 'maverick-4d9rp8', callsign: 'Maverick', vtx_types: [] }, + { id: 'goose-yla6dp', callsign: 'Goose', vtx_types: [] } +]; +const HEATS: HeatSummary[] = [ + { + heat: 'q1-heat', + lineup: ['maverick-4d9rp8', 'goose-yla6dp'], + round: 'r1', + class: 'c1', + frequencies: [], + phase: 'Final', + is_current: false + }, + { + heat: 'q2-heat', + lineup: ['maverick-4d9rp8', 'goose-yla6dp'], + round: 'r1', + class: 'c1', + frequencies: [], + phase: 'Unofficial', + is_current: true + } +]; + +// The server's trail: newest first (descending at_ref), heat-tagged. The reversal (#40) targets +// the DQ (#20) so its pilot resolves by CHASING the chain; the void's target (a pass offset) is +// not an audit entry and the page holds no lap list, so that row renders with no pilot chip. +const ENTRIES: EventAuditEntry[] = [ + { + heat: 'q1-heat', + kind: 'RulingReversed', + at: 1_700_000_400_000_000, + at_ref: 40, + competitor: null, + summary: 'Ruling reversed (ref 20)' + }, + { + heat: 'q2-heat', + kind: 'Voided', + at: 1_700_000_300_000_000, + at_ref: 30, + competitor: null, + summary: 'Detection voided (ref 25)' + }, + { + heat: 'q1-heat', + kind: 'PenaltyApplied', + at: 1_700_000_200_000_000, + at_ref: 20, + competitor: 'goose-yla6dp', + summary: 'DQ applied' + }, + { + heat: 'q1-heat', + kind: 'ProtestFiled', + at: 1_700_000_100_000_000, + at_ref: 10, + competitor: 'maverick-4d9rp8', + summary: 'Protest filed: contact' + } +]; + +function renderAudit(overrides: Parameters[0] = {}) { + const made = makeTestSession({ + event: EVENT, + eventAuditImpl: vi.fn(async () => ENTRIES), + listHeatsImpl: vi.fn(async () => HEATS), + listPilotsImpl: vi.fn(async () => PILOTS as unknown as never), + listChannelsImpl: vi.fn(async () => []), + ...overrides + }); + render(EventAudit, { session: made.session }); + return made; +} + +const rows = () => within(screen.getByLabelText('Audit entries')).getAllByRole('listitem'); + +describe('EventAudit — the event-wide audit page', () => { + it('renders the trail newest-first with kind badges, callsigns, and friendly heat names', async () => { + renderAudit(); + await waitFor(() => expect(rows()).toHaveLength(4)); + const entries = rows(); + + // Newest first, as served (descending at_ref) — the page must not re-order. + expect(entries[0]).toHaveTextContent('Ruling reversed'); + expect(entries[3]).toHaveTextContent('Protest filed'); + + // The PenaltyApplied entry's badge derives the finer 'DQ' from the summary. + expect(within(entries[2]).getByText('DQ')).toBeInTheDocument(); + + // Pilot resolution: structured ref (Goose), and CHASED through the reversal's target (#20 → + // the DQ → Goose). The heat renders as its friendly " Heat N" name. + await waitFor(() => { + expect(within(entries[0]).getByRole('button', { name: 'Goose' })).toBeInTheDocument(); + expect(within(entries[2]).getByRole('button', { name: 'Goose' })).toBeInTheDocument(); + expect( + within(entries[0]).getByRole('button', { name: 'Qualifying R1 Heat 1' }) + ).toBeInTheDocument(); + expect( + within(entries[1]).getByRole('button', { name: 'Qualifying R1 Heat 2' }) + ).toBeInTheDocument(); + }); + + // The trailing detail is the entry's own log ref; the raw "(ref N)" clause is stripped from + // the text (the target renders only as the "· #N" tail, #337). + expect(within(entries[0]).getByText('#40')).toBeInTheDocument(); + expect(entries[0]).toHaveTextContent('Ruling reversed · #20'); + expect(screen.queryByText(/\(ref \d+\)/)).not.toBeInTheDocument(); + + // No raw ids anywhere in the rendered rows. + expect(screen.queryByText(/goose-yla6dp/)).not.toBeInTheDocument(); + expect(screen.queryByText(/maverick-4d9rp8/)).not.toBeInTheDocument(); + expect(screen.queryByText(/q1-heat/)).not.toBeInTheDocument(); + }); + + it('clicking a pilot chip filters to that pilot; a heat chip filters to that heat', async () => { + renderAudit(); + await waitFor(() => expect(rows()).toHaveLength(4)); + + // Click Goose's chip on the DQ row → only the rows resolving to Goose remain (the DQ and + // the reversal that chases to it), and the pilot select reflects the filter. + await fireEvent.click(within(rows()[2]).getByRole('button', { name: 'Goose' })); + await waitFor(() => expect(rows()).toHaveLength(2)); + expect((screen.getByLabelText('Filter by pilot') as HTMLSelectElement).value).toBe( + 'goose-yla6dp' + ); + + // Reset, then click the q2 heat chip → only q2's row remains. + await fireEvent.click(screen.getByRole('button', { name: 'Clear filters' })); + await waitFor(() => expect(rows()).toHaveLength(4)); + await fireEvent.click(within(rows()[1]).getByRole('button', { name: 'Qualifying R1 Heat 2' })); + await waitFor(() => expect(rows()).toHaveLength(1)); + expect(rows()[0]).toHaveTextContent('Detection voided'); + }); + + it('filters by kind and by free text over the rendered row', async () => { + renderAudit(); + await waitFor(() => expect(rows()).toHaveLength(4)); + + // The kind select offers exactly the badges present. + const kind = screen.getByLabelText('Filter by kind') as HTMLSelectElement; + const offered = Array.from(kind.options).map((o) => o.textContent?.trim()); + expect(offered).toEqual([ + 'All kinds', + 'Ruling reversed', + 'Detection voided', + 'DQ', + 'Protest filed' + ]); + await fireEvent.change(kind, { target: { value: 'DQ' } }); + await waitFor(() => expect(rows()).toHaveLength(1)); + expect(rows()[0]).toHaveTextContent('DQ applied'); + + await fireEvent.change(kind, { target: { value: '' } }); + // Free text matches the RENDERED text — the resolved callsign, not the raw ref. + await fireEvent.input(screen.getByLabelText('Search audit entries'), { + target: { value: 'maverick' } + }); + await waitFor(() => expect(rows()).toHaveLength(1)); + expect(rows()[0]).toHaveTextContent('Protest filed: contact'); + // A raw ref must NOT match (nothing renders it). + await fireEvent.input(screen.getByLabelText('Search audit entries'), { + target: { value: 'maverick-4d9rp8' } + }); + await waitFor(() => + expect(screen.getByText('No entries match the current filters.')).toBeInTheDocument() + ); + }); + + it('consumes (and clears) the cross-screen prefilter on mount', async () => { + // Another screen deposited a heat prefilter and switched tabs (the auditFilter seam). + const setTab = vi.fn(); + openAudit(setTab, { heat: 'q2-heat' }); + expect(setTab).toHaveBeenCalledWith('audit'); + + renderAudit(); + // The heat filter arrives pre-set → only q2's entry shows. + await waitFor(() => expect(rows()).toHaveLength(1)); + expect((screen.getByLabelText('Filter by heat') as HTMLSelectElement).value).toBe('q2-heat'); + // Consumed: nothing left in the mailbox for a later visit. + expect(consumeAuditPrefilter()).toBeUndefined(); + }); + + it('surfaces a visible error with retry when the audit read fails (#340 — no silent [])', async () => { + let fail = true; + renderAudit({ + eventAuditImpl: vi.fn(async () => { + if (fail) throw new Error('boom'); + return ENTRIES; + }) + }); + + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent(/Couldn.t load the audit trail/); + + // Retry with the read healthy: the error clears and the trail renders. + fail = false; + await fireEvent.click(within(alert).getByRole('button', { name: 'Try again' })); + await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()); + await waitFor(() => expect(rows()).toHaveLength(4)); + }); + + it('is a pure read — renders identically for a read-only session (no gated controls)', async () => { + renderAudit({ role: 'readonly' }); + await waitFor(() => expect(rows()).toHaveLength(4)); + // The chips + filters are navigation/filtering, not mutations — all present read-only. + expect(screen.getByLabelText('Filter by pilot')).toBeInTheDocument(); + expect(within(rows()[2]).getByRole('button', { name: 'Goose' })).toBeInTheDocument(); + }); +}); diff --git a/frontend/apps/rd-console/tests/MarshalingScreen.test.ts b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts index bfff7d6..c0593d1 100644 --- a/frontend/apps/rd-console/tests/MarshalingScreen.test.ts +++ b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts @@ -374,25 +374,56 @@ describe('Marshaling (Slice 3)', () => { expect(sendSpy).toHaveBeenCalledWith({ Revert: { heat: 'heat-1' } }); }); - it('renders the audit trail newest-first', () => { + // ── The Recent-rulings strip (the full trail moved to the event-wide Audit page) ───────────── + it('the Recent-rulings strip renders the latest entries newest-first (composed lines)', () => { const { session } = makeTestSession({ live: liveRunning, laps: lapList, audit: marshalingAudit }); render(Marshaling, { session }); - const panel = within(screen.getByRole('complementary', { name: 'Audit trail' })); + const panel = within(screen.getByRole('complementary', { name: 'Recent rulings' })); const entries = panel.getAllByRole('listitem'); // Newest first: the DQ (at_ref 20) precedes the void (at_ref 18). The competitor name is composed // from the STRUCTURED ref (resolved to its callsign — here the bare ref, no directory seeded). expect(entries[0]).toHaveTextContent('CARMEN · DQ applied'); // The void names no competitor structurally, but its target (ref 12) is ALICE's lap-1 end pass // in the lap list — the line resolves her name and renders the raw "(ref 12)" only as the - // trailing "· #12" detail (#337). + // trailing "· #12" detail (#337 — the SHARED auditRender helpers). expect(entries[1]).toHaveTextContent('ALICE · Detection voided · #12'); expect(entries[1]).not.toHaveTextContent('(ref 12)'); }); + it('the strip shows at most the latest 3 entries — the full history lives on the Audit page', () => { + // Four rulings, newest first (as the server serves them): only the first three render. + const audit: AuditEntry[] = [40, 30, 20, 10].map((at_ref) => ({ + kind: 'PenaltyApplied' as const, + at: 1_700_000_000_000_000 + at_ref, + at_ref, + competitor: 'BOB', + summary: `DQ applied (strike ${at_ref})` + })); + const { session } = makeTestSession({ live: liveRunning, laps: lapList, audit }); + render(Marshaling, { session }); + const panel = within(screen.getByRole('complementary', { name: 'Recent rulings' })); + expect(panel.getAllByRole('listitem')).toHaveLength(3); + expect(panel.getByText(/strike 40/)).toBeInTheDocument(); + expect(panel.queryByText(/strike 10/)).toBeNull(); + }); + + it('"View full audit →" jumps to the Audit tab pre-filtered to the MARSHALED heat', async () => { + const onviewaudit = vi.fn(); + const { session } = makeTestSession({ + live: liveRunning, + laps: lapList, + audit: marshalingAudit + }); + render(Marshaling, { session, onviewaudit }); + await fireEvent.click(screen.getByRole('button', { name: 'View full audit →' })); + // The seam receives the marshaled heat as the prefilter (the shell deposits it + switches tab). + expect(onviewaudit).toHaveBeenCalledWith({ heat: 'heat-1' }); + }); + // ── Slice 4: the signal-as-evidence RSSI graph ──────────────────────────────────────── describe('signal-as-evidence graph (Slice 4)', () => { it('renders the graph with threshold lines + a lap marker per lap when a trace is present', () => { @@ -731,10 +762,10 @@ describe('Marshaling (Slice 3)', () => { expect(goose.value).toBe('goose-yla6dp'); }); - it('composes the audit line with the RESOLVED callsign from the structured ref', async () => { + it('composes the strip line with the RESOLVED callsign from the structured ref', async () => { const { session } = renderFN(); render(Marshaling, { session }); - const panel = within(screen.getByRole('complementary', { name: 'Audit trail' })); + const panel = within(screen.getByRole('complementary', { name: 'Recent rulings' })); // The DQ line shows the callsign, never the raw pilot id. await waitFor(() => expect(panel.getByText('Goose · DQ applied')).toBeInTheDocument()); expect(panel.getByText('Maverick · Protest filed: cut the course')).toBeInTheDocument(); @@ -809,10 +840,11 @@ describe('Marshaling (Slice 3)', () => { const resolveLabels = Array.from(resolve.options).map((o) => o.textContent?.trim()); expect(resolveLabels).toContain('Protest filed: contact — Maverick · #21'); - // The audit lines resolve the same targets: callsign first, the offset only as "· #N". - const panel = within(screen.getByRole('complementary', { name: 'Audit trail' })); + // The strip's lines resolve the same targets (SHARED helpers): callsign first, the offset + // only as "· #N", never a server-baked "(ref N)". The lap-addressed throw-out (3rd-newest, + // within the latest-3 strip) resolves Maverick through the lap list. + const panel = within(screen.getByRole('complementary', { name: 'Recent rulings' })); expect(panel.getByText('Maverick · Lap thrown out · #12')).toBeInTheDocument(); - expect(panel.getByText('Maverick · Protest upheld · #21')).toBeInTheDocument(); expect(panel.queryByText(/\(ref \d+\)/)).not.toBeInTheDocument(); }); @@ -950,8 +982,8 @@ describe('Marshaling (Slice 3)', () => { expect(opts).toContain('Maverick'); const mav = Array.from(ruling.options).find((o) => o.textContent?.trim() === 'Maverick')!; expect(mav.value).toBe('node-0'); - // The audit line composes the resolved callsign from the structured ref. - const panel = within(screen.getByRole('complementary', { name: 'Audit trail' })); + // The strip line composes the resolved callsign from the structured ref. + const panel = within(screen.getByRole('complementary', { name: 'Recent rulings' })); expect(panel.getByText('Maverick · DQ applied')).toBeInTheDocument(); // The raw "node-0" must appear NOWHERE the resolver renders a name. expect(screen.queryByRole('heading', { name: 'node-0' })).not.toBeInTheDocument(); diff --git a/frontend/apps/rd-console/tests/ResultsScreen.test.ts b/frontend/apps/rd-console/tests/ResultsScreen.test.ts index 3ac7c58..29a54aa 100644 --- a/frontend/apps/rd-console/tests/ResultsScreen.test.ts +++ b/frontend/apps/rd-console/tests/ResultsScreen.test.ts @@ -241,6 +241,26 @@ describe('Results — phase-aware views (round / per-class selector)', () => { expect(within(table).getByText('Bolt')).toBeInTheDocument(); expect(within(table).getByText('AceOne')).toBeInTheDocument(); }); + + it('each ROUND-standings row offers an audit jump pre-filtered to that pilot', async () => { + // The per-pilot "audit" affordance (the defensible-results cross-link): clicking it hands the + // pilot's competitor ref to the auditFilter seam, which the shell wires to the Audit tab. + const onviewaudit = vi.fn(); + const { session } = makeTestSession({ + event: { ...EVENT, rounds: [QUAL] }, + listClassesImpl: vi.fn(async () => [OPEN]), + listPilotsImpl: vi.fn(async () => [ACE, BOLT]), + listHeatsImpl: vi.fn(async () => [QUAL_HEAT]), + roundRankingImpl: rankingImpl, + classStandingsImpl: vi.fn(async () => STANDINGS) + }); + render(Results, { session, onviewaudit }); + + const table = (await screen.findByLabelText(/Qualifying standings/i)) as HTMLElement; + // The affordance is labelled by the resolved callsign — never the raw pilot id. + await fireEvent.click(within(table).getByRole('button', { name: 'View audit for Bolt' })); + expect(onviewaudit).toHaveBeenCalledWith({ pilot: 'p2' }); + }); }); describe('Results — time-trial round standings (Best lap + win-condition metric)', () => { diff --git a/frontend/apps/rd-console/tests/support.ts b/frontend/apps/rd-console/tests/support.ts index 3cb8164..a33cf13 100644 --- a/frontend/apps/rd-console/tests/support.ts +++ b/frontend/apps/rd-console/tests/support.ts @@ -38,6 +38,7 @@ import type { updateRound, deleteRound, listHeats, + eventAudit, roundRanking, roundStandings, classStandings @@ -112,6 +113,8 @@ export interface TimerImpls { deleteRoundImpl?: typeof deleteRound; /** The scheduled-heats read (race redesign Slice 3b) — backs the EventRounds Heats UI tests. */ listHeatsImpl?: typeof listHeats; + /** The event-wide audit read — backs the EventAudit page tests. */ + eventAuditImpl?: typeof eventAudit; /** The round-ranking + class-standings reads (race redesign Slice 5/6a) — back the Results + * EventRounds standings/advance tests. */ roundRankingImpl?: typeof roundRanking; @@ -223,6 +226,9 @@ export function makeTestSession( // Scheduled-heats read seam (race redesign Slice 3b): inert success unless a test overrides it // (see the listPilotsImpl note — a failing default would trip the #340 error state everywhere). listHeatsImpl: opts?.listHeatsImpl ?? (async () => []), + // The event-wide audit read: inert success (empty trail) unless a test overrides it — a + // failing default would trip the Audit page's #340 error state in unrelated tests. + eventAuditImpl: opts?.eventAuditImpl ?? (async () => []), // Ranking + standings read seams (race redesign Slice 5/6a): inert unless overridden. roundRankingImpl: opts?.roundRankingImpl, roundStandingsImpl: opts?.roundStandingsImpl, diff --git a/frontend/contract/audit.contract.ts b/frontend/contract/audit.contract.ts new file mode 100644 index 0000000..1429fba --- /dev/null +++ b/frontend/contract/audit.contract.ts @@ -0,0 +1,159 @@ +/** + * Event-wide audit contract: `GET /events/{id}/audit` plus the real `eventAudit` client helper. + * + * Drives a real Director end-to-end: directory setup (class + pilots + round), schedule + run + + * finalize a heat, then two marshaling rulings — a penalty (heat-tagged) and a detection void + * (target-addressed, aimed at a real pass offset read back from the laps projection). The + * event-wide audit must then serve both rulings through the real `@gridfpv/protocol-client`, + * each **tagged with the heat** it rules on and in **newest-first** order (descending global + * append offset) — the same window-attributed entries Marshaling's per-heat `?projection=audit` + * serves, so the Audit page and Marshaling can never disagree. If the route, the flattened + * `EventAuditEntry` binding, or the merge/sort were wrong, the shapes would not round-trip. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + createClass, + createPilot, + createRound, + eventAudit, + PRACTICE_EVENT_ID, + setClassMembership, + setEventClasses, + setEventRoster +} from '../packages/protocol-client/dist/index.js'; +import { type Director } from '../test-harness/director.ts'; +import { driveToRunning, eventRoot, rdControl, startContractDirector } from './harness.ts'; + +const TOKEN = 'rd-audit-contract'; +const HEAT = 'a-1'; + +let director: Director; + +beforeAll(async () => { + // A brisk sim (one quick lap each) so the heat closes fast and the rulings can land. + director = await startContractDirector({ token: TOKEN, simLaps: 1, simLapMs: 30 }); +}); + +afterAll(async () => { + await director?.stop(); +}); + +describe('GET /events/{id}/audit serves the heat-tagged, newest-first event audit', () => { + it('rulings on a finalized heat arrive heat-tagged, newest first', async () => { + // Real directory setup: a class + two pilots, selected + rostered + membered on Practice. + const klass = await createClass(director.baseUrl, { name: 'Open' }, TOKEN); + const pilotA = await createPilot(director.baseUrl, { callsign: 'alpha' }, TOKEN); + const pilotB = await createPilot(director.baseUrl, { callsign: 'bravo' }, TOKEN); + await setEventClasses(director.baseUrl, PRACTICE_EVENT_ID, [klass.id], TOKEN); + await setEventRoster(director.baseUrl, PRACTICE_EVENT_ID, [pilotA.id, pilotB.id], TOKEN); + await setClassMembership( + director.baseUrl, + PRACTICE_EVENT_ID, + klass.id, + [pilotA.id, pilotB.id], + TOKEN + ); + const round = await createRound( + director.baseUrl, + PRACTICE_EVENT_ID, + { + label: 'Qualifying', + classes: [klass.id], + format: 'timed_qual', + params: { rounds: '1' }, + win_condition: 'BestLap', + time_limit_secs: 60, + seeding: 'FromRoster' + }, + TOKEN + ); + + // An empty event has an empty audit (a 200 [], not an error). + expect(await eventAudit(director.baseUrl, PRACTICE_EVENT_ID)).toEqual([]); + + // Schedule + run + finalize the round's heat. + expect( + ( + await rdControl(director.baseUrl, TOKEN, { + ScheduleHeat: { + heat: HEAT, + lineup: [pilotA.id, pilotB.id], + class: klass.id, + round: round.id + } + }) + ).ok + ).toBe(true); + await driveToRunning(director.baseUrl, TOKEN, HEAT); + await waitForBothLaps(director.baseUrl, HEAT); + expect((await rdControl(director.baseUrl, TOKEN, { ForceEnd: { heat: HEAT } })).ok).toBe(true); + expect((await rdControl(director.baseUrl, TOKEN, { Finalize: { heat: HEAT } })).ok).toBe(true); + + // Two rulings on the finalized heat: a heat-tagged penalty, then a target-addressed void + // aimed at a REAL pass offset (pilot A's first lap's end pass, read from the laps projection). + expect( + ( + await rdControl(director.baseUrl, TOKEN, { + ApplyPenalty: { + heat: HEAT, + competitor: pilotB.id, + penalty: { Disqualify: { reason: 'contract: flew the wrong course' } } + } + }) + ).ok + ).toBe(true); + const voidTarget = await firstLapEndRef(director.baseUrl, HEAT); + expect( + (await rdControl(director.baseUrl, TOKEN, { VoidDetection: { target: voidTarget } })).ok + ).toBe(true); + + // The event-wide audit, through the real client helper. + const trail = await eventAudit(director.baseUrl, PRACTICE_EVENT_ID); + expect(trail).toHaveLength(2); + + // Newest first: descending global append offset — the void (appended last) leads. + expect(trail[0].at_ref).toBeGreaterThan(trail[1].at_ref); + expect(trail[0].kind).toBe('Voided'); + expect(trail[1].kind).toBe('PenaltyApplied'); + + // Every entry carries the heat it rules on (the flattened EventAuditEntry shape: the per-heat + // AuditEntry fields plus `heat`), with the structured competitor on the penalty. + for (const entry of trail) expect(entry.heat).toBe(HEAT); + expect(trail[1].competitor).toBe(pilotB.id); + expect(trail[1].summary).toContain('DQ applied'); + expect(trail[0].summary).toContain(`(ref ${voidTarget})`); + }); +}); + +/** Poll the heat's `laps` projection until every competitor present has at least one completed lap. */ +async function waitForBothLaps(baseUrl: string, heat: string, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const competitors = await lapCompetitors(baseUrl, heat); + if (competitors.length >= 2 && competitors.every((c) => c.laps.length >= 1)) return; + await new Promise((r) => setTimeout(r, 30)); + } + throw new Error(`both pilots did not bank a lap within ${timeoutMs}ms`); +} + +/** The heat's first competitor's first-lap end pass offset — a real `LogRef` a void can target. */ +async function firstLapEndRef(baseUrl: string, heat: string): Promise { + const competitors = await lapCompetitors(baseUrl, heat); + const endRef = competitors[0]?.laps[0]?.end_ref; + if (typeof endRef !== 'number') throw new Error('no lap end_ref to void'); + return endRef; +} + +/** The heat's `?projection=laps` competitor list (empty on a non-2xx read). */ +async function lapCompetitors( + baseUrl: string, + heat: string +): Promise }>> { + const resp = await fetch(`${eventRoot(baseUrl)}/snapshot/heat/${heat}?projection=laps`); + if (!resp.ok) return []; + const snap = (await resp.json()) as { + body: { LapList?: { competitors: Array<{ laps: Array<{ end_ref: number }> }> } }; + }; + return snap.body.LapList?.competitors ?? []; +} diff --git a/frontend/packages/protocol-client/src/client.ts b/frontend/packages/protocol-client/src/client.ts index 74a7b4d..3453aa9 100644 --- a/frontend/packages/protocol-client/src/client.ts +++ b/frontend/packages/protocol-client/src/client.ts @@ -29,6 +29,7 @@ import type { CreatePilotRequest, CreateTimerRequest, Cursor, + EventAuditEntry, EventId, EventMeta, FormatSchema, @@ -1028,6 +1029,26 @@ export async function listHeats( return (await resp.json()) as HeatSummary[]; } +/** + * Read an event's **event-wide audit trail** (`GET /events/{id}/audit`) — the "defensible + * results" review surface. A read (open, no token): every heat's marshaling audit fold, + * heat-tagged ({@link EventAuditEntry} = the per-heat `AuditEntry` fields plus `heat`) and merged + * **newest first** across the whole event — what the console's Audit page renders and filters. + * Resolves the list, or rejects on a non-2xx / transport failure; an unknown event is a 404. + */ +export async function eventAudit( + baseUrl: string, + eventId: EventId, + options: { token?: string; fetch?: FetchLike } = {} +): Promise { + const fetchImpl: FetchLike = options.fetch ?? ((input, init) => globalThis.fetch(input, init)); + const headers: Record = { Accept: 'application/json' }; + if (options.token) headers.Authorization = `Bearer ${options.token}`; + const resp = await fetchImpl(`${trimSlash(baseUrl)}${eventRoot(eventId)}/audit`, { headers }); + if (!resp.ok) throw new Error(`GET /events/${eventId}/audit failed: HTTP ${resp.status}`); + return (await resp.json()) as EventAuditEntry[]; +} + /** * Read a round's **ranking** (`GET /events/{id}/rounds/{round}/ranking`) — race redesign Slice 5/6a. * A read (open, no token): the ordered per-pilot {@link RankEntry} list the engine seeds diff --git a/frontend/packages/protocol-client/src/index.ts b/frontend/packages/protocol-client/src/index.ts index 9a60471..b24ab95 100644 --- a/frontend/packages/protocol-client/src/index.ts +++ b/frontend/packages/protocol-client/src/index.ts @@ -46,6 +46,7 @@ export { updateRound, deleteRound, listHeats, + eventAudit, roundRanking, roundStandings, classStandings, diff --git a/frontend/packages/types/src/generated.ts b/frontend/packages/types/src/generated.ts index d682221..9c2c82c 100644 --- a/frontend/packages/types/src/generated.ts +++ b/frontend/packages/types/src/generated.ts @@ -55,6 +55,7 @@ export type * from '@bindings/CreateTimerRequest'; export type * from '@bindings/Cursor'; export type * from '@bindings/ErrorCode'; export type * from '@bindings/Event'; +export type * from '@bindings/EventAuditEntry'; export type * from '@bindings/EventId'; export type * from '@bindings/EventMeta'; export type * from '@bindings/EventOutcome';
{row.position}{resolveName(row.competitor)}{resolveName(row.competitor)}{@render auditLink(row.competitor)}
{row.position}{resolveName(row.competitor)}{resolveName(row.competitor)}{@render auditLink(row.competitor)} {formatMicros(row.best_lap_micros)}{ttMetricValue(row)}