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