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
92 changes: 45 additions & 47 deletions frontend/apps/rd-console/src/screens/Marshaling.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -281,13 +281,19 @@
if (selected && selected.competitor === competitor && selected.lap.end_ref === lap.end_ref) {
selected = null; // toggle off
} else {
// A fresh selection gets a fresh time input: a value typed for lap 2 must not sit
// pre-armed in lap 5's editor (one misread click would re-time it).
editSeconds = 0;
// The editor opens showing the lap's CURRENT time — the RD reads it, tweaks it, and
// Save arms only once the value actually changes (no free-typed instants, no value
// from another lap's editor sitting pre-armed).
editSeconds = lapSeconds(lap);
selected = { competitor, lap };
}
}

/** A lap's duration in display seconds (the editor's unit — 3 decimals like the list). */
function lapSeconds(lap: Lap): number {
return Number((lap.duration_micros / 1_000_000).toFixed(3));
}

function isSelected(competitor: CompetitorRef, lap: Lap): boolean {
return (
selected !== null &&
Expand All @@ -314,15 +320,10 @@
let editSeconds = $state<number | null>(0);
const editSecondsValid = $derived(typeof editSeconds === 'number' && editSeconds > 0);
const editSecondsTitle = 'Enter a positive time (s) first';
/** The validated time input (µs), or `undefined` — toasting the refusal (visible feedback). */
function editTimeMicros(): number | undefined {
const seconds = editSeconds;
if (typeof seconds !== 'number' || !(seconds > 0)) {
toast.error('Enter a positive time (seconds) first.');
return undefined;
}
return secondsToSourceTime(seconds);
}
/** Whether the RD actually CHANGED the pre-filled lap time — Save arms only then. */
const editDirty = $derived(
selected !== null && editSecondsValid && editSeconds !== lapSeconds(selected.lap)
);

// One shared in-flight guard for the correction / ruling / lifecycle submits (the `committing`
// pattern): every correction APPENDS a ruling, so a double-clicked Apply lands the penalty
Expand All @@ -346,32 +347,33 @@
if (heat) await session.refreshMarshaling(heat);
}

/** SPLIT the selected lap at its MIDPOINT — no input needed: the case is a missed gate
* crossing folding two real laps into one double-length lap, and halving is the best
* first approximation (the RD then tunes either half's time with Save if needed). */
function doSplitSelected(): Promise<void> {
return submitCorrection(async () => {
if (!selected) return;
const at = editTimeMicros();
if (at === undefined) return;
const ack = await session.send(splitLapCommand(selected.lap.end_ref, at));
const lap = selected.lap;
const at = Math.round(lap.at - lap.duration_micros / 2);
const ack = await session.send(splitLapCommand(lap.end_ref, at));
if (ack.ok) await afterCorrection();
});
}

function doEditTimeSelected(): Promise<void> {
/** SAVE the edited lap time: the new duration re-times the lap's CLOSING pass (start
* instant + new duration). The next lap's start shifts with it — one crossing moved. */
function doSaveTimeSelected(): Promise<void> {
return submitCorrection(async () => {
if (!selected) return;
const at = editTimeMicros();
if (at === undefined) return;
const ack = await session.send(adjustLapCommand(selected.lap.end_ref, at));
if (ack.ok) await afterCorrection();
});
}

function doInsertAfterSelected(): Promise<void> {
return submitCorrection(async () => {
if (!selected || !heat) return;
const at = editTimeMicros();
if (at === undefined) return;
const ack = await session.send(insertLapCommand(adapter, selected.competitor, at, heat));
const seconds = editSeconds;
if (typeof seconds !== 'number' || !(seconds > 0)) {
toast.error('Enter a positive lap time (seconds) first.');
return;
}
const lap = selected.lap;
const start = lap.at - lap.duration_micros;
const at = start + secondsToSourceTime(seconds);
const ack = await session.send(adjustLapCommand(lap.end_ref, at));
if (ack.ok) await afterCorrection();
});
}
Expand Down Expand Up @@ -1123,7 +1125,8 @@
onclick={() => doVoidLap(cl.competitor.competitor, lap)}
disabled={busy}
aria-label={`Remove lap ${lap.number}`}
title="Remove (void) this lap's closing pass">Remove</button
title="This crossing never really happened (noise / a reflection) — the pass is removed and the neighbouring laps merge. Throw out is for a real lap that shouldn't count."
>Remove</button
>
{/if}
</li>
Expand All @@ -1132,40 +1135,35 @@
corrections box, moved to where the lap is. -->
<li class="lap-editor" aria-label={`Edit lap ${lap.number}`}>
<label class="time"
>Time (s)
>Lap time (s)
<input
type="number"
step="0.001"
min="0.001"
bind:value={editSeconds}
aria-label="Correction time"
aria-label="Lap time"
/>
</label>
<button
type="button"
onclick={doSplitSelected}
disabled={busy || !editSecondsValid}
title={!editSecondsValid ? editSecondsTitle : undefined}>Split</button
onclick={doSaveTimeSelected}
disabled={busy || !editDirty}
title={editDirty
? 'Re-time this lap to the entered time'
: 'Change the time to enable saving'}>Save time</button
>
<button
type="button"
onclick={doEditTimeSelected}
disabled={busy || !editSecondsValid}
title={!editSecondsValid ? editSecondsTitle : undefined}
>Edit time</button
>
<button
type="button"
onclick={doInsertAfterSelected}
disabled={busy || !editSecondsValid}
title={!editSecondsValid ? editSecondsTitle : undefined}
>Insert after</button
onclick={doSplitSelected}
disabled={busy}
title="Split this lap into two at its midpoint (a missed crossing) — tune either half after"
>Split</button
>
<button
type="button"
onclick={doThrowOutSelected}
disabled={busy}
title="Exclude this valid lap from the scored count (the lap stays real)"
title="The lap HAPPENED but doesn't count (course cut / penalty) — it stays on the clock, excluded from scoring. Remove is for a crossing that never really happened."
>Throw out</button
>
</li>
Expand Down
56 changes: 29 additions & 27 deletions frontend/apps/rd-console/tests/MarshalingScreen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,15 @@ describe('Marshaling (Slice 3)', () => {
expect(sendSpy).toHaveBeenCalledWith({ VoidDetection: { target: 14 } });
});

it('splits the selected lap at the entered time, targeting its end_ref', async () => {
it('splits the selected lap at its MIDPOINT — no time input needed (a missed crossing)', async () => {
const { session, sendSpy } = makeTestSession({ live: liveRunning, laps: lapList });
render(Marshaling, { session });

// Marshal one pilot at a time: show BOB, then select his only lap (end_ref 13).
// Marshal one pilot at a time: show BOB, then select his only lap (end_ref 13, 0→43.0s).
await fireEvent.change(screen.getByLabelText('Pilot to marshal'), { target: { value: 'BOB' } });
await fireEvent.click(screen.getByRole('button', { name: /Lap 1\s*43\.000/ }));
await fireEvent.input(screen.getByLabelText('Correction time'), { target: { value: '21' } });
await fireEvent.click(screen.getByRole('button', { name: 'Split' }));
expect(sendSpy).toHaveBeenCalledWith({ SplitLap: { target: 13, at: 21_000_000 } });
expect(sendSpy).toHaveBeenCalledWith({ SplitLap: { target: 13, at: 21_500_000 } });
});

it('marshals a different heat WITHOUT moving Race Control’s current heat', async () => {
Expand Down Expand Up @@ -90,14 +89,20 @@ describe('Marshaling (Slice 3)', () => {
expect(movedCurrent).toBeUndefined();
});

it('edits the selected lap time (AdjustLap on end_ref)', async () => {
it('the editor pre-fills the lap time; Save arms on change and re-times the closing pass', async () => {
const { session, sendSpy } = makeTestSession({ live: liveRunning, laps: lapList });
render(Marshaling, { session });

// ALICE lap 1: 41.000s (start at 0, end_ref 12). The input opens PRE-FILLED with it and
// Save stays disabled until the value changes — no free-typed instants.
await fireEvent.click(screen.getByRole('button', { name: /Lap 1\s*41\.000/ }));
await fireEvent.input(screen.getByLabelText('Correction time'), { target: { value: '40' } });
await fireEvent.click(screen.getByRole('button', { name: 'Edit time' }));
// ALICE lap 1 end_ref = 12.
expect((screen.getByLabelText('Lap time') as HTMLInputElement).value).toBe('41');
expect(screen.getByRole('button', { name: 'Save time' })).toBeDisabled();

await fireEvent.input(screen.getByLabelText('Lap time'), { target: { value: '40' } });
expect(screen.getByRole('button', { name: 'Save time' })).toBeEnabled();
await fireEvent.click(screen.getByRole('button', { name: 'Save time' }));
// New duration 40s from the lap's start (0) → the closing pass re-times to 40.0s.
expect(sendSpy).toHaveBeenCalledWith({ AdjustLap: { target: 12, at: 40_000_000 } });
});

Expand Down Expand Up @@ -407,8 +412,7 @@ describe('Marshaling (Slice 3)', () => {
// …but every result-changing surface is gone: the per-row Remove + the inline editor…
expect(screen.queryByRole('button', { name: /Remove lap/ })).toBeNull();
expect(screen.queryByRole('button', { name: 'Split' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Edit time' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Insert after' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Save time' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Throw out' })).toBeNull();
// …and the inline Add-lap control.
expect(screen.queryByRole('button', { name: '+ Add lap' })).toBeNull();
Expand Down Expand Up @@ -528,29 +532,27 @@ describe('Marshaling (Slice 3)', () => {
expect(sendSpy).toHaveBeenCalledTimes(1);
});

it('requires a positive time for Split / Edit time / Insert after (never sends at=0)', async () => {
it('Save time never sends a degenerate value: disabled pristine, emptied, or zeroed', async () => {
const { session, sendSpy } = makeTestSession({ live: liveRunning, laps: lapList });
render(Marshaling, { session });

// Select a lap with the time input still at its 0 default: the time-based corrections stay
// DISABLED (with the explaining title) — an `AdjustLap at: 0` wrecks the whole lap chain.
// Pristine (pre-filled with the lap's own time): nothing to save.
await fireEvent.click(screen.getByRole('button', { name: /Lap 1\s*41\.000/ }));
for (const name of ['Split', 'Edit time', 'Insert after']) {
const button = screen.getByRole('button', { name });
expect(button).toBeDisabled();
expect(button).toHaveAttribute('title', 'Enter a positive time (s) first');
}
await fireEvent.click(screen.getByRole('button', { name: 'Edit time' }));
expect(sendSpy).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'Save time' })).toBeDisabled();
// Insert after is GONE (the + Add lap footer covers insertion).
expect(screen.queryByRole('button', { name: 'Insert after' })).toBeNull();

// An EMPTIED input (binds null) is refused the same way.
await fireEvent.input(screen.getByLabelText('Correction time'), { target: { value: '' } });
expect(screen.getByRole('button', { name: 'Edit time' })).toBeDisabled();
// An EMPTIED input (binds null) and a zero both keep Save disabled — an
// `AdjustLap at: start+0` wrecks the lap chain.
await fireEvent.input(screen.getByLabelText('Lap time'), { target: { value: '' } });
expect(screen.getByRole('button', { name: 'Save time' })).toBeDisabled();
await fireEvent.input(screen.getByLabelText('Lap time'), { target: { value: '0' } });
expect(screen.getByRole('button', { name: 'Save time' })).toBeDisabled();
expect(sendSpy).not.toHaveBeenCalled();

// A positive time enables them and the command carries it.
await fireEvent.input(screen.getByLabelText('Correction time'), { target: { value: '40' } });
expect(screen.getByRole('button', { name: 'Edit time' })).toBeEnabled();
await fireEvent.click(screen.getByRole('button', { name: 'Edit time' }));
// A changed positive time arms Save and the command carries the re-timed instant.
await fireEvent.input(screen.getByLabelText('Lap time'), { target: { value: '40' } });
await fireEvent.click(screen.getByRole('button', { name: 'Save time' }));
expect(sendSpy).toHaveBeenCalledWith({ AdjustLap: { target: 12, at: 40_000_000 } });
});

Expand Down