Skip to main content

netspeed_cli/
phases.rs

1//! Phase definitions for the speed test lifecycle.
2//!
3//! ## Design
4//!
5//! - [`PhaseContext`] — shared state with private fields (ISP: clients use accessors)
6//! - [`PhaseOutcome`] — result of phase execution  
7//! - Each phase is an async function that takes (orch, ctx)
8//! - [`PhaseExecutor`] — runs phases in sequence
9
10use crate::error::Error;
11use crate::services::Services;
12use crate::theme::Colors;
13use futures::future::BoxFuture;
14use std::sync::Arc;
15
16use crate::orchestrator::Orchestrator;
17use crate::task_runner::TestRunResult;
18use crate::types::Server;
19
20/// Named result from a ping/latency test — replaces the positional tuple.
21#[derive(Debug, Clone)]
22pub struct PingResult {
23    pub latency_ms: f64,
24    pub jitter_ms: f64,
25    pub packet_loss_pct: f64,
26    pub samples: Vec<f64>,
27}
28
29/// Context passed between phases — holds all data accumulated during execution.
30pub struct PhaseContext {
31    client_location: Option<crate::types::ClientLocation>,
32    client_ip: Option<String>,
33    server: Option<Server>,
34    ping_result: Option<PingResult>,
35    download_result: Option<TestRunResult>,
36    upload_result: Option<TestRunResult>,
37    list_printed: bool,
38    elapsed: Option<std::time::Duration>,
39    services: std::sync::Arc<dyn Services>,
40}
41
42impl PhaseContext {
43    /// Create a new context with the given services.
44    pub fn new(services: std::sync::Arc<dyn Services>) -> Self {
45        Self {
46            client_location: None,
47            client_ip: None,
48            server: None,
49            ping_result: None,
50            download_result: None,
51            upload_result: None,
52            list_printed: false,
53            elapsed: None,
54            services,
55        }
56    }
57
58    // === New setter/taker methods for encapsulation ===
59
60    /// Take the server (removes from context).
61    pub fn take_server(&mut self) -> Option<Server> {
62        self.server.take()
63    }
64
65    /// Set the server.
66    pub fn set_server(&mut self, server: Server) {
67        self.server = Some(server);
68    }
69
70    /// Set client IP.
71    pub fn set_client_ip(&mut self, ip: String) {
72        self.client_ip = Some(ip);
73    }
74
75    /// Set client location.
76    pub fn set_client_location(&mut self, location: Option<crate::types::ClientLocation>) {
77        self.client_location = location;
78    }
79
80    /// Set ping result.
81    pub fn set_ping_result(&mut self, result: PingResult) {
82        self.ping_result = Some(result);
83    }
84
85    /// Take ping result.
86    pub fn take_ping_result(&mut self) -> Option<PingResult> {
87        self.ping_result.take()
88    }
89
90    /// Set download result.
91    pub fn set_download_result(&mut self, result: TestRunResult) {
92        self.download_result = Some(result);
93    }
94
95    /// Take download result.
96    pub fn take_download_result(&mut self) -> Option<TestRunResult> {
97        self.download_result.take()
98    }
99
100    /// Set upload result.
101    pub fn set_upload_result(&mut self, result: TestRunResult) {
102        self.upload_result = Some(result);
103    }
104
105    /// Take upload result.
106    pub fn take_upload_result(&mut self) -> Option<TestRunResult> {
107        self.upload_result.take()
108    }
109
110    /// Mark list as printed.
111    pub fn set_list_printed(&mut self) {
112        self.list_printed = true;
113    }
114}
115
116impl std::fmt::Debug for PhaseContext {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        f.debug_struct("PhaseContext")
119            .field("client_location", &self.client_location)
120            .field("client_ip", &self.client_ip)
121            .field("server", &self.server)
122            .field("ping_result", &self.ping_result)
123            .field("download_result", &self.download_result)
124            .field("upload_result", &self.upload_result)
125            .field("list_printed", &self.list_printed)
126            .field("elapsed", &self.elapsed)
127            .field("services", &"dyn Services")
128            .finish()
129    }
130}
131
132/// Phase outcome.
133#[derive(Debug)]
134pub enum PhaseOutcome {
135    PhaseCompleted,
136    PhaseEarlyExit,
137    PhaseError(Error),
138}
139
140/// Async phase function signature.
141pub type PhaseFn =
142    for<'a> fn(&'a Orchestrator, &'a mut PhaseContext) -> BoxFuture<'a, PhaseOutcome>;
143
144impl Default for PhaseExecutor {
145    fn default() -> Self {
146        Self::new()
147    }
148}
149
150pub struct PhaseExecutor {
151    phases: Vec<PhaseFn>,
152}
153
154impl PhaseExecutor {
155    pub fn new() -> Self {
156        Self { phases: Vec::new() }
157    }
158
159    pub fn register(mut self, phase: PhaseFn) -> Self {
160        self.phases.push(phase);
161        self
162    }
163
164    pub async fn execute_all(&self, orch: &Orchestrator) -> Result<(), Error> {
165        let mut ctx = PhaseContext::new(orch.services_arc());
166        for phase in &self.phases {
167            let outcome = phase(orch, &mut ctx).await;
168            match outcome {
169                PhaseOutcome::PhaseCompleted => {}
170                PhaseOutcome::PhaseEarlyExit => return Ok(()),
171                PhaseOutcome::PhaseError(e) => return Err(e),
172            }
173        }
174        Ok(())
175    }
176}
177
178pub type PhaseResults = (
179    Option<PingResult>,
180    Option<TestRunResult>,
181    Option<TestRunResult>,
182);
183
184/// PhaseContext accessor methods.
185impl PhaseContext {
186    pub fn client_location(&self) -> Option<&crate::types::ClientLocation> {
187        self.client_location.as_ref()
188    }
189
190    pub fn client_ip(&self) -> Option<&str> {
191        self.client_ip.as_deref()
192    }
193
194    pub fn server(&self) -> Option<&Server> {
195        self.server.as_ref()
196    }
197
198    pub fn ping_result(&self) -> Option<&PingResult> {
199        self.ping_result.as_ref()
200    }
201
202    pub fn download_result(&self) -> Option<&TestRunResult> {
203        self.download_result.as_ref()
204    }
205
206    pub fn upload_result(&self) -> Option<&TestRunResult> {
207        self.upload_result.as_ref()
208    }
209
210    pub fn is_list_printed(&self) -> bool {
211        self.list_printed
212    }
213
214    pub fn elapsed(&self) -> Option<std::time::Duration> {
215        self.elapsed
216    }
217
218    pub fn services(&self) -> &dyn Services {
219        self.services.as_ref()
220    }
221
222    pub fn services_arc(&self) -> std::sync::Arc<dyn Services> {
223        self.services.clone()
224    }
225
226    pub fn with_client_ip(mut self, ip: impl Into<String>) -> Self {
227        self.client_ip = Some(ip.into());
228        self
229    }
230
231    pub fn with_client_location(mut self, location: crate::types::ClientLocation) -> Self {
232        self.client_location = Some(location);
233        self
234    }
235
236    pub fn with_server(mut self, server: Server) -> Self {
237        self.server = Some(server);
238        self
239    }
240
241    pub fn with_ping_result(mut self, ping: PingResult) -> Self {
242        self.ping_result = Some(ping);
243        self
244    }
245
246    pub fn with_download_result(mut self, result: TestRunResult) -> Self {
247        self.download_result = Some(result);
248        self
249    }
250
251    pub fn with_upload_result(mut self, result: TestRunResult) -> Self {
252        self.upload_result = Some(result);
253        self
254    }
255
256    pub fn mark_list_printed(&mut self) {
257        self.list_printed = true;
258    }
259
260    pub fn set_elapsed(&mut self, elapsed: std::time::Duration) {
261        self.elapsed = Some(elapsed);
262    }
263
264    pub fn take_results(&mut self) -> PhaseResults {
265        let ping = self.ping_result.take();
266        let download = self.download_result.take();
267        let upload = self.upload_result.take();
268        (ping, download, upload)
269    }
270
271    pub fn with_services(mut self, services: std::sync::Arc<dyn Services>) -> Self {
272        self.services = services;
273        self
274    }
275}
276
277// ============================================================================
278// Phase Implementations (use task_runner for async operations)
279// ============================================================================
280
281pub(crate) fn run_early_exit<'a>(
282    orch: &'a Orchestrator,
283    _ctx: &'a mut PhaseContext,
284) -> BoxFuture<'a, PhaseOutcome> {
285    let early_exit = orch.early_exit().clone();
286    Box::pin(async move {
287        // early_exit already cloned above
288
289        if early_exit.show_config_path {
290            match crate::config::get_config_path_internal() {
291                Some(path) => eprintln!("Configuration file: {}", path.display()),
292                None => eprintln!("No configuration path available."),
293            }
294            return PhaseOutcome::PhaseEarlyExit;
295        }
296
297        if let Some(shell) = early_exit.generate_completion {
298            let shell_name = match shell {
299                crate::cli::ShellType::Bash => "netspeed-cli.bash",
300                crate::cli::ShellType::Zsh => "_netspeed-cli",
301                crate::cli::ShellType::Fish => "netspeed-cli.fish",
302                crate::cli::ShellType::PowerShell => "_netspeed-cli.ps1",
303                crate::cli::ShellType::Elvish => "netspeed-cli.elv",
304            };
305            eprintln!("Shell completions for {shell:?}: {shell_name}");
306            return PhaseOutcome::PhaseEarlyExit;
307        }
308
309        if early_exit.history {
310            match crate::history::show(orch.config().theme()) {
311                Ok(()) => PhaseOutcome::PhaseEarlyExit,
312                Err(e) => PhaseOutcome::PhaseError(e),
313            }
314        } else if early_exit.dry_run {
315            orch.run_dry_run();
316            PhaseOutcome::PhaseEarlyExit
317        } else {
318            PhaseOutcome::PhaseCompleted
319        }
320    })
321}
322
323pub(crate) fn run_header<'a>(
324    orch: &'a Orchestrator,
325    _ctx: &'a mut PhaseContext,
326) -> BoxFuture<'a, PhaseOutcome> {
327    Box::pin(async move {
328        if orch.is_verbose() {
329            let version = env!("CARGO_PKG_VERSION");
330            let nc = crate::terminal::no_color();
331            let theme = orch.config().theme();
332            eprintln!();
333            if nc {
334                eprintln!("  netspeed-cli v{version}  ·  speedtest.net");
335                eprintln!();
336            } else {
337                eprintln!(
338                    "  {} v{}  {}  {}",
339                    Colors::header("NetSpeed CLI", theme),
340                    version,
341                    Colors::dimmed("·", theme),
342                    Colors::muted("speedtest.net", theme)
343                );
344                eprintln!();
345            }
346        }
347        PhaseOutcome::PhaseCompleted
348    })
349}
350
351pub(crate) fn run_server_discovery<'a>(
352    orch: &'a Orchestrator,
353    ctx: &'a mut PhaseContext,
354) -> BoxFuture<'a, PhaseOutcome> {
355    let is_verbose = orch.is_verbose();
356    let spinner = if is_verbose {
357        Some(crate::progress::create_spinner("Finding servers..."))
358    } else {
359        None
360    };
361
362    Box::pin(async move {
363        // Discover servers asynchronously using injected service
364        let result = ctx.services().server_service().fetch_servers().await;
365        let (mut servers, client_location) = match result {
366            Ok((servers, location)) => (servers, location),
367            Err(e) => return PhaseOutcome::PhaseError(e),
368        };
369        ctx.set_client_location(client_location);
370
371        if let Some(ref pb) = spinner {
372            let theme = orch.config().theme();
373            crate::progress::finish_ok(pb, &format!("Found {} servers", servers.len()), theme);
374            eprintln!();
375        }
376
377        if orch.config().list() {
378            if let Err(e) = crate::formatter::format_list(&servers, orch.config().theme()) {
379                return PhaseOutcome::PhaseError(e.into());
380            }
381            ctx.set_list_printed();
382            return PhaseOutcome::PhaseEarlyExit;
383        }
384
385        if !orch.config().server_ids().is_empty() {
386            servers.retain(|s| orch.config().server_ids().contains(&s.id));
387        }
388        if !orch.config().exclude_ids().is_empty() {
389            servers.retain(|s| !orch.config().exclude_ids().contains(&s.id));
390        }
391
392        if servers.is_empty() {
393            return PhaseOutcome::PhaseError(crate::error::Error::ServerNotFound(
394                "No servers match your criteria.".to_string(),
395            ));
396        }
397
398        let server = match ctx.services().server_service().select_best(&servers) {
399            Ok(s) => s,
400            Err(e) => return PhaseOutcome::PhaseError(e),
401        };
402
403        if is_verbose {
404            let dist = crate::common::format_distance(server.distance);
405            eprintln!();
406            let theme = orch.config().theme();
407            if crate::terminal::no_color() {
408                eprintln!("  Server:   {} ({})", server.sponsor, server.name);
409                eprintln!("  Location: {} ({dist})", server.country);
410            } else {
411                eprintln!(
412                    "  {}   {} ({})",
413                    Colors::dimmed("Server:", theme),
414                    Colors::bold(&server.sponsor, theme),
415                    server.name
416                );
417                eprintln!(
418                    "  {} {} ({dist})",
419                    Colors::dimmed("Location:", theme),
420                    server.country
421                );
422            }
423            eprintln!();
424        }
425
426        ctx.set_server(server);
427        PhaseOutcome::PhaseCompleted
428    })
429}
430
431pub(crate) fn run_ip_discovery<'a>(
432    orch: &'a Orchestrator,
433    ctx: &'a mut PhaseContext,
434) -> BoxFuture<'a, PhaseOutcome> {
435    Box::pin(async move {
436        let is_verbose = orch.is_verbose();
437        let result = ctx.services().ip_service().discover_ip().await;
438        match result {
439            Ok(ip) => ctx.set_client_ip(ip),
440            Err(e) => {
441                if is_verbose {
442                    eprintln!("Warning: Could not discover client IP: {e}");
443                }
444            }
445        }
446        PhaseOutcome::PhaseCompleted
447    })
448}
449
450pub(crate) fn run_ping<'a>(
451    orch: &'a Orchestrator,
452    ctx: &'a mut PhaseContext,
453) -> BoxFuture<'a, PhaseOutcome> {
454    let no_download = orch.config().no_download();
455    let no_upload = orch.config().no_upload();
456    if no_download && no_upload {
457        return Box::pin(async { PhaseOutcome::PhaseCompleted });
458    }
459
460    let server = match ctx.take_server() {
461        Some(s) => s,
462        None => {
463            return Box::pin(async {
464                PhaseOutcome::PhaseError(crate::error::Error::context("No server selected"))
465            });
466        }
467    };
468
469    let is_verbose = orch.is_verbose();
470    let spinner = if is_verbose {
471        Some(crate::progress::create_spinner("Testing latency..."))
472    } else {
473        None
474    };
475
476    let services = ctx.services_arc();
477
478    Box::pin(async move {
479        let result = services.server_service().ping_server(&server).await;
480        let ping_result = match result {
481            Ok(r) => r,
482            Err(e) => return PhaseOutcome::PhaseError(e),
483        };
484
485        if let Some(ref pb) = spinner {
486            let theme = orch.config().theme();
487            let msg = if crate::terminal::no_color() {
488                format!("Latency: {:.2} ms", ping_result.0)
489            } else {
490                format!(
491                    "Latency: {}",
492                    Colors::info(&format!("{:.2} ms", ping_result.0), theme)
493                )
494            };
495            crate::progress::finish_ok(pb, &msg, theme);
496        }
497
498        ctx.set_ping_result(PingResult {
499            latency_ms: ping_result.0,
500            jitter_ms: ping_result.1,
501            packet_loss_pct: ping_result.2,
502            samples: ping_result.3,
503        });
504        // Put server back for download/upload phases
505        ctx.set_server(server);
506        PhaseOutcome::PhaseCompleted
507    })
508}
509
510pub(crate) fn run_download<'a>(
511    orch: &'a Orchestrator,
512    ctx: &'a mut PhaseContext,
513) -> BoxFuture<'a, PhaseOutcome> {
514    let single = orch.config().single();
515    let is_verbose = orch.is_verbose();
516    // Only show spinner in non-verbose mode (verbose mode has progress bar which is better)
517    let spinner = if !is_verbose {
518        Some(crate::progress::create_spinner("Testing download..."))
519    } else {
520        None
521    };
522
523    Box::pin(async move {
524        if orch.config().no_download() {
525            return PhaseOutcome::PhaseCompleted;
526        }
527
528        let server = match ctx.take_server() {
529            Some(s) => s,
530            None => {
531                return PhaseOutcome::PhaseError(crate::error::Error::context(
532                    "No server selected",
533                ));
534            }
535        };
536
537        let client = orch.http_client();
538        let progress = if is_verbose {
539            Arc::new(crate::progress::Tracker::new_animated("Download"))
540        } else {
541            Arc::new(crate::progress::Tracker::with_target(
542                "Download",
543                indicatif::ProgressDrawTarget::hidden(),
544            ))
545        };
546
547        match crate::download::run(client, &server, single, progress).await {
548            Ok((avg, peak, total_bytes, samples)) => {
549                if let Some(ref pb) = spinner {
550                    let theme = orch.config().theme();
551                    let msg = if crate::terminal::no_color() {
552                        format!("Download: {:.2} Mbps", avg / 1_000_000.0)
553                    } else {
554                        format!(
555                            "Download: {}",
556                            Colors::good(&format!("{:.2} Mbps", avg / 1_000_000.0), theme)
557                        )
558                    };
559                    crate::progress::finish_ok(pb, &msg, theme);
560                }
561                ctx.set_download_result(crate::task_runner::TestRunResult {
562                    avg_bps: avg,
563                    peak_bps: peak,
564                    total_bytes,
565                    duration_secs: 0.0,
566                    speed_samples: samples,
567                    latency_under_load: None,
568                });
569                // Put server back for upload phase
570                ctx.set_server(server);
571                PhaseOutcome::PhaseCompleted
572            }
573            Err(e) => PhaseOutcome::PhaseError(e),
574        }
575    })
576}
577
578pub(crate) fn run_upload<'a>(
579    orch: &'a Orchestrator,
580    ctx: &'a mut PhaseContext,
581) -> BoxFuture<'a, PhaseOutcome> {
582    let single = orch.config().single();
583    let is_verbose = orch.is_verbose();
584    // Only show spinner in non-verbose mode (verbose mode has progress bar which is better)
585    let spinner = if !is_verbose {
586        Some(crate::progress::create_spinner("Testing upload..."))
587    } else {
588        None
589    };
590
591    Box::pin(async move {
592        if orch.config().no_upload() {
593            return PhaseOutcome::PhaseCompleted;
594        }
595
596        let server = match ctx.take_server() {
597            Some(s) => s,
598            None => {
599                return PhaseOutcome::PhaseError(crate::error::Error::context(
600                    "No server selected",
601                ));
602            }
603        };
604
605        let client = orch.http_client();
606        let progress = if is_verbose {
607            Arc::new(crate::progress::Tracker::new_animated("Upload"))
608        } else {
609            Arc::new(crate::progress::Tracker::with_target(
610                "Upload",
611                indicatif::ProgressDrawTarget::hidden(),
612            ))
613        };
614
615        match crate::upload::run(client, &server, single, progress).await {
616            Ok((avg, peak, total_bytes, samples)) => {
617                if let Some(ref pb) = spinner {
618                    let theme = orch.config().theme();
619                    let msg = if crate::terminal::no_color() {
620                        format!("Upload: {:.2} Mbps", avg / 1_000_000.0)
621                    } else {
622                        format!(
623                            "Upload: {}",
624                            Colors::good(&format!("{:.2} Mbps", avg / 1_000_000.0), theme)
625                        )
626                    };
627                    crate::progress::finish_ok(pb, &msg, theme);
628                }
629                ctx.set_upload_result(crate::task_runner::TestRunResult {
630                    avg_bps: avg,
631                    peak_bps: peak,
632                    total_bytes,
633                    duration_secs: 0.0,
634                    speed_samples: samples,
635                    latency_under_load: None,
636                });
637                // Put server back for result phase
638                ctx.set_server(server);
639                PhaseOutcome::PhaseCompleted
640            }
641            Err(e) => PhaseOutcome::PhaseError(e),
642        }
643    })
644}
645
646// Bandwidth and result phases use async task_runner - handled in legacy for now
647
648pub(crate) fn run_result<'a>(
649    orch: &'a Orchestrator,
650    ctx: &'a mut PhaseContext,
651) -> BoxFuture<'a, PhaseOutcome> {
652    Box::pin(async move {
653        // Take server info before taking results
654        let server_info = match ctx.take_server() {
655            Some(s) => crate::types::ServerInfo {
656                id: s.id.clone(),
657                name: s.name.clone(),
658                sponsor: s.sponsor.clone(),
659                country: s.country.clone(),
660                distance: s.distance,
661            },
662            None => return PhaseOutcome::PhaseCompleted,
663        };
664
665        let (ping_result, download_result, upload_result) = ctx.take_results();
666
667        let (ping, jitter, packet_loss, ping_samples) = match ping_result {
668            Some(r) => (
669                Some(r.latency_ms),
670                Some(r.jitter_ms),
671                Some(r.packet_loss_pct),
672                r.samples,
673            ),
674            None => (None, None, None, Vec::new()),
675        };
676
677        let dl_result = download_result.unwrap_or_default();
678        let ul_result = upload_result.unwrap_or_default();
679
680        let mut result = crate::types::TestResult::from_test_runs(
681            server_info,
682            ping,
683            jitter,
684            packet_loss,
685            &ping_samples,
686            &dl_result,
687            &ul_result,
688            ctx.client_ip().map(|s| s.to_string()),
689            ctx.client_location().cloned(),
690        );
691
692        let config = orch.config();
693        result.phases = crate::types::TestPhases {
694            ping: if config.no_download() && config.no_upload() {
695                crate::types::PhaseResult::skipped("both bandwidth phases disabled")
696            } else {
697                crate::types::PhaseResult::completed()
698            },
699            download: if config.no_download() {
700                crate::types::PhaseResult::skipped("disabled by user")
701            } else {
702                crate::types::PhaseResult::completed()
703            },
704            upload: if config.no_upload() {
705                crate::types::PhaseResult::skipped("disabled by user")
706            } else {
707                crate::types::PhaseResult::completed()
708            },
709        };
710
711        if config.should_save_history() {
712            if let Err(e) = orch.saver().save(&result) {
713                eprintln!("Warning: Failed to save test result: {e}");
714            }
715        }
716
717        // Delegate to orchestrator for output
718        match orch.output_results(
719            &mut result,
720            &dl_result,
721            &ul_result,
722            std::time::Duration::from_secs(0),
723        ) {
724            Ok(()) => PhaseOutcome::PhaseCompleted,
725            Err(e) => PhaseOutcome::PhaseError(e),
726        }
727    })
728}
729
730// ============================================================================
731// Default Phase Registry
732// ============================================================================
733
734pub fn create_default_executor() -> PhaseExecutor {
735    PhaseExecutor::new()
736        .register(run_early_exit)
737        .register(run_header)
738        .register(run_server_discovery)
739        .register(run_ip_discovery)
740        .register(run_ping)
741        .register(run_download)
742        .register(run_upload)
743        .register(run_result)
744}
745
746/// Run all phases in order.
747pub async fn run_all_phases(orch: &Orchestrator) -> Result<(), Error> {
748    let executor = create_default_executor();
749    executor.execute_all(orch).await
750}
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755
756    fn make_test_services() -> std::sync::Arc<dyn Services> {
757        let client = reqwest::Client::new();
758        std::sync::Arc::new(crate::services::ServiceContainer::new(client))
759    }
760
761    #[test]
762    fn test_phase_context_default() {
763        let ctx = PhaseContext::new(make_test_services());
764        assert!(ctx.client_ip().is_none());
765        assert!(ctx.server().is_none());
766    }
767
768    #[test]
769    fn test_phase_context_builder() {
770        let ctx = PhaseContext::new(make_test_services()).with_client_ip("192.168.1.1");
771
772        assert_eq!(ctx.client_ip(), Some("192.168.1.1"));
773    }
774
775    #[test]
776    fn test_phase_executor_register() {
777        let _executor = PhaseExecutor::new()
778            .register(run_early_exit)
779            .register(run_header);
780    }
781
782    fn make_ping_result() -> PingResult {
783        PingResult {
784            latency_ms: 12.5,
785            jitter_ms: 1.3,
786            packet_loss_pct: 0.0,
787            samples: vec![11.0, 12.0, 14.0],
788        }
789    }
790
791    #[test]
792    fn test_ping_result_fields() {
793        let r = make_ping_result();
794        assert!((r.latency_ms - 12.5).abs() < f64::EPSILON);
795        assert!((r.jitter_ms - 1.3).abs() < f64::EPSILON);
796        assert!((r.packet_loss_pct - 0.0).abs() < f64::EPSILON);
797        assert_eq!(r.samples, vec![11.0, 12.0, 14.0]);
798    }
799
800    #[test]
801    fn test_phase_context_set_take_ping_result() {
802        let mut ctx = PhaseContext::new(make_test_services());
803        assert!(ctx.ping_result().is_none());
804
805        ctx.set_ping_result(make_ping_result());
806        assert!(ctx.ping_result().is_some());
807        assert!((ctx.ping_result().unwrap().latency_ms - 12.5).abs() < f64::EPSILON);
808
809        let taken = ctx.take_ping_result().unwrap();
810        assert!((taken.latency_ms - 12.5).abs() < f64::EPSILON);
811        assert!(ctx.ping_result().is_none());
812    }
813
814    #[test]
815    fn test_phase_context_with_ping_result_builder() {
816        let ctx = PhaseContext::new(make_test_services()).with_ping_result(make_ping_result());
817        assert!((ctx.ping_result().unwrap().jitter_ms - 1.3).abs() < f64::EPSILON);
818    }
819
820    #[test]
821    fn test_take_results_returns_ping() {
822        let mut ctx = PhaseContext::new(make_test_services());
823        ctx.set_ping_result(make_ping_result());
824
825        let (ping, dl, ul) = ctx.take_results();
826        assert!(ping.is_some());
827        assert!((ping.unwrap().packet_loss_pct).abs() < f64::EPSILON);
828        assert!(dl.is_none());
829        assert!(ul.is_none());
830        // Fields consumed — context is empty after take
831        assert!(ctx.ping_result().is_none());
832    }
833}