From d73abdef0b97e9c2387a32bd17a4ee6f02fe312f Mon Sep 17 00:00:00 2001 From: "Ryan Johnson (ntninja)" Date: Fri, 3 Jul 2026 11:26:15 +0000 Subject: [PATCH] fix(console): marshaling friendly names + robustness batch (#337, #340, #341) - (#337) Marshaling audit trail + "Reverse a ruling"/"Resolve" pickers: the server bakes raw log offsets ("(ref N)") into some summaries and names no competitor structurally for lap-/target-addressed rulings. The screen now re-composes each line from the structured fields: a lap-addressed target resolves its competitor through the lap list, a protest-resolution/reversal chases its target audit entry, and the callsign renders via the shared resolver. Picker options read " - . #offset" (offset only a trailing detail); audit lines strip the raw "(ref N)" the same way. - (#340a) Silent [] on directory loads: LiveRaceControl, Marshaling, and the global ContextHeader no longer swallow failed pilots/heats reads into empty arrays (raw refs with no error state). Each tracks a load-error flag, keeps the last good data, shows a visible "Couldn't load - retry" state (+ one toast on the transition into error), and retries via a nonce. - (#340b) Marshaling open-protest count now matches the server's Finalize gate (open_protest_count, control_handler.rs): a filing is closed only by an UNreversed resolution targeting it, so a ProtestResolved later undone by RulingReversed counts the protest as open again - no more Finalize offered client-side only to be rejected by the server. - (#340c) Rounds form: clearing "Laps" (First-to-N / Best-of-N) or the FromRanking "Take top" no longer silently saves 1 - both block submit via the same canSubmit pattern as the timed race-time field (#329). - (#341, verified) Results JSON export: the legacy standings / heatResult sections carried raw competitor refs. Both now resolve through the shared resolver with the raw ref kept alongside (competitor_ref), like every other exported view. Tests: new/updated coverage in MarshalingScreen, LiveRaceControl, ContextHeader, EventRounds, and results tests; test-session pilot/heat seams default to inert success so the new error state only shows when a test injects a failure. Both svelte-check passes, vitest (491), and lint clean. Co-Authored-By: Claude Fable 5 --- .../apps/rd-console/src/ContextHeader.svelte | 46 ++++- frontend/apps/rd-console/src/lib/results.ts | 43 ++++- .../rd-console/src/screens/EventRounds.svelte | 17 +- .../src/screens/LiveRaceControl.svelte | 57 +++++- .../rd-console/src/screens/Marshaling.svelte | 165 ++++++++++++++++-- .../rd-console/src/screens/Results.svelte | 2 +- .../rd-console/tests/ContextHeader.test.ts | 28 ++- .../apps/rd-console/tests/EventRounds.test.ts | 60 +++++++ .../rd-console/tests/LiveRaceControl.test.ts | 32 ++++ .../rd-console/tests/MarshalingScreen.test.ts | 164 ++++++++++++++++- .../apps/rd-console/tests/results.test.ts | 37 +++- frontend/apps/rd-console/tests/support.ts | 11 +- 12 files changed, 615 insertions(+), 47 deletions(-) diff --git a/frontend/apps/rd-console/src/ContextHeader.svelte b/frontend/apps/rd-console/src/ContextHeader.svelte index 47041f3..6b61b33 100644 --- a/frontend/apps/rd-console/src/ContextHeader.svelte +++ b/frontend/apps/rd-console/src/ContextHeader.svelte @@ -21,7 +21,7 @@ * freezes on Unofficial/Final, and resets when there's no live heat — everywhere, not just * on the live screen, which now drives its clock from the same source. */ - import { StatusPill, RaceClock } from '@gridfpv/components'; + import { StatusPill, RaceClock, toast } from '@gridfpv/components'; import type { HeatSummary } from '@gridfpv/types'; import type { Session } from './lib/session.svelte.js'; import { useRaceClock } from './lib/raceClock.svelte.js'; @@ -58,13 +58,27 @@ // Touching `currentEvent` makes it re-read the instant the event settles. Rounds come straight off // the event, so the `$derived` name re-resolves the moment either heats or the event changes. let heats = $state([]); + // A FAILED heats read must be visible (#340): swallowing it into an empty array made the header + // silently render the raw heat id with no hint anything was wrong. Track a load-error flag + // (keeping the last good data rather than wiping it), show a compact "Couldn't load — retry" + // state in place of the heat name, and toast once on the transition into the error state. + let heatsError = $state(false); + let heatsRetryNonce = $state(0); + function retryHeats(): void { + heatsRetryNonce += 1; + } $effect(() => { void session.currentEvent; void session.protocolState; + void heatsRetryNonce; session .listHeats() - .then((h) => (heats = h)) - .catch(() => (heats = [])); + .then((h) => ((heats = h), (heatsError = false))) + .catch(() => { + if (!heatsError) + toast.error('Couldn’t load the heats directory — heat names may not resolve.'); + heatsError = true; + }); }); const heatName = $derived( heat ? heatNameById(heat, heats, session.currentEvent?.rounds ?? []) : '' @@ -112,7 +126,19 @@
Heat - {heatName} + {#if heatsError} + + + {:else} + {heatName} + {/if}
{#if phase} @@ -240,6 +266,18 @@ font-size: var(--gf-font-size-sm); color: var(--gf-text-faint); } + /* The heats-directory load-error state (#340): compact, in place of the heat name. */ + .ctx-load-error { + background: var(--gf-danger-soft); + border: 1px solid color-mix(in srgb, var(--gf-danger) 45%, var(--gf-border)); + border-radius: var(--gf-radius-sm); + padding: var(--gf-space-1) var(--gf-space-2); + color: var(--gf-text); + font-family: inherit; + font-size: var(--gf-font-size-xs); + cursor: pointer; + white-space: nowrap; + } .ctx-phase { display: inline-flex; } diff --git a/frontend/apps/rd-console/src/lib/results.ts b/frontend/apps/rd-console/src/lib/results.ts index 13fa991..7c148c7 100644 --- a/frontend/apps/rd-console/src/lib/results.ts +++ b/frontend/apps/rd-console/src/lib/results.ts @@ -5,7 +5,13 @@ * refs resolved to callsigns and class/round ids to their labels (CLAUDE.md: never leak a raw id) — * while preserving the raw ref alongside so it stays traceable. */ -import type { ClassStanding, CompetitorRef, HeatResult, RankEntry } from '@gridfpv/types'; +import type { + ClassStanding, + CompetitorRef, + HeatResult, + Placement, + RankEntry +} from '@gridfpv/types'; /** Serialize a value to pretty JSON; the bigint replacer is a defensive no-op now * that wire numerics are plain `number`s. */ @@ -16,13 +22,26 @@ export function toExportJson(value: unknown): string { /** A standings/ranking row with its competitor ref resolved to a friendly name (raw ref kept). */ type WithName = Omit & { competitor: string; competitor_ref: CompetitorRef }; +/** + * A scored heat's placement with the competitor resolved to its friendly name. The raw + * source-local ref stays alongside as `competitor_ref` (the same traceability convention as the + * standings rows), replacing the wire's `CompetitorKey`. + */ +type PlacementWithName = Omit & { + competitor: string; + competitor_ref: CompetitorRef; +}; + +/** A {@link HeatResult} with every placement's competitor resolved (raw ref kept alongside). */ +export type HeatResultExport = Omit & { places: PlacementWithName[] }; + /** The friendly, human-usable results payload (whichever views are present). */ export interface ResultsExport { class_standings?: { class: string; standings: WithName[] }; round_ranking?: { round: string; ranking: WithName[] }; - /** The legacy event-level projection, carried through as-is. */ - standings?: RankEntry[]; - heatResult?: HeatResult; + /** The legacy event-level projection — competitor refs resolved like every other view (#341). */ + standings?: WithName[]; + heatResult?: HeatResultExport; } /** The inputs the Results screen passes to {@link buildResultsExport}. */ @@ -60,8 +79,20 @@ export function buildResultsExport(input: ResultsExportInput): ResultsExport { if (input.roundRanking) { out.round_ranking = { round: input.roundLabel ?? '—', ranking: withName(input.roundRanking) }; } - if (input.standings) out.standings = input.standings; - if (input.heatResult) out.heatResult = input.heatResult; + // The legacy event-level projections resolve too (#341): the standings rows through the same + // `withName`, and the heat result's placements — whose wire competitor is a `CompetitorKey` + // (`{ adapter, competitor }`) — through the resolver on the key's ref, raw ref kept alongside. + if (input.standings) out.standings = withName(input.standings); + if (input.heatResult) { + out.heatResult = { + ...input.heatResult, + places: input.heatResult.places.map((p) => ({ + ...p, + competitor: input.resolveCompetitor(p.competitor.competitor), + competitor_ref: p.competitor.competitor + })) + }; + } return out; } diff --git a/frontend/apps/rd-console/src/screens/EventRounds.svelte b/frontend/apps/rd-console/src/screens/EventRounds.svelte index 7f82f27..dd2f466 100644 --- a/frontend/apps/rd-console/src/screens/EventRounds.svelte +++ b/frontend/apps/rd-console/src/screens/EventRounds.svelte @@ -903,11 +903,18 @@ // is always timed): both read `winSeconds` and would build a NaN/0-µs window if it were left blank. const needsRaceTime = $derived(winKind === 'Timed' || winKind === 'BestOfN'); const raceTimeValid = $derived(Number.isFinite(Number(winSeconds)) && Number(winSeconds) >= 1); + // The "Laps" field backs First-to-N and Best-of-N: clearing it left `winLaps` blank and the + // builder silently clamped it to 1 (#340) — block submit instead, the race-time pattern (#329). + const needsLaps = $derived(winKind === 'FirstToLaps' || winKind === 'BestOfN'); + const lapsValid = $derived(Number.isFinite(Number(winLaps)) && Number(winLaps) >= 1); + // Same for the FromRanking "Take top" cut: a cleared field silently saved `top_n: 1` (#340). + const seedTopNValid = $derived(Number.isFinite(Number(seedTopN)) && Number(seedTopN) >= 1); // The form is submittable once it has a label, a single eligible class, a format, and — when - // seeding from a ranking — at least one chosen source round (the multi-select, issue #51). When the - // win condition is timed (Timed / Best-of-N) a valid race time is also required (else the heat - // would run forever / build a degenerate window). + // seeding from a ranking — at least one chosen source round (the multi-select, issue #51) plus a + // valid "Take top" cut. When the win condition is timed (Timed / Best-of-N) a valid race time is + // also required (else the heat would run forever / build a degenerate window), and a lap-target + // condition (First-to-N / Best-of-N) requires a valid lap count (else 1 would silently save). const canSubmit = $derived( isOpenPractice ? canSubmitOpenPractice @@ -915,7 +922,9 @@ selectedClass !== '' && format.length > 0 && (!needsRaceTime || raceTimeValid) && - (seedKind === 'FromRoster' || (seedKind === 'FromRanking' && seedSources.size > 0)) + (!needsLaps || lapsValid) && + (seedKind === 'FromRoster' || + (seedKind === 'FromRanking' && seedSources.size > 0 && seedTopNValid)) ); async function submit() { diff --git a/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte index f9ad996..f98eecb 100644 --- a/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte +++ b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte @@ -70,6 +70,22 @@ // re-fetched whenever the stream advances (so a freshly-staged OR freshly-scheduled heat appears). let catalog = $state([]); let heats = $state([]); + // A FAILED pilots/heats directory read must be visible (#340): swallowing it into an empty array + // left every ref/heat-id rendering raw with no hint anything was wrong. Track a load-error flag + // per read (keeping the last good data rather than wiping it), surface a "Couldn't load — retry" + // state + a toast (the Results-screen pattern), and let the RD retry explicitly via the nonce. + let pilotsError = $state(false); + let heatsError = $state(false); + let directoryRetryNonce = $state(0); + const directoryError = $derived(pilotsError || heatsError); + function retryDirectory(): void { + directoryRetryNonce += 1; + } + /** Toast once on the transition INTO the error state (the effects re-run on every stream tick). */ + function noteDirectoryError(alreadyFailing: boolean): void { + if (!alreadyFailing) + toast.error('Couldn’t load the pilot/heat directory — names may show as raw ids.'); + } $effect(() => { session .listChannels() @@ -84,10 +100,14 @@ // (the backend force-emits one when a heat is scheduled), so touching it refreshes the picker the // moment a heat appears — without changing `current_heat` (no focus steal). void session.protocolState; + void directoryRetryNonce; session .listHeats() - .then((h) => (heats = h)) - .catch(() => (heats = [])); + .then((h) => ((heats = h), (heatsError = false))) + .catch(() => { + noteDirectoryError(directoryError); + heatsError = true; + }); }); // The current heat's ref → channel-label map (race redesign Slice 4b). Empty for a sim/free-text @@ -111,10 +131,14 @@ let pilots = $state([]); $effect(() => { void session.protocolState; + void directoryRetryNonce; session .listPilots() - .then((p) => (pilots = p)) - .catch(() => (pilots = [])); + .then((p) => ((pilots = p), (pilotsError = false))) + .catch(() => { + noteDirectoryError(directoryError); + pilotsError = true; + }); }); const pilotById = $derived(new Map(pilots.map((p) => [p.id, p]))); // A competitor ref → its bound pilot id from the live `progress`, which carries an **explicit** @@ -636,6 +660,15 @@ session.clearCommandError()} /> {/if} + {#if directoryError} + + + {/if} + {#if canControl}
Transitions @@ -898,6 +931,22 @@ } /* ── Transition controls ─────────────────────────────────────────────────── */ + /* The directory-load error state (#340): visible, with an explicit retry. */ + .dir-error { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--gf-space-3); + padding: var(--gf-space-3) var(--gf-space-4); + border: 1px solid color-mix(in srgb, var(--gf-danger) 45%, var(--gf-border)); + border-radius: var(--gf-radius-md); + background: var(--gf-danger-soft); + } + .dir-error p { + margin: 0; + color: var(--gf-text); + font-size: var(--gf-font-size-sm); + } .controls { display: flex; flex-direction: column; diff --git a/frontend/apps/rd-console/src/screens/Marshaling.svelte b/frontend/apps/rd-console/src/screens/Marshaling.svelte index 2aaa47b..fbcc05a 100644 --- a/frontend/apps/rd-console/src/screens/Marshaling.svelte +++ b/frontend/apps/rd-console/src/screens/Marshaling.svelte @@ -31,7 +31,7 @@ PilotProgress, SignalTraceView } from '@gridfpv/types'; - import { formatMicros, Select } from '@gridfpv/components'; + import { formatMicros, Select, toast } from '@gridfpv/components'; import { channelLabel } from '../lib/channels.js'; import { createCompetitorNameResolver } from '../lib/competitorName.js'; import { heatNameById } from '../lib/heats.js'; @@ -125,21 +125,45 @@ let pilots = $state([]); let heats = $state([]); let catalog = $state([]); + // A FAILED pilots/heats directory read must be visible (#340): swallowing it into an empty array + // left every ref rendering raw with no hint anything was wrong. Track a load-error flag per read + // (keeping the last good data rather than wiping it), surface a "Couldn't load — retry" state + + // a toast (the Results-screen pattern), and let the RD retry explicitly via the nonce. + let pilotsError = $state(false); + let heatsError = $state(false); + let directoryRetryNonce = $state(0); + const directoryError = $derived(pilotsError || heatsError); + function retryDirectory(): void { + directoryRetryNonce += 1; + } + /** Toast once on the transition INTO the error state (the effects re-run on every stream tick). */ + function noteDirectoryError(alreadyFailing: boolean): void { + if (!alreadyFailing) + toast.error('Couldn’t load the pilot/heat directory — names may show as raw ids.'); + } $effect(() => { void session.currentEvent; void session.protocolState; + void directoryRetryNonce; session .listPilots() - .then((p) => (pilots = p)) - .catch(() => (pilots = [])); + .then((p) => ((pilots = p), (pilotsError = false))) + .catch(() => { + noteDirectoryError(directoryError); + pilotsError = true; + }); }); $effect(() => { void session.currentEvent; void session.protocolState; + void directoryRetryNonce; session .listHeats() - .then((h) => (heats = h)) - .catch(() => (heats = [])); + .then((h) => ((heats = h), (heatsError = false))) + .catch(() => { + noteDirectoryError(directoryError); + heatsError = true; + }); }); $effect(() => { session @@ -374,12 +398,33 @@ } // Filed protests are resolvable by their log offset (the audit entry's `at_ref`). const filedProtests = $derived((audit ?? []).filter((e) => e.kind === 'ProtestFiled')); - const resolvedProtests = $derived((audit ?? []).filter((e) => e.kind === 'ProtestResolved')); - // Open (unresolved) protests = filed minus resolved (the audit carries no filed→resolved link, so - // this is a count). Finalizing while a protest is open would lock a result that's still under - // dispute, so the Finalize action is gated on this being zero (a result isn't "defensible" with an - // open protest). Clamped ≥ 0 defensively. - const openProtestCount = $derived(Math.max(0, filedProtests.length - resolvedProtests.length)); + // Ruling offsets a later `RulingReversed` undid. The audit entry carries its target only inside + // the server-baked summary ("Ruling reversed (ref N)"), so it is parsed back out here. + const reversedRulingTargets = $derived.by(() => { + const targets = new Set(); + for (const e of audit ?? []) { + if (e.kind !== 'RulingReversed') continue; + const t = summaryTargetRef(e.summary); + if (t !== undefined) targets.add(t); + } + return targets; + }); + // Open (unresolved) protests, matching the server's Finalize gate (`open_protest_count`, + // control_handler.rs — the source of truth): a filing is closed only by an EFFECTIVE resolution — + // a `ProtestResolved` targeting it that was NOT itself undone by a `RulingReversed`. The old + // filed-minus-resolved count diverged the moment a resolution was reversed: the server counted + // the protest as open again and rejected Finalize while the UI still offered it (#340). + // Finalizing while a protest is open would lock a result that's still under dispute, so the + // Finalize action is gated on this being zero. + const openProtestCount = $derived.by(() => { + const resolvedFilings = new Set(); + for (const e of audit ?? []) { + if (e.kind !== 'ProtestResolved' || reversedRulingTargets.has(e.at_ref)) continue; + const t = summaryTargetRef(e.summary); + if (t !== undefined) resolvedFilings.add(t); + } + return filedProtests.filter((e) => !resolvedFilings.has(e.at_ref)).length; + }); let resolveProtestRef = $state(''); let protestOutcome = $state('Upheld'); async function doResolveProtest(): Promise { @@ -483,13 +528,70 @@ return new Date(at / 1000).toLocaleTimeString(); } - // The displayed audit line: the server-baked `summary` (which no longer carries the raw competitor - // ref) with the **resolved callsign** prepended when the entry names a competitor. This is the - // client-side composition the AuditEntry restructure enables — a baked raw-id string couldn't be - // re-resolved, so the structured `competitor` ref is resolved here and joined to the summary. + // ── 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]))); + + /** The competitor whose lap a pass offset (a lap's start/end ref) bounds, from the lap list. */ + function competitorForPassRef(target: number): CompetitorRef | undefined { + for (const cl of laps?.competitors ?? []) + for (const l of cl.laps) + if (l.end_ref === target || l.start_ref === target) return cl.competitor.competitor; + 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 { - if (entry.competitor == null) return entry.summary; - return `${competitorName(entry.competitor)} · ${entry.summary}`; + 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; + } + + // 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}`; } @@ -522,6 +624,15 @@ session.clearCommandError()} /> {/if} + {#if directoryError} + + + {/if} +
{#if heats.length > 0} @@ -752,7 +863,7 @@ @@ -797,7 +908,7 @@ @@ -922,6 +1033,22 @@ font-size: var(--gf-font-size-sm); margin: var(--gf-space-1) 0 0; } + /* The directory-load error state (#340): visible, with an explicit retry. */ + .dir-error { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--gf-space-3); + padding: var(--gf-space-3) var(--gf-space-4); + border: 1px solid color-mix(in srgb, var(--gf-danger) 45%, var(--gf-border)); + border-radius: var(--gf-radius-md); + background: var(--gf-danger-soft); + } + .dir-error p { + margin: 0; + color: var(--gf-text); + font-size: var(--gf-font-size-sm); + } .layout { display: grid; grid-template-columns: 2fr 1fr; diff --git a/frontend/apps/rd-console/src/screens/Results.svelte b/frontend/apps/rd-console/src/screens/Results.svelte index d0d0fe0..8e693dd 100644 --- a/frontend/apps/rd-console/src/screens/Results.svelte +++ b/frontend/apps/rd-console/src/screens/Results.svelte @@ -311,7 +311,7 @@ // Export the CURRENT views with FRIENDLY names baked in (P1-2): competitor refs → callsigns and // class/round ids → their labels, so the JSON is human-usable, not a raw-ref wire dump. The raw ref // is preserved alongside (`competitor_ref`) so the export stays traceable. The legacy event-level - // projection (`heatResult` / `standings`) is carried through as well. + // projection (`heatResult` / `standings`) resolves the same way (#341). function exportAll() { const payload = buildResultsExport({ resolveCompetitor: resolveName, diff --git a/frontend/apps/rd-console/tests/ContextHeader.test.ts b/frontend/apps/rd-console/tests/ContextHeader.test.ts index 0738a1a..beab538 100644 --- a/frontend/apps/rd-console/tests/ContextHeader.test.ts +++ b/frontend/apps/rd-console/tests/ContextHeader.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/svelte'; +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; import { tick } from 'svelte'; import type { EventMeta, HeatSummary, LiveRaceState, RoundDef } from '@gridfpv/types'; import ContextHeader from '../src/ContextHeader.svelte'; @@ -157,4 +157,30 @@ describe('ContextHeader heat name', () => { await waitFor(() => expect(heatId()?.textContent).toBe('Qualifying R1 Heat 1')); expect(heatId()?.textContent).not.toContain('q1-heat'); }); + + // #340: a FAILED heats read used to swallow into an empty list, so the header silently rendered + // the raw heat id with no hint anything was wrong. It must show a visible error state (with an + // explicit retry) in place of the heat name instead. + it('shows an error state with retry — not the raw heat id — when the heats read fails (#340)', async () => { + let fail = true; + const { session } = makeTestSession({ + event: EVENT, + live: live(), + listHeatsImpl: vi.fn(async () => { + if (fail) throw new Error('boom'); + return [HEAT]; + }) + }); + render(ContextHeader, { session, ongolive: () => {}, onswitchevent: () => {} }); + + // The error state renders in place of the heat name — the raw id never reaches the screen. + const retry = await screen.findByRole('button', { name: /Couldn.t load — retry/ }); + expect(heatId()).toBeNull(); + + // Retry with the read healthy again: the friendly name resolves. + fail = false; + await fireEvent.click(retry); + await waitFor(() => expect(heatId()?.textContent).toBe('Qualifying R1 Heat 1')); + expect(screen.queryByRole('button', { name: /Couldn.t load — retry/ })).toBeNull(); + }); }); diff --git a/frontend/apps/rd-console/tests/EventRounds.test.ts b/frontend/apps/rd-console/tests/EventRounds.test.ts index ae50e02..0e5cb8c 100644 --- a/frontend/apps/rd-console/tests/EventRounds.test.ts +++ b/frontend/apps/rd-console/tests/EventRounds.test.ts @@ -1007,6 +1007,66 @@ describe('EventRounds (open practice — no win condition + time limit)', () => await fireEvent.input(raceTime, { target: { value: '90' } }); expect(addBtn().disabled).toBe(false); }); + + it('blocks submit when a lap-target win condition has its Laps cleared (#340)', async () => { + const createRoundImpl = vi.fn(async (_b, _e, _req) => ({ ...QUAL, id: 'r2' })); + const { session } = makeTestSession({ + ...baseImpls(), + createRoundImpl, + event: { ...EVENT, rounds: [] } + }); + render(EventRounds, { session }); + + await fireEvent.click(await screen.findByRole('button', { name: '+ Add round' })); + await fireEvent.input(await screen.findByLabelText('Label'), { + target: { value: 'Best of 3' } + }); + await fireEvent.change(screen.getByLabelText('Format'), { target: { value: 'timed_qual' } }); + await fireEvent.change(screen.getByLabelText('Eligible class'), { target: { value: 'c1' } }); + // Best-of-N reveals the "Laps" field (N), seeded valid (3). + await fireEvent.change(screen.getByLabelText('Win condition'), { + target: { value: 'BestOfN' } + }); + const laps = await screen.findByLabelText('Laps'); + const addBtn = () => screen.getByRole('button', { name: 'Add round' }) as HTMLButtonElement; + expect(addBtn().disabled).toBe(false); + + // Clearing the Laps field blocks submit — it used to silently save n = 1 (#340). + await fireEvent.input(laps, { target: { value: '' } }); + expect(addBtn().disabled).toBe(true); + await fireEvent.click(addBtn()); + expect(createRoundImpl).not.toHaveBeenCalled(); + + // Restoring a valid lap count re-enables submit. + await fireEvent.input(laps, { target: { value: '3' } }); + expect(addBtn().disabled).toBe(false); + }); + + it('blocks submit when the FromRanking "Take top" is cleared (#340)', async () => { + const createRoundImpl = vi.fn(async (_b, _e, _req) => ({ ...QUAL, id: 'r2' })); + const { session } = makeTestSession({ ...baseImpls(), createRoundImpl, event: EVENT }); + render(EventRounds, { session }); + + await fireEvent.click(await screen.findByRole('button', { name: '+ Add round' })); + await fireEvent.input(await screen.findByLabelText('Label'), { target: { value: 'Mains' } }); + await fireEvent.change(screen.getByLabelText('Format'), { target: { value: 'single_elim' } }); + await fireEvent.change(screen.getByLabelText('Eligible class'), { target: { value: 'c1' } }); + await fireEvent.change(screen.getByLabelText('Seeding'), { target: { value: 'FromRanking' } }); + await fireEvent.click(await screen.findByLabelText('Seed from Qualifying R1')); + const topN = screen.getByLabelText('Top N'); + const addBtn = () => screen.getByRole('button', { name: 'Add round' }) as HTMLButtonElement; + expect(addBtn().disabled).toBe(false); + + // Clearing "Take top" blocks submit — it used to silently save top_n = 1 (#340). + await fireEvent.input(topN, { target: { value: '' } }); + expect(addBtn().disabled).toBe(true); + await fireEvent.click(addBtn()); + expect(createRoundImpl).not.toHaveBeenCalled(); + + // Restoring a valid cut re-enables submit. + await fireEvent.input(topN, { target: { value: '2' } }); + expect(addBtn().disabled).toBe(false); + }); }); describe('EventRounds (Heats — fill round, heats list, manual build)', () => { diff --git a/frontend/apps/rd-console/tests/LiveRaceControl.test.ts b/frontend/apps/rd-console/tests/LiveRaceControl.test.ts index 12b2e19..bba6a21 100644 --- a/frontend/apps/rd-console/tests/LiveRaceControl.test.ts +++ b/frontend/apps/rd-console/tests/LiveRaceControl.test.ts @@ -985,6 +985,38 @@ describe('LiveRaceControl — friendly names (no raw ids/refs)', () => { expect(within(standing).queryByText('node-0')).not.toBeInTheDocument(); expect(within(standing).queryByText('node-1')).not.toBeInTheDocument(); }); + + // #340: a FAILED pilots/heats read used to swallow into empty arrays, so the raw refs rendered + // with no error state. It must surface a visible "Couldn't load — retry" state instead. + it('surfaces a visible retry state when the pilot/heat directory reads fail (#340)', async () => { + let fail = true; + const { session } = makeTestSession({ + event: FN_EVENT, + live: fnLive, + listHeatsImpl: vi.fn(async () => { + if (fail) throw new Error('boom'); + return [HEAT_1_FREQ, HEAT_2]; + }), + listPilotsImpl: vi.fn(async () => { + if (fail) throw new Error('boom'); + return PILOTS as unknown as never; + }), + listChannelsImpl: vi.fn(async () => FN_CATALOG), + listTimersImpl: vi.fn(async () => [FN_TIMER]) + }); + render(LiveRaceControl, { session }); + + // The failure is visible — no more silently-empty directory + raw refs. + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent(/Couldn.t load the pilot\/heat directory/); + + // Retry with the reads healthy again: the error clears and the names resolve. + fail = false; + await fireEvent.click(within(alert).getByRole('button', { name: 'Try again' })); + await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()); + const title = document.querySelector('.heat-id .value') as HTMLElement; + await waitFor(() => expect(title.textContent?.trim()).toBe('Qualifying R1 Heat 1')); + }); }); // ── Roster-seeded pilot callsigns resolve from the roster binding, BEFORE the heat runs ────────── diff --git a/frontend/apps/rd-console/tests/MarshalingScreen.test.ts b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts index d2fee18..ad9ddbb 100644 --- a/frontend/apps/rd-console/tests/MarshalingScreen.test.ts +++ b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts @@ -293,7 +293,8 @@ describe('Marshaling (Slice 3)', () => { phase: 'Unofficial', lifecycle: { Provisional: {} } }; - // A filed protest AND its resolution → no open protests. + // A filed protest AND its resolution (the server-baked summary carries the filing's offset, + // which the open-count derivation matches against) → no open protests. const audit: AuditEntry[] = [ { kind: 'ProtestFiled', @@ -306,8 +307,8 @@ describe('Marshaling (Slice 3)', () => { kind: 'ProtestResolved', at: 1_700_000_000_000_001, at_ref: 23, - competitor: 'BOB', - summary: 'Protest resolved: denied' + competitor: null, + summary: 'Protest denied (ref 22)' } ]; const { session, sendSpy } = makeTestSession({ live, laps: lapList, audit }); @@ -319,6 +320,50 @@ describe('Marshaling (Slice 3)', () => { expect(sendSpy).toHaveBeenCalledWith({ Finalize: { heat: 'heat-1' } }); }); + it('counts a protest as OPEN again when its resolution was reversed (#340, matches the server gate)', async () => { + const live: LiveRaceState = { + ...liveRunning, + phase: 'Unofficial', + lifecycle: { Provisional: {} } + }; + // Filed (#22) → resolved (#23, targeting #22) → the RESOLUTION reversed (#24, targeting #23). + // The server's Finalize gate (`open_protest_count`, control_handler.rs) counts the protest as + // open again; the old filed-minus-resolved UI count offered Finalize and the server rejected it. + const audit: AuditEntry[] = [ + { + kind: 'ProtestFiled', + at: 1_700_000_000_000_000, + at_ref: 22, + competitor: 'BOB', + summary: 'Protest filed: contact' + }, + { + kind: 'ProtestResolved', + at: 1_700_000_000_000_001, + at_ref: 23, + competitor: null, + summary: 'Protest denied (ref 22)' + }, + { + kind: 'RulingReversed', + at: 1_700_000_000_000_002, + at_ref: 24, + competitor: null, + summary: 'Ruling reversed (ref 23)' + } + ]; + const { session, sendSpy } = makeTestSession({ live, laps: lapList, audit }); + render(Marshaling, { session }); + + const finalize = screen.getByRole('button', { name: /Finalize/ }) as HTMLButtonElement; + expect(finalize.disabled).toBe(true); + expect(screen.getByText(/Resolve 1 open protest/)).toBeInTheDocument(); + await fireEvent.click(finalize); + expect( + sendSpy.mock.calls.find(([c]) => typeof c === 'object' && c !== null && 'Finalize' in c) + ).toBeUndefined(); + }); + it('reverts a finalized marshaled heat (Final→Unofficial) after confirm', async () => { const live: LiveRaceState = { ...liveRunning, phase: 'Final', lifecycle: 'Official' }; const { session, sendSpy } = makeTestSession({ live, laps: lapList }); @@ -341,7 +386,11 @@ describe('Marshaling (Slice 3)', () => { // 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'); - expect(entries[1]).toHaveTextContent('Detection voided (ref 12)'); + // 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). + expect(entries[1]).toHaveTextContent('ALICE · Detection voided · #12'); + expect(entries[1]).not.toHaveTextContent('(ref 12)'); }); // ── Slice 4: the signal-as-evidence RSSI graph ──────────────────────────────────────── @@ -659,6 +708,113 @@ describe('Marshaling (Slice 3)', () => { expect(panel.queryByText(/maverick-4d9rp8/)).not.toBeInTheDocument(); }); + // ── #337: the audit + "Reverse a ruling" picker leaked raw log offsets / unresolved refs ────── + // + // The server bakes "(ref N)" into some summaries and names no competitor structurally for the + // lap-/target-addressed rulings. The screen must re-compose the line from the structured fields: + // a lap-addressed target resolves through the lap list, a protest-resolution / reversal chases + // its target audit entry — so the picker reads "‹what› — ‹callsign› · #‹offset›", never "(ref N)". + it('labels the reverse-ruling picker "‹what› — ‹callsign›" with the offset only trailing (#337)', async () => { + const audit: AuditEntry[] = [ + // Named structurally — resolves directly. + { + kind: 'PenaltyApplied', + at: 1_700_000_000_000_000, + at_ref: 20, + competitor: 'goose-yla6dp', + summary: 'DQ applied' + }, + // Filed protest (the resolution below targets it). + { + kind: 'ProtestFiled', + at: 1_700_000_000_000_001, + at_ref: 21, + competitor: 'maverick-4d9rp8', + summary: 'Protest filed: contact' + }, + // Lap-addressed: no structured competitor; ref 12 is Maverick's lap end pass in FN_LAPS. + { + kind: 'LapThrownOut', + at: 1_700_000_000_000_002, + at_ref: 30, + competitor: null, + summary: 'Lap thrown out (ref 12)' + }, + // Target-addressed: resolves by chasing the filed entry at ref 21 (Maverick). + { + kind: 'ProtestResolved', + at: 1_700_000_000_000_003, + at_ref: 32, + competitor: null, + summary: 'Protest upheld (ref 21)' + } + ]; + const { session } = renderFN(audit); + render(Marshaling, { session }); + + const reverse = screen.getByLabelText('Reverse ruling') as HTMLSelectElement; + await waitFor(() => { + const labels = Array.from(reverse.options).map((o) => o.textContent?.trim()); + expect(labels).toContain('DQ applied — Goose · #20'); + expect(labels).toContain('Lap thrown out — Maverick · #30'); + expect(labels).toContain('Protest upheld — Maverick · #32'); + }); + // No option leaks a raw "(ref N)" or an unresolved pilot-id ref; values stay the offsets. + const labels = Array.from(reverse.options).map((o) => o.textContent ?? ''); + expect(labels.some((l) => l.includes('(ref'))).toBe(false); + expect(labels.some((l) => l.includes('goose-yla6dp') || l.includes('maverick-4d9rp8'))).toBe( + false + ); + const thrownOut = Array.from(reverse.options).find((o) => + o.textContent?.includes('Lap thrown out') + )!; + expect(thrownOut.value).toBe('30'); + + // The resolve-protest picker composes the same way. + const resolve = screen.getByLabelText('Resolve protest') as HTMLSelectElement; + 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' })); + expect(panel.getByText('Maverick · Lap thrown out · #12')).toBeInTheDocument(); + expect(panel.getByText('Maverick · Protest upheld · #21')).toBeInTheDocument(); + expect(panel.queryByText(/\(ref \d+\)/)).not.toBeInTheDocument(); + }); + + // ── #340: a failed pilots/heats read must surface, not silently strand raw refs ────────────── + it('surfaces a visible retry state when the pilot/heat directory reads fail (#340)', async () => { + let fail = true; + const { session } = makeTestSession({ + event: EVENT, + live: FN_LIVE, + laps: FN_LAPS, + audit: FN_AUDIT, + listHeatsImpl: vi.fn(async () => { + if (fail) throw new Error('boom'); + return [FN_HEAT]; + }), + listPilotsImpl: vi.fn(async () => { + if (fail) throw new Error('boom'); + return PILOTS as unknown as never; + }), + listChannelsImpl: vi.fn(async () => []) + }); + render(Marshaling, { session }); + + // The failure is visible — no more silently-empty directory + raw refs. + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent(/Couldn.t load the pilot\/heat directory/); + + // Retry with the reads healthy again: the error clears and the names resolve. + fail = false; + await fireEvent.click(within(alert).getByRole('button', { name: 'Try again' })); + await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()); + await waitFor(() => + expect(screen.getByRole('heading', { name: 'Maverick' })).toBeInTheDocument() + ); + }); + // ── The actual regression #236 left open: the context-load race ───────────────────────────── // // #236 wired the resolvers but its tests always rendered with the event + its heats/pilots diff --git a/frontend/apps/rd-console/tests/results.test.ts b/frontend/apps/rd-console/tests/results.test.ts index a6823b4..4e56d42 100644 --- a/frontend/apps/rd-console/tests/results.test.ts +++ b/frontend/apps/rd-console/tests/results.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import type { ClassStanding, CompetitorRef, RankEntry } from '@gridfpv/types'; +import type { ClassStanding, CompetitorRef, HeatResult, RankEntry } from '@gridfpv/types'; import { buildResultsExport, toExportJson } from '../src/lib/results.js'; describe('toExportJson', () => { @@ -60,4 +60,39 @@ describe('buildResultsExport (friendly names, P1-2)', () => { const parsed = JSON.parse(json); expect(parsed.class_standings.standings[0].competitor).toBe('AceOne'); }); + + // #341: the legacy event-level sections (`standings` / `heatResult`) were carried through as-is, + // so their competitor fields leaked raw refs even though every other view resolved. They must + // resolve through the same resolver, raw ref kept alongside. + it('resolves the legacy standings + heat-result sections too (#341)', () => { + const standings: RankEntry[] = [{ competitor: 'p1', position: 1 }]; + const heatResult: HeatResult = { + places: [ + { + competitor: { adapter: 'rh-1', competitor: 'p1' }, + position: 1, + laps: 3, + metric: { BestLapMicros: 41_250_000 }, + best_lap_micros: 41_250_000 + } + ] + }; + const out = buildResultsExport({ resolveCompetitor, standings, heatResult }); + + // The standings rows resolve like every other view (raw ref kept alongside). + expect(out.standings?.[0].competitor).toBe('AceOne'); + expect(out.standings?.[0].competitor_ref).toBe('p1'); + expect(out.standings?.[0].position).toBe(1); + + // The heat result's placements resolve the CompetitorKey's ref; the payload is preserved. + expect(out.heatResult?.places[0].competitor).toBe('AceOne'); + expect(out.heatResult?.places[0].competitor_ref).toBe('p1'); + expect(out.heatResult?.places[0].position).toBe(1); + expect(out.heatResult?.places[0].laps).toBe(3); + + // Nothing in the serialized export shows a bare raw ref as the display field. + const parsed = JSON.parse(toExportJson(out)); + expect(parsed.standings[0].competitor).toBe('AceOne'); + expect(parsed.heatResult.places[0].competitor).toBe('AceOne'); + }); }); diff --git a/frontend/apps/rd-console/tests/support.ts b/frontend/apps/rd-console/tests/support.ts index 5cd6076..3cb8164 100644 --- a/frontend/apps/rd-console/tests/support.ts +++ b/frontend/apps/rd-console/tests/support.ts @@ -189,7 +189,11 @@ export function makeTestSession( deleteTimerImpl: opts?.deleteTimerImpl, setEventTimersImpl: opts?.setEventTimersImpl, setPrimaryTimerImpl: opts?.setPrimaryTimerImpl, - listPilotsImpl: opts?.listPilotsImpl, + // The pilots/heats directory reads default to an INERT SUCCESS (empty list), not the real + // fetch-backed impls: `fetch` is stubbed to fail below, and a failed directory read now renders + // a visible error state (#340) — which would leak into every test that doesn't override these + // seams. Resolving `[]` preserves the old default semantics (empty directory, no error). + listPilotsImpl: opts?.listPilotsImpl ?? (async () => []), // Pilot-directory write seams (issue #74): inert unless a test overrides them. createPilotImpl: opts?.createPilotImpl, updatePilotImpl: opts?.updatePilotImpl, @@ -216,8 +220,9 @@ export function makeTestSession( createRoundImpl: opts?.createRoundImpl, updateRoundImpl: opts?.updateRoundImpl, deleteRoundImpl: opts?.deleteRoundImpl, - // Scheduled-heats read seam (race redesign Slice 3b): inert unless a test overrides it. - listHeatsImpl: opts?.listHeatsImpl, + // 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 () => []), // Ranking + standings read seams (race redesign Slice 5/6a): inert unless overridden. roundRankingImpl: opts?.roundRankingImpl, roundStandingsImpl: opts?.roundStandingsImpl,