Skip to main content

rustriff_lib/services/round_trip_latency_session/
measurement_state.rs

1//! State machine types and per-sample driver for the round-trip measurement protocol.
2//!
3//! [`RoundTripMeasurementState`] owns all transient data for one measurement session
4//! and is advanced sample-by-sample via [`RoundTripMeasurementState::tick`].
5
6use crate::services::round_trip_latency_session::constants::{
7    CALIBRATION_SAMPLES, GUARD_SAMPLES, IMPULSE_AMPLITUDE, IMPULSE_COUNT, INTER_IMPULSE_GAP,
8};
9use std::time::{Duration, Instant};
10
11/// Result of processing a single input sample through [`RoundTripMeasurementState::tick`].
12///
13/// The session loop calls `tick` for every sample that arrives on the input ring buffer and
14/// acts on the returned outcome to decide whether to continue, finish, or abort.
15pub enum RoundTripTickOutcome {
16    /// The measurement is still in progress; more input samples are required.
17    Ongoing,
18    /// All [`IMPULSE_COUNT`] echoes were detected successfully.
19    ///
20    /// Contains the arithmetic mean of the individual round-trip durations in milliseconds.
21    Complete(f64),
22    /// The current impulse timed out before an echo was detected.
23    ///
24    /// This usually means the output is not physically routed back to the input, or the
25    /// signal level is too low to cross the derived threshold.
26    TimedOut,
27}
28
29/// High-level phases of the [`RoundTripMeasurementState`] state machine.
30///
31/// The machine progresses linearly through these phases on each measurement session:
32///
33/// ```text
34/// CalibrationAmbient  →  WaitingForEcho(0)  →  WaitingForEcho(1)  →  …  →  Idle
35/// ```
36///
37/// It never transitions backwards.  Once `Idle` is reached the session loop exits.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum RoundTripMeasurementPhase {
40    /// The measurement has concluded (either successfully or via timeout).
41    ///
42    /// The session loop writes silence to the output and exits on the next iteration.
43    Idle,
44    /// The state machine is consuming ambient input samples to estimate the noise floor.
45    ///
46    /// No impulses are emitted during this phase.  Transitions to
47    /// `WaitingForEcho(0)` once [`CALIBRATION_SAMPLES`] have been processed.
48    CalibrationAmbient,
49    /// An impulse has been (or is about to be) emitted and the state machine is listening
50    /// for its return on the input.
51    ///
52    /// The `usize` payload is the zero-based index of the current impulse (0 …
53    /// [`IMPULSE_COUNT`] − 1).  On a successful echo detection the index advances and
54    /// the machine stays in this variant; after the last echo it transitions to `Idle`.
55    WaitingForEcho(usize),
56}
57
58/// All mutable state required to run one complete round-trip measurement session.
59///
60/// `RoundTripMeasurementState` is entirely owned by the session thread — there is no
61/// shared ownership, no `Arc`, and no locking.  It is driven sample-by-sample through
62/// [`tick`] until a terminal outcome is reached.
63///
64/// [`tick`]: RoundTripMeasurementState::tick
65pub struct RoundTripMeasurementState {
66    /// Current phase in the calibration/measurement lifecycle.
67    pub phase: RoundTripMeasurementPhase,
68    /// Derived amplitude threshold an incoming sample must exceed to be accepted as an echo.
69    ///
70    /// Set at the end of [`CalibrationAmbient`] and held constant for the rest of the session.
71    ///
72    /// [`CalibrationAmbient`]: RoundTripMeasurementPhase::CalibrationAmbient
73    pub threshold: f32,
74    /// Wall-clock time at which the currently active impulse was written to the output buffer.
75    ///
76    /// `None` before the first impulse is emitted and between echo detection and the next
77    /// impulse emission.  The elapsed time from this instant to echo detection is the raw
78    /// round-trip duration.
79    pub impulse_sent_at: Option<Instant>,
80    /// Peak absolute amplitude observed across all calibration samples.
81    ambient_peak: f32,
82    /// Running count of calibration samples consumed so far.
83    ambient_count: usize,
84    /// Remaining samples in the post-impulse guard window.
85    ///
86    /// Decremented on every call to [`check_echo`] while non-zero.  Echo detection is
87    /// suppressed until this reaches zero.
88    ///
89    /// [`check_echo`]: RoundTripMeasurementState::check_echo
90    guard_remaining: usize,
91    /// Measured round-trip duration for each successfully detected echo, in milliseconds.
92    ///
93    /// Grows by one entry per successful impulse/echo pair.  The final result is the
94    /// mean of all entries.
95    echo_durations_ms: Vec<f64>,
96    /// Deadline by which the current impulse must produce an echo before timing out.
97    impulse_deadline: Option<Instant>,
98    /// Earliest wall-clock time at which the next impulse may be emitted.
99    ///
100    /// Enforces the [`INTER_IMPULSE_GAP`] quiet period between consecutive impulses.
101    next_impulse_not_before: Option<Instant>,
102}
103
104impl RoundTripMeasurementState {
105    /// Creates a fresh measurement state, starting in [`CalibrationAmbient`] phase.
106    ///
107    /// All counters are zeroed and no impulse timer is active.  The first call to [`tick`]
108    /// will begin consuming ambient samples.
109    ///
110    /// [`CalibrationAmbient`]: RoundTripMeasurementPhase::CalibrationAmbient
111    /// [`tick`]: RoundTripMeasurementState::tick
112    pub fn new() -> Self {
113        Self {
114            phase: RoundTripMeasurementPhase::CalibrationAmbient,
115            threshold: 0.0,
116            impulse_sent_at: None,
117            ambient_peak: 0.0,
118            ambient_count: 0,
119            guard_remaining: 0,
120            echo_durations_ms: Vec::with_capacity(IMPULSE_COUNT),
121            impulse_deadline: None,
122            next_impulse_not_before: None,
123        }
124    }
125
126    /// Ingests one ambient sample and returns `true` when calibration is complete.
127    ///
128    /// Tracks the running peak absolute amplitude.  Once [`CALIBRATION_SAMPLES`] have been
129    /// consumed the detection threshold is finalised as:
130    ///
131    /// ```text
132    /// threshold = clamp(ambient_peak × 2, 0.05, IMPULSE_AMPLITUDE × 0.5)
133    /// ```
134    ///
135    /// The lower bound (`0.05`) ensures a minimum detectable signal even in a perfectly
136    /// silent environment.  The upper bound (`IMPULSE_AMPLITUDE × 0.5`) guarantees the
137    /// threshold can never exceed half the impulse amplitude, keeping detection reachable
138    /// even on a lossy signal path.
139    fn feed_ambient_sample(&mut self, sample: f32) -> bool {
140        self.ambient_peak = self.ambient_peak.max(sample.abs());
141        self.ambient_count += 1;
142
143        if self.ambient_count < CALIBRATION_SAMPLES {
144            return false;
145        }
146
147        self.threshold = (self.ambient_peak * 2.0).clamp(0.05, IMPULSE_AMPLITUDE * 0.5);
148
149        println!(
150            "[RT-MEASURE] Calibration done. Peak: {:.4}, threshold: {:.4}",
151            self.ambient_peak, self.threshold
152        );
153        true
154    }
155
156    /// Arms the impulse timer and guard window for a newly emitted impulse.
157    ///
158    /// Records the current wall-clock time as [`impulse_sent_at`], sets the
159    /// [`impulse_deadline`] to `now + per_impulse_timeout`, and resets
160    /// [`guard_remaining`] to [`GUARD_SAMPLES`].
161    ///
162    /// [`impulse_sent_at`]: RoundTripMeasurementState::impulse_sent_at
163    /// [`impulse_deadline`]: RoundTripMeasurementState::impulse_deadline
164    /// [`guard_remaining`]: RoundTripMeasurementState::guard_remaining
165    fn arm_impulse(&mut self, per_impulse_timeout: Duration) {
166        let now = Instant::now();
167        self.impulse_sent_at = Some(now);
168        self.impulse_deadline = Some(now + per_impulse_timeout);
169        self.guard_remaining = GUARD_SAMPLES;
170    }
171
172    /// Returns `true` if `sample` exceeds the detection threshold and the guard window has elapsed.
173    ///
174    /// While [`guard_remaining`] is non-zero the function always returns `false` and
175    /// decrements the counter, enforcing the post-impulse blind period.  Once the guard
176    /// expires, any sample whose absolute value is ≥ [`threshold`] is accepted as an echo.
177    ///
178    /// [`guard_remaining`]: RoundTripMeasurementState::guard_remaining
179    /// [`threshold`]: RoundTripMeasurementState::threshold
180    fn check_echo(&mut self, sample: f32) -> bool {
181        if self.guard_remaining > 0 {
182            self.guard_remaining -= 1;
183            return false;
184        }
185        sample.abs() >= self.threshold
186    }
187
188    /// Returns `true` if the current impulse deadline has passed without an echo.
189    fn is_timed_out(&self) -> bool {
190        self.impulse_deadline
191            .map(|deadline| Instant::now() >= deadline)
192            .unwrap_or(false)
193    }
194
195    /// Advances the measurement state machine by one input sample.
196    ///
197    /// This is the core driver of the entire measurement protocol.  The caller must
198    /// invoke it for every sample that arrives from the input ring buffer.
199    ///
200    /// # Arguments
201    ///
202    /// * `sample` — The raw `f32` sample captured from the audio input.
203    /// * `push_output` — A closure that writes one `f32` value to the output ring buffer
204    ///   and returns `true` if the push succeeded.  The state machine uses this to emit
205    ///   either silence (`0.0`) or the test impulse ([`IMPULSE_AMPLITUDE`]).
206    /// * `per_impulse_timeout` — How long to wait for an echo after each impulse before
207    ///   declaring a timeout.
208    ///
209    /// # State machine transitions
210    ///
211    /// | Current phase | Event | Next phase |
212    /// |---|---|---|
213    /// | `CalibrationAmbient` | `CALIBRATION_SAMPLES` consumed | `WaitingForEcho(0)` |
214    /// | `WaitingForEcho(n)` | Impulse push succeeds | (same, timer armed) |
215    /// | `WaitingForEcho(n)` | Echo detected, more impulses remain | `WaitingForEcho(n+1)` |
216    /// | `WaitingForEcho(n)` | Echo detected, all impulses done | `Idle` → `Complete` |
217    /// | `WaitingForEcho(n)` | Deadline exceeded | `Idle` → `TimedOut` |
218    ///
219    /// # Returns
220    ///
221    /// A [`RoundTripTickOutcome`] indicating whether the session should continue, has
222    /// finished successfully, or has timed out.
223    pub fn tick(
224        &mut self,
225        sample: f32,
226        push_output: &mut impl FnMut(f32) -> bool,
227        per_impulse_timeout: Duration,
228    ) -> RoundTripTickOutcome {
229        match self.phase {
230            RoundTripMeasurementPhase::CalibrationAmbient => {
231                if self.feed_ambient_sample(sample) {
232                    self.phase = RoundTripMeasurementPhase::WaitingForEcho(0);
233                }
234                push_output(0.0);
235                RoundTripTickOutcome::Ongoing
236            }
237            RoundTripMeasurementPhase::WaitingForEcho(idx) => {
238                if self.impulse_sent_at.is_none() {
239                    if self
240                        .next_impulse_not_before
241                        .map(|t| Instant::now() < t)
242                        .unwrap_or(false)
243                    {
244                        push_output(0.0);
245                        return RoundTripTickOutcome::Ongoing;
246                    }
247
248                    if push_output(IMPULSE_AMPLITUDE) {
249                        self.arm_impulse(per_impulse_timeout);
250                        println!(
251                            "[RT-MEASURE] Impulse {}/{} injected (threshold={:.4}).",
252                            idx + 1,
253                            IMPULSE_COUNT,
254                            self.threshold
255                        );
256                    }
257
258                    RoundTripTickOutcome::Ongoing
259                } else {
260                    push_output(0.0);
261
262                    if self.check_echo(sample) {
263                        let elapsed_ms =
264                            self.impulse_sent_at.take().unwrap().elapsed().as_secs_f64() * 1000.0;
265                        self.impulse_deadline = None;
266                        self.echo_durations_ms.push(elapsed_ms);
267
268                        println!(
269                            "[RT-MEASURE] Echo {}/{}: {:.2} ms",
270                            idx + 1,
271                            IMPULSE_COUNT,
272                            elapsed_ms
273                        );
274
275                        if self.echo_durations_ms.len() >= IMPULSE_COUNT {
276                            let avg = self.echo_durations_ms.iter().sum::<f64>()
277                                / self.echo_durations_ms.len() as f64;
278                            println!("[RT-MEASURE] Done. Avg round-trip: {:.2} ms", avg);
279                            self.phase = RoundTripMeasurementPhase::Idle;
280                            RoundTripTickOutcome::Complete(avg)
281                        } else {
282                            self.next_impulse_not_before = Some(Instant::now() + INTER_IMPULSE_GAP);
283                            self.phase = RoundTripMeasurementPhase::WaitingForEcho(idx + 1);
284                            RoundTripTickOutcome::Ongoing
285                        }
286                    } else if self.is_timed_out() {
287                        println!(
288                            "[RT-MEASURE] TIMEOUT waiting for echo {} (threshold={:.4}).",
289                            idx + 1,
290                            self.threshold
291                        );
292                        self.phase = RoundTripMeasurementPhase::Idle;
293                        RoundTripTickOutcome::TimedOut
294                    } else {
295                        RoundTripTickOutcome::Ongoing
296                    }
297                }
298            }
299            RoundTripMeasurementPhase::Idle => {
300                push_output(0.0);
301                RoundTripTickOutcome::Ongoing
302            }
303        }
304    }
305}
306
307impl Default for RoundTripMeasurementState {
308    fn default() -> Self {
309        Self::new()
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    // -----------------------------------------------------------------------
318    // Helpers
319    // -----------------------------------------------------------------------
320
321    fn complete_calibration(state: &mut RoundTripMeasurementState) {
322        for _ in 0..CALIBRATION_SAMPLES {
323            state.tick(0.0, &mut |_| true, Duration::from_secs(10));
324        }
325    }
326
327    fn complete_calibration_with_peak(state: &mut RoundTripMeasurementState, peak: f32) {
328        state.tick(peak, &mut |_| true, Duration::from_secs(10));
329        for _ in 1..CALIBRATION_SAMPLES {
330            state.tick(0.0, &mut |_| true, Duration::from_secs(10));
331        }
332    }
333
334    fn emit_impulse(state: &mut RoundTripMeasurementState, timeout: Duration) {
335        state.tick(0.0, &mut |_| true, timeout);
336    }
337
338    fn drain_guard(state: &mut RoundTripMeasurementState) {
339        for _ in 0..GUARD_SAMPLES {
340            state.tick(0.0, &mut |_| true, Duration::from_secs(10));
341        }
342    }
343
344    fn drive_to_idle_via_timeout(state: &mut RoundTripMeasurementState) {
345        complete_calibration(state);
346        emit_impulse(state, Duration::ZERO);
347        for _ in 0..(GUARD_SAMPLES + 2) {
348            if matches!(
349                state.tick(0.0, &mut |_| true, Duration::ZERO),
350                RoundTripTickOutcome::TimedOut
351            ) {
352                break;
353            }
354        }
355    }
356
357    // -----------------------------------------------------------------------
358    // Initial state
359    // -----------------------------------------------------------------------
360
361    #[cfg(test)]
362    mod initial_state {
363        use super::*;
364
365        #[test]
366        fn new_starts_in_calibration_phase() {
367            let state = RoundTripMeasurementState::new();
368            assert_eq!(state.phase, RoundTripMeasurementPhase::CalibrationAmbient);
369        }
370
371        #[test]
372        fn new_has_zero_threshold() {
373            let state = RoundTripMeasurementState::new();
374            assert_eq!(state.threshold, 0.0);
375        }
376
377        #[test]
378        fn new_has_no_impulse_timer() {
379            let state = RoundTripMeasurementState::new();
380            assert!(state.impulse_sent_at.is_none());
381        }
382    }
383
384    // -----------------------------------------------------------------------
385    // Calibration phase
386    // -----------------------------------------------------------------------
387
388    #[cfg(test)]
389    mod calibration_phase {
390        use super::*;
391
392        #[test]
393        fn stays_in_calibration_until_enough_samples() {
394            let mut state = RoundTripMeasurementState::new();
395            for _ in 0..(CALIBRATION_SAMPLES - 1) {
396                let outcome = state.tick(0.0, &mut |_| true, Duration::from_secs(10));
397                assert!(matches!(outcome, RoundTripTickOutcome::Ongoing));
398                assert_eq!(state.phase, RoundTripMeasurementPhase::CalibrationAmbient);
399            }
400        }
401
402        #[test]
403        fn transitions_to_waiting_for_echo_after_calibration_samples() {
404            let mut state = RoundTripMeasurementState::new();
405            complete_calibration(&mut state);
406            assert_eq!(state.phase, RoundTripMeasurementPhase::WaitingForEcho(0));
407        }
408
409        #[test]
410        fn outputs_silence_during_calibration() {
411            let mut state = RoundTripMeasurementState::new();
412            let mut emitted = Vec::new();
413            for _ in 0..CALIBRATION_SAMPLES {
414                state.tick(
415                    0.0,
416                    &mut |v| {
417                        emitted.push(v);
418                        true
419                    },
420                    Duration::from_secs(10),
421                );
422            }
423            assert!(
424                emitted.iter().all(|&v| v == 0.0),
425                "calibration must output only silence"
426            );
427        }
428
429        #[test]
430        fn threshold_is_double_the_ambient_peak_clamped_to_min() {
431            let mut state = RoundTripMeasurementState::new();
432            complete_calibration(&mut state);
433            assert!((state.threshold - 0.05).abs() < 1e-6);
434        }
435
436        #[test]
437        fn threshold_is_double_ambient_peak_when_above_minimum() {
438            let mut state = RoundTripMeasurementState::new();
439            let peak = 0.1_f32;
440            complete_calibration_with_peak(&mut state, peak);
441            let expected = (peak * 2.0).clamp(0.05, IMPULSE_AMPLITUDE * 0.5);
442            assert!((state.threshold - expected).abs() < 1e-6);
443        }
444
445        #[test]
446        fn threshold_is_clamped_to_max_when_ambient_peak_is_very_high() {
447            let mut state = RoundTripMeasurementState::new();
448            complete_calibration_with_peak(&mut state, IMPULSE_AMPLITUDE);
449            assert!((state.threshold - IMPULSE_AMPLITUDE * 0.5).abs() < 1e-6);
450        }
451    }
452
453    // -----------------------------------------------------------------------
454    // WaitingForEcho — impulse emission
455    // -----------------------------------------------------------------------
456
457    #[cfg(test)]
458    mod impulse_emission {
459        use super::*;
460
461        #[test]
462        fn first_tick_after_calibration_emits_impulse() {
463            let mut state = RoundTripMeasurementState::new();
464            complete_calibration(&mut state);
465
466            let mut emitted_values: Vec<f32> = Vec::new();
467            state.tick(
468                0.0,
469                &mut |v| {
470                    emitted_values.push(v);
471                    true
472                },
473                Duration::from_secs(10),
474            );
475
476            assert!(
477                emitted_values.contains(&IMPULSE_AMPLITUDE),
478                "first post-calibration tick must emit the impulse"
479            );
480        }
481
482        #[test]
483        fn impulse_timer_is_armed_after_emission() {
484            let mut state = RoundTripMeasurementState::new();
485            complete_calibration(&mut state);
486            emit_impulse(&mut state, Duration::from_secs(10));
487            assert!(state.impulse_sent_at.is_some());
488        }
489
490        #[test]
491        fn output_is_silence_while_waiting_for_echo() {
492            let mut state = RoundTripMeasurementState::new();
493            complete_calibration(&mut state);
494            emit_impulse(&mut state, Duration::from_secs(10));
495
496            let mut emitted: Vec<f32> = Vec::new();
497            state.tick(
498                0.0,
499                &mut |v| {
500                    emitted.push(v);
501                    true
502                },
503                Duration::from_secs(10),
504            );
505            assert!(emitted.iter().all(|&v| v == 0.0));
506        }
507    }
508
509    // -----------------------------------------------------------------------
510    // WaitingForEcho — echo detection
511    // -----------------------------------------------------------------------
512
513    #[cfg(test)]
514    mod echo_detection {
515        use super::*;
516
517        #[test]
518        fn echo_is_not_detected_during_guard_window() {
519            let mut state = RoundTripMeasurementState::new();
520            complete_calibration(&mut state);
521            emit_impulse(&mut state, Duration::from_secs(10));
522
523            let outcome = state.tick(1.0, &mut |_| true, Duration::from_secs(10));
524            assert!(matches!(outcome, RoundTripTickOutcome::Ongoing));
525            assert_eq!(state.phase, RoundTripMeasurementPhase::WaitingForEcho(0));
526        }
527
528        #[test]
529        fn echo_above_threshold_detected_after_guard_window() {
530            let mut state = RoundTripMeasurementState::new();
531            complete_calibration(&mut state);
532            emit_impulse(&mut state, Duration::from_secs(10));
533            drain_guard(&mut state);
534
535            let outcome = state.tick(1.0, &mut |_| true, Duration::from_secs(10));
536            assert!(!matches!(outcome, RoundTripTickOutcome::TimedOut));
537        }
538
539        #[test]
540        fn sample_below_threshold_is_not_accepted_as_echo() {
541            let mut state = RoundTripMeasurementState::new();
542            complete_calibration(&mut state);
543            emit_impulse(&mut state, Duration::from_secs(10));
544            drain_guard(&mut state);
545
546            let outcome = state.tick(0.01, &mut |_| true, Duration::from_secs(10));
547            assert!(matches!(outcome, RoundTripTickOutcome::Ongoing));
548        }
549
550        #[test]
551        fn completing_all_impulses_returns_complete_with_positive_average() {
552            let mut state = RoundTripMeasurementState::new();
553            let test_deadline = Instant::now() + Duration::from_secs(5);
554
555            loop {
556                assert!(
557                    Instant::now() < test_deadline,
558                    "test timed out — Complete outcome was never reached"
559                );
560
561                // Feed a strong signal whenever we are listening for an echo so it is
562                // detected as soon as the guard window expires.  Inter-impulse gaps
563                // (200 ms) pass naturally while we spin.
564                let input = match state.phase {
565                    RoundTripMeasurementPhase::WaitingForEcho(_)
566                        if state.impulse_sent_at.is_some() =>
567                    {
568                        1.0
569                    }
570                    _ => 0.0,
571                };
572
573                match state.tick(input, &mut |_| true, Duration::from_secs(10)) {
574                    RoundTripTickOutcome::Complete(avg) => {
575                        assert!(avg >= 0.0, "average round-trip must be non-negative");
576                        return;
577                    }
578                    RoundTripTickOutcome::TimedOut => panic!("unexpected timeout during test"),
579                    RoundTripTickOutcome::Ongoing => {}
580                }
581            }
582        }
583    }
584
585    // -----------------------------------------------------------------------
586    // WaitingForEcho — timeout
587    // -----------------------------------------------------------------------
588
589    #[cfg(test)]
590    mod timeout {
591        use super::*;
592
593        #[test]
594        fn zero_timeout_causes_timed_out_outcome_after_impulse() {
595            let mut state = RoundTripMeasurementState::new();
596            complete_calibration(&mut state);
597            emit_impulse(&mut state, Duration::ZERO);
598
599            let mut outcome = RoundTripTickOutcome::Ongoing;
600            for _ in 0..(GUARD_SAMPLES + 2) {
601                outcome = state.tick(0.0, &mut |_| true, Duration::ZERO);
602                if matches!(outcome, RoundTripTickOutcome::TimedOut) {
603                    break;
604                }
605            }
606            assert!(
607                matches!(outcome, RoundTripTickOutcome::TimedOut),
608                "expected TimedOut but measurement kept running"
609            );
610        }
611
612        #[test]
613        fn timed_out_transitions_to_idle() {
614            let mut state = RoundTripMeasurementState::new();
615            complete_calibration(&mut state);
616            emit_impulse(&mut state, Duration::ZERO);
617
618            for _ in 0..(GUARD_SAMPLES + 2) {
619                if matches!(
620                    state.tick(0.0, &mut |_| true, Duration::ZERO),
621                    RoundTripTickOutcome::TimedOut
622                ) {
623                    break;
624                }
625            }
626            assert_eq!(state.phase, RoundTripMeasurementPhase::Idle);
627        }
628    }
629
630    // -----------------------------------------------------------------------
631    // Idle phase
632    // -----------------------------------------------------------------------
633
634    #[cfg(test)]
635    mod idle_phase {
636        use super::*;
637
638        #[test]
639        fn idle_phase_outputs_silence() {
640            let mut state = RoundTripMeasurementState::new();
641            drive_to_idle_via_timeout(&mut state);
642
643            let mut emitted: Vec<f32> = Vec::new();
644            state.tick(
645                0.0,
646                &mut |v| {
647                    emitted.push(v);
648                    true
649                },
650                Duration::from_secs(10),
651            );
652            assert!(emitted.iter().all(|&v| v == 0.0));
653        }
654
655        #[test]
656        fn idle_phase_returns_ongoing() {
657            let mut state = RoundTripMeasurementState::new();
658            drive_to_idle_via_timeout(&mut state);
659            let outcome = state.tick(0.0, &mut |_| true, Duration::from_secs(10));
660            assert!(matches!(outcome, RoundTripTickOutcome::Ongoing));
661        }
662    }
663}