diff --git a/frontend/apps/rd-console/src/screens/Marshaling.svelte b/frontend/apps/rd-console/src/screens/Marshaling.svelte index 2225dba..0ed2f0e 100644 --- a/frontend/apps/rd-console/src/screens/Marshaling.svelte +++ b/frontend/apps/rd-console/src/screens/Marshaling.svelte @@ -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 && @@ -314,15 +320,10 @@ let editSeconds = $state(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 @@ -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 { 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 { + /** 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 { 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 { - 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(); }); } @@ -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">RemoveRemove {/if} @@ -1132,40 +1135,35 @@ corrections box, moved to where the lap is. -->
  • Save time - Split
  • diff --git a/frontend/apps/rd-console/tests/MarshalingScreen.test.ts b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts index 4117285..b06f5ca 100644 --- a/frontend/apps/rd-console/tests/MarshalingScreen.test.ts +++ b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts @@ -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 () => { @@ -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 } }); }); @@ -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(); @@ -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 } }); });