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