Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 14 additions & 15 deletions frontend/apps/rd-console/src/lib/transitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,6 @@ export const DESTRUCTIVE_ACTIONS: ReadonlySet<HeatAction> = new Set<HeatAction>(
* *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<HeatAction> = new Set<HeatAction>([
'SkipCountdown',
'ForceEnd'
]);

/**
* The forward "primary" action for each phase (the obvious next step), if any.
*
Expand Down Expand Up @@ -100,8 +95,10 @@ const PRIMARY_BY_PHASE: Record<HeatPhase, HeatAction | null> = {
const LEGAL_BY_PHASE: Record<HeatPhase, ReadonlySet<HeatAction>> = {
Scheduled: new Set<HeatAction>(['Stage']),
Staged: new Set<HeatAction>(['Start', 'Abort']),
// Armed/Running auto-advance via the runtime clock; the overrides are the manual escape hatches.
Armed: new Set<HeatAction>(['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<HeatAction>(['Abort', 'Restart']),
Running: new Set<HeatAction>(['ForceEnd', 'Abort', 'Restart']),
Unofficial: new Set<HeatAction>(['Finalize', 'Restart', 'Discard']),
Final: new Set<HeatAction>(['Advance', 'Revert', 'Discard'])
Expand All @@ -111,7 +108,6 @@ const LEGAL_BY_PHASE: Record<HeatPhase, ReadonlySet<HeatAction>> = {
export const ACTION_ORDER: readonly HeatAction[] = [
'Stage',
'Start',
'SkipCountdown',
'ForceEnd',
'Finalize',
'Advance',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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. */
Expand All @@ -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':
Expand Down
21 changes: 2 additions & 19 deletions frontend/apps/rd-console/src/screens/LiveRaceControl.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@
actionDescription,
commandForAction,
isActionLegal,
actionLabel,
isDestructive,
isOverride,
primaryAction,
type HeatAction
} from '../lib/transitions.js';
Expand Down Expand Up @@ -796,10 +796,7 @@
variant={action === primary ? 'primary' : isDestructive(action) ? 'danger' : 'default'}
title={actionDescription(action)}
>
<span class="action-btn" class:override={isOverride(action)}>
{#if isOverride(action)}<span class="override-tag" aria-hidden="true">override</span
>{/if}{action}
</span>
<span class="action-btn">{actionLabel(action)}</span>
</ConfirmButton>
{/each}
</div>
Expand Down Expand Up @@ -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);
Expand Down
32 changes: 13 additions & 19 deletions frontend/apps/rd-console/tests/LiveRaceControl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' } });
});

Expand All @@ -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');
});

Expand All @@ -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)', () => {
Expand Down
38 changes: 7 additions & 31 deletions frontend/apps/rd-console/tests/transitions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import type { HeatPhase } from '@gridfpv/types';
import {
ACTION_ORDER,
commandForAction,
actionLabel,
isActionLegal,
isDestructive,
isOverride,
legalActions,
primaryAction,
type HeatAction
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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);
Expand Down