Skip to main content

netspeed_cli/formatter/
ratings.rs

1//! Rating helper functions for speed test results.
2
3use crate::terminal::no_emoji;
4use crate::theme::{Colors, Theme};
5use crate::types::TestResult;
6use owo_colors::OwoColorize;
7
8#[must_use]
9pub fn ping_rating(ping_ms: f64) -> &'static str {
10    if ping_ms < 10.0 {
11        "Excellent"
12    } else if ping_ms < 30.0 {
13        "Good"
14    } else if ping_ms < 60.0 {
15        "Fair"
16    } else if ping_ms < 100.0 {
17        "Poor"
18    } else {
19        "Bad"
20    }
21}
22
23#[must_use]
24pub fn speed_rating_mbps(mbps: f64) -> &'static str {
25    if mbps >= 500.0 {
26        "Excellent"
27    } else if mbps >= 200.0 {
28        "Great"
29    } else if mbps >= 100.0 {
30        "Good"
31    } else if mbps >= 50.0 {
32        "Fair"
33    } else if mbps >= 25.0 {
34        "Moderate"
35    } else if mbps >= 10.0 {
36        "Slow"
37    } else {
38        "Very Slow"
39    }
40}
41
42#[must_use]
43pub fn colorize_rating(rating: &str, nc: bool, theme: Theme) -> String {
44    if nc || no_emoji() {
45        format!("[{rating}]")
46    } else {
47        let (icon, colored) = match rating {
48            "Excellent" => ("⚡ ", Colors::good(rating, theme)),
49            "Great" => ("🔵  ", Colors::info(rating, theme)),
50            "Good" => ("🟢  ", Colors::good(rating, theme)),
51            "Fair" => ("🟡  ", Colors::warn(rating, theme)),
52            "Moderate" => ("🟠  ", Colors::warn(rating, theme)),
53            "Poor" => ("🔴  ", Colors::bad(rating, theme)),
54            "Slow" => ("🟤  ", Colors::bad(rating, theme)),
55            "Very Slow" => ("⚠️  ", Colors::bad(rating, theme)),
56            _ => ("", rating.to_string()),
57        };
58        format!("{icon}{colored}")
59    }
60}
61
62/// Helper struct to hold speed formatting components.
63struct SpeedComponents {
64    value: f64,
65    unit: &'static str,
66}
67
68/// Extract speed components for formatting.
69fn speed_components(bps: f64, bytes: bool) -> SpeedComponents {
70    let divider = if bytes { 8.0 } else { 1.0 };
71    let unit = if bytes { "MB/s" } else { "Mb/s" };
72    let value = bps / divider / 1_000_000.0;
73    SpeedComponents { value, unit }
74}
75
76#[must_use]
77pub fn format_speed_colored(bps: f64, bytes: bool, theme: Theme) -> String {
78    let SpeedComponents { value, unit } = speed_components(bps, bytes);
79    let mbps = bps / 1_000_000.0;
80    let rating = speed_rating_mbps(mbps);
81    let text = format!("{value:.2} {unit}");
82    match rating {
83        "Excellent" | "Great" | "Good" => Colors::good(&text, theme),
84        "Fair" | "Moderate" => Colors::warn(&text, theme),
85        "Poor" | "Slow" | "Very Slow" => Colors::bad(&text, theme),
86        _ => text,
87    }
88}
89
90#[must_use]
91pub fn format_speed_plain(bps: f64, bytes: bool) -> String {
92    let SpeedComponents { value, unit } = speed_components(bps, bytes);
93    format!("{value:.2} {unit}")
94}
95
96#[must_use]
97pub fn format_duration(secs: f64) -> String {
98    if secs < 60.0 {
99        format!("{secs:.1}s")
100    } else {
101        // Safe: secs is test duration (seconds), always non-negative and small.
102        let mins = (secs / 60.0).clamp(0.0, u64::MAX as f64) as u64;
103        let secs = secs % 60.0;
104        format!("{mins}m {secs:.0}s")
105    }
106}
107
108#[must_use]
109pub fn connection_rating(result: &TestResult) -> &'static str {
110    /// Score a "lower is better" metric (ping, jitter) on a 0–100 scale.
111    fn score_lower(value: f64, thresholds: [f64; 5]) -> f64 {
112        if value < thresholds[0] {
113            100.0
114        } else if value < thresholds[1] {
115            80.0
116        } else if value < thresholds[2] {
117            60.0
118        } else if value < thresholds[3] {
119            40.0
120        } else {
121            20.0
122        }
123    }
124
125    /// Score a "higher is better" metric (download, upload) on a 0–100 scale.
126    fn score_higher(mbps: f64, thresholds: [f64; 6]) -> f64 {
127        if mbps >= thresholds[0] {
128            100.0
129        } else if mbps >= thresholds[1] {
130            85.0
131        } else if mbps >= thresholds[2] {
132            70.0
133        } else if mbps >= thresholds[3] {
134            55.0
135        } else if mbps >= thresholds[4] {
136            40.0
137        } else if mbps >= thresholds[5] {
138            25.0
139        } else {
140            10.0
141        }
142    }
143
144    let mut score = 0.0;
145    let mut factors = 0.0;
146
147    // Ping (lower is better)
148    if let Some(ping) = result.ping {
149        score += score_lower(ping, [10.0, 30.0, 60.0, 100.0, f64::MAX]);
150        factors += 1.0;
151    }
152
153    // Jitter (lower is better)
154    if let Some(jitter) = result.jitter {
155        score += score_lower(jitter, [2.0, 5.0, 10.0, 20.0, f64::MAX]);
156        factors += 1.0;
157    }
158
159    // Download speed (higher is better)
160    if let Some(dl) = result.download {
161        score += score_higher(dl / 1_000_000.0, [500.0, 200.0, 100.0, 50.0, 25.0, 10.0]);
162        factors += 1.0;
163    }
164
165    // Upload speed (higher is better)
166    if let Some(ul) = result.upload {
167        score += score_higher(ul / 1_000_000.0, [500.0, 200.0, 100.0, 50.0, 25.0, 10.0]);
168        factors += 1.0;
169    }
170
171    if factors == 0.0 {
172        return "Unknown";
173    }
174
175    let avg = score / factors;
176    if avg >= 90.0 {
177        "Excellent"
178    } else if avg >= 75.0 {
179        "Great"
180    } else if avg >= 55.0 {
181        "Good"
182    } else if avg >= 40.0 {
183        "Fair"
184    } else if avg >= 25.0 {
185        "Moderate"
186    } else {
187        "Poor"
188    }
189}
190
191/// Bufferbloat grade based on added latency under load.
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub enum BufferbloatGrade {
194    A,
195    B,
196    C,
197    D,
198    F,
199}
200
201impl BufferbloatGrade {
202    #[must_use]
203    pub fn as_str(&self) -> &'static str {
204        match self {
205            Self::A => "A",
206            Self::B => "B",
207            Self::C => "C",
208            Self::D => "D",
209            Self::F => "F",
210        }
211    }
212}
213
214/// Compute bufferbloat grade from latency degradation under load.
215#[must_use]
216pub fn bufferbloat_grade(load_latency: f64, idle_latency: f64) -> (BufferbloatGrade, f64) {
217    let added = if idle_latency > 0.0 {
218        load_latency - idle_latency
219    } else {
220        load_latency
221    };
222    let grade = if added < 5.0 {
223        BufferbloatGrade::A
224    } else if added < 20.0 {
225        BufferbloatGrade::B
226    } else if added < 50.0 {
227        BufferbloatGrade::C
228    } else if added < 100.0 {
229        BufferbloatGrade::D
230    } else {
231        BufferbloatGrade::F
232    };
233    (grade, added.max(0.0))
234}
235
236#[must_use]
237pub fn bufferbloat_colorized(
238    grade: BufferbloatGrade,
239    added_ms: f64,
240    nc: bool,
241    theme: Theme,
242) -> String {
243    if nc {
244        format!("{} ({added_ms:.0}ms)", grade.as_str())
245    } else {
246        let text = format!("{} ({added_ms:.0}ms added)", grade.as_str());
247        match grade {
248            BufferbloatGrade::A | BufferbloatGrade::B => Colors::good(&text, theme),
249            BufferbloatGrade::C | BufferbloatGrade::D => Colors::warn(&text, theme),
250            BufferbloatGrade::F => Colors::bad(&text, theme),
251        }
252    }
253}
254
255#[must_use]
256pub fn format_overall_rating(result: &TestResult, nc: bool, theme: Theme) -> String {
257    let rating = connection_rating(result);
258    if nc || no_emoji() {
259        format!("  Overall: {rating}")
260    } else {
261        let (icon, colored) = match rating {
262            "Excellent" => ("⚡ ", Colors::good(rating, theme)),
263            "Great" => ("🔵  ", Colors::info(rating, theme)),
264            "Good" => ("🟢  ", Colors::good(rating, theme)),
265            "Fair" => ("🟡  ", Colors::warn(rating, theme)),
266            "Moderate" => ("🟠  ", Colors::warn(rating, theme)),
267            "Poor" => ("🔴  ", Colors::bad(rating, theme)),
268            _ => ("", rating.to_string()),
269        };
270        format!("  {} {icon}{colored}", "Overall:".dimmed())
271    }
272}
273
274#[must_use]
275pub fn degradation_str(lat_load: f64, idle_ping: Option<f64>, nc: bool, theme: Theme) -> String {
276    let Some(idle) = idle_ping else {
277        return String::new();
278    };
279    if idle <= 0.0 {
280        return String::new();
281    }
282    let pct = ((lat_load / idle) - 1.0) * 100.0;
283    let text = format!(
284        "+{pct:.0}% ({})",
285        if pct < 25.0 {
286            "minimal"
287        } else if pct < 50.0 {
288            "moderate"
289        } else {
290            "significant"
291        }
292    );
293    if nc {
294        format!("  [{text:>8}]")
295    } else {
296        let colored = if pct < 25.0 {
297            Colors::good(&text, theme)
298        } else if pct < 50.0 {
299            Colors::warn(&text, theme)
300        } else {
301            Colors::bad(&text, theme)
302        };
303        format!("  {colored}")
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use crate::types::{PhaseResult, ServerInfo, TestPhases, TestResult};
311
312    fn make_test_result() -> TestResult {
313        TestResult {
314            status: "ok".to_string(),
315            version: env!("CARGO_PKG_VERSION").to_string(),
316            test_id: None,
317            server: ServerInfo {
318                id: "1".to_string(),
319                name: "Test".to_string(),
320                sponsor: "Test ISP".to_string(),
321                country: "US".to_string(),
322                distance: 10.0,
323            },
324            ping: Some(15.0),
325            jitter: Some(1.5),
326            packet_loss: Some(0.0),
327            download: Some(100_000_000.0),
328            download_peak: Some(120_000_000.0),
329            upload: Some(50_000_000.0),
330            upload_peak: Some(60_000_000.0),
331            latency_download: Some(20.0),
332            latency_upload: Some(18.0),
333            download_samples: None,
334            upload_samples: None,
335            ping_samples: None,
336            timestamp: "2026-01-01T00:00:00Z".to_string(),
337            client_ip: None,
338            client_location: None,
339            download_cv: None,
340            upload_cv: None,
341            download_ci_95: None,
342            upload_ci_95: None,
343            overall_grade: None,
344            download_grade: None,
345            upload_grade: None,
346            connection_rating: None,
347            phases: TestPhases {
348                ping: PhaseResult::completed(),
349                download: PhaseResult::completed(),
350                upload: PhaseResult::completed(),
351            },
352        }
353    }
354
355    // ── ping_rating tests ───────────────────────────────────────────────────
356
357    #[test]
358    fn test_ping_rating_excellent() {
359        assert_eq!(ping_rating(5.0), "Excellent");
360        assert_eq!(ping_rating(9.9), "Excellent");
361        assert_eq!(ping_rating(0.0), "Excellent");
362    }
363
364    #[test]
365    fn test_ping_rating_good() {
366        assert_eq!(ping_rating(10.0), "Good");
367        assert_eq!(ping_rating(29.9), "Good");
368        assert_eq!(ping_rating(15.0), "Good");
369    }
370
371    #[test]
372    fn test_ping_rating_fair() {
373        assert_eq!(ping_rating(30.0), "Fair");
374        assert_eq!(ping_rating(59.9), "Fair");
375        assert_eq!(ping_rating(45.0), "Fair");
376    }
377
378    #[test]
379    fn test_ping_rating_poor() {
380        assert_eq!(ping_rating(60.0), "Poor");
381        assert_eq!(ping_rating(99.9), "Poor");
382        assert_eq!(ping_rating(80.0), "Poor");
383    }
384
385    #[test]
386    fn test_ping_rating_bad() {
387        assert_eq!(ping_rating(100.0), "Bad");
388        assert_eq!(ping_rating(500.0), "Bad");
389        assert_eq!(ping_rating(10000.0), "Bad");
390    }
391
392    // ── speed_rating_mbps tests ─────────────────────────────────────────────
393
394    #[test]
395    fn test_speed_rating_excellent() {
396        assert_eq!(speed_rating_mbps(500.0), "Excellent");
397        assert_eq!(speed_rating_mbps(1000.0), "Excellent");
398    }
399
400    #[test]
401    fn test_speed_rating_great() {
402        assert_eq!(speed_rating_mbps(200.0), "Great");
403        assert_eq!(speed_rating_mbps(499.9), "Great");
404        assert_eq!(speed_rating_mbps(300.0), "Great");
405    }
406
407    #[test]
408    fn test_speed_rating_good() {
409        assert_eq!(speed_rating_mbps(100.0), "Good");
410        assert_eq!(speed_rating_mbps(199.9), "Good");
411        assert_eq!(speed_rating_mbps(150.0), "Good");
412    }
413
414    #[test]
415    fn test_speed_rating_fair() {
416        assert_eq!(speed_rating_mbps(50.0), "Fair");
417        assert_eq!(speed_rating_mbps(99.9), "Fair");
418    }
419
420    #[test]
421    fn test_speed_rating_moderate() {
422        assert_eq!(speed_rating_mbps(25.0), "Moderate");
423        assert_eq!(speed_rating_mbps(49.9), "Moderate");
424        assert_eq!(speed_rating_mbps(30.0), "Moderate");
425    }
426
427    #[test]
428    fn test_speed_rating_slow() {
429        assert_eq!(speed_rating_mbps(10.0), "Slow");
430        assert_eq!(speed_rating_mbps(24.9), "Slow");
431        assert_eq!(speed_rating_mbps(15.0), "Slow");
432    }
433
434    #[test]
435    fn test_speed_rating_very_slow() {
436        assert_eq!(speed_rating_mbps(0.0), "Very Slow");
437        assert_eq!(speed_rating_mbps(9.9), "Very Slow");
438        assert_eq!(speed_rating_mbps(1.0), "Very Slow");
439    }
440
441    // ── colorize_rating tests ───────────────────────────────────────────────
442
443    #[test]
444    fn test_colorize_rating_excellent() {
445        let result = colorize_rating("Excellent", true, Theme::Dark);
446        assert!(result.contains("[Excellent]"));
447    }
448
449    #[test]
450    fn test_colorize_rating_great() {
451        let result = colorize_rating("Great", true, Theme::Dark);
452        assert!(result.contains("[Great]"));
453    }
454
455    #[test]
456    fn test_colorize_rating_good() {
457        let result = colorize_rating("Good", true, Theme::Dark);
458        assert!(result.contains("[Good]"));
459    }
460
461    #[test]
462    fn test_colorize_rating_fair() {
463        let result = colorize_rating("Fair", true, Theme::Dark);
464        assert!(result.contains("[Fair]"));
465    }
466
467    #[test]
468    fn test_colorize_rating_moderate() {
469        let result = colorize_rating("Moderate", true, Theme::Dark);
470        assert!(result.contains("[Moderate]"));
471    }
472
473    #[test]
474    fn test_colorize_rating_poor() {
475        let result = colorize_rating("Poor", true, Theme::Dark);
476        assert!(result.contains("[Poor]"));
477    }
478
479    #[test]
480    fn test_colorize_rating_slow() {
481        let result = colorize_rating("Slow", true, Theme::Dark);
482        assert!(result.contains("[Slow]"));
483    }
484
485    #[test]
486    fn test_colorize_rating_very_slow() {
487        let result = colorize_rating("Very Slow", true, Theme::Dark);
488        assert!(result.contains("[Very Slow]"));
489    }
490
491    #[test]
492    fn test_colorize_rating_unknown() {
493        // Unknown ratings should just return the text
494        let result = colorize_rating("Unknown", false, Theme::Dark);
495        assert_eq!(result, "Unknown");
496    }
497
498    // ── format_speed_colored tests ──────────────────────────────────────────
499
500    #[test]
501    fn test_format_speed_colored_mbps_excellent() {
502        let result = format_speed_colored(500_000_000.0, false, Theme::Dark);
503        assert!(result.contains("500.00"));
504        assert!(result.contains("Mb/s"));
505    }
506
507    #[test]
508    fn test_format_speed_colored_bytes_mode() {
509        // 8_000_000 bps / 8 = 1_000_000 bytes/sec = 1.00 MB/s
510        let result = format_speed_colored(8_000_000.0, true, Theme::Dark);
511        assert!(result.contains("1.00"));
512        assert!(result.contains("MB/s"));
513    }
514
515    #[test]
516    fn test_format_speed_colored_light_theme() {
517        let result = format_speed_colored(100_000_000.0, false, Theme::Light);
518        assert!(result.contains("100.00"));
519    }
520
521    #[test]
522    fn test_format_speed_colored_low_speed() {
523        // Low speed should use bad color
524        let result = format_speed_colored(5_000_000.0, false, Theme::Dark);
525        assert!(result.contains("5.00"));
526    }
527
528    // ── format_speed_plain tests ────────────────────────────────────────────
529
530    #[test]
531    fn test_format_speed_plain_mbps() {
532        let result = format_speed_plain(100_000_000.0, false);
533        assert_eq!(result, "100.00 Mb/s");
534    }
535
536    #[test]
537    fn test_format_speed_plain_bytes() {
538        // 8_000_000 bps / 8 = 1_000_000 bytes/sec = 1.00 MB/s
539        let result = format_speed_plain(8_000_000.0, true);
540        assert_eq!(result, "1.00 MB/s");
541    }
542
543    #[test]
544    fn test_format_speed_plain_zero() {
545        let result = format_speed_plain(0.0, false);
546        assert_eq!(result, "0.00 Mb/s");
547    }
548
549    #[test]
550    fn test_format_speed_plain_fractional() {
551        let result = format_speed_plain(55_555_555.0, false);
552        assert!(result.contains("55.56"));
553    }
554
555    // ── format_duration tests ───────────────────────────────────────────────
556
557    #[test]
558    fn test_format_duration_seconds() {
559        assert_eq!(format_duration(30.0), "30.0s");
560        assert_eq!(format_duration(59.9), "59.9s");
561        assert_eq!(format_duration(0.5), "0.5s");
562    }
563
564    #[test]
565    fn test_format_duration_minutes() {
566        assert_eq!(format_duration(60.0), "1m 0s");
567        assert_eq!(format_duration(90.0), "1m 30s");
568        assert_eq!(format_duration(125.0), "2m 5s");
569    }
570
571    #[test]
572    fn test_format_duration_many_minutes() {
573        assert_eq!(format_duration(600.0), "10m 0s");
574        assert_eq!(format_duration(3661.0), "61m 1s");
575    }
576
577    // ── connection_rating tests ─────────────────────────────────────────────
578
579    #[test]
580    fn test_connection_rating_excellent() {
581        let mut result = make_test_result();
582        result.ping = Some(5.0);
583        result.jitter = Some(1.0);
584        result.download = Some(500_000_000.0);
585        result.upload = Some(250_000_000.0);
586        assert_eq!(connection_rating(&result), "Excellent");
587    }
588
589    #[test]
590    fn test_connection_rating_great() {
591        let mut result = make_test_result();
592        result.ping = Some(20.0);
593        result.jitter = Some(3.0);
594        result.download = Some(200_000_000.0);
595        result.upload = Some(100_000_000.0);
596        assert_eq!(connection_rating(&result), "Great");
597    }
598
599    #[test]
600    fn test_connection_rating_good() {
601        let mut result = make_test_result();
602        result.ping = Some(40.0);
603        result.jitter = Some(8.0);
604        result.download = Some(100_000_000.0);
605        result.upload = Some(50_000_000.0);
606        assert_eq!(connection_rating(&result), "Good");
607    }
608
609    #[test]
610    fn test_connection_rating_fair() {
611        let mut result = make_test_result();
612        result.ping = Some(60.0);
613        result.jitter = Some(15.0);
614        result.download = Some(50_000_000.0);
615        result.upload = Some(25_000_000.0);
616        assert_eq!(connection_rating(&result), "Fair");
617    }
618
619    #[test]
620    fn test_connection_rating_moderate() {
621        let mut result = make_test_result();
622        result.ping = Some(80.0);
623        result.jitter = Some(18.0);
624        result.download = Some(25_000_000.0);
625        result.upload = Some(12_000_000.0);
626        assert_eq!(connection_rating(&result), "Moderate");
627    }
628
629    #[test]
630    fn test_connection_rating_poor() {
631        let mut result = make_test_result();
632        result.ping = Some(150.0);
633        result.jitter = Some(30.0);
634        result.download = Some(5_000_000.0);
635        result.upload = Some(1_000_000.0);
636        assert_eq!(connection_rating(&result), "Poor");
637    }
638
639    #[test]
640    fn test_connection_rating_unknown_no_data() {
641        let mut result = make_test_result();
642        result.ping = None;
643        result.jitter = None;
644        result.download = None;
645        result.upload = None;
646        assert_eq!(connection_rating(&result), "Unknown");
647    }
648
649    #[test]
650    fn test_connection_rating_partial_data() {
651        let mut result = make_test_result();
652        result.ping = Some(10.0);
653        result.jitter = None;
654        result.download = None;
655        result.upload = None;
656        // With only ping, should still return a rating
657        let rating = connection_rating(&result);
658        assert!(!rating.is_empty());
659    }
660
661    // ── BufferbloatGrade tests ──────────────────────────────────────────────
662
663    #[test]
664    fn test_bufferbloat_grade_a() {
665        let (grade, added) = bufferbloat_grade(10.0, 8.0);
666        assert_eq!(grade, BufferbloatGrade::A);
667        assert!((added - 2.0).abs() < 0.1);
668    }
669
670    #[test]
671    fn test_bufferbloat_grade_b() {
672        let (grade, added) = bufferbloat_grade(30.0, 15.0);
673        assert_eq!(grade, BufferbloatGrade::B);
674        assert!((added - 15.0).abs() < 0.1);
675    }
676
677    #[test]
678    fn test_bufferbloat_grade_c() {
679        let (grade, added) = bufferbloat_grade(60.0, 20.0);
680        assert_eq!(grade, BufferbloatGrade::C);
681        assert!((added - 40.0).abs() < 0.1);
682    }
683
684    #[test]
685    fn test_bufferbloat_grade_d() {
686        let (grade, added) = bufferbloat_grade(120.0, 30.0);
687        assert_eq!(grade, BufferbloatGrade::D);
688        assert!((added - 90.0).abs() < 0.1);
689    }
690
691    #[test]
692    fn test_bufferbloat_grade_f() {
693        let (grade, added) = bufferbloat_grade(200.0, 50.0);
694        assert_eq!(grade, BufferbloatGrade::F);
695        assert!((added - 150.0).abs() < 0.1);
696    }
697
698    #[test]
699    fn test_bufferbloat_grade_zero_idle() {
700        // When idle is 0, uses load_latency directly (10.0)
701        // 10.0 < 5.0 (A)? No → 10.0 < 20.0 (B)? Yes → Grade B
702        let (grade, added) = bufferbloat_grade(10.0, 0.0);
703        assert_eq!(grade, BufferbloatGrade::B);
704        assert!((added - 10.0).abs() < 0.1);
705    }
706
707    #[test]
708    fn test_bufferbloat_grade_boundaries() {
709        // Test exact boundary values
710        let (grade, _) = bufferbloat_grade(4.99, 0.0);
711        assert_eq!(grade, BufferbloatGrade::A);
712
713        let (grade, _) = bufferbloat_grade(5.0, 0.0);
714        assert_eq!(grade, BufferbloatGrade::B);
715
716        let (grade, _) = bufferbloat_grade(19.99, 0.0);
717        assert_eq!(grade, BufferbloatGrade::B);
718
719        let (grade, _) = bufferbloat_grade(20.0, 0.0);
720        assert_eq!(grade, BufferbloatGrade::C);
721    }
722
723    // ── BufferbloatGrade as_str tests ───────────────────────────────────────
724
725    #[test]
726    fn test_bufferbloat_grade_as_str() {
727        assert_eq!(BufferbloatGrade::A.as_str(), "A");
728        assert_eq!(BufferbloatGrade::B.as_str(), "B");
729        assert_eq!(BufferbloatGrade::C.as_str(), "C");
730        assert_eq!(BufferbloatGrade::D.as_str(), "D");
731        assert_eq!(BufferbloatGrade::F.as_str(), "F");
732    }
733
734    // ── bufferbloat_colorized tests ─────────────────────────────────────────
735
736    #[test]
737    fn test_bufferbloat_colorized_nc_a() {
738        let result = bufferbloat_colorized(BufferbloatGrade::A, 2.0, true, Theme::Dark);
739        assert!(result.contains("A"));
740        assert!(result.contains("2ms"));
741    }
742
743    #[test]
744    fn test_bufferbloat_colorized_nc_f() {
745        let result = bufferbloat_colorized(BufferbloatGrade::F, 150.0, true, Theme::Dark);
746        assert!(result.contains("F"));
747        assert!(result.contains("150ms"));
748    }
749
750    #[test]
751    fn test_bufferbloat_colorized_colored_a() {
752        let result = bufferbloat_colorized(BufferbloatGrade::A, 2.0, false, Theme::Dark);
753        assert!(result.contains("A"));
754        assert!(result.contains("added"));
755    }
756
757    #[test]
758    fn test_bufferbloat_colorized_colored_b() {
759        let result = bufferbloat_colorized(BufferbloatGrade::B, 15.0, false, Theme::Dark);
760        assert!(result.contains("B"));
761    }
762
763    #[test]
764    fn test_bufferbloat_colorized_colored_c() {
765        let result = bufferbloat_colorized(BufferbloatGrade::C, 40.0, false, Theme::Dark);
766        assert!(result.contains("C"));
767    }
768
769    #[test]
770    fn test_bufferbloat_colorized_colored_d() {
771        let result = bufferbloat_colorized(BufferbloatGrade::D, 90.0, false, Theme::Dark);
772        assert!(result.contains("D"));
773    }
774
775    #[test]
776    fn test_bufferbloat_colorized_colored_f() {
777        let result = bufferbloat_colorized(BufferbloatGrade::F, 150.0, false, Theme::Dark);
778        assert!(result.contains("F"));
779    }
780
781    // ── format_overall_rating tests ─────────────────────────────────────────
782
783    #[test]
784    fn test_format_overall_rating_nc_excellent() {
785        let result = make_test_result();
786        let output = format_overall_rating(&result, true, Theme::Dark);
787        assert!(output.contains("Overall:"));
788    }
789
790    #[test]
791    fn test_format_overall_rating_colored() {
792        let result = make_test_result();
793        let output = format_overall_rating(&result, false, Theme::Dark);
794        assert!(output.contains("Overall:"));
795    }
796
797    #[test]
798    fn test_format_overall_rating_light_theme() {
799        let result = make_test_result();
800        let output = format_overall_rating(&result, false, Theme::Light);
801        assert!(output.contains("Overall:"));
802    }
803
804    // ── degradation_str tests ───────────────────────────────────────────────
805
806    #[test]
807    fn test_degradation_str_minimal() {
808        // 20% increase = minimal
809        let result = degradation_str(12.0, Some(10.0), true, Theme::Dark);
810        assert!(result.contains("minimal"));
811    }
812
813    #[test]
814    fn test_degradation_str_moderate() {
815        // 40% increase = moderate
816        let result = degradation_str(14.0, Some(10.0), true, Theme::Dark);
817        assert!(result.contains("moderate"));
818    }
819
820    #[test]
821    fn test_degradation_str_significant() {
822        // 60% increase = significant
823        let result = degradation_str(16.0, Some(10.0), true, Theme::Dark);
824        assert!(result.contains("significant"));
825    }
826
827    #[test]
828    fn test_degradation_str_no_idle() {
829        let result = degradation_str(15.0, None, false, Theme::Dark);
830        assert_eq!(result, "");
831    }
832
833    #[test]
834    fn test_degradation_str_zero_idle() {
835        let result = degradation_str(15.0, Some(0.0), false, Theme::Dark);
836        assert_eq!(result, "");
837    }
838
839    #[test]
840    fn test_degradation_str_negative_idle() {
841        let result = degradation_str(15.0, Some(-5.0), false, Theme::Dark);
842        assert_eq!(result, "");
843    }
844
845    #[test]
846    fn test_degradation_str_nc_mode() {
847        let result = degradation_str(12.0, Some(10.0), true, Theme::Dark);
848        assert!(result.contains("["));
849    }
850
851    #[test]
852    fn test_degradation_str_colored_minimal() {
853        let result = degradation_str(12.0, Some(10.0), false, Theme::Dark);
854        assert!(!result.is_empty());
855    }
856
857    // ── SpeedComponents helper tests ────────────────────────────────────────
858
859    #[test]
860    fn test_speed_components_helper() {
861        // Test through format_speed_plain which uses SpeedComponents
862        let result = format_speed_plain(100_000_000.0, false);
863        assert!(result.contains("100.00"));
864        assert!(result.contains("Mb/s"));
865    }
866}