diff --git a/frontend/apps/rd-console/src/lib/transitions.ts b/frontend/apps/rd-console/src/lib/transitions.ts index d2ffe22..965e953 100644 --- a/frontend/apps/rd-console/src/lib/transitions.ts +++ b/frontend/apps/rd-console/src/lib/transitions.ts @@ -57,11 +57,6 @@ export const DESTRUCTIVE_ACTIONS: ReadonlySet = new Set( * *secondary* to the forward path — the RD waits for the clock by default — so the console styles * them as clearly-labelled "override" buttons, not the obvious next step. */ -export const OVERRIDE_ACTIONS: ReadonlySet = new Set([ - 'SkipCountdown', - 'ForceEnd' -]); - /** * The forward "primary" action for each phase (the obvious next step), if any. * @@ -100,8 +95,10 @@ const PRIMARY_BY_PHASE: Record = { const LEGAL_BY_PHASE: Record> = { Scheduled: new Set(['Stage']), Staged: new Set(['Start', 'Abort']), - // Armed/Running auto-advance via the runtime clock; the overrides are the manual escape hatches. - Armed: new Set(['SkipCountdown', 'Abort', 'Restart']), + // Armed/Running auto-advance via the runtime clock. Armed exposes only the off-ramps: the + // countdown is seconds long and racing it with a Skip button was never used in the field — + // "I usually just hit start anyway". Running keeps one plain Stop (the ForceEnd command). + Armed: new Set(['Abort', 'Restart']), Running: new Set(['ForceEnd', 'Abort', 'Restart']), Unofficial: new Set(['Finalize', 'Restart', 'Discard']), Final: new Set(['Advance', 'Revert', 'Discard']) @@ -111,7 +108,6 @@ const LEGAL_BY_PHASE: Record> = { export const ACTION_ORDER: readonly HeatAction[] = [ 'Stage', 'Start', - 'SkipCountdown', 'ForceEnd', 'Finalize', 'Advance', @@ -141,11 +137,6 @@ export function isDestructive(action: HeatAction): boolean { return DESTRUCTIVE_ACTIONS.has(action); } -/** Is this action a **runtime-clock override** (the console styles it as a secondary "override")? */ -export function isOverride(action: HeatAction): boolean { - return OVERRIDE_ACTIONS.has(action); -} - /** * Build the `Command` an action emits for a heat. Every heat-loop action maps to a * single externally-tagged `Command` variant carrying `{ heat }` — the action name @@ -178,7 +169,15 @@ export function commandForAction(action: HeatAction, heat: HeatId): Command { /** A short human label for an action button. */ export function actionLabel(action: HeatAction): string { - return action; + switch (action) { + // The command stays ForceEnd on the wire; to the RD it is simply the race's Stop button. + case 'ForceEnd': + return 'Stop'; + case 'SkipCountdown': + return 'Skip countdown'; + default: + return action; + } } /** A one-line description of what an action does, for tooltips / confirms. */ @@ -191,7 +190,7 @@ export function actionDescription(action: HeatAction): string { case 'SkipCountdown': return 'Skip the countdown — start the race now.'; case 'ForceEnd': - return 'End the race window now. Pilots land.'; + return 'Stop the race now. Pilots land.'; case 'Finalize': return 'Finalize the heat and lock in the result.'; case 'Advance': diff --git a/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte index b0704bf..1a38e9a 100644 --- a/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte +++ b/frontend/apps/rd-console/src/screens/LiveRaceControl.svelte @@ -38,8 +38,8 @@ actionDescription, commandForAction, isActionLegal, + actionLabel, isDestructive, - isOverride, primaryAction, type HeatAction } from '../lib/transitions.js'; @@ -796,10 +796,7 @@ variant={action === primary ? 'primary' : isDestructive(action) ? 'danger' : 'default'} title={actionDescription(action)} > - - {#if isOverride(action)}{/if}{action} - + {actionLabel(action)} {/each} @@ -1107,20 +1104,6 @@ align-items: center; gap: var(--gf-space-2); } - /* Override actions (SkipCountdown/ForceEnd): a clear, smaller "override" tag prefixes the label - so they read as secondary escape hatches, not the obvious forward step. */ - .override-tag { - font-size: var(--gf-font-size-2xs); - font-weight: var(--gf-font-weight-bold); - text-transform: uppercase; - letter-spacing: var(--gf-tracking-caps); - padding: 0.05rem var(--gf-space-2); - border-radius: var(--gf-radius-pill); - background: color-mix(in srgb, var(--gf-warning, #d08700) 22%, transparent); - color: var(--gf-warning, #d08700); - border: 1px solid color-mix(in srgb, var(--gf-warning, #d08700) 45%, transparent); - } - /* ── Staging countdown ───────────────────────────────────────────────────── */ .staging { --_stage: var(--gf-phase-staged); diff --git a/frontend/apps/rd-console/tests/LiveRaceControl.test.ts b/frontend/apps/rd-console/tests/LiveRaceControl.test.ts index b36947c..cd06d85 100644 --- a/frontend/apps/rd-console/tests/LiveRaceControl.test.ts +++ b/frontend/apps/rd-console/tests/LiveRaceControl.test.ts @@ -138,26 +138,27 @@ describe('LiveRaceControl', () => { render(LiveRaceControl, { session }); const btn = (label: string) => screen.getByRole('button', { name: label }) as HTMLButtonElement; - // The runtime-clock override + off-ramps legal in Running (the manual Finish is gone — the - // clock auto-completes; ForceEnd is the override). - expect(btn('ForceEnd').disabled).toBe(false); + // Stop (the ForceEnd command) + off-ramps legal in Running (the manual Finish is gone — + // the clock auto-completes; Stop is the plain manual end). + expect(btn('Stop').disabled).toBe(false); expect(btn('Abort').disabled).toBe(false); expect(btn('Restart').disabled).toBe(false); // Illegal in Running. expect(btn('Stage').disabled).toBe(true); expect(btn('Start').disabled).toBe(true); - expect(btn('SkipCountdown').disabled).toBe(true); expect(btn('Finalize').disabled).toBe(true); expect(btn('Advance').disabled).toBe(true); expect(btn('Revert').disabled).toBe(true); expect(btn('Discard').disabled).toBe(true); + // SkipCountdown is retired from the console entirely — no button renders for it. + expect(screen.queryByRole('button', { name: /Skip/ })).toBeNull(); }); - it('fires the matching Command for the runtime-clock override', async () => { + it('Stop fires the ForceEnd command (the wire name is unchanged)', async () => { const { session, sendSpy } = makeTestSession({ live: liveRunning }); render(LiveRaceControl, { session }); - await fireEvent.click(screen.getByRole('button', { name: 'ForceEnd' })); + await fireEvent.click(screen.getByRole('button', { name: 'Stop' })); expect(sendSpy).toHaveBeenCalledWith({ ForceEnd: { heat: 'heat-1' } }); }); @@ -178,7 +179,7 @@ describe('LiveRaceControl', () => { const { session } = makeTestSession({ live: liveRunning, ack: failAck }); render(LiveRaceControl, { session }); - await fireEvent.click(screen.getByRole('button', { name: 'ForceEnd' })); + await fireEvent.click(screen.getByRole('button', { name: 'Stop' })); expect(await screen.findByRole('alert')).toHaveTextContent('illegal transition'); }); @@ -189,27 +190,20 @@ describe('LiveRaceControl', () => { expect(screen.getAllByText('ALICE').length).toBeGreaterThan(0); }); - it('styles the runtime-clock overrides as secondary "override" buttons (legality intact)', () => { - // In Armed the legal actions are the override SkipCountdown + the off-ramps Abort/Restart. + it('Armed exposes only the off-ramps — the countdown runs itself, no Skip, no override tag', () => { const { session } = makeTestSession({ live: liveAt('Armed') }); render(LiveRaceControl, { session }); const btn = (label: string) => screen.getByRole('button', { name: label }) as HTMLButtonElement; - expect(btn('SkipCountdown').disabled).toBe(false); expect(btn('Abort').disabled).toBe(false); expect(btn('Restart').disabled).toBe(false); // Forward steps are illegal in Armed (the runtime clock drives Armed → Running). expect(btn('Stage').disabled).toBe(true); expect(btn('Start').disabled).toBe(true); - expect(btn('ForceEnd').disabled).toBe(true); + expect(btn('Stop').disabled).toBe(true); expect(btn('Finalize').disabled).toBe(true); - // Both clock overrides (SkipCountdown + ForceEnd) carry the "override" tag that distinguishes - // them from forward/off-ramp buttons; the forward/off-ramp buttons do not. - const tags = screen.getAllByText('override'); - const taggedButtons = tags.map((t) => t.closest('button')); - expect(taggedButtons).toContain(btn('SkipCountdown')); - expect(taggedButtons).toContain(btn('ForceEnd')); - expect(taggedButtons).not.toContain(btn('Abort')); - expect(taggedButtons).not.toContain(btn('Start')); + // The whole override concept is retired: no Skip button, no "override" tag anywhere. + expect(screen.queryByRole('button', { name: /Skip/ })).toBeNull(); + expect(screen.queryByText('override')).toBeNull(); }); describe('heat picker (manual current-heat selection)', () => { diff --git a/frontend/apps/rd-console/tests/transitions.test.ts b/frontend/apps/rd-console/tests/transitions.test.ts index e1833b4..ba40cb1 100644 --- a/frontend/apps/rd-console/tests/transitions.test.ts +++ b/frontend/apps/rd-console/tests/transitions.test.ts @@ -3,9 +3,9 @@ import type { HeatPhase } from '@gridfpv/types'; import { ACTION_ORDER, commandForAction, + actionLabel, isActionLegal, isDestructive, - isOverride, legalActions, primaryAction, type HeatAction @@ -30,15 +30,18 @@ describe('transitions: phase → legal actions', () => { expect(isActionLegal('Scheduled', 'Start')).toBe(false); }); - it('exposes the clock overrides only in their state (SkipCountdown from Armed, ForceEnd from Running)', () => { - expect(isActionLegal('Armed', 'SkipCountdown')).toBe(true); + it('Stop (ForceEnd) is legal exactly while Running; SkipCountdown is retired from the console', () => { + // The countdown is seconds long and the field never raced it with a Skip button — Armed + // exposes only the off-ramps. The ForceEnd command survives on the wire, labeled Stop. expect(isActionLegal('Running', 'ForceEnd')).toBe(true); - for (const p of PHASES.filter((p) => p !== 'Armed')) { + expect(actionLabel('ForceEnd')).toBe('Stop'); + for (const p of PHASES) { expect(isActionLegal(p, 'SkipCountdown')).toBe(false); } for (const p of PHASES.filter((p) => p !== 'Running')) { expect(isActionLegal(p, 'ForceEnd')).toBe(false); } + expect(legalActions('Armed')).toEqual(['Abort', 'Restart']); }); it('allows abort once committed (Staged/Armed/Running) but not from Scheduled', () => { @@ -95,33 +98,6 @@ describe('transitions: phase → legal actions', () => { } }); - it('marks exactly the two runtime-clock overrides (SkipCountdown/ForceEnd) as overrides', () => { - // The override styling in the console keys off this set; it must be exactly the two clock - // escape hatches — and an override is never also a forward primary or an off-ramp. - expect(isOverride('SkipCountdown')).toBe(true); - expect(isOverride('ForceEnd')).toBe(true); - for (const a of [ - 'Stage', - 'Start', - 'Finalize', - 'Advance', - 'Revert', - 'Abort', - 'Restart', - 'Discard' - ] as HeatAction[]) { - expect(isOverride(a)).toBe(false); - } - // An override is legal exactly in its one phase (Armed for SkipCountdown, Running for ForceEnd) - // and is never the phase's primary forward step. - expect( - isActionLegal('Armed', 'SkipCountdown') && primaryAction('Armed') !== 'SkipCountdown' - ).toBe(true); - expect(isActionLegal('Running', 'ForceEnd') && primaryAction('Running') !== 'ForceEnd').toBe( - true - ); - }); - it('marks exactly the four off-ramps destructive', () => { expect(isDestructive('Revert')).toBe(true); expect(isDestructive('Abort')).toBe(true);