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}