diff --git a/frontend/apps/rd-console/src/lib/RssiGraph.svelte b/frontend/apps/rd-console/src/lib/RssiGraph.svelte index bb0a0bf..eb00640 100644 --- a/frontend/apps/rd-console/src/lib/RssiGraph.svelte +++ b/frontend/apps/rd-console/src/lib/RssiGraph.svelte @@ -3,9 +3,17 @@ * RssiGraph (#55, Marshaling Slice 4) — the **signal-as-evidence** layer on top of the * lap-level Marshaling UI. For a RotorHazard heat that captured a trace, it renders the * per-competitor RSSI-vs-time graph the marshal reviews against, so they can see *why* the - * timer called (or missed) a lap (marshaling.html §3.2). Display only — there is **no - * re-detection** here: the RotorHazard-style "Recalculate" with draggable thresholds is - * explicitly deferred (marshaling.html §5). We draw, we do not re-derive. + * timer called (or missed) a lap (marshaling.html §3.2). + * + * The graph itself stays display-only by default. The RotorHazard-style "Recalculate with + * draggable thresholds" (marshaling.html §5) is now opt-in via the tuning props: when the + * parent supplies `onthresholds`, the enter/exit lines grow draggable (and keyboard-nudgeable) + * handles that emit the tuned levels back up; `tuned` overrides the drawn levels for one + * competitor while the marshal adjusts; and `preview` draws the re-detection diff — hollow + * dashed markers for passes the new levels would ADD, and a struck/dimmed restyle on official + * lap markers the new levels would REMOVE. The graph still re-derives nothing and commits + * nothing — the parent runs the detection (`redetect.ts`) and sends the commands on an + * explicit Commit. Without the new props the behavior is exactly the old display-only graph. * * What it draws, per competitor trace ([`CompetitorTrace`]): * • the **sample line** — the streaming-cadence RSSI samples placed on the source clock @@ -33,7 +41,10 @@ onselect, onaddlap, canControl = false, - nameFor = (r) => r + nameFor = (r) => r, + onthresholds, + tuned, + preview }: { /** The captured trace for the heat — one entry per competitor that produced signal facts. */ trace: { competitors: CompetitorTrace[] }; @@ -61,6 +72,24 @@ * don't pass a resolver keep showing the ref unchanged. */ nameFor?: (ref: CompetitorRef) => string; + /** + * Enables live threshold tuning (the RH-style "Recalculate"): when supplied, the enter/exit + * lines get draggable, keyboard-nudgeable handles that emit the adjusted levels. Emitted per + * competitor — the parent owns the tuned values and feeds them back via `tuned`. + */ + onthresholds?: (competitor: CompetitorRef, enter: number, exit: number) => void; + /** + * The live tuned levels for ONE competitor (two-way with the parent's tuning inputs): while + * present, this competitor's threshold lines/handles draw at these levels instead of the + * trace's recorded ones. Other competitors keep their recorded levels. + */ + tuned?: { competitor: CompetitorRef; enter: number; exit: number }; + /** + * The re-detection preview diff for ONE competitor: `added` pass times (µs) draw as hollow + * dashed candidate markers; official lap markers whose closing pass ref is in `removedRefs` + * restyle struck/dimmed (they would be voided on commit). Preview only — nothing commits. + */ + preview?: { competitor: CompetitorRef; added: number[]; removedRefs: number[] }; } = $props(); // Plot geometry. A fixed viewBox keeps the SVG crisp at any rendered size; strokes are in @@ -112,15 +141,32 @@ return { from, to }; } + /** + * The enter/exit levels a trace is DRAWN (and its crossing windows shaded) against: the live + * `tuned` values while this competitor is being adjusted, else the trace's recorded thresholds. + */ + function effectiveThresholds(t: CompetitorTrace): { + enter: number | undefined; + exit: number | undefined; + } { + if (tuned && tuned.competitor === t.competitor.competitor) + return { enter: tuned.enter, exit: tuned.exit }; + return { enter: t.enter, exit: t.exit }; + } + /** RSSI value range for a trace, padded so the thresholds and peaks aren't flush to the edge. */ - function valueRange(t: CompetitorTrace): { lo: number; hi: number } { + function valueRange( + t: CompetitorTrace, + enter: number | undefined = t.enter, + exit: number | undefined = t.exit + ): { lo: number; hi: number } { let lo = Infinity; let hi = -Infinity; for (const v of t.samples) { if (v < lo) lo = v; if (v > hi) hi = v; } - for (const th of [t.enter, t.exit]) { + for (const th of [t.enter, t.exit, enter, exit]) { if (th != null) { if (th < lo) lo = th; if (th > hi) hi = th; @@ -174,11 +220,15 @@ * enter→exit hysteresis over the captured samples: a window OPENS at the first sample that rises * to/above `enter` and CLOSES at the first subsequent sample that falls to/below `exit` (one window * per detected pass). A window still open at the trace end extends to the last sample. Empty unless - * both levels are present. Display-only — this visualises what the detector saw, it does not - * re-detect or change any lap. + * both levels are present. Display-only — this visualises what the detector saw (at the tuned + * levels while adjusting), it does not re-detect or change any lap. */ - function crossingWindows(t: CompetitorTrace): { from: number; to: number }[] { - if (t.enter == null || t.exit == null) return []; + function crossingWindows( + t: CompetitorTrace, + enter: number | undefined = t.enter, + exit: number | undefined = t.exit + ): { from: number; to: number }[] { + if (enter == null || exit == null) return []; const n = t.samples.length; const out: { from: number; to: number }[] = []; let inCrossing = false; @@ -187,11 +237,11 @@ const v = t.samples[i]; const time = sampleTimeOf(t, i); if (!inCrossing) { - if (v >= t.enter) { + if (v >= enter) { inCrossing = true; start = time; } - } else if (v <= t.exit) { + } else if (v <= exit) { out.push({ from: start, to: time }); inCrossing = false; } @@ -278,6 +328,72 @@ const x = Math.min(PAD_L + plotW, Math.max(PAD_L, px)); onaddlap(ct.competitor.competitor, Math.round(timeAt(x, span))); } + + // ── Draggable enter/exit threshold handles (the RH-style live tuning) ───────────────────────── + // Only wired when `onthresholds` is supplied. A pointer drag on a threshold handle maps the + // pointer's Y back to an RSSI level and emits BOTH levels (the dragged one replaced) so the + // parent's tuning state stays a single (enter, exit) pair. Arrow keys nudge ±1 for keyboard + // access. All preview-only — the graph never re-detects or sends anything itself. + let dragging = $state<{ ref: CompetitorRef; which: 'enter' | 'exit' } | null>(null); + + /** Invert {@link yOf}: a pointer event's Y back to an RSSI level (rounded — RSSI is integral). */ + function valueFromPointer(e: PointerEvent, range: { lo: number; hi: number }): number { + const svg = (e.currentTarget as Element).closest('svg'); + if (!svg) return range.lo; + const rect = svg.getBoundingClientRect(); + const y = rect.height === 0 ? PAD_T : ((e.clientY - rect.top) / rect.height) * H; + const frac = Math.min(1, Math.max(0, (PAD_T + plotH - y) / plotH)); + return Math.round(range.lo + frac * (range.hi - range.lo)); + } + + /** Emit the tuned pair with one level replaced by `value`. */ + function emitThreshold(ct: CompetitorTrace, which: 'enter' | 'exit', value: number): void { + if (!onthresholds) return; + const { enter, exit } = effectiveThresholds(ct); + onthresholds( + ct.competitor.competitor, + which === 'enter' ? value : (enter ?? value), + which === 'exit' ? value : (exit ?? value) + ); + } + + function startThresholdDrag(e: PointerEvent, ct: CompetitorTrace, which: 'enter' | 'exit'): void { + if (!onthresholds) return; + dragging = { ref: ct.competitor.competitor, which }; + // Keep receiving moves outside the handle while dragging (jsdom has no pointer capture). + (e.currentTarget as Element).setPointerCapture?.(e.pointerId); + } + + function moveThresholdDrag( + e: PointerEvent, + ct: CompetitorTrace, + which: 'enter' | 'exit', + range: { lo: number; hi: number } + ): void { + if (!dragging || dragging.ref !== ct.competitor.competitor || dragging.which !== which) return; + emitThreshold(ct, which, valueFromPointer(e, range)); + } + + function endThresholdDrag(): void { + dragging = null; + } + + /** Keyboard access: Arrow Up/Down nudges the focused threshold ±1 RSSI count. */ + function nudgeThreshold(e: KeyboardEvent, ct: CompetitorTrace, which: 'enter' | 'exit'): void { + if (!onthresholds) return; + const delta = e.key === 'ArrowUp' ? 1 : e.key === 'ArrowDown' ? -1 : 0; + if (delta === 0) return; + e.preventDefault(); + const current = effectiveThresholds(ct)[which]; + if (current == null) return; + emitThreshold(ct, which, current + delta); + } + + /** The preview diff for a competitor (empty when the preview prop targets someone else). */ + function previewFor(ref: CompetitorRef): { added: number[]; removedRefs: Set } { + if (!preview || preview.competitor !== ref) return { added: [], removedRefs: new Set() }; + return { added: preview.added, removedRefs: new Set(preview.removedRefs) }; + }
@@ -287,6 +403,9 @@ Exit Detection window Lap pass + {#if preview} + Preview pass (uncommitted) + {/if} Streaming-cadence trace — one sample per timer emit, not RotorHazard's dense marshal history. @@ -296,15 +415,17 @@ {@const ref = ct.competitor.competitor} {@const compLaps = lapsFor(ref)} {@const span = spanOf(ct, compLaps)} - {@const range = valueRange(ct)} + {@const th = effectiveThresholds(ct)} + {@const range = valueRange(ct, th.enter, th.exit)} {@const who = nameFor(ref)} + {@const pv = previewFor(ref)}
{who} {ct.samples.length} samples - {#if ct.enter != null}· enter {ct.enter}{/if} - {#if ct.exit != null}· exit {ct.exit}{/if} + {#if th.enter != null}· enter {th.enter}{/if} + {#if th.exit != null}· exit {th.exit}{/if}
{#if ct.samples.length === 0} @@ -334,20 +455,64 @@ sample that rises above `enter` to the one that falls below `exit` (the timer's hysteresis). Drawn behind the signal so the trace reads on top; lets the marshal see exactly what the lap-detection engine registered as a pass. --> - {#each crossingWindows(ct) as cw (cw.from)} + {#each crossingWindows(ct, th.enter, th.exit) as cw (cw.from)} {@const x1 = xOf(cw.from, span)} {@const x2 = xOf(cw.to, span)} {/each} - - {#if ct.enter != null} - {@const y = yOf(ct.enter, range)} + + {#if th.enter != null} + {@const y = yOf(th.enter, range)} + {#if onthresholds} + startThresholdDrag(e, ct, 'enter')} + onpointermove={(e: PointerEvent) => moveThresholdDrag(e, ct, 'enter', range)} + onpointerup={endThresholdDrag} + onpointercancel={endThresholdDrag} + onkeydown={(e: KeyboardEvent) => nudgeThreshold(e, ct, 'enter')} + > + + + + EN + + {/if} {/if} - {#if ct.exit != null} - {@const y = yOf(ct.exit, range)} + {#if th.exit != null} + {@const y = yOf(th.exit, range)} + {#if onthresholds} + startThresholdDrag(e, ct, 'exit')} + onpointermove={(e: PointerEvent) => moveThresholdDrag(e, ct, 'exit', range)} + onpointerup={endThresholdDrag} + onpointercancel={endThresholdDrag} + onkeydown={(e: KeyboardEvent) => nudgeThreshold(e, ct, 'exit')} + > + + + EX + + {/if} {/if} @@ -359,6 +524,7 @@ {/each} + + {#each pv.added as t (t)} + {@const x = xOf(t, span)} + + {/each} + {#if isHover && hover} @@ -466,6 +643,10 @@ .swatch.marker { background: var(--gf-text-secondary); } + .swatch.preview { + background: transparent; + border: 1px dashed #ffd24a; + } .cadence-note { flex-basis: 100%; text-transform: none; @@ -576,6 +757,69 @@ stroke: var(--gf-accent); stroke-width: 2; } + /* An official lap marker the tuned thresholds would REMOVE: dimmed + struck (a short dash), + so "this pass goes away on commit" reads at a glance without hiding the marker. */ + .marker.removed .rule { + stroke: var(--gf-danger); + opacity: 0.55; + stroke-dasharray: 2 6; + } + .marker.removed .label { + fill: var(--gf-danger); + opacity: 0.7; + text-decoration: line-through; + } + + /* Draggable threshold handles (live tuning). Big grab bands — field-usable on a trackpad. */ + .th-handle { + cursor: ns-resize; + } + .th-handle .grab { + stroke: transparent; + stroke-width: 16; + vector-effect: non-scaling-stroke; + } + .th-handle .knob { + stroke-width: 1; + vector-effect: non-scaling-stroke; + } + .th-handle.enter .knob { + fill: color-mix(in srgb, var(--gf-success) 30%, #0c1118); + stroke: var(--gf-success); + } + .th-handle.exit .knob { + fill: color-mix(in srgb, var(--gf-danger) 30%, #0c1118); + stroke: var(--gf-danger); + } + .th-handle .knob-label { + font-family: var(--gf-font-mono); + font-size: 9px; + fill: #fff; + pointer-events: none; + } + .th-handle:focus-visible { + outline: none; + } + .th-handle:focus-visible .knob { + stroke-width: 2; + filter: drop-shadow(0 0 3px currentColor); + } + + /* Preview pass candidates (would be ADDED on commit): hollow dashed verticals, visually + distinct from the solid official markers. */ + .preview-added line { + stroke: #ffd24a; + stroke-width: 1.5; + stroke-dasharray: 5 4; + vector-effect: non-scaling-stroke; + pointer-events: none; + } + .preview-added .label { + fill: #ffd24a; + font-family: var(--gf-font-mono); + font-size: 12px; + font-weight: 600; + } /* Hover crosshair + readout (the "where exactly is this?" guide). */ .plot.addable { diff --git a/frontend/apps/rd-console/src/lib/redetect.ts b/frontend/apps/rd-console/src/lib/redetect.ts new file mode 100644 index 0000000..72335bc --- /dev/null +++ b/frontend/apps/rd-console/src/lib/redetect.ts @@ -0,0 +1,184 @@ +/** + * Threshold re-detection (the RotorHazard-style "Recalculate", marshaling.html §5) — the pure + * math behind the Marshaling screen's "Tune detection" panel. + * + * The marshal moves the enter/exit levels live; these helpers re-run the timer's own hysteresis + * over the CAPTURED samples ({@link detectPasses}), diff the re-detected passes against the + * competitor's current official ones ({@link diffPasses}), and derive the preview lap list + * ({@link previewLaps}) — all **preview-only**. Nothing here talks to the Director: committing + * the diff is the screen's job (a `VoidDetection` per removed pass + an `InsertLap` per added + * one, the existing marshaling primitives), and the tuned thresholds themselves are never + * written anywhere — pushing calibration back to RotorHazard via the plugin is a separate, + * future feature. + * + * Everything is a pure function of its inputs so the semantics are unit-testable without a DOM. + */ + +import type { CompetitorTrace, Lap } from '@gridfpv/types'; + +/** + * How close (µs) a re-detected pass must land to a current official pass to count as the SAME + * pass in {@link diffPasses}. Real crossings are seconds apart, so half a second comfortably + * separates "the same gate pass, re-timed to the sample peak" from a genuinely new/lost one. + */ +export const DEFAULT_MATCH_TOLERANCE_MICROS = 500_000; + +/** A current official pass: its source-clock time and the log offset a correction targets. */ +export interface OfficialPass { + /** Source-clock time (µs) of the pass. */ + at: number; + /** The global log offset (`LogRef`) — the `VoidDetection` target if this pass is removed. */ + ref: number; +} + +/** The re-detected-vs-official diff {@link diffPasses} produces. */ +export interface PassDiff { + /** Official passes a re-detected pass matched (within tolerance) — unchanged by a commit. */ + kept: { at: number; ref: number; detectedAt: number }[]; + /** Re-detected pass times (µs) with no official counterpart — each becomes an `InsertLap`. */ + added: number[]; + /** Official passes the re-detection no longer sees — each becomes a `VoidDetection`. */ + removed: OfficialPass[]; +} + +/** A preview lap derived from consecutive re-detected passes (see {@link previewLaps}). */ +export interface PreviewLap { + /** 1-based lap number. */ + number: number; + /** Source-clock time (µs) of the pass that closes this lap. */ + at: number; + /** Lap duration (µs). */ + durationMicros: number; +} + +/** + * The source-clock time (µs) of sample `i`: the dense per-sample `times` when the trace carries + * them (RH's marshal history is bursty — the uniform grid badly misplaces it), else the uniform + * `from + i·period_micros` grid. Mirrors the RssiGraph's placement so a re-detected pass lands + * exactly where the graph draws that sample. + */ +export function sampleTimeOf(trace: CompetitorTrace, i: number): number { + return trace.times?.[i] ?? (trace.from ?? 0) + i * trace.period_micros; +} + +/** + * Re-run RotorHazard-semantics enter/exit hysteresis over a trace's samples and return the + * detected gate-pass times (µs, source clock), oldest first. + * + * A crossing OPENS at the first sample at/above `enter`, tracks the PEAK sample within the open + * window (ties keep the first peak), and CLOSES at the first subsequent sample at/below `exit`; + * the pass time is the peak sample's time. A window still open at the end of the trace emits + * NO pass (an incomplete crossing — the quad may still be at the gate). + * + * Detection requires `enter > exit` (the hysteresis gap): equal or inverted thresholds detect + * nothing — the UI flags that state rather than guessing. + */ +export function detectPasses(trace: CompetitorTrace, enter: number, exit: number): number[] { + if (!(enter > exit)) return []; + const passes: number[] = []; + let open = false; + let peakValue = -Infinity; + let peakTime = 0; + for (let i = 0; i < trace.samples.length; i++) { + const v = trace.samples[i]; + if (!open) { + if (v >= enter) { + open = true; + peakValue = v; + peakTime = sampleTimeOf(trace, i); + } + } else if (v <= exit) { + passes.push(peakTime); + open = false; + peakValue = -Infinity; + } else if (v > peakValue) { + peakValue = v; + peakTime = sampleTimeOf(trace, i); + } + } + // An open window at trace end is an incomplete crossing — deliberately NOT a pass. + return passes; +} + +/** + * Diff re-detected pass times against the current official passes: a greedy nearest-first + * match within `toleranceMicros` (inclusive), each official pass matching at most one detected + * pass and vice versa. Unmatched detected times are `added`; unmatched official passes are + * `removed` — together, the exact command batch a commit sends. + */ +export function diffPasses( + current: OfficialPass[], + detected: number[], + toleranceMicros: number = DEFAULT_MATCH_TOLERANCE_MICROS +): PassDiff { + // Every candidate pairing within tolerance, nearest first — the greedy order. + const candidates: { ci: number; di: number; dist: number }[] = []; + for (let ci = 0; ci < current.length; ci++) { + for (let di = 0; di < detected.length; di++) { + const dist = Math.abs(detected[di] - current[ci].at); + if (dist <= toleranceMicros) candidates.push({ ci, di, dist }); + } + } + candidates.sort((a, b) => a.dist - b.dist); + + const matchedCurrent = new Set(); + const matchedDetected = new Set(); + const kept: PassDiff['kept'] = []; + for (const { ci, di } of candidates) { + if (matchedCurrent.has(ci) || matchedDetected.has(di)) continue; + matchedCurrent.add(ci); + matchedDetected.add(di); + kept.push({ at: current[ci].at, ref: current[ci].ref, detectedAt: detected[di] }); + } + kept.sort((a, b) => a.at - b.at); + + return { + kept, + added: detected.filter((_, di) => !matchedDetected.has(di)), + removed: current.filter((_, ci) => !matchedCurrent.has(ci)) + }; +} + +/** + * The lap list a set of gate-pass times implies: the FIRST pass is the holeshot (it opens lap 1, + * closing no lap), and every consecutive pair of passes is a lap. `k` passes → `k − 1` laps. + */ +export function previewLaps(passTimes: number[]): PreviewLap[] { + const laps: PreviewLap[] = []; + for (let i = 1; i < passTimes.length; i++) { + laps.push({ + number: i, + at: passTimes[i], + durationMicros: passTimes[i] - passTimes[i - 1] + }); + } + return laps; +} + +/** + * A competitor's current OFFICIAL passes, reconstructed from their (marshaling-corrected) lap + * list: lap 1's opening pass (`start_ref`, at `lap1.at − lap1.duration`) plus every lap's + * closing pass (`end_ref` at `lap.at`). These are the refs a re-detection commit voids when the + * new thresholds no longer see them. + */ +export function officialPasses(laps: Lap[]): OfficialPass[] { + if (laps.length === 0) return []; + const first = laps[0]; + const passes: OfficialPass[] = [{ at: first.at - first.duration_micros, ref: first.start_ref }]; + for (const lap of laps) passes.push({ at: lap.at, ref: lap.end_ref }); + return passes; +} + +/** + * Sensible starting thresholds for a trace that recorded none: enter at the 75th percentile of + * the samples, exit at the 25th (the recorded `enter`/`exit` are always preferred when present). + * Returns `undefined` for an empty trace. + */ +export function defaultThresholds( + trace: CompetitorTrace +): { enter: number; exit: number } | undefined { + if (trace.samples.length === 0) return undefined; + const sorted = [...trace.samples].sort((a, b) => a - b); + const at = (q: number) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]; + return { enter: at(0.75), exit: at(0.25) }; +} diff --git a/frontend/apps/rd-console/src/screens/Marshaling.svelte b/frontend/apps/rd-console/src/screens/Marshaling.svelte index fbcc05a..cad99e4 100644 --- a/frontend/apps/rd-console/src/screens/Marshaling.svelte +++ b/frontend/apps/rd-console/src/screens/Marshaling.svelte @@ -21,6 +21,7 @@ AuditKind, ChannelCatalogEntry, CompetitorRef, + CompetitorTrace, HeatId, HeatSummary, Lap, @@ -52,6 +53,13 @@ voidHeatCommand } from '../lib/marshaling.js'; import type { ProtestOutcome } from '@gridfpv/types'; + import { + defaultThresholds, + detectPasses, + diffPasses, + officialPasses, + previewLaps + } from '../lib/redetect.js'; import { commandForAction } from '../lib/transitions.js'; import type { Session } from '../lib/session.svelte.js'; import { useProtestClock, formatProtest } from '../lib/protestClock.svelte.js'; @@ -492,6 +500,115 @@ : undefined ); + // ── Tune detection (the RH-style threshold re-detection, marshaling.html §5) ────────────────── + // The RD moves the enter/exit levels live — number inputs here, two-way with the graph's drag + // handles — and the screen re-runs the timer's hysteresis over the CAPTURED trace (redetect.ts), + // previewing the resulting lap list + the diff against the current official passes. NOTHING is + // sent while adjusting: an explicit Commit turns the diff into the existing marshaling primitives + // (a `VoidDetection` per removed pass, a heat-tagged `InsertLap` per added one). The thresholds + // themselves are a UI/preview concern only — they are NEVER written back to the timer; pushing + // calibration to RotorHazard via the plugin is a separate future feature. Scoped to the shown + // pilot (the "Marshal pilot" picker already selects exactly one). + const tuneTrace = $derived( + signalTrace?.competitors.find((c) => c.competitor.competitor === shownPilot) + ); + let tuneFor = $state(undefined); + let tuneEnter = $state(0); + let tuneExit = $state(0); + + /** The trace's recorded thresholds; an unset trace falls back to a percentile derivation. */ + function recordedThresholds(t: CompetitorTrace): { enter: number; exit: number } { + const fallback = defaultThresholds(t); + return { + enter: t.enter ?? fallback?.enter ?? 0, + exit: t.exit ?? fallback?.exit ?? 0 + }; + } + + // (Re-)seed the tuning levels whenever the tuned competitor changes (pilot picked / heat + // switched / trace arrives) — but never while the SAME competitor is being adjusted. + $effect(() => { + const t = tuneTrace; + if (!t) { + tuneFor = undefined; + return; + } + if (tuneFor === t.competitor.competitor) return; + tuneFor = t.competitor.competitor; + const rec = recordedThresholds(t); + tuneEnter = rec.enter; + tuneExit = rec.exit; + }); + + /** The graph's drag handles emit here — two-way with the number inputs. */ + function onGraphThresholds(competitor: CompetitorRef, enter: number, exit: number): void { + if (competitor !== shownPilot) return; + tuneEnter = enter; + tuneExit = exit; + } + + // The LIVE preview: re-detect at the tuned levels, diff against the current official passes + // (lap 1's opening pass + every lap's closing pass, from the marshaling-corrected lap list). + const tuneValid = $derived(tuneEnter > tuneExit); + const shownPilotLaps = $derived(shownLaps?.competitors[0]?.laps ?? []); + const detectedPassTimes = $derived( + tuneTrace && tuneValid && tuneFor === shownPilot + ? detectPasses(tuneTrace, tuneEnter, tuneExit) + : [] + ); + const redetectDiff = $derived(diffPasses(officialPasses(shownPilotLaps), detectedPassTimes)); + const redetectDirty = $derived(redetectDiff.added.length > 0 || redetectDiff.removed.length > 0); + const previewLapList = $derived(previewLaps(detectedPassTimes)); + + function doResetThresholds(): void { + if (!tuneTrace) return; + const rec = recordedThresholds(tuneTrace); + tuneEnter = rec.enter; + tuneExit = rec.exit; + } + + // Commit: turn the previewed diff into the marshaling command batch — voids first (the passes + // the tuned levels no longer see), then heat-tagged inserts (the newly detected ones) — + // sequentially, so the audit reads in a sane order and a mid-batch failure stops cleanly + // (the error banner shows; the refresh reflects whatever landed). + let committing = $state(false); + async function doCommitRedetect(): Promise { + if (!canControl || !heat || !tuneTrace || !tuneValid || !redetectDirty || committing) return; + committing = true; + try { + const key = tuneTrace.competitor; + const { added, removed } = redetectDiff; + let ok = true; + for (const pass of removed) { + const ack = await session.send(voidDetectionCommand(pass.ref)); + if (!ack.ok) { + ok = false; + break; + } + } + if (ok) { + for (const at of added) { + const ack = await session.send( + insertLapCommand(key.adapter, key.competitor, Math.round(at), heat) + ); + if (!ack.ok) { + ok = false; + break; + } + } + } + await afterCorrection(); + if (ok) { + const plus = added.length === 1 ? '+1 pass' : `+${added.length} passes`; + toast.success( + `Committed re-detection for ${competitorName(key.competitor)}: ${plus}, −${removed.length} removed` + ); + } + } finally { + committing = false; + } + } + // ── Audit rendering helpers ── function auditLabel(kind: AuditKind): string { switch (kind) { @@ -679,10 +796,13 @@ {/if} {#if hasShownTrace && shownTrace} + {@const tuning = canControl && tuneTrace != null && tuneFor === shownPilot} + highlights the same marker (two-way — `selectLap` is the one shared selection). With + control, the graph also carries the LIVE tuning surface (marshaling.html §5): draggable + enter/exit handles two-way with the "Tune detection" inputs below, plus the preview + pass markers of the uncommitted re-detection diff. Sim heats (no trace) skip this. --> p.ref) + } + : undefined} /> {/if} + {#if canControl && hasShownTrace && tuneTrace && shownPilot !== undefined} + +
+ Tune detection — {competitorName(shownPilot)} +

+ Drag the enter/exit handles on the graph or type levels here. Laps re-detect live as a + preview — nothing changes until you commit. +

+
+ + + + +
+ {#if !tuneValid} +

+ Enter must be above exit — these levels detect nothing. +

+ {:else} +

+ Would be {previewLapList.length} lap{previewLapList.length === 1 ? '' : 's'} + (+{redetectDiff.added.length} added, −{redetectDiff.removed.length} removed) +

+ {#if redetectDirty} +
    + {#each previewLapList as lap (lap.at)} +
  1. + Lap {lap.number} + {formatMicros(lap.durationMicros)} +
  2. + {/each} +
+ {/if} + {/if} +
+ {/if} + {#if shownLaps && shownLaps.competitors.length > 0}
{#each shownLaps.competitors as cl (cl.competitor.competitor)} @@ -1224,6 +1418,60 @@ border-color: color-mix(in srgb, var(--gf-danger) 45%, var(--gf-border)); background: var(--gf-danger-soft); } + /* Tune detection: the live re-detection panel. Big readable summary — the "+A / −R" is what + the marshal decides on, outdoors on a laptop (the field-readability bar). */ + .tune-detection { + border-color: color-mix(in srgb, var(--gf-accent) 35%, var(--gf-border)); + } + .tune-detection input[type='number'] { + width: 6rem; + font-size: var(--gf-font-size-md); + font-family: var(--gf-font-mono); + } + .tune-summary { + margin: var(--gf-space-3) 0 0; + font-size: var(--gf-font-size-md); + font-weight: var(--gf-font-weight-semibold); + color: var(--gf-text); + font-variant-numeric: tabular-nums; + } + .tune-invalid { + margin: var(--gf-space-3) 0 0; + font-size: var(--gf-font-size-sm); + font-weight: var(--gf-font-weight-semibold); + color: color-mix(in srgb, var(--gf-danger) 80%, var(--gf-text)); + } + .commit { + border: 1px solid var(--gf-accent); + background: var(--gf-accent-soft); + } + .commit:hover:not(:disabled) { + background: var(--gf-accent); + color: #061018; + } + .preview-laps { + margin: var(--gf-space-2) 0 0; + padding: 0; + list-style: none; + display: flex; + flex-wrap: wrap; + gap: var(--gf-space-2); + } + .preview-laps li { + display: flex; + align-items: baseline; + gap: var(--gf-space-2); + padding: var(--gf-space-1) var(--gf-space-3); + border: 1px dashed color-mix(in srgb, var(--gf-accent) 55%, var(--gf-border)); + border-radius: var(--gf-radius-sm); + background: var(--gf-surface-sunken); + font-family: var(--gf-font-mono); + font-size: var(--gf-font-size-sm); + color: var(--gf-text-secondary); + } + .preview-laps .lap-num { + font-weight: var(--gf-font-weight-semibold); + } .result-actions { border-color: color-mix(in srgb, var(--gf-accent) 35%, var(--gf-border)); } diff --git a/frontend/apps/rd-console/tests/MarshalingScreen.test.ts b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts index ad9ddbb..74a8905 100644 --- a/frontend/apps/rd-console/tests/MarshalingScreen.test.ts +++ b/frontend/apps/rd-console/tests/MarshalingScreen.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { render, screen, waitFor, within } from '@testing-library/svelte'; +import { cleanup, render, screen, waitFor, within } from '@testing-library/svelte'; import { fireEvent } from '@testing-library/dom'; import type { AuditEntry, @@ -969,6 +969,167 @@ describe('Marshaling (Slice 3)', () => { }); }); + // ── Tune detection (the RH-style threshold re-detection with manual commit) ────────────────── + describe('tune detection (threshold re-detection)', () => { + // A tuning-friendly fixture: ALICE's trace has clean 1-sample peaks at exactly her official + // passes (holeshot 1s, gates 41s + 81s) plus a SMALLER peak at 60s that only crosses when the + // enter level is lowered. Laps are timed so `officialPasses` reconstructs [1s, 41s, 81s]. + const TUNE_LAPS: LapList = { + competitors: [ + { + competitor: { adapter: 'rh-1', competitor: 'ALICE' }, + laps: [ + { number: 1, duration_micros: 40_000_000, at: 41_000_000, start_ref: 10, end_ref: 12 }, + { number: 2, duration_micros: 40_000_000, at: 81_000_000, start_ref: 12, end_ref: 14 } + ] + } + ] + }; + const TUNE_TRACE = { + competitors: [ + { + competitor: { adapter: 'rh-1', competitor: 'ALICE' }, + from: 0, + period_micros: 1_000_000, + // Baseline 70; peaks: 130 @1s (holeshot), 132 @41s, 105 @60s (sub-threshold), 130 @81s. + samples: Array.from({ length: 91 }, (_, i) => + i === 1 ? 130 : i === 41 ? 132 : i === 60 ? 105 : i === 81 ? 130 : 70 + ), + enter: 110, + exit: 95 + } + ] + }; + + function renderTune(role?: 'readonly') { + return makeTestSession({ + live: liveRunning, + laps: TUNE_LAPS, + signal: TUNE_TRACE, + role + }); + } + + it('shows the panel (seeded from the recorded thresholds) when a trace + control are present', () => { + const { session } = renderTune(); + render(Marshaling, { session }); + expect(screen.getByText(/Tune detection/)).toBeInTheDocument(); + // Inputs initialize from the trace's recorded enter/exit. + expect((screen.getByLabelText('Enter threshold') as HTMLInputElement).value).toBe('110'); + expect((screen.getByLabelText('Exit threshold') as HTMLInputElement).value).toBe('95'); + // At the recorded levels the re-detection matches the official passes: nothing to commit. + expect(screen.getByTestId('redetect-summary')).toHaveTextContent( + 'Would be 2 laps (+0 added, −0 removed)' + ); + expect( + (screen.getByRole('button', { name: 'Commit re-detection' }) as HTMLButtonElement).disabled + ).toBe(true); + }); + + it('is hidden without a trace and for a read-only session', () => { + const noTrace = makeTestSession({ + live: liveRunning, + laps: TUNE_LAPS, + signal: emptySignalTrace + }); + render(Marshaling, { session: noTrace.session }); + expect(screen.queryByText(/Tune detection/)).toBeNull(); + cleanup(); + const { session } = renderTune('readonly'); + render(Marshaling, { session }); + expect(screen.queryByText(/Tune detection/)).toBeNull(); + }); + + it('adjusting a threshold updates the LIVE preview without sending anything', async () => { + const { session, sendSpy } = renderTune(); + render(Marshaling, { session }); + + // Lower enter to 100: the 105-peak at 60s now crosses → one ADDED pass, a 3-lap preview. + await fireEvent.input(screen.getByLabelText('Enter threshold'), { + target: { value: '100' } + }); + expect(screen.getByTestId('redetect-summary')).toHaveTextContent( + 'Would be 3 laps (+1 added, −0 removed)' + ); + // The preview lap list renders the would-be laps (the 41→60s split: 19s + 21s). + const previewList = screen.getByLabelText(/Preview laps for ALICE/); + expect(previewList).toHaveTextContent('Lap 2'); + expect(previewList).toHaveTextContent('19.000'); + expect(previewList).toHaveTextContent('21.000'); + // NOTHING was sent while adjusting — commit is explicit. + expect(sendSpy).not.toHaveBeenCalled(); + expect( + (screen.getByRole('button', { name: 'Commit re-detection' }) as HTMLButtonElement).disabled + ).toBe(false); + }); + + it('flags equal/inverted thresholds instead of detecting', async () => { + const { session } = renderTune(); + render(Marshaling, { session }); + await fireEvent.input(screen.getByLabelText('Enter threshold'), { target: { value: '95' } }); + expect(screen.getByText(/Enter must be above exit/)).toBeInTheDocument(); + expect( + (screen.getByRole('button', { name: 'Commit re-detection' }) as HTMLButtonElement).disabled + ).toBe(true); + }); + + it('Commit sends a heat-tagged InsertLap per ADDED pass', async () => { + const { session, sendSpy } = renderTune(); + render(Marshaling, { session }); + + await fireEvent.input(screen.getByLabelText('Enter threshold'), { + target: { value: '100' } + }); + await fireEvent.click(screen.getByRole('button', { name: 'Commit re-detection' })); + // Exactly one command: the added pass at the 60s peak, tagged with the marshaled heat and + // the adapter from the trace's CompetitorKey. + await waitFor(() => + expect(sendSpy).toHaveBeenCalledWith({ + InsertLap: { adapter: 'rh-1', competitor: 'ALICE', at: 60_000_000, heat: 'heat-1' } + }) + ); + expect(sendSpy).toHaveBeenCalledTimes(1); + }); + + it('Commit sends a VoidDetection per REMOVED pass (the exact boundary refs)', async () => { + const { session, sendSpy } = renderTune(); + render(Marshaling, { session }); + + // Raise enter above every peak but the 132 one: only the 41s pass survives — the holeshot + // (ref 10) and the 81s gate pass (ref 14) are removed. + await fireEvent.input(screen.getByLabelText('Enter threshold'), { + target: { value: '131' } + }); + expect(screen.getByTestId('redetect-summary')).toHaveTextContent( + 'Would be 0 laps (+0 added, −2 removed)' + ); + await fireEvent.click(screen.getByRole('button', { name: 'Commit re-detection' })); + await waitFor(() => expect(sendSpy).toHaveBeenCalledTimes(2)); + expect(sendSpy).toHaveBeenNthCalledWith(1, { VoidDetection: { target: 10 } }); + expect(sendSpy).toHaveBeenNthCalledWith(2, { VoidDetection: { target: 14 } }); + }); + + it('Reset restores the recorded thresholds and clears the preview diff', async () => { + const { session, sendSpy } = renderTune(); + render(Marshaling, { session }); + + await fireEvent.input(screen.getByLabelText('Enter threshold'), { + target: { value: '100' } + }); + expect(screen.getByTestId('redetect-summary')).toHaveTextContent('+1 added'); + await fireEvent.click(screen.getByRole('button', { name: 'Reset' })); + expect((screen.getByLabelText('Enter threshold') as HTMLInputElement).value).toBe('110'); + expect((screen.getByLabelText('Exit threshold') as HTMLInputElement).value).toBe('95'); + expect(screen.getByTestId('redetect-summary')).toHaveTextContent( + 'Would be 2 laps (+0 added, −0 removed)' + ); + expect( + (screen.getByRole('button', { name: 'Commit re-detection' }) as HTMLButtonElement).disabled + ).toBe(true); + expect(sendSpy).not.toHaveBeenCalled(); + }); + }); + it('a read-only session hides every mutating control but shows laps + audit', () => { const { session } = makeTestSession({ live: liveRunning, diff --git a/frontend/apps/rd-console/tests/RssiGraph.test.ts b/frontend/apps/rd-console/tests/RssiGraph.test.ts index bf6b2cb..67e3d89 100644 --- a/frontend/apps/rd-console/tests/RssiGraph.test.ts +++ b/frontend/apps/rd-console/tests/RssiGraph.test.ts @@ -248,6 +248,15 @@ describe('RssiGraph add-lap', () => { expect(onaddlap).not.toHaveBeenCalled(); }); + it('drag + preview props absent → display-only: no handles, no preview markers', () => { + render(RssiGraph, { trace: signalTrace, laps: lapList, selected: null, onselect: () => {} }); + const svg = screen.getByLabelText(/RSSI trace for ALICE/); + expect(svg.querySelector('.th-handle')).toBeNull(); + expect(svg.querySelector('.preview-added')).toBeNull(); + // The threshold lines themselves still draw (the recorded levels). + expect(svg.querySelector('.enter-line')).not.toBeNull(); + }); + it('is review-only when canControl is false: a trace click does nothing', async () => { const onaddlap = vi.fn(); render(RssiGraph, { @@ -267,3 +276,137 @@ describe('RssiGraph add-lap', () => { expect(screen.queryByRole('button', { name: /Add lap for/ })).toBeNull(); }); }); + +/** + * Live threshold tuning (the RH-style "Recalculate", marshaling.html §5): with `onthresholds` + * supplied, the enter/exit lines carry draggable + keyboard-nudgeable handles emitting the tuned + * levels; `tuned` overrides the drawn levels; `preview` draws the uncommitted re-detection diff + * (hollow dashed added-pass candidates, struck/dimmed would-be-removed lap markers). All + * preview-only — the graph itself never re-detects or sends anything. + */ +describe('RssiGraph threshold tuning + preview', () => { + // The value↔Y mapping mirrors the component's: the fixture's range is min/max over samples AND + // the recorded thresholds, padded 8% on each side; Y spans PAD_T..PAD_T+plotH top-down. + const PAD_T = 10; + const PAD_B = 18; + const PLOT_H = 220 - PAD_T - PAD_B; // 192 + const ct = signalTrace.competitors[0]; + const rawLo = Math.min(...ct.samples, ct.enter!, ct.exit!); + const rawHi = Math.max(...ct.samples, ct.enter!, ct.exit!); + const PAD = (rawHi - rawLo) * 0.08; + const LO = rawLo - PAD; + const HI = rawHi + PAD; + /** The clientY (box pinned 1:1 to the viewBox) that maps back to the given RSSI value. */ + function yForValue(v: number): number { + return PAD_T + PLOT_H - ((v - LO) / (HI - LO)) * PLOT_H; + } + /** jsdom has no PointerEvent — dispatch a MouseEvent with the pointer type (same fields). */ + function firePointer(el: Element, type: string, init: MouseEventInit): boolean { + return fireEvent(el, new MouseEvent(type, { bubbles: true, ...init })); + } + + it('dragging the enter handle emits onthresholds with the level at the pointer', async () => { + const onthresholds = vi.fn(); + render(RssiGraph, { + trace: signalTrace, + laps: lapList, + selected: null, + onselect: () => {}, + canControl: true, + onthresholds + }); + const svg = screen.getByLabelText(/RSSI trace for ALICE/); + pinSvgBox(svg); + + const handle = screen.getByRole('slider', { name: 'Enter threshold for ALICE' }); + await firePointer(handle, 'pointerdown', { clientY: yForValue(110) }); + await firePointer(handle, 'pointermove', { clientY: yForValue(120) }); + // The drag emits BOTH levels: the dragged enter at the pointer, the exit unchanged. + expect(onthresholds).toHaveBeenLastCalledWith('ALICE', 120, 95); + // After release, further moves emit nothing. + await firePointer(handle, 'pointerup', {}); + onthresholds.mockClear(); + await firePointer(handle, 'pointermove', { clientY: yForValue(80) }); + expect(onthresholds).not.toHaveBeenCalled(); + }); + + it('dragging the exit handle emits the tuned exit with the enter unchanged', async () => { + const onthresholds = vi.fn(); + render(RssiGraph, { + trace: signalTrace, + laps: lapList, + selected: null, + onselect: () => {}, + canControl: true, + onthresholds + }); + const svg = screen.getByLabelText(/RSSI trace for ALICE/); + pinSvgBox(svg); + + const handle = screen.getByRole('slider', { name: 'Exit threshold for ALICE' }); + await firePointer(handle, 'pointerdown', { clientY: yForValue(95) }); + await firePointer(handle, 'pointermove', { clientY: yForValue(85) }); + expect(onthresholds).toHaveBeenLastCalledWith('ALICE', 110, 85); + }); + + it('arrow keys nudge the focused handle ±1 (keyboard access)', async () => { + const onthresholds = vi.fn(); + render(RssiGraph, { + trace: signalTrace, + laps: lapList, + selected: null, + onselect: () => {}, + canControl: true, + onthresholds + }); + const enter = screen.getByRole('slider', { name: 'Enter threshold for ALICE' }); + await fireEvent.keyDown(enter, { key: 'ArrowUp' }); + expect(onthresholds).toHaveBeenLastCalledWith('ALICE', 111, 95); + await fireEvent.keyDown(enter, { key: 'ArrowDown' }); + expect(onthresholds).toHaveBeenLastCalledWith('ALICE', 109, 95); + const exit = screen.getByRole('slider', { name: 'Exit threshold for ALICE' }); + await fireEvent.keyDown(exit, { key: 'ArrowDown' }); + expect(onthresholds).toHaveBeenLastCalledWith('ALICE', 110, 94); + }); + + it('`tuned` overrides the drawn levels for the tuned competitor', () => { + render(RssiGraph, { + trace: signalTrace, + laps: lapList, + selected: null, + onselect: () => {}, + canControl: true, + onthresholds: () => {}, + tuned: { competitor: 'ALICE', enter: 120, exit: 80 } + }); + // The handles report the TUNED values, not the recorded 110/95. + const enter = screen.getByRole('slider', { name: 'Enter threshold for ALICE' }); + expect(enter).toHaveAttribute('aria-valuenow', '120'); + const exit = screen.getByRole('slider', { name: 'Exit threshold for ALICE' }); + expect(exit).toHaveAttribute('aria-valuenow', '80'); + // The caption meta reflects the live levels too. + const graph = screen.getByLabelText('RSSI signal graph'); + expect(within(graph).getByText(/enter 120/)).toBeInTheDocument(); + }); + + it('renders preview markers for added passes and restyles would-be-removed lap markers', () => { + render(RssiGraph, { + trace: signalTrace, + laps: lapList, + selected: null, + onselect: () => {}, + canControl: true, + preview: { competitor: 'ALICE', added: [30_000_000, 60_000_000], removedRefs: [14] } + }); + const svg = screen.getByLabelText(/RSSI trace for ALICE/); + // Two hollow/dashed candidate markers for the added passes. + expect(svg.querySelectorAll('.preview-added')).toHaveLength(2); + // ALICE's lap 2 (end_ref 14) is restyled as would-be-removed; lap 1 (end_ref 12) is not. + const markers = svg.querySelectorAll('.marker'); + expect(markers[1].classList.contains('removed')).toBe(true); + expect(markers[0].classList.contains('removed')).toBe(false); + // The legend explains the preview style. + const graph = screen.getByLabelText('RSSI signal graph'); + expect(within(graph).getByText(/Preview pass/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/apps/rd-console/tests/redetect.test.ts b/frontend/apps/rd-console/tests/redetect.test.ts new file mode 100644 index 0000000..c2af8e9 --- /dev/null +++ b/frontend/apps/rd-console/tests/redetect.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest'; +import type { CompetitorTrace, Lap } from '@gridfpv/types'; +import { + DEFAULT_MATCH_TOLERANCE_MICROS, + defaultThresholds, + detectPasses, + diffPasses, + officialPasses, + previewLaps +} from '../src/lib/redetect.js'; + +/** A uniform-grid trace: sample `i` at `i·period` (1s cadence by default). */ +function trace(samples: number[], opts?: Partial): CompetitorTrace { + return { + competitor: { adapter: 'rh-1', competitor: 'ALICE' }, + from: 0, + period_micros: 1_000_000, + samples, + ...opts + }; +} + +const S = 1_000_000; // one second in µs + +describe('detectPasses (RH-semantics hysteresis)', () => { + it('detects one pass per enter→exit crossing, timed at the window PEAK', () => { + // 0 1 2 3 4 5 6 7 8 + const t = trace([70, 120, 150, 90, 70, 100, 160, 80, 70]); + // enter=110/exit=95: window 1 opens at i=1, peaks at i=2 (150), closes at i=3 (90). + // window 2 opens at i=6 (160 ≥ 110 — 100 at i=5 does NOT open), closes at i=7 (80). + expect(detectPasses(t, 110, 95)).toEqual([2 * S, 6 * S]); + }); + + it('a noisy double-peak within ONE open window is ONE pass at the higher peak', () => { + // The signal dips between the two humps but never to/below exit — still one crossing. + // 0 1 2 3 4 5 6 + const t = trace([70, 120, 100, 140, 100, 80, 70]); + // Opens at i=1; the dip to 100 stays above exit=95; higher peak 140 at i=3; closes at i=5. + expect(detectPasses(t, 110, 95)).toEqual([3 * S]); + }); + + it('a tie between peaks keeps the FIRST peak sample time', () => { + const t = trace([70, 130, 100, 130, 80]); + expect(detectPasses(t, 110, 95)).toEqual([1 * S]); + }); + + it('a window still open at trace end emits NO pass (incomplete crossing)', () => { + const t = trace([70, 120, 150, 130]); // never falls back to/below exit + expect(detectPasses(t, 110, 95)).toEqual([]); + // …but a completed earlier crossing still counts. + const t2 = trace([70, 120, 80, 70, 125, 140]); + expect(detectPasses(t2, 110, 95)).toEqual([1 * S]); + }); + + it('equal or inverted thresholds detect nothing (enter must exceed exit)', () => { + const t = trace([70, 120, 150, 90, 70]); + expect(detectPasses(t, 100, 100)).toEqual([]); + expect(detectPasses(t, 95, 110)).toEqual([]); + }); + + it('boundary samples open/close the window inclusively (≥ enter, ≤ exit)', () => { + const t = trace([70, 110, 95, 70]); // opens exactly AT enter, closes exactly AT exit + expect(detectPasses(t, 110, 95)).toEqual([1 * S]); + }); + + it('uses the dense per-sample `times` when present (bursty marshal history)', () => { + const t = trace([70, 120, 150, 90, 70], { + // Non-uniform: the peak sample really sits at 12.34s, far off the 1s grid. + times: [0, 12_000_000, 12_340_000, 12_900_000, 30_000_000] + }); + expect(detectPasses(t, 110, 95)).toEqual([12_340_000]); + }); + + it('falls back to the uniform from + i·period grid without `times`', () => { + const t = trace([70, 120, 80], { from: 5_000_000, period_micros: 500_000 }); + expect(detectPasses(t, 110, 95)).toEqual([5_500_000]); + }); + + it('an empty trace detects nothing', () => { + expect(detectPasses(trace([]), 110, 95)).toEqual([]); + }); +}); + +describe('diffPasses (greedy nearest-match)', () => { + const current = [ + { at: 1 * S, ref: 10 }, + { at: 41 * S, ref: 12 }, + { at: 81 * S, ref: 14 } + ]; + + it('an identical re-detection keeps everything (empty diff)', () => { + const d = diffPasses(current, [1 * S, 41 * S, 81 * S]); + expect(d.added).toEqual([]); + expect(d.removed).toEqual([]); + expect(d.kept.map((k) => k.ref)).toEqual([10, 12, 14]); + }); + + it('matches within tolerance INCLUSIVE; just-over-tolerance is an add + a remove', () => { + // 41.5s is exactly 500ms from the official 41.0s pass — still the same pass. + const atTol = diffPasses(current, [1 * S, 41 * S + DEFAULT_MATCH_TOLERANCE_MICROS, 81 * S]); + expect(atTol.added).toEqual([]); + expect(atTol.removed).toEqual([]); + expect(atTol.kept.find((k) => k.ref === 12)?.detectedAt).toBe(41_500_000); + + // One µs beyond tolerance: no longer the same pass — the official one is removed, the + // detected one added. + const over = diffPasses(current, [1 * S, 41 * S + DEFAULT_MATCH_TOLERANCE_MICROS + 1, 81 * S]); + expect(over.added).toEqual([41 * S + DEFAULT_MATCH_TOLERANCE_MICROS + 1]); + expect(over.removed).toEqual([{ at: 41 * S, ref: 12 }]); + }); + + it('each official pass matches at most ONE detected pass (nearest wins)', () => { + // Two detected passes both within tolerance of the 41s official pass: the nearer one keeps + // it; the other is a genuine add. + const d = diffPasses([{ at: 41 * S, ref: 12 }], [41 * S - 100_000, 41 * S + 300_000]); + expect(d.kept).toEqual([{ at: 41 * S, ref: 12, detectedAt: 41 * S - 100_000 }]); + expect(d.added).toEqual([41 * S + 300_000]); + expect(d.removed).toEqual([]); + }); + + it('officially-present passes the re-detection no longer sees are removed', () => { + const d = diffPasses(current, [1 * S]); + expect(d.removed).toEqual([ + { at: 41 * S, ref: 12 }, + { at: 81 * S, ref: 14 } + ]); + expect(d.added).toEqual([]); + }); + + it('a custom tolerance is honored', () => { + const d = diffPasses([{ at: 10 * S, ref: 5 }], [10 * S + 900_000], 1_000_000); + expect(d.kept).toHaveLength(1); + expect(d.added).toEqual([]); + }); +}); + +describe('previewLaps (consecutive-pass lap derivation)', () => { + it('the first pass is the holeshot: k passes → k−1 laps', () => { + expect(previewLaps([1 * S, 41 * S, 81 * S])).toEqual([ + { number: 1, at: 41 * S, durationMicros: 40 * S }, + { number: 2, at: 81 * S, durationMicros: 40 * S } + ]); + }); + + it('zero or one pass implies no laps', () => { + expect(previewLaps([])).toEqual([]); + expect(previewLaps([5 * S])).toEqual([]); + }); +}); + +describe('officialPasses (lap list → boundary passes)', () => { + it('reconstructs lap 1’s opening pass plus every closing pass', () => { + const laps: Lap[] = [ + { number: 1, duration_micros: 40 * S, at: 41 * S, start_ref: 10, end_ref: 12 }, + { number: 2, duration_micros: 40 * S, at: 81 * S, start_ref: 12, end_ref: 14 } + ]; + expect(officialPasses(laps)).toEqual([ + { at: 1 * S, ref: 10 }, // the holeshot: lap 1's at − duration + { at: 41 * S, ref: 12 }, + { at: 81 * S, ref: 14 } + ]); + }); + + it('no laps → no passes', () => { + expect(officialPasses([])).toEqual([]); + }); +}); + +describe('defaultThresholds (unset-trace fallback)', () => { + it('derives enter at the 75th and exit at the 25th sample percentile', () => { + const t = trace([10, 20, 30, 40, 50, 60, 70, 80]); + expect(defaultThresholds(t)).toEqual({ enter: 70, exit: 30 }); + }); + + it('is undefined for an empty trace', () => { + expect(defaultThresholds(trace([]))).toBeUndefined(); + }); +});