Skip to main content

netspeed_cli/
grades.rs

1//! Comprehensive quality grade system (A-F) for all connection metrics.
2//!
3//! Provides letter grades with color support for:
4//! - Ping/Latency
5//! - Jitter
6//! - Download speed
7//! - Upload speed
8//! - Bufferbloat
9//! - Stability (CV%)
10//! - Overall connection quality
11
12use crate::profiles::UserProfile;
13use crate::terminal;
14use crate::theme::{Colors, Theme};
15
16/// Letter grade for connection quality.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
18pub enum LetterGrade {
19    APlus,
20    A,
21    AMinus,
22    BPlus,
23    B,
24    BMinus,
25    CPlus,
26    C,
27    CMinus,
28    D,
29    F,
30}
31
32impl LetterGrade {
33    /// Display string for the grade.
34    #[must_use]
35    pub fn as_str(&self) -> &'static str {
36        match self {
37            Self::APlus => "A+",
38            Self::A => "A",
39            Self::AMinus => "A-",
40            Self::BPlus => "B+",
41            Self::B => "B",
42            Self::BMinus => "B-",
43            Self::CPlus => "C+",
44            Self::C => "C",
45            Self::CMinus => "C-",
46            Self::D => "D",
47            Self::F => "F",
48        }
49    }
50
51    /// Color for the grade (green = good, red = bad).
52    #[must_use]
53    pub fn color_str(&self, nc: bool, theme: Theme) -> String {
54        let s = self.as_str();
55        if nc {
56            return format!("[{s}]");
57        }
58        match self {
59            Self::APlus | Self::A | Self::AMinus | Self::BPlus | Self::B => Colors::good(s, theme),
60            Self::BMinus | Self::CPlus | Self::C | Self::CMinus | Self::D => Colors::warn(s, theme),
61            Self::F => Colors::bad(s, theme),
62        }
63    }
64
65    /// Emoji indicator for the grade.
66    #[must_use]
67    pub fn emoji(&self) -> &'static str {
68        if terminal::no_emoji() {
69            return "";
70        }
71        match self {
72            Self::APlus | Self::A | Self::AMinus => "⚡",
73            Self::BPlus | Self::B => "✅",
74            Self::BMinus | Self::CPlus | Self::C => "⚠️",
75            Self::CMinus | Self::D => "❌",
76            Self::F => "🚫",
77        }
78    }
79
80    /// Numeric score 0-100 for the grade.
81    #[must_use]
82    pub fn score(&self) -> f64 {
83        match self {
84            Self::APlus => 100.0,
85            Self::A => 95.0,
86            Self::AMinus => 90.0,
87            Self::BPlus => 85.0,
88            Self::B => 80.0,
89            Self::BMinus => 75.0,
90            Self::CPlus => 70.0,
91            Self::C => 65.0,
92            Self::CMinus => 60.0,
93            Self::D => 50.0,
94            Self::F => 25.0,
95        }
96    }
97
98    /// Human-readable description.
99    #[must_use]
100    pub fn description(&self) -> &'static str {
101        match self {
102            Self::APlus => "Exceptional",
103            Self::A => "Excellent",
104            Self::AMinus => "Very Good",
105            Self::BPlus => "Good+",
106            Self::B => "Good",
107            Self::BMinus => "Above Average",
108            Self::CPlus => "Average+",
109            Self::C => "Average",
110            Self::CMinus => "Below Average",
111            Self::D => "Poor",
112            Self::F => "Unacceptable",
113        }
114    }
115}
116
117/// Grade a ping value (lower is better).
118/// Profile-aware thresholds.
119#[must_use]
120pub fn grade_ping(ping_ms: f64, profile: UserProfile) -> LetterGrade {
121    let excellent = profile.excellent_ping_threshold();
122    let good = excellent * 3.0;
123    let average = excellent * 6.0;
124
125    if ping_ms <= excellent * 0.5 {
126        LetterGrade::APlus
127    } else if ping_ms <= excellent {
128        LetterGrade::A
129    } else if ping_ms <= excellent * 1.5 {
130        LetterGrade::AMinus
131    } else if ping_ms <= good {
132        LetterGrade::B
133    } else if ping_ms <= good * 1.5 {
134        LetterGrade::C
135    } else if ping_ms <= average {
136        LetterGrade::D
137    } else {
138        LetterGrade::F
139    }
140}
141
142/// Grade jitter value (lower is better).
143#[must_use]
144pub fn grade_jitter(jitter_ms: f64, profile: UserProfile) -> LetterGrade {
145    let excellent = profile.excellent_jitter_threshold();
146    let good = excellent * 3.0;
147    let average = excellent * 8.0;
148
149    if jitter_ms <= excellent * 0.5 {
150        LetterGrade::APlus
151    } else if jitter_ms <= excellent {
152        LetterGrade::A
153    } else if jitter_ms <= excellent * 2.0 {
154        LetterGrade::B
155    } else if jitter_ms <= good {
156        LetterGrade::C
157    } else if jitter_ms <= average {
158        LetterGrade::D
159    } else {
160        LetterGrade::F
161    }
162}
163
164/// Grade download speed (higher is better).
165#[must_use]
166pub fn grade_download(speed_mbps: f64, profile: UserProfile) -> LetterGrade {
167    let excellent = profile.excellent_speed_threshold();
168    let good = excellent * 0.4;
169    let average = excellent * 0.15;
170
171    if speed_mbps >= excellent * 2.0 {
172        LetterGrade::APlus
173    } else if speed_mbps >= excellent {
174        LetterGrade::A
175    } else if speed_mbps >= excellent * 0.75 {
176        LetterGrade::B
177    } else if speed_mbps >= good {
178        LetterGrade::C
179    } else if speed_mbps >= average {
180        LetterGrade::D
181    } else {
182        LetterGrade::F
183    }
184}
185
186/// Grade upload speed (higher is better).
187#[must_use]
188pub fn grade_upload(speed_mbps: f64, profile: UserProfile) -> LetterGrade {
189    // Upload thresholds are typically 50% of download
190    let excellent = profile.excellent_speed_threshold() * 0.5;
191    let good = excellent * 0.4;
192    let average = excellent * 0.15;
193
194    if speed_mbps >= excellent * 2.0 {
195        LetterGrade::APlus
196    } else if speed_mbps >= excellent {
197        LetterGrade::A
198    } else if speed_mbps >= excellent * 0.75 {
199        LetterGrade::B
200    } else if speed_mbps >= good {
201        LetterGrade::C
202    } else if speed_mbps >= average {
203        LetterGrade::D
204    } else {
205        LetterGrade::F
206    }
207}
208
209/// Grade bufferbloat based on added latency under load.
210#[must_use]
211pub fn grade_bufferbloat(added_latency_ms: f64) -> LetterGrade {
212    if added_latency_ms < 3.0 {
213        LetterGrade::APlus
214    } else if added_latency_ms < 5.0 {
215        LetterGrade::A
216    } else if added_latency_ms < 10.0 {
217        LetterGrade::AMinus
218    } else if added_latency_ms < 20.0 {
219        LetterGrade::BPlus
220    } else if added_latency_ms < 30.0 {
221        LetterGrade::B
222    } else if added_latency_ms < 50.0 {
223        LetterGrade::C
224    } else if added_latency_ms < 100.0 {
225        LetterGrade::D
226    } else {
227        LetterGrade::F
228    }
229}
230
231/// Grade stability based on CV% (lower = more stable).
232#[must_use]
233pub fn grade_stability(cv_pct: f64) -> LetterGrade {
234    if cv_pct < 3.0 {
235        LetterGrade::APlus
236    } else if cv_pct < 5.0 {
237        LetterGrade::A
238    } else if cv_pct < 8.0 {
239        LetterGrade::B
240    } else if cv_pct < 15.0 {
241        LetterGrade::C
242    } else if cv_pct < 25.0 {
243        LetterGrade::D
244    } else {
245        LetterGrade::F
246    }
247}
248
249/// Compute overall connection grade from individual grades and profile weights.
250#[must_use]
251pub fn grade_overall(
252    ping: Option<f64>,
253    jitter: Option<f64>,
254    download_bps: Option<f64>,
255    upload_bps: Option<f64>,
256    profile: UserProfile,
257) -> LetterGrade {
258    let (ping_w, jitter_w, dl_w, ul_w) = profile.scoring_weights();
259    let mut total_score = 0.0;
260    let mut total_weight = 0.0;
261
262    if let Some(p) = ping {
263        total_score += grade_ping(p, profile).score() * ping_w;
264        total_weight += ping_w;
265    }
266    if let Some(j) = jitter {
267        total_score += grade_jitter(j, profile).score() * jitter_w;
268        total_weight += jitter_w;
269    }
270    if let Some(dl) = download_bps {
271        total_score += grade_download(dl / 1_000_000.0, profile).score() * dl_w;
272        total_weight += dl_w;
273    }
274    if let Some(ul) = upload_bps {
275        total_score += grade_upload(ul / 1_000_000.0, profile).score() * ul_w;
276        total_weight += ul_w;
277    }
278
279    if total_weight == 0.0 {
280        return LetterGrade::F;
281    }
282
283    let avg_score = total_score / total_weight;
284    score_to_grade(avg_score)
285}
286
287/// Convert a numeric score (0-100) to a letter grade.
288#[must_use]
289pub fn score_to_grade(score: f64) -> LetterGrade {
290    if score >= 97.0 {
291        LetterGrade::APlus
292    } else if score >= 93.0 {
293        LetterGrade::A
294    } else if score >= 90.0 {
295        LetterGrade::AMinus
296    } else if score >= 87.0 {
297        LetterGrade::BPlus
298    } else if score >= 80.0 {
299        LetterGrade::B
300    } else if score >= 77.0 {
301        LetterGrade::BMinus
302    } else if score >= 70.0 {
303        LetterGrade::CPlus
304    } else if score >= 65.0 {
305        LetterGrade::C
306    } else if score >= 60.0 {
307        LetterGrade::CMinus
308    } else if score >= 50.0 {
309        LetterGrade::D
310    } else {
311        LetterGrade::F
312    }
313}
314
315/// Format a grade line with label, grade, and optional value.
316#[must_use]
317pub fn format_grade_line(
318    label: &str,
319    grade: LetterGrade,
320    value: Option<&str>,
321    nc: bool,
322    theme: Theme,
323) -> String {
324    let emoji = grade.emoji();
325    let grade_display = grade.color_str(nc, theme);
326    let value_str = value.map(|v| format!(" ({v})")).unwrap_or_default();
327
328    if nc || terminal::no_emoji() {
329        format!("  {label:>14}:   {grade_display}{value_str}")
330    } else {
331        format!("  {label:>14}:   {emoji} {grade_display}{value_str}")
332    }
333}
334
335#[must_use]
336pub fn grade_badge(grade: LetterGrade, nc: bool, theme: Theme) -> String {
337    let emoji = grade.emoji();
338    let grade_display = grade.color_str(nc, theme);
339    if nc {
340        format!("[{grade_display}]")
341    } else if terminal::no_emoji() {
342        grade_display.clone()
343    } else {
344        format!("{emoji} {grade_display}")
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use crate::theme::Theme;
352
353    // ── LetterGrade enum tests ──────────────────────────────────────────────
354
355    #[test]
356    fn test_letter_grade_ordering() {
357        // LetterGrade derives PartialOrd based on enum order
358        // APlus=0, A=1, ... F=10, so lower discriminant = "greater"
359        assert!(LetterGrade::APlus.score() > LetterGrade::A.score());
360        assert!(LetterGrade::A.score() > LetterGrade::AMinus.score());
361        assert!(LetterGrade::B.score() > LetterGrade::C.score());
362        assert!(LetterGrade::F.score() < LetterGrade::D.score());
363    }
364
365    #[test]
366    fn test_letter_grade_as_str_all_variants() {
367        assert_eq!(LetterGrade::APlus.as_str(), "A+");
368        assert_eq!(LetterGrade::A.as_str(), "A");
369        assert_eq!(LetterGrade::AMinus.as_str(), "A-");
370        assert_eq!(LetterGrade::BPlus.as_str(), "B+");
371        assert_eq!(LetterGrade::B.as_str(), "B");
372        assert_eq!(LetterGrade::BMinus.as_str(), "B-");
373        assert_eq!(LetterGrade::CPlus.as_str(), "C+");
374        assert_eq!(LetterGrade::C.as_str(), "C");
375        assert_eq!(LetterGrade::CMinus.as_str(), "C-");
376        assert_eq!(LetterGrade::D.as_str(), "D");
377        assert_eq!(LetterGrade::F.as_str(), "F");
378    }
379
380    #[test]
381    fn test_letter_grade_score_all_variants() {
382        assert_eq!(LetterGrade::APlus.score(), 100.0);
383        assert_eq!(LetterGrade::A.score(), 95.0);
384        assert_eq!(LetterGrade::AMinus.score(), 90.0);
385        assert_eq!(LetterGrade::BPlus.score(), 85.0);
386        assert_eq!(LetterGrade::B.score(), 80.0);
387        assert_eq!(LetterGrade::BMinus.score(), 75.0);
388        assert_eq!(LetterGrade::CPlus.score(), 70.0);
389        assert_eq!(LetterGrade::C.score(), 65.0);
390        assert_eq!(LetterGrade::CMinus.score(), 60.0);
391        assert_eq!(LetterGrade::D.score(), 50.0);
392        assert_eq!(LetterGrade::F.score(), 25.0);
393    }
394
395    #[test]
396    fn test_letter_grade_description_all_variants() {
397        assert_eq!(LetterGrade::APlus.description(), "Exceptional");
398        assert_eq!(LetterGrade::A.description(), "Excellent");
399        assert_eq!(LetterGrade::AMinus.description(), "Very Good");
400        assert_eq!(LetterGrade::BPlus.description(), "Good+");
401        assert_eq!(LetterGrade::B.description(), "Good");
402        assert_eq!(LetterGrade::BMinus.description(), "Above Average");
403        assert_eq!(LetterGrade::CPlus.description(), "Average+");
404        assert_eq!(LetterGrade::C.description(), "Average");
405        assert_eq!(LetterGrade::CMinus.description(), "Below Average");
406        assert_eq!(LetterGrade::D.description(), "Poor");
407        assert_eq!(LetterGrade::F.description(), "Unacceptable");
408    }
409
410    #[test]
411    fn test_letter_grade_color_str_nc_mode() {
412        // In NC mode, should return bracketed string
413        assert_eq!(LetterGrade::A.color_str(true, Theme::Dark), "[A]");
414        assert_eq!(LetterGrade::F.color_str(true, Theme::Dark), "[F]");
415        assert_eq!(LetterGrade::BPlus.color_str(true, Theme::Dark), "[B+]");
416    }
417
418    #[test]
419    fn test_letter_grade_color_str_all_grades() {
420        // Test all grades have color output (not empty in non-NC mode)
421        for grade in &[
422            LetterGrade::APlus,
423            LetterGrade::A,
424            LetterGrade::AMinus,
425            LetterGrade::BPlus,
426            LetterGrade::B,
427            LetterGrade::BMinus,
428            LetterGrade::CPlus,
429            LetterGrade::C,
430            LetterGrade::CMinus,
431            LetterGrade::D,
432            LetterGrade::F,
433        ] {
434            let colored = grade.color_str(false, Theme::Dark);
435            assert!(
436                !colored.is_empty(),
437                "Grade {:?} should have color output",
438                grade
439            );
440            assert!(
441                colored.contains(grade.as_str()),
442                "Colored output should contain grade string"
443            );
444        }
445    }
446
447    #[test]
448    fn test_letter_grade_color_str_different_themes() {
449        let grade = LetterGrade::A;
450        // Test with different themes
451        let dark = grade.color_str(false, Theme::Dark);
452        let light = grade.color_str(false, Theme::Light);
453        assert!(!dark.is_empty());
454        assert!(!light.is_empty());
455        // Both should contain the grade letter
456        assert!(dark.contains("A"));
457        assert!(light.contains("A"));
458    }
459
460    // ── grade_ping tests ────────────────────────────────────────────────────
461
462    #[test]
463    fn test_grade_ping_excellent() {
464        let p = UserProfile::PowerUser;
465        // PowerUser has 10ms excellent threshold
466        // APlus: <= 5ms
467        assert_eq!(grade_ping(3.0, p), LetterGrade::APlus);
468        assert_eq!(grade_ping(5.0, p), LetterGrade::APlus);
469        // A: <= 10ms
470        assert_eq!(grade_ping(6.0, p), LetterGrade::A);
471        assert_eq!(grade_ping(10.0, p), LetterGrade::A);
472        // A-: <= 15ms
473        assert_eq!(grade_ping(11.0, p), LetterGrade::AMinus);
474        assert_eq!(grade_ping(15.0, p), LetterGrade::AMinus);
475    }
476
477    #[test]
478    fn test_grade_ping_good_to_fail() {
479        let p = UserProfile::PowerUser;
480        // B: <= 30ms (good = 3 * 10 = 30)
481        assert_eq!(grade_ping(20.0, p), LetterGrade::B);
482        assert_eq!(grade_ping(30.0, p), LetterGrade::B);
483        // C: <= 45ms (good * 1.5)
484        assert_eq!(grade_ping(40.0, p), LetterGrade::C);
485        assert_eq!(grade_ping(45.0, p), LetterGrade::C);
486        // D: <= 60ms (average = 6 * 10 = 60)
487        assert_eq!(grade_ping(50.0, p), LetterGrade::D);
488        assert_eq!(grade_ping(60.0, p), LetterGrade::D);
489        // F: > 60ms
490        assert_eq!(grade_ping(61.0, p), LetterGrade::F);
491        assert_eq!(grade_ping(500.0, p), LetterGrade::F);
492    }
493
494    #[test]
495    fn test_grade_ping_gamer_profile() {
496        let g = UserProfile::Gamer;
497        // Gamer has 5ms excellent threshold
498        // APlus: <= 2.5ms
499        assert_eq!(grade_ping(2.0, g), LetterGrade::APlus);
500        // A: <= 5ms
501        assert_eq!(grade_ping(3.0, g), LetterGrade::A);
502        assert_eq!(grade_ping(5.0, g), LetterGrade::A);
503        // A-: <= 7.5ms
504        assert_eq!(grade_ping(7.0, g), LetterGrade::AMinus);
505        // B: <= 15ms (good = 3 * 5 = 15)
506        assert_eq!(grade_ping(10.0, g), LetterGrade::B);
507        // F: > 30ms (average = 6 * 5 = 30)
508        assert_eq!(grade_ping(100.0, g), LetterGrade::F);
509    }
510
511    #[test]
512    fn test_grade_ping_streamer_profile() {
513        let s = UserProfile::Streamer;
514        // Streamer has 30ms excellent threshold (2x Gamer)
515        // APlus: <= 15ms, A: <= 30ms, A-: <= 45ms
516        assert_eq!(grade_ping(10.0, s), LetterGrade::APlus);
517        assert_eq!(grade_ping(30.0, s), LetterGrade::A);
518        assert_eq!(grade_ping(200.0, s), LetterGrade::F);
519    }
520
521    // ── grade_jitter tests ──────────────────────────────────────────────────
522
523    #[test]
524    fn test_grade_jitter_excellent() {
525        let p = UserProfile::PowerUser;
526        // PowerUser has 2ms excellent threshold
527        // APlus: <= 1ms
528        assert_eq!(grade_jitter(0.5, p), LetterGrade::APlus);
529        assert_eq!(grade_jitter(1.0, p), LetterGrade::APlus);
530        // A: <= 2ms
531        assert_eq!(grade_jitter(1.5, p), LetterGrade::A);
532        assert_eq!(grade_jitter(2.0, p), LetterGrade::A);
533        // B: <= 4ms (excellent * 2.0)
534        assert_eq!(grade_jitter(3.0, p), LetterGrade::B);
535        assert_eq!(grade_jitter(4.0, p), LetterGrade::B);
536    }
537
538    #[test]
539    fn test_grade_jitter_good_to_fail() {
540        let p = UserProfile::PowerUser;
541        // C: <= 6ms (good = 3 * 2 = 6)
542        assert_eq!(grade_jitter(5.0, p), LetterGrade::C);
543        // D: <= 16ms (average = 8 * 2 = 16)
544        assert_eq!(grade_jitter(10.0, p), LetterGrade::D);
545        assert_eq!(grade_jitter(16.0, p), LetterGrade::D);
546        // F: > 16ms
547        assert_eq!(grade_jitter(50.0, p), LetterGrade::F);
548    }
549
550    // ── grade_download tests ────────────────────────────────────────────────
551
552    #[test]
553    fn test_grade_download_excellent() {
554        let p = UserProfile::PowerUser;
555        // PowerUser has 500 Mbps excellent threshold
556        // APlus: >= 1000 Mbps (2x)
557        assert_eq!(grade_download(1000.0, p), LetterGrade::APlus);
558        assert_eq!(grade_download(500.0, p), LetterGrade::A);
559        // B: >= 375 Mbps (0.75x)
560        assert_eq!(grade_download(400.0, p), LetterGrade::B);
561        assert_eq!(grade_download(375.0, p), LetterGrade::B);
562    }
563
564    #[test]
565    fn test_grade_download_good_to_fail() {
566        let p = UserProfile::PowerUser;
567        // C: >= 200 Mbps (0.4x = good)
568        assert_eq!(grade_download(200.0, p), LetterGrade::C);
569        // D: >= 75 Mbps (0.15x = average)
570        assert_eq!(grade_download(75.0, p), LetterGrade::D);
571        // F: < 75 Mbps
572        assert_eq!(grade_download(50.0, p), LetterGrade::F);
573        assert_eq!(grade_download(1.0, p), LetterGrade::F);
574    }
575
576    #[test]
577    fn test_grade_download_streamer_profile() {
578        let s = UserProfile::Streamer;
579        // Streamer has 200 Mbps excellent threshold
580        // APlus: >= 400 Mbps, A: >= 200 Mbps
581        // B: >= 150 Mbps (0.75x), C: >= 80 Mbps (0.4x), D: >= 30 Mbps (0.15x)
582        assert_eq!(grade_download(400.0, s), LetterGrade::APlus);
583        assert_eq!(grade_download(200.0, s), LetterGrade::A);
584        // 100 < 150, so C (100 >= 80)
585        assert_eq!(grade_download(100.0, s), LetterGrade::C);
586        // 80 >= 80 → C boundary
587        assert_eq!(grade_download(80.0, s), LetterGrade::C);
588        assert_eq!(grade_download(40.0, s), LetterGrade::D);
589        assert_eq!(grade_download(10.0, s), LetterGrade::F);
590    }
591
592    #[test]
593    fn test_grade_download_gamer_profile() {
594        let g = UserProfile::Gamer;
595        // Gamer has 100 Mbps excellent threshold
596        assert_eq!(grade_download(200.0, g), LetterGrade::APlus);
597        assert_eq!(grade_download(100.0, g), LetterGrade::A);
598        assert_eq!(grade_download(50.0, g), LetterGrade::C);
599    }
600
601    // ── grade_upload tests ──────────────────────────────────────────────────
602
603    #[test]
604    fn test_grade_upload_excellent() {
605        let p = UserProfile::PowerUser;
606        // Upload excellent is 50% of download (250 Mbps for PowerUser)
607        assert_eq!(grade_upload(500.0, p), LetterGrade::APlus);
608        assert_eq!(grade_upload(250.0, p), LetterGrade::A);
609        // B: >= 187.5 Mbps (0.75x)
610        assert_eq!(grade_upload(200.0, p), LetterGrade::B);
611    }
612
613    #[test]
614    fn test_grade_upload_good_to_fail() {
615        let p = UserProfile::PowerUser;
616        // C: >= 100 Mbps (0.4x)
617        assert_eq!(grade_upload(100.0, p), LetterGrade::C);
618        // D: >= 37.5 Mbps (0.15x)
619        assert_eq!(grade_upload(40.0, p), LetterGrade::D);
620        // F: < 37.5 Mbps
621        assert_eq!(grade_upload(1.0, p), LetterGrade::F);
622    }
623
624    // ── grade_bufferbloat tests ─────────────────────────────────────────────
625
626    #[test]
627    fn test_grade_bufferbloat_all_levels() {
628        // APlus: < 3ms
629        assert_eq!(grade_bufferbloat(0.0), LetterGrade::APlus);
630        assert_eq!(grade_bufferbloat(2.0), LetterGrade::APlus);
631        // A: 3-5ms
632        assert_eq!(grade_bufferbloat(3.0), LetterGrade::A);
633        assert_eq!(grade_bufferbloat(4.0), LetterGrade::A);
634        // A-: 5-10ms
635        assert_eq!(grade_bufferbloat(5.0), LetterGrade::AMinus);
636        assert_eq!(grade_bufferbloat(8.0), LetterGrade::AMinus);
637        // B+: 10-20ms
638        assert_eq!(grade_bufferbloat(15.0), LetterGrade::BPlus);
639        // B: 20-30ms
640        assert_eq!(grade_bufferbloat(25.0), LetterGrade::B);
641        // C: 30-50ms
642        assert_eq!(grade_bufferbloat(40.0), LetterGrade::C);
643        // D: 50-100ms
644        assert_eq!(grade_bufferbloat(75.0), LetterGrade::D);
645        // F: >= 100ms
646        assert_eq!(grade_bufferbloat(100.0), LetterGrade::F);
647        assert_eq!(grade_bufferbloat(200.0), LetterGrade::F);
648    }
649
650    #[test]
651    fn test_grade_bufferbloat_boundary_cases() {
652        // Exact boundary values
653        assert_eq!(grade_bufferbloat(2.999), LetterGrade::APlus);
654        assert_eq!(grade_bufferbloat(3.001), LetterGrade::A);
655        assert_eq!(grade_bufferbloat(4.999), LetterGrade::A);
656        assert_eq!(grade_bufferbloat(5.001), LetterGrade::AMinus);
657        assert_eq!(grade_bufferbloat(9.999), LetterGrade::AMinus);
658        assert_eq!(grade_bufferbloat(10.001), LetterGrade::BPlus);
659    }
660
661    // ── grade_stability tests ───────────────────────────────────────────────
662
663    #[test]
664    fn test_grade_stability_all_levels() {
665        // APlus: < 3%, A: 3-5%, B: 5-8%, C: 8-15%, D: 15-25%, F: >= 25%
666        assert_eq!(grade_stability(0.0), LetterGrade::APlus);
667        assert_eq!(grade_stability(2.0), LetterGrade::APlus);
668        assert_eq!(grade_stability(3.0), LetterGrade::A);
669        assert_eq!(grade_stability(4.0), LetterGrade::A);
670        assert_eq!(grade_stability(5.0), LetterGrade::B);
671        assert_eq!(grade_stability(7.0), LetterGrade::B);
672        assert_eq!(grade_stability(10.0), LetterGrade::C);
673        assert_eq!(grade_stability(20.0), LetterGrade::D);
674        assert_eq!(grade_stability(50.0), LetterGrade::F);
675    }
676
677    // ── score_to_grade tests ────────────────────────────────────────────────
678
679    #[test]
680    fn test_score_to_grade_all_levels() {
681        // APlus: >= 97
682        assert_eq!(score_to_grade(97.0), LetterGrade::APlus);
683        assert_eq!(score_to_grade(100.0), LetterGrade::APlus);
684        // A: >= 93
685        assert_eq!(score_to_grade(93.0), LetterGrade::A);
686        assert_eq!(score_to_grade(96.99), LetterGrade::A);
687        // A-: >= 90
688        assert_eq!(score_to_grade(90.0), LetterGrade::AMinus);
689        assert_eq!(score_to_grade(92.99), LetterGrade::AMinus);
690        // B+: >= 87
691        assert_eq!(score_to_grade(87.0), LetterGrade::BPlus);
692        assert_eq!(score_to_grade(89.99), LetterGrade::BPlus);
693        // B: >= 80
694        assert_eq!(score_to_grade(80.0), LetterGrade::B);
695        assert_eq!(score_to_grade(86.99), LetterGrade::B);
696        // B-: >= 77
697        assert_eq!(score_to_grade(77.0), LetterGrade::BMinus);
698        assert_eq!(score_to_grade(79.99), LetterGrade::BMinus);
699        // C+: >= 70
700        assert_eq!(score_to_grade(70.0), LetterGrade::CPlus);
701        assert_eq!(score_to_grade(76.99), LetterGrade::CPlus);
702        // C: >= 65
703        assert_eq!(score_to_grade(65.0), LetterGrade::C);
704        assert_eq!(score_to_grade(69.99), LetterGrade::C);
705        // C-: >= 60
706        assert_eq!(score_to_grade(60.0), LetterGrade::CMinus);
707        assert_eq!(score_to_grade(64.99), LetterGrade::CMinus);
708        // D: >= 50
709        assert_eq!(score_to_grade(50.0), LetterGrade::D);
710        assert_eq!(score_to_grade(59.99), LetterGrade::D);
711        // F: < 50
712        assert_eq!(score_to_grade(49.99), LetterGrade::F);
713        assert_eq!(score_to_grade(0.0), LetterGrade::F);
714    }
715
716    #[test]
717    fn test_score_to_grade_boundary_cases() {
718        // Thresholds: A+>=97, A>=93, A->=90, B+>=87, B>=80, B->=77, C+>=70, C>=65, C->=60, D>=50, F<50
719        assert_eq!(score_to_grade(96.99), LetterGrade::A); // 96.99 < 97
720        assert_eq!(score_to_grade(92.99), LetterGrade::AMinus); // 92.99 < 93
721        assert_eq!(score_to_grade(86.99), LetterGrade::B); // 86.99 < 87
722        assert_eq!(score_to_grade(79.99), LetterGrade::BMinus); // 79.99 < 80 but >= 77
723        assert_eq!(score_to_grade(76.99), LetterGrade::CPlus); // 76.99 < 77
724        assert_eq!(score_to_grade(69.99), LetterGrade::C); // 69.99 < 70
725        assert_eq!(score_to_grade(64.99), LetterGrade::CMinus); // 64.99 < 65
726        assert_eq!(score_to_grade(59.99), LetterGrade::D); // 59.99 >= 50
727    }
728
729    // ── grade_overall tests ─────────────────────────────────────────────────
730
731    #[test]
732    fn test_grade_overall_all_none() {
733        let p = UserProfile::PowerUser;
734        // All None should return F
735        let grade = grade_overall(None, None, None, None, p);
736        assert_eq!(grade, LetterGrade::F);
737    }
738
739    #[test]
740    fn test_grade_overall_excellent() {
741        let p = UserProfile::PowerUser;
742        let grade = grade_overall(
743            Some(5.0),           // Excellent ping
744            Some(1.0),           // Excellent jitter
745            Some(600_000_000.0), // Excellent download
746            Some(300_000_000.0), // Excellent upload
747            p,
748        );
749        assert!(grade.score() >= LetterGrade::A.score());
750    }
751
752    #[test]
753    fn test_grade_overall_poor() {
754        let p = UserProfile::PowerUser;
755        let grade = grade_overall(
756            Some(200.0),       // Bad ping
757            Some(50.0),        // Bad jitter
758            Some(5_000_000.0), // Bad download
759            Some(1_000_000.0), // Bad upload
760            p,
761        );
762        assert!(grade.score() <= LetterGrade::F.score());
763    }
764
765    #[test]
766    fn test_grade_overall_partial_data() {
767        let p = UserProfile::PowerUser;
768        // Only ping and download
769        let grade = grade_overall(Some(5.0), None, Some(600_000_000.0), None, p);
770        assert!(grade.score() >= LetterGrade::A.score());
771
772        // Only download
773        let grade = grade_overall(None, None, Some(600_000_000.0), None, p);
774        assert!(grade.score() >= LetterGrade::A.score());
775
776        // Only ping
777        let grade = grade_overall(Some(5.0), None, None, None, p);
778        assert!(grade.score() >= LetterGrade::A.score());
779
780        // Only jitter
781        let grade = grade_overall(None, Some(1.0), None, None, p);
782        assert!(grade.score() >= LetterGrade::A.score());
783    }
784
785    #[test]
786    fn test_grade_overall_mixed_quality() {
787        let p = UserProfile::PowerUser;
788        // Good ping, bad download
789        let grade = grade_overall(Some(5.0), None, Some(5_000_000.0), None, p);
790        // Should be in D-F range (bad download drags down good ping)
791        assert!(grade.score() <= LetterGrade::D.score());
792        assert!(grade.score() >= LetterGrade::F.score());
793    }
794
795    // ── format_grade_line tests ─────────────────────────────────────────────
796
797    #[test]
798    fn test_format_grade_line_with_value() {
799        let grade = LetterGrade::A;
800        let line = format_grade_line("Ping", grade, Some("5.2 ms"), false, Theme::Dark);
801        assert!(line.contains("Ping"));
802        assert!(line.contains("5.2 ms"));
803        assert!(line.contains("A"));
804    }
805
806    #[test]
807    fn test_format_grade_line_without_value() {
808        let grade = LetterGrade::B;
809        let line = format_grade_line("Jitter", grade, None, false, Theme::Dark);
810        assert!(line.contains("Jitter"));
811        assert!(line.contains("B"));
812        // Should not have extra parentheses without value
813        assert!(!line.contains("()"));
814    }
815
816    #[test]
817    fn test_format_grade_line_nc_mode() {
818        let grade = LetterGrade::C;
819        let line = format_grade_line("Download", grade, Some("100 Mbps"), true, Theme::Dark);
820        // In NC mode, should use brackets
821        assert!(line.contains("["));
822        assert!(line.contains("]"));
823    }
824
825    #[test]
826    fn test_format_grade_line_all_grade_levels() {
827        // Test all grade levels produce valid output
828        let metrics = ["Ping", "Jitter", "Download", "Upload", "Bufferbloat"];
829        for grade in &[
830            LetterGrade::APlus,
831            LetterGrade::A,
832            LetterGrade::B,
833            LetterGrade::C,
834            LetterGrade::D,
835            LetterGrade::F,
836        ] {
837            for metric in &metrics {
838                let line = format_grade_line(metric, *grade, Some("value"), false, Theme::Dark);
839                assert!(!line.is_empty());
840            }
841        }
842    }
843
844    // ── grade_badge tests ───────────────────────────────────────────────────
845
846    #[test]
847    fn test_grade_badge_nc_mode() {
848        let badge = grade_badge(LetterGrade::A, true, Theme::Dark);
849        // NC mode uses brackets, format: [grade_str]
850        assert!(badge.starts_with('['));
851        assert!(badge.ends_with(']'));
852        assert!(badge.contains('A'));
853    }
854
855    #[test]
856    fn test_grade_badge_colored_mode() {
857        let badge = grade_badge(LetterGrade::B, false, Theme::Dark);
858        assert!(badge.contains('B'));
859        assert!(!badge.is_empty());
860    }
861
862    #[test]
863    fn test_grade_badge_all_grades() {
864        for grade in &[
865            LetterGrade::APlus,
866            LetterGrade::A,
867            LetterGrade::B,
868            LetterGrade::C,
869            LetterGrade::F,
870        ] {
871            let badge = grade_badge(*grade, false, Theme::Dark);
872            assert!(!badge.is_empty());
873            assert!(badge.contains(grade.as_str()));
874        }
875    }
876
877    // ── Copy trait verification ─────────────────────────────────────────────
878
879    #[test]
880    fn test_letter_grade_is_copy() {
881        let grade = LetterGrade::BPlus;
882        let _copied = grade; // Copy is implicit for Copy types
883        assert_eq!(grade, _copied);
884    }
885
886    // ── Debug trait verification ────────────────────────────────────────────
887
888    #[test]
889    fn test_letter_grade_debug() {
890        let grade = LetterGrade::C;
891        let debug_str = format!("{:?}", grade);
892        assert!(debug_str.contains("C"));
893    }
894
895    // ── PartialEq and Eq verification ───────────────────────────────────────
896
897    #[test]
898    fn test_letter_grade_partial_eq() {
899        assert_eq!(LetterGrade::A, LetterGrade::A);
900        assert_ne!(LetterGrade::A, LetterGrade::B);
901        assert_eq!(LetterGrade::APlus, LetterGrade::APlus);
902    }
903
904    // ── Ord verification ────────────────────────────────────────────────────
905
906    #[test]
907    fn test_letter_grade_ord() {
908        assert!(LetterGrade::APlus < LetterGrade::A);
909        assert!(LetterGrade::A < LetterGrade::B);
910        assert!(LetterGrade::B < LetterGrade::C);
911        assert!(LetterGrade::C < LetterGrade::D);
912        assert!(LetterGrade::D < LetterGrade::F);
913    }
914}