Skip to main content

netspeed_cli/formatter/
mod.rs

1//! Output formatting for speed test results.
2//!
3//! This module is organized into submodules:
4//! - [`ratings`] — Rating helper functions (ping, speed, connection, bufferbloat)
5//! - [`sections`] — Output section formatters (latency, download, upload, etc.)
6//! - [`stability`] — Speed stability analysis and latency percentiles
7//! - [`estimates`] — Usage check targets and download time estimates
8
9use crate::common;
10use crate::error::Error;
11use crate::grades;
12use crate::profiles::UserProfile;
13use crate::terminal;
14use crate::theme::{Colors, Theme};
15use crate::types::{CsvOutput, TestResult};
16use owo_colors::OwoColorize;
17
18/// Which test phases were skipped by the user (e.g. `--no-download`).
19#[derive(Debug, Clone, Copy, Default)]
20pub struct SkipState {
21    /// Download test was skipped.
22    pub download: bool,
23    /// Upload test was skipped.
24    pub upload: bool,
25}
26
27/// Build a section header with consistent formatting.
28fn section_header(title: &str, nc: bool, theme: Theme) -> String {
29    if nc {
30        format!("  {title}")
31    } else {
32        format!("  {}", Colors::header(title, theme))
33    }
34}
35
36/// Output format selection — Strategy pattern.
37/// Add new variants here to extend output formats (OCP).
38#[derive(Debug)]
39pub enum OutputFormat {
40    Json,
41    Csv {
42        delimiter: char,
43        header: bool,
44    },
45    Simple {
46        theme: Theme,
47    },
48    Minimal {
49        theme: Theme,
50    },
51    Jsonl,
52    Compact {
53        dl_bytes: u64,
54        ul_bytes: u64,
55        dl_duration: f64,
56        ul_duration: f64,
57        elapsed: std::time::Duration,
58        profile: UserProfile,
59        theme: Theme,
60    },
61    Detailed {
62        dl_bytes: u64,
63        ul_bytes: u64,
64        dl_duration: f64,
65        ul_duration: f64,
66        skipped: SkipState,
67        elapsed: std::time::Duration,
68        profile: UserProfile,
69        minimal: bool,
70        theme: Theme,
71    },
72    Dashboard {
73        dl_mbps: f64,
74        dl_peak_mbps: f64,
75        dl_bytes: u64,
76        dl_duration: f64,
77        ul_mbps: f64,
78        ul_peak_mbps: f64,
79        ul_bytes: u64,
80        ul_duration: f64,
81        elapsed: std::time::Duration,
82        profile: UserProfile,
83        theme: Theme,
84    },
85}
86
87impl OutputFormat {
88    /// Execute the formatting strategy.
89    ///
90    /// # Errors
91    ///
92    /// Returns an error if output serialization or writing fails.
93    pub fn format(&self, result: &TestResult, bytes: bool) -> Result<(), Error> {
94        match self {
95            OutputFormat::Json => format_json(result),
96            OutputFormat::Jsonl => format_jsonl(result),
97            OutputFormat::Csv { delimiter, header } => format_csv(result, *delimiter, *header),
98            OutputFormat::Simple { theme } => format_simple(result, bytes, *theme),
99            OutputFormat::Minimal { theme } => format_minimal(result, bytes, *theme),
100            OutputFormat::Compact {
101                dl_bytes,
102                ul_bytes,
103                dl_duration,
104                ul_duration,
105                elapsed,
106                profile,
107                theme,
108            } => {
109                format_compact(
110                    result,
111                    bytes,
112                    *dl_bytes,
113                    *ul_bytes,
114                    *dl_duration,
115                    *ul_duration,
116                    *elapsed,
117                    *profile,
118                    *theme,
119                );
120                Ok(())
121            }
122            OutputFormat::Detailed {
123                dl_bytes,
124                ul_bytes,
125                dl_duration,
126                ul_duration,
127                skipped,
128                elapsed,
129                profile,
130                minimal,
131                theme,
132            } => {
133                format_detailed(
134                    result,
135                    bytes,
136                    *dl_bytes,
137                    *ul_bytes,
138                    *dl_duration,
139                    *ul_duration,
140                    *skipped,
141                    *elapsed,
142                    *profile,
143                    *minimal,
144                    *theme,
145                )?;
146                format_verbose_sections(result, *profile, *minimal, *theme);
147                Ok(())
148            }
149            OutputFormat::Dashboard {
150                dl_mbps,
151                dl_peak_mbps,
152                dl_bytes,
153                dl_duration,
154                ul_mbps,
155                ul_peak_mbps,
156                ul_bytes,
157                ul_duration,
158                elapsed,
159                profile,
160                theme,
161            } => {
162                dashboard::show(
163                    result,
164                    &dashboard::Summary {
165                        dl_mbps: *dl_mbps,
166                        dl_peak_mbps: *dl_peak_mbps,
167                        dl_bytes: *dl_bytes,
168                        dl_duration: *dl_duration,
169                        ul_mbps: *ul_mbps,
170                        ul_peak_mbps: *ul_peak_mbps,
171                        ul_bytes: *ul_bytes,
172                        ul_duration: *ul_duration,
173                        elapsed: *elapsed,
174                        profile: *profile,
175                        theme: *theme,
176                    },
177                )?;
178                Ok(())
179            }
180        }
181    }
182}
183
184/// Trait for output formatting strategies.
185///
186/// Implement this trait to provide custom output formatters.
187/// This enables the Open-Closed Principle: new formatters can be added
188/// without modifying existing code that uses formatters.
189///
190/// # Example
191///
192/// ```
193/// use netspeed_cli::formatter::{Formatter, OutputFormat};
194/// use netspeed_cli::types::{Server, TestResult};
195/// use netspeed_cli::error::Error;
196///
197/// struct MyFormatter;
198///
199/// impl Formatter for MyFormatter {
200///     fn format(&self, result: &TestResult, use_bytes: bool) -> Result<(), Error> {
201///         println!("Custom: {:?}", result.ping);
202///         Ok(())
203///     }
204///
205///     fn format_list(&self, servers: &[Server]) -> Result<(), Error> {
206///         println!("Servers: {}", servers.len());
207///         Ok(())
208///     }
209/// }
210/// ```
211pub trait Formatter: Send + Sync {
212    /// Format a test result for output.
213    ///
214    /// # Errors
215    ///
216    /// Returns an error if output fails.
217    fn format(
218        &self,
219        result: &crate::types::TestResult,
220        use_bytes: bool,
221    ) -> Result<(), crate::error::Error>;
222
223    /// Format a list of servers for output.
224    ///
225    /// # Errors
226    ///
227    /// Returns an error if output fails.
228    fn format_list(&self, servers: &[crate::types::Server]) -> Result<(), crate::error::Error>;
229}
230
231/// Allows using `OutputFormat` polymorphically through the trait.
232impl Formatter for OutputFormat {
233    fn format(
234        &self,
235        result: &crate::types::TestResult,
236        use_bytes: bool,
237    ) -> Result<(), crate::error::Error> {
238        self.format(result, use_bytes)
239    }
240
241    fn format_list(&self, servers: &[crate::types::Server]) -> Result<(), crate::error::Error> {
242        sections::format_list(servers).map_err(crate::error::Error::IoError)
243    }
244}
245
246pub mod dashboard;
247pub mod estimates;
248pub mod ratings;
249pub mod scenarios;
250pub mod sections;
251pub mod stability;
252
253// Re-export commonly used functions for backward compatibility
254pub use estimates::{format_targets, show};
255pub use ratings::{
256    BufferbloatGrade, bufferbloat_colorized, bufferbloat_grade, colorize_rating, connection_rating,
257    degradation_str, format_duration, format_overall_rating, format_speed_colored,
258    format_speed_plain, ping_rating, speed_rating_mbps,
259};
260pub use sections::{
261    build_elapsed_time, format_connection_info, format_download_section, format_elapsed_time,
262    format_footer, format_latency_section, format_list, format_test_summary, format_upload_section,
263};
264pub use stability::{compute_cv, compute_percentiles, format_stability_line};
265
266/// Simple mode — single line.
267///
268/// # Errors
269///
270/// This function does not currently return errors, but the signature is
271/// `Result` for future extensibility.
272pub fn format_simple(result: &TestResult, bytes: bool, theme: Theme) -> Result<(), Error> {
273    let nc = terminal::no_color();
274    let mut parts = Vec::new();
275
276    if let Some(ping) = result.ping {
277        parts.push(if nc {
278            format!("Latency: {ping:.1} ms")
279        } else {
280            format!("Latency: {} ms", Colors::info(&format!("{ping:.1}"), theme))
281        });
282    }
283
284    if let Some(dl) = result.download {
285        let speed = if nc {
286            ratings::format_speed_plain(dl, bytes)
287        } else {
288            ratings::format_speed_colored(dl, bytes, theme)
289        };
290        parts.push(format!("Download: {speed}"));
291    }
292
293    if let Some(ul) = result.upload {
294        let speed = if nc {
295            ratings::format_speed_plain(ul, bytes)
296        } else {
297            ratings::format_speed_colored(ul, bytes, theme)
298        };
299        parts.push(format!("Upload: {speed}"));
300    }
301
302    eprintln!("{}", parts.join(" | "));
303    Ok(())
304}
305
306/// Minimal mode — ultra-compact: just "B+ 150.5↓ 25.3↑ 12ms"
307///
308/// # Errors
309///
310/// This function does not currently return errors, but the signature is
311/// `Result` for future extensibility.
312pub fn format_minimal(result: &TestResult, _bytes: bool, theme: Theme) -> Result<(), Error> {
313    let nc = terminal::no_color();
314    let profile = UserProfile::default();
315
316    let overall_grade = grades::grade_overall(
317        result.ping,
318        result.jitter,
319        result.download,
320        result.upload,
321        profile,
322    );
323    let grade_str = if nc {
324        format!("[{}]", overall_grade.as_str())
325    } else {
326        overall_grade.color_str(nc, theme)
327    };
328
329    let dl_str = result.download.map_or_else(
330        || "—↓".to_string(),
331        |d| {
332            let mbps = d / 1_000_000.0;
333            format!("{mbps:.1}↓")
334        },
335    );
336
337    let ul_str = result.upload.map_or_else(
338        || "—↑".to_string(),
339        |u| {
340            let mbps = u / 1_000_000.0;
341            format!("{mbps:.1}↑")
342        },
343    );
344
345    let lat_str = result
346        .ping
347        .map_or_else(|| "—ms".to_string(), |p| format!("{p:.0}ms"));
348
349    eprintln!("{grade_str}  {dl_str}  {ul_str}  {lat_str}");
350    Ok(())
351}
352
353/// JSONL mode — one JSON object per line, ideal for logging/parsing.
354///
355/// # Errors
356///
357/// Returns [`Error::ParseJson`] if serialization fails.
358pub fn format_jsonl(result: &TestResult) -> Result<(), Error> {
359    println!("{}", serde_json::to_string(result)?);
360    Ok(())
361}
362
363/// Compact mode — key metrics with ratings and brief summary.
364/// Middle ground between simple (too minimal) and detailed (too verbose).
365pub fn format_compact(
366    result: &TestResult,
367    bytes: bool,
368    dl_bytes: u64,
369    ul_bytes: u64,
370    dl_duration: f64,
371    ul_duration: f64,
372    elapsed: std::time::Duration,
373    profile: UserProfile,
374    theme: Theme,
375) {
376    let nc = terminal::no_color();
377    let overall_grade = grades::grade_overall(
378        result.ping,
379        result.jitter,
380        result.download,
381        result.upload,
382        profile,
383    );
384
385    eprintln!();
386    eprintln!("{}", section_header("TEST RESULTS", nc, theme));
387    eprintln!("{}", ratings::format_overall_rating(result, nc, theme));
388    if !nc {
389        eprintln!(
390            "  {} {}",
391            "Grade:".dimmed(),
392            overall_grade.color_str(nc, theme)
393        );
394    }
395    eprintln!();
396
397    sections::format_latency_section(result, nc, theme);
398    eprintln!();
399
400    sections::format_download_section(result, bytes, nc, false, theme);
401    eprintln!();
402
403    sections::format_upload_section(result, bytes, nc, false, theme);
404    eprintln!();
405
406    if let Some(ip) = &result.client_ip {
407        if nc {
408            eprintln!("  Server: {} · {}", result.server.sponsor, ip);
409        } else {
410            eprintln!(
411                "  {} {} · {}",
412                "Server:".dimmed(),
413                Colors::bold(&result.server.sponsor, theme),
414                Colors::muted(ip, theme),
415            );
416        }
417        eprintln!();
418    }
419
420    if nc {
421        eprintln!("  SUMMARY");
422    } else {
423        eprintln!("  {}", Colors::header("SUMMARY", theme));
424    }
425    if dl_bytes > 0 {
426        eprintln!(
427            "  {:>14}:   {} in {:.1}s",
428            "Download".dimmed(),
429            common::format_data_size(dl_bytes),
430            dl_duration
431        );
432    }
433    if ul_bytes > 0 {
434        eprintln!(
435            "  {:>14}:   {} in {:.1}s",
436            "Upload".dimmed(),
437            common::format_data_size(ul_bytes),
438            ul_duration
439        );
440    }
441
442    eprintln!();
443    if nc {
444        eprintln!("  Total time: {:.1}s", elapsed.as_secs_f64());
445    } else {
446        eprintln!(
447            "  {}: {}",
448            "Total time".dimmed(),
449            Colors::info(&format!("{:.1}s", elapsed.as_secs_f64()), theme),
450        );
451    }
452    sections::format_footer(&result.timestamp, nc, theme);
453}
454
455/// Detailed mode — clean key/value pairs.
456///
457/// # Errors
458///
459/// This function does not currently return errors, but the signature is
460/// `Result` for future extensibility.
461#[allow(clippy::too_many_arguments)]
462pub fn format_detailed(
463    result: &TestResult,
464    bytes: bool,
465    dl_bytes: u64,
466    ul_bytes: u64,
467    dl_duration: f64,
468    ul_duration: f64,
469    skipped: SkipState,
470    elapsed: std::time::Duration,
471    profile: UserProfile,
472    minimal: bool,
473    theme: Theme,
474) -> Result<(), Error> {
475    let nc = terminal::no_color() || minimal;
476    let overall_grade = grades::grade_overall(
477        result.ping,
478        result.jitter,
479        result.download,
480        result.upload,
481        profile,
482    );
483
484    if nc {
485        eprintln!("\n  TEST RESULTS");
486    } else {
487        eprintln!("\n  {}", Colors::header("TEST RESULTS", theme));
488    }
489    eprintln!("{}", ratings::format_overall_rating(result, nc, theme));
490    if !nc {
491        eprintln!(
492            "  {} {}",
493            "Grade:".dimmed(),
494            overall_grade.color_str(nc, theme)
495        );
496    }
497    eprintln!();
498
499    sections::format_latency_section(result, nc, theme);
500    sections::format_download_section(result, bytes, nc, skipped.download, theme);
501    sections::format_upload_section(result, bytes, nc, skipped.upload, theme);
502    sections::format_connection_info(result, nc, theme);
503    sections::format_test_summary(dl_bytes, ul_bytes, dl_duration, ul_duration, nc);
504
505    eprintln!();
506    if nc {
507        eprintln!("  Total time: {:.1}s", elapsed.as_secs_f64());
508    } else {
509        eprintln!(
510            "  {}: {}",
511            "Total time".dimmed(),
512            Colors::info(&format!("{:.1}s", elapsed.as_secs_f64()), theme),
513        );
514    }
515
516    sections::format_footer(&result.timestamp, nc, theme);
517
518    Ok(())
519}
520
521/// Output test results as JSON to stdout.
522///
523/// # Errors
524///
525/// Returns [`Error::ParseJson`] if serialization fails.
526pub fn format_json(result: &TestResult) -> Result<(), Error> {
527    let is_tty = {
528        use std::io::IsTerminal;
529        std::io::stdout().is_terminal()
530    };
531    let output = if is_tty {
532        serde_json::to_string_pretty(result)?
533    } else {
534        serde_json::to_string(result)?
535    };
536    println!("{output}");
537    Ok(())
538}
539
540/// Output test results as CSV to stdout.
541///
542/// # Errors
543///
544/// Returns [`Error::Csv`] if CSV serialization fails.
545pub fn format_csv(result: &TestResult, delimiter: char, print_header: bool) -> Result<(), Error> {
546    let stdout = std::io::stdout();
547    let mut wtr = csv::WriterBuilder::new()
548        .delimiter(delimiter as u8)
549        .from_writer(stdout);
550    if print_header {
551        wtr.write_record([
552            "Server ID",
553            "Sponsor",
554            "Server Name",
555            "Timestamp",
556            "Distance",
557            "Ping",
558            "Jitter",
559            "Packet Loss",
560            "Download",
561            "Download Peak",
562            "Upload",
563            "Upload Peak",
564            "IP Address",
565        ])?;
566    }
567    let csv_output = CsvOutput {
568        server_id: result.server.id.clone(),
569        sponsor: result.server.sponsor.clone(),
570        server_name: result.server.name.clone(),
571        timestamp: result.timestamp.clone(),
572        distance: result.server.distance,
573        ping: result.ping.unwrap_or(0.0),
574        jitter: result.jitter.unwrap_or(0.0),
575        packet_loss: result.packet_loss.unwrap_or(0.0),
576        download: result.download.unwrap_or(0.0),
577        download_peak: result.download_peak.unwrap_or(0.0),
578        upload: result.upload.unwrap_or(0.0),
579        upload_peak: result.upload_peak.unwrap_or(0.0),
580        ip_address: result.client_ip.clone().unwrap_or_default(),
581    };
582    wtr.serialize(csv_output)?;
583    wtr.flush()?;
584    Ok(())
585}
586
587/// Format additional verbose output sections: stability, latency percentiles, and historical comparison.
588/// Only used in detailed (verbose) mode.
589pub fn format_verbose_sections(
590    result: &TestResult,
591    profile: UserProfile,
592    minimal: bool,
593    theme: Theme,
594) {
595    let nc = terminal::no_color() || minimal;
596
597    if profile.show_estimates() {
598        let estimates = estimates::build(result.download, nc, theme);
599        if !estimates.is_empty() {
600            eprintln!("{estimates}");
601        }
602    }
603
604    if profile.show_stability() {
605        if let (Some(dl_s), Some(ul_s)) = (&result.download_samples, &result.upload_samples) {
606            let dl_cv = compute_cv(dl_s);
607            let ul_cv = compute_cv(ul_s);
608            let dl_grade = grades::grade_stability(dl_cv);
609            let ul_grade = grades::grade_stability(ul_cv);
610            let dl_stability = format_stability_line(dl_cv, nc, theme);
611            let ul_stability = format_stability_line(ul_cv, nc, theme);
612            eprintln!();
613            eprintln!("{}", section_header("STABILITY", nc, theme));
614            if nc {
615                eprintln!("  {:>14}:   [{dl_stability}]", "Download");
616                eprintln!("  {:>14}:   [{ul_stability}]", "Upload");
617            } else {
618                eprintln!(
619                    "  {:>14}:   {} {dl_stability}",
620                    "Download".dimmed(),
621                    dl_grade.color_str(nc, theme)
622                );
623                eprintln!(
624                    "  {:>14}:   {} {ul_stability}",
625                    "Upload".dimmed(),
626                    ul_grade.color_str(nc, theme)
627                );
628            }
629        }
630    }
631
632    if profile.show_percentiles() {
633        if let Some(ref samples) = result.ping_samples {
634            if let Some((p50, p95, p99)) = compute_percentiles(samples) {
635                eprintln!();
636                eprintln!("{}", section_header("LATENCY PERCENTILES", nc, theme));
637                let p50_str = format!("{p50:.1} ms");
638                let p95_str = format!("{p95:.1} ms");
639                let p99_str = format!("{p99:.1} ms");
640                if nc {
641                    eprintln!("  P50: {p50_str}  P95: {p95_str}  P99: {p99_str}");
642                } else {
643                    eprintln!(
644                        "  {}: {}  {}: {}  {}: {}",
645                        "P50".dimmed(),
646                        Colors::info(&p50_str, theme),
647                        "P95".dimmed(),
648                        Colors::warn(&p95_str, theme),
649                        "P99".dimmed(),
650                        Colors::bad(&p99_str, theme),
651                    );
652                }
653            }
654        }
655    }
656
657    if profile.show_history() {
658        let dl_mbps = result.download.map_or(0.0, |d| d / 1_000_000.0);
659        let ul_mbps = result.upload.map_or(0.0, |u| u / 1_000_000.0);
660        if let Some(comparison) = crate::history::format_comparison(dl_mbps, ul_mbps, nc) {
661            eprintln!();
662            eprintln!("  {comparison}");
663        }
664    }
665}
666
667// =============================================================================
668// Formatter Factory - SOLID: Factory pattern for flexible creation
669// =============================================================================
670
671/// Factory for creating formatter instances.
672///
673/// Enables runtime formatter selection and dependency injection.
674pub struct FormatterFactory;
675
676impl FormatterFactory {
677    /// Create a formatter from config format option.
678    pub fn create(format: Option<crate::config::Format>) -> Box<dyn Formatter> {
679        match format {
680            Some(crate::config::Format::Json) => Box::new(OutputFormat::Json),
681            Some(crate::config::Format::Jsonl) => Box::new(OutputFormat::Jsonl),
682            Some(crate::config::Format::Csv) => Box::new(OutputFormat::Csv {
683                delimiter: ',',
684                header: true,
685            }),
686            Some(crate::config::Format::Simple) => Box::new(OutputFormat::Simple {
687                theme: crate::theme::Theme::Dark,
688            }),
689            Some(crate::config::Format::Minimal) => Box::new(OutputFormat::Minimal {
690                theme: crate::theme::Theme::Dark,
691            }),
692            Some(crate::config::Format::Compact) => Box::new(OutputFormat::Compact {
693                dl_bytes: 0,
694                ul_bytes: 0,
695                dl_duration: 0.0,
696                ul_duration: 0.0,
697                elapsed: std::time::Duration::ZERO,
698                profile: crate::profiles::UserProfile::default(),
699                theme: crate::theme::Theme::Dark,
700            }),
701            Some(crate::config::Format::Detailed) => Box::new(OutputFormat::Detailed {
702                dl_bytes: 0,
703                ul_bytes: 0,
704                dl_duration: 0.0,
705                ul_duration: 0.0,
706                skipped: SkipState::default(),
707                elapsed: std::time::Duration::ZERO,
708                profile: crate::profiles::UserProfile::default(),
709                minimal: false,
710                theme: crate::theme::Theme::Dark,
711            }),
712            Some(crate::config::Format::Dashboard) => Box::new(OutputFormat::Dashboard {
713                dl_mbps: 0.0,
714                dl_peak_mbps: 0.0,
715                dl_bytes: 0,
716                dl_duration: 0.0,
717                ul_mbps: 0.0,
718                ul_peak_mbps: 0.0,
719                ul_bytes: 0,
720                ul_duration: 0.0,
721                elapsed: std::time::Duration::ZERO,
722                profile: crate::profiles::UserProfile::default(),
723                theme: crate::theme::Theme::Dark,
724            }),
725            None => Box::new(OutputFormat::Simple {
726                theme: crate::theme::Theme::Dark,
727            }),
728        }
729    }
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735    use crate::types::{PhaseResult, ServerInfo, TestPhases, TestResult};
736
737    fn make_test_result() -> TestResult {
738        TestResult {
739            status: "ok".to_string(),
740            version: env!("CARGO_PKG_VERSION").to_string(),
741            test_id: None,
742            server: ServerInfo {
743                id: "1".to_string(),
744                name: "Test".to_string(),
745                sponsor: "Test ISP".to_string(),
746                country: "US".to_string(),
747                distance: 10.0,
748            },
749            ping: Some(15.0),
750            jitter: Some(1.5),
751            packet_loss: Some(0.0),
752            download: Some(100_000_000.0),
753            download_peak: Some(120_000_000.0),
754            upload: Some(50_000_000.0),
755            upload_peak: Some(60_000_000.0),
756            latency_download: Some(20.0),
757            latency_upload: Some(18.0),
758            download_samples: Some(vec![95_000_000.0, 100_000_000.0, 105_000_000.0]),
759            upload_samples: Some(vec![48_000_000.0, 50_000_000.0, 52_000_000.0]),
760            ping_samples: Some(vec![12.0, 15.0, 18.0]),
761            timestamp: "2026-01-01T00:00:00Z".to_string(),
762            client_ip: Some("192.168.1.100".to_string()),
763            client_location: None,
764            download_cv: Some(0.05),
765            upload_cv: Some(0.04),
766            download_ci_95: Some((140.0, 160.0)),
767            upload_ci_95: Some((45.0, 55.0)),
768            overall_grade: None,
769            download_grade: None,
770            upload_grade: None,
771            connection_rating: None,
772            phases: TestPhases {
773                ping: PhaseResult::completed(),
774                download: PhaseResult::completed(),
775                upload: PhaseResult::completed(),
776            },
777        }
778    }
779
780    #[test]
781    fn test_format_simple_with_data() {
782        let result = make_test_result();
783        // Just verify it doesn't panic
784        let _ = format_simple(&result, false, Theme::Dark);
785    }
786
787    #[test]
788    fn test_format_simple_no_ping() {
789        let mut result = make_test_result();
790        result.ping = None;
791        let _ = format_simple(&result, false, Theme::Dark);
792    }
793
794    #[test]
795    fn test_format_simple_no_download() {
796        let mut result = make_test_result();
797        result.download = None;
798        let _ = format_simple(&result, false, Theme::Dark);
799    }
800
801    #[test]
802    fn test_format_simple_no_upload() {
803        let mut result = make_test_result();
804        result.upload = None;
805        let _ = format_simple(&result, false, Theme::Dark);
806    }
807
808    #[test]
809    fn test_format_simple_bytes_mode() {
810        let result = make_test_result();
811        let _ = format_simple(&result, true, Theme::Dark);
812    }
813
814    #[test]
815    fn test_format_simple_light_theme() {
816        let result = make_test_result();
817        let _ = format_simple(&result, false, Theme::Light);
818    }
819
820    #[test]
821    fn test_format_minimal_basic() {
822        let result = make_test_result();
823        let _ = format_minimal(&result, false, Theme::Dark);
824    }
825
826    #[test]
827    fn test_format_minimal_no_download() {
828        let mut result = make_test_result();
829        result.download = None;
830        let _ = format_minimal(&result, false, Theme::Dark);
831    }
832
833    #[test]
834    fn test_format_minimal_no_upload() {
835        let mut result = make_test_result();
836        result.upload = None;
837        let _ = format_minimal(&result, false, Theme::Dark);
838    }
839
840    #[test]
841    fn test_format_minimal_no_ping() {
842        let mut result = make_test_result();
843        result.ping = None;
844        let _ = format_minimal(&result, false, Theme::Dark);
845    }
846
847    #[test]
848    fn test_format_jsonl_basic() {
849        let result = make_test_result();
850        let _ = format_jsonl(&result);
851    }
852
853    #[test]
854    fn test_format_compact_basic() {
855        let result = make_test_result();
856        format_compact(
857            &result,
858            false,
859            10_000_000,
860            5_000_000,
861            2.0,
862            1.0,
863            std::time::Duration::from_secs(5),
864            UserProfile::default(),
865            Theme::Dark,
866        );
867    }
868
869    #[test]
870    fn test_format_compact_with_client_ip() {
871        let result = make_test_result();
872        format_compact(
873            &result,
874            false,
875            10_000_000,
876            5_000_000,
877            2.0,
878            1.0,
879            std::time::Duration::from_secs(5),
880            UserProfile::default(),
881            Theme::Dark,
882        );
883    }
884
885    #[test]
886    fn test_format_compact_bytes_mode() {
887        let result = make_test_result();
888        format_compact(
889            &result,
890            true,
891            10_000_000,
892            5_000_000,
893            2.0,
894            1.0,
895            std::time::Duration::from_secs(5),
896            UserProfile::default(),
897            Theme::Dark,
898        );
899    }
900
901    #[test]
902    fn test_format_compact_nc_mode() {
903        let result = make_test_result();
904        // NC mode should not use colors
905        format_compact(
906            &result,
907            false,
908            10_000_000,
909            5_000_000,
910            2.0,
911            1.0,
912            std::time::Duration::from_secs(5),
913            UserProfile::default(),
914            Theme::Monochrome,
915        );
916    }
917
918    #[test]
919    fn test_format_compact_gamer_profile() {
920        let result = make_test_result();
921        format_compact(
922            &result,
923            false,
924            10_000_000,
925            5_000_000,
926            2.0,
927            1.0,
928            std::time::Duration::from_secs(5),
929            UserProfile::Gamer,
930            Theme::Dark,
931        );
932    }
933
934    #[test]
935    fn test_format_detailed_basic() {
936        let result = make_test_result();
937        let _ = format_detailed(
938            &result,
939            false,
940            10_000_000,
941            5_000_000,
942            2.0,
943            1.0,
944            SkipState {
945                download: false,
946                upload: false,
947            },
948            std::time::Duration::from_secs(5),
949            UserProfile::default(),
950            false,
951            Theme::Dark,
952        );
953    }
954
955    #[test]
956    fn test_format_detailed_with_skipped() {
957        let result = make_test_result();
958        let _ = format_detailed(
959            &result,
960            false,
961            10_000_000,
962            5_000_000,
963            2.0,
964            1.0,
965            SkipState {
966                download: true,
967                upload: true,
968            },
969            std::time::Duration::from_secs(3),
970            UserProfile::default(),
971            false,
972            Theme::Dark,
973        );
974    }
975
976    #[test]
977    fn test_format_detailed_minimal_mode() {
978        let result = make_test_result();
979        let _ = format_detailed(
980            &result,
981            false,
982            10_000_000,
983            5_000_000,
984            2.0,
985            1.0,
986            SkipState {
987                download: false,
988                upload: false,
989            },
990            std::time::Duration::from_secs(5),
991            UserProfile::default(),
992            true,
993            Theme::Dark,
994        );
995    }
996
997    #[test]
998    fn test_format_detailed_bytes_mode() {
999        let result = make_test_result();
1000        let _ = format_detailed(
1001            &result,
1002            true,
1003            10_000_000,
1004            5_000_000,
1005            2.0,
1006            1.0,
1007            SkipState {
1008                download: false,
1009                upload: false,
1010            },
1011            std::time::Duration::from_secs(5),
1012            UserProfile::default(),
1013            false,
1014            Theme::Dark,
1015        );
1016    }
1017
1018    #[test]
1019    fn test_format_json_basic() {
1020        let result = make_test_result();
1021        let _ = format_json(&result);
1022    }
1023
1024    #[test]
1025    fn test_format_csv_basic() {
1026        let result = make_test_result();
1027        let _ = format_csv(&result, ',', true);
1028    }
1029
1030    #[test]
1031    fn test_format_csv_no_header() {
1032        let result = make_test_result();
1033        let _ = format_csv(&result, ';', false);
1034    }
1035
1036    #[test]
1037    fn test_format_csv_tab_delimiter() {
1038        let result = make_test_result();
1039        let _ = format_csv(&result, '\t', true);
1040    }
1041
1042    #[test]
1043    fn test_format_csv_with_missing_values() {
1044        let mut result = make_test_result();
1045        result.ping = None;
1046        result.jitter = None;
1047        result.packet_loss = None;
1048        let _ = format_csv(&result, ',', true);
1049    }
1050
1051    #[test]
1052    fn test_skip_state_default() {
1053        let skip = SkipState::default();
1054        assert!(!skip.download);
1055        assert!(!skip.upload);
1056    }
1057
1058    #[test]
1059    fn test_skip_state_custom() {
1060        let skip = SkipState {
1061            download: true,
1062            upload: false,
1063        };
1064        assert!(skip.download);
1065        assert!(!skip.upload);
1066    }
1067
1068    #[test]
1069    fn test_output_format_json() {
1070        let fmt = OutputFormat::Json;
1071        let result = make_test_result();
1072        assert!(fmt.format(&result, false).is_ok());
1073    }
1074
1075    #[test]
1076    fn test_output_format_jsonl() {
1077        let fmt = OutputFormat::Jsonl;
1078        let result = make_test_result();
1079        assert!(fmt.format(&result, false).is_ok());
1080    }
1081
1082    #[test]
1083    fn test_output_format_csv() {
1084        let fmt = OutputFormat::Csv {
1085            delimiter: ',',
1086            header: true,
1087        };
1088        let result = make_test_result();
1089        assert!(fmt.format(&result, false).is_ok());
1090    }
1091
1092    #[test]
1093    fn test_output_format_simple() {
1094        let fmt = OutputFormat::Simple { theme: Theme::Dark };
1095        let result = make_test_result();
1096        assert!(fmt.format(&result, false).is_ok());
1097    }
1098
1099    #[test]
1100    fn test_output_format_minimal() {
1101        let fmt = OutputFormat::Minimal { theme: Theme::Dark };
1102        let result = make_test_result();
1103        assert!(fmt.format(&result, false).is_ok());
1104    }
1105
1106    #[test]
1107    fn test_output_format_detailed() {
1108        let fmt = OutputFormat::Detailed {
1109            dl_bytes: 10_000_000,
1110            ul_bytes: 5_000_000,
1111            dl_duration: 2.0,
1112            ul_duration: 1.0,
1113            skipped: SkipState::default(),
1114            elapsed: std::time::Duration::from_secs(5),
1115            profile: UserProfile::default(),
1116            minimal: false,
1117            theme: Theme::Dark,
1118        };
1119        let result = make_test_result();
1120        assert!(fmt.format(&result, false).is_ok());
1121    }
1122
1123    #[test]
1124    fn test_output_format_compact() {
1125        let fmt = OutputFormat::Compact {
1126            dl_bytes: 10_000_000,
1127            ul_bytes: 5_000_000,
1128            dl_duration: 2.0,
1129            ul_duration: 1.0,
1130            elapsed: std::time::Duration::from_secs(5),
1131            profile: UserProfile::default(),
1132            theme: Theme::Dark,
1133        };
1134        let result = make_test_result();
1135        assert!(fmt.format(&result, false).is_ok());
1136    }
1137
1138    #[test]
1139    fn test_output_format_dashboard() {
1140        let fmt = OutputFormat::Dashboard {
1141            dl_mbps: 100.0,
1142            dl_peak_mbps: 120.0,
1143            dl_bytes: 10_000_000,
1144            dl_duration: 2.0,
1145            ul_mbps: 50.0,
1146            ul_peak_mbps: 60.0,
1147            ul_bytes: 5_000_000,
1148            ul_duration: 1.0,
1149            elapsed: std::time::Duration::from_secs(5),
1150            profile: UserProfile::default(),
1151            theme: Theme::Dark,
1152        };
1153        let result = make_test_result();
1154        assert!(fmt.format(&result, false).is_ok());
1155    }
1156
1157    #[test]
1158    fn test_section_header_nc_mode() {
1159        let header = section_header("Test Header", true, Theme::Dark);
1160        assert!(header.contains("Test Header"));
1161        assert!(!header.contains("\x1b")); // No ANSI codes
1162    }
1163
1164    #[test]
1165    fn test_section_header_colored() {
1166        let header = section_header("Test Header", false, Theme::Dark);
1167        assert!(header.contains("Test Header"));
1168    }
1169
1170    #[test]
1171    fn test_format_verbose_sections_power_user() {
1172        let result = make_test_result();
1173        // PowerUser sees estimates, stability, percentiles, history
1174        format_verbose_sections(&result, UserProfile::PowerUser, false, Theme::Dark);
1175    }
1176
1177    #[test]
1178    fn test_format_verbose_sections_casual() {
1179        let result = make_test_result();
1180        // Casual only sees estimates
1181        format_verbose_sections(&result, UserProfile::Casual, false, Theme::Dark);
1182    }
1183
1184    #[test]
1185    fn test_format_verbose_sections_gamer() {
1186        let result = make_test_result();
1187        // Gamer sees bufferbloat but not stability
1188        format_verbose_sections(&result, UserProfile::Gamer, false, Theme::Dark);
1189    }
1190
1191    #[test]
1192    fn test_format_verbose_sections_remote_worker() {
1193        let result = make_test_result();
1194        // RemoteWorker sees stability and history
1195        format_verbose_sections(&result, UserProfile::RemoteWorker, false, Theme::Dark);
1196    }
1197
1198    #[test]
1199    fn test_format_verbose_sections_minimal() {
1200        let result = make_test_result();
1201        format_verbose_sections(&result, UserProfile::default(), true, Theme::Dark);
1202    }
1203
1204    #[test]
1205    fn test_format_verbose_sections_integration() {
1206        // Exercise the full integration path
1207        format_verbose_sections(
1208            &make_test_result(),
1209            UserProfile::default(),
1210            false,
1211            Theme::Dark,
1212        );
1213    }
1214
1215    #[test]
1216    fn test_format_verbose_sections_empty() {
1217        // Should not panic with all None values
1218        let mut result = make_test_result();
1219        result.ping = None;
1220        result.jitter = None;
1221        result.download = None;
1222        result.upload = None;
1223        result.download_samples = None;
1224        result.upload_samples = None;
1225        result.ping_samples = None;
1226        format_verbose_sections(&result, UserProfile::default(), false, Theme::Dark);
1227    }
1228
1229    #[test]
1230    fn test_format_data_kb() {
1231        assert_eq!(crate::common::format_data_size(5120), "5.0 KB");
1232    }
1233
1234    #[test]
1235    fn test_format_data_mb() {
1236        assert_eq!(crate::common::format_data_size(5_242_880), "5.0 MB");
1237    }
1238
1239    #[test]
1240    fn test_format_data_gb() {
1241        assert_eq!(crate::common::format_data_size(1_073_741_824), "1.00 GB");
1242    }
1243}