Skip to main content

netspeed_cli/formatter/
scenarios.rs

1//! Grouped, category-based internet usage scenario display.
2//!
3//! Replaces the flat usage checklist with a visually grouped TUI showing
4//! 14 modern internet scenarios across 5 categories, with capacity bars,
5//! simultaneous stream counts, and personalized recommendations.
6//!
7//! # Layout
8//! ```text
9//! ┌──────────────────── USAGE CAPABILITY ────────────────────┐
10//! │  ┌─ COMMUNICATION ─────────────────────────────────┐   │
11//! │  │ 📹 HD Video Calls         8 Mbps  [████████░░] 62× ✅ │
12//! │  └──────────────────────────────────────────────────┘   │
13//! │  ... (4 more categories)                                │
14//! │  ──── SUMMARY ─────────────────────────────────────     │
15//! │  Your 500 Mbps connection supports: ...                 │
16//! │  ⚠️  Recommendation: ...                                │
17//! └──────────────────────────────────────────────────────────┘
18//! ```
19
20#![allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
21
22use owo_colors::OwoColorize;
23
24use crate::terminal;
25
26// ── Constants ────────────────────────────────────────────────────────────────
27
28const BAR_WIDTH: usize = 10;
29const NAME_WIDTH: usize = 26;
30const LINE_WIDTH: usize = 86;
31
32// ── Data Structures ──────────────────────────────────────────────────────────
33
34/// A single usage scenario with bandwidth requirements.
35pub struct UsageScenario {
36    pub name: &'static str,
37    pub required_mbps: f64,
38    pub icon: &'static str,
39    pub concurrent_label: &'static str,
40}
41
42/// A category grouping scenarios.
43pub struct ScenarioCategory {
44    pub name: &'static str,
45    pub icon: &'static str,
46    pub scenarios: &'static [UsageScenario],
47}
48
49/// Computed status for a single scenario.
50pub struct ScenarioStatus {
51    pub scenario: &'static UsageScenario,
52    pub concurrent: u32,
53    pub headroom_pct: f64,
54    pub is_met: bool,
55}
56
57/// Overall headroom level for exit code determination.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
59pub enum HeadroomLevel {
60    Green,  // >50% headroom
61    Yellow, // 20-50% headroom
62    Red,    // <20% headroom
63}
64
65// ── Scenario Definitions ─────────────────────────────────────────────────────
66
67static CAT_COMMUNICATION: ScenarioCategory = ScenarioCategory {
68    name: "COMMUNICATION & COLLABORATION",
69    icon: "💬",
70    scenarios: &[
71        UsageScenario {
72            name: "HD Video Calls (Zoom/Teams+Share)",
73            required_mbps: 8.0,
74            icon: "📹",
75            concurrent_label: "calls",
76        },
77        UsageScenario {
78            name: "4K Video Calls (FaceTime/Meet)",
79            required_mbps: 25.0,
80            icon: "📹",
81            concurrent_label: "calls",
82        },
83        UsageScenario {
84            name: "VoIP + Encrypted VPN",
85            required_mbps: 2.0,
86            icon: "🔒",
87            concurrent_label: "sessions",
88        },
89    ],
90};
91
92static CAT_STREAMING: ScenarioCategory = ScenarioCategory {
93    name: "STREAMING & ENTERTAINMENT",
94    icon: "🎬",
95    scenarios: &[
96        UsageScenario {
97            name: "4K HDR Streaming (Netflix/Disney+)",
98            required_mbps: 35.0,
99            icon: "📺",
100            concurrent_label: "streams",
101        },
102        UsageScenario {
103            name: "Cloud Gaming (GeForce Now/Xbox)",
104            required_mbps: 50.0,
105            icon: "🎮",
106            concurrent_label: "sessions",
107        },
108        UsageScenario {
109            name: "Live Broadcast Upload (Twitch/YT)",
110            required_mbps: 30.0,
111            icon: "📡",
112            concurrent_label: "streams",
113        },
114    ],
115};
116
117static CAT_PRODUCTIVITY: ScenarioCategory = ScenarioCategory {
118    name: "WORK & PRODUCTIVITY",
119    icon: "💼",
120    scenarios: &[
121        UsageScenario {
122            name: "Cloud Sync Bulk Upload (Drive/Dropbox)",
123            required_mbps: 50.0,
124            icon: "☁️",
125            concurrent_label: "syncs",
126        },
127        UsageScenario {
128            name: "4K Video Upload (YouTube Creator)",
129            required_mbps: 80.0,
130            icon: "🎥",
131            concurrent_label: "uploads",
132        },
133        UsageScenario {
134            name: "Remote Desktop HD (Parsec/TeamViewer)",
135            required_mbps: 30.0,
136            icon: "🖥️",
137            concurrent_label: "sessions",
138        },
139    ],
140};
141
142static CAT_SMART_HOME: ScenarioCategory = ScenarioCategory {
143    name: "SMART HOME & IOT",
144    icon: "🏠",
145    scenarios: &[
146        UsageScenario {
147            name: "4x 1080p Security Cameras",
148            required_mbps: 20.0,
149            icon: "📷",
150            concurrent_label: "arrays",
151        },
152        UsageScenario {
153            name: "50+ IoT Devices Hub",
154            required_mbps: 5.0,
155            icon: "🔌",
156            concurrent_label: "hubs",
157        },
158    ],
159};
160
161static CAT_NEXTGEN: ScenarioCategory = ScenarioCategory {
162    name: "NEXT-GEN / HEAVY USAGE",
163    icon: "🚀",
164    scenarios: &[
165        UsageScenario {
166            name: "8K Streaming (YouTube 8K/AV1)",
167            required_mbps: 100.0,
168            icon: "🎬",
169            concurrent_label: "streams",
170        },
171        UsageScenario {
172            name: "VR/AR Streaming (Quest 3/Vision Pro)",
173            required_mbps: 80.0,
174            icon: "🥽",
175            concurrent_label: "sessions",
176        },
177        UsageScenario {
178            name: "AI Model Download (7-70GB LLM)",
179            required_mbps: 200.0,
180            icon: "🤖",
181            concurrent_label: "downloads",
182        },
183        UsageScenario {
184            name: "4x Simultaneous 4K Streams",
185            required_mbps: 140.0,
186            icon: "👨‍👩‍👧‍👦",
187            concurrent_label: "households",
188        },
189    ],
190};
191
192/// All scenario categories in default display order.
193const ALL_CATEGORIES: &[&ScenarioCategory] = &[
194    &CAT_COMMUNICATION,
195    &CAT_STREAMING,
196    &CAT_PRODUCTIVITY,
197    &CAT_SMART_HOME,
198    &CAT_NEXTGEN,
199];
200
201/// Get all scenario categories.
202#[must_use]
203pub fn all_categories() -> &'static [&'static ScenarioCategory] {
204    ALL_CATEGORIES
205}
206
207// ── Status Computation ───────────────────────────────────────────────────────
208
209/// Compute status for all scenarios given download speed in Mbps.
210#[must_use]
211pub fn compute_all_statuses(dl_mbps: f64) -> Vec<Vec<ScenarioStatus>> {
212    all_categories()
213        .iter()
214        .map(|cat| {
215            cat.scenarios
216                .iter()
217                .map(|s| compute_scenario_status(dl_mbps, s))
218                .collect()
219        })
220        .collect()
221}
222
223fn compute_scenario_status(dl_mbps: f64, scenario: &'static UsageScenario) -> ScenarioStatus {
224    // Safe: dl_mbps/required_mbps is a small ratio; floor→u32 is bounded by
225    // realistic bandwidth values (never approaching u32::MAX).
226    let concurrent = if scenario.required_mbps > 0.0 {
227        (dl_mbps / scenario.required_mbps)
228            .floor()
229            .clamp(0.0, f64::from(u32::MAX)) as u32
230    } else {
231        0
232    };
233    let headroom_pct = if scenario.required_mbps > 0.0 {
234        ((dl_mbps - scenario.required_mbps) / scenario.required_mbps * 100.0).max(0.0)
235    } else {
236        100.0
237    };
238    let is_met = dl_mbps >= scenario.required_mbps;
239
240    ScenarioStatus {
241        scenario,
242        concurrent,
243        headroom_pct,
244        is_met,
245    }
246}
247
248/// Determine the worst headroom level across all statuses.
249#[must_use]
250pub fn worst_headroom_level(statuses: &[Vec<ScenarioStatus>]) -> HeadroomLevel {
251    let mut worst = HeadroomLevel::Green;
252    for cat in statuses {
253        for s in cat {
254            let level = headroom_level(s.headroom_pct);
255            if level > worst {
256                worst = level;
257            }
258        }
259    }
260    worst
261}
262
263fn headroom_level(pct: f64) -> HeadroomLevel {
264    if pct > 50.0 {
265        HeadroomLevel::Green
266    } else if pct >= 20.0 {
267        HeadroomLevel::Yellow
268    } else {
269        HeadroomLevel::Red
270    }
271}
272
273// ── Rendering ────────────────────────────────────────────────────────────────
274
275/// Render a capacity bar: [████████░░]
276fn render_capacity_bar(headroom_pct: f64, is_met: bool, _nc: bool, minimal: bool) -> String {
277    let fill = if is_met {
278        ((headroom_pct / 100.0) * BAR_WIDTH as f64)
279            .ceil()
280            .min(BAR_WIDTH as f64) as usize
281    } else {
282        0
283    };
284    let empty = BAR_WIDTH.saturating_sub(fill);
285
286    if minimal {
287        format!("[{}{}]", "#".repeat(fill), "-".repeat(empty))
288    } else if terminal::no_color() {
289        format!("[{}{}]", "█".repeat(fill), "░".repeat(empty))
290    } else {
291        let bar_color = if headroom_pct > 50.0 {
292            "green"
293        } else if headroom_pct >= 20.0 {
294            "yellow"
295        } else {
296            "red"
297        };
298        let filled = "█".repeat(fill);
299        let empty_str = "░".repeat(empty);
300        match bar_color {
301            "green" => format!("[{}{}]", filled.green(), empty_str),
302            "yellow" => format!("[{}{}]", filled.yellow(), empty_str),
303            _ => format!("[{}{}]", filled.red().bold(), empty_str),
304        }
305    }
306}
307
308/// Render a status emoji/symbol.
309fn render_status_symbol(headroom_pct: f64, is_met: bool) -> String {
310    let hide_emoji = terminal::no_emoji();
311    if !is_met {
312        if hide_emoji { "FAIL" } else { "❌" }.to_string()
313    } else if headroom_pct > 50.0 {
314        if hide_emoji { "OK" } else { "✅" }.to_string()
315    } else if headroom_pct >= 20.0 {
316        if hide_emoji { "WARN" } else { "⚠️" }.to_string()
317    } else {
318        if hide_emoji { "LOW" } else { "🔴" }.to_string()
319    }
320}
321
322/// Render a single scenario row.
323fn render_scenario_row(status: &ScenarioStatus, nc: bool, minimal: bool) -> String {
324    let s = status.scenario;
325    let bar = render_capacity_bar(status.headroom_pct, status.is_met, nc, minimal);
326    let symbol = render_status_symbol(status.headroom_pct, status.is_met);
327    let concurrent = status.concurrent;
328
329    let name_display = if minimal || terminal::no_emoji() {
330        format!("{:<NAME_WIDTH$}", s.name)
331    } else {
332        format!("{} {:<NAME_WIDTH$}", s.icon, s.name)
333    };
334
335    let req_display = format!("{:>6.0} Mbps", s.required_mbps);
336
337    // Build inner content (everything between the │ borders)
338    let inner = if minimal || nc {
339        format!("{name_display:<NAME_WIDTH$} {req_display}  {bar} {concurrent:>3}x {symbol:<5}",)
340    } else {
341        // Colorize the requirement based on whether it's met
342        let req_colored = if status.is_met {
343            req_display.dimmed().to_string()
344        } else {
345            req_display.red().bold().to_string()
346        };
347        format!("{name_display:<NAME_WIDTH$} {req_colored}  {bar} {concurrent:>3}x {symbol:<5}",)
348    };
349
350    // Right-pad to exactly LINE_WIDTH - 2 (1 space padding each side inside borders)
351    let content_width = LINE_WIDTH - 2;
352    let padded = format!("{inner:<content_width$}");
353    format!("  │ {padded} │")
354}
355
356/// Render a category box header.
357fn render_category_header(cat: &ScenarioCategory, nc: bool, minimal: bool) -> String {
358    let content_width = LINE_WIDTH - 2; // 1 space padding each side
359    let title = format!(" {} {} ", cat.icon, cat.name);
360    let dashes = "─".repeat(content_width.saturating_sub(title.len()));
361    let inner = format!("{title}{dashes}");
362    // Right-pad to exactly content_width so the closing │ always aligns
363    let padded = format!("{inner:<content_width$}");
364    if minimal || nc {
365        format!("  │ {padded} │")
366    } else {
367        // For colored output, compute the inner content length, then pad
368        let inner_len = title.len() + dashes.len();
369        let pad = " ".repeat(content_width.saturating_sub(inner_len));
370        format!("  │ {}{}{pad} │", title.cyan().bold(), dashes.dimmed())
371    }
372}
373
374/// Render a category box (header + rows + footer).
375fn render_category_box(
376    cat: &ScenarioCategory,
377    statuses: &[ScenarioStatus],
378    nc: bool,
379    minimal: bool,
380) -> String {
381    let mut lines = Vec::new();
382    let content_width = LINE_WIDTH - 2; // 1 space padding each side
383
384    // Top border — use │ for interior lines (not ┌┐)
385    if minimal || nc {
386        lines.push(format!("  │ {:-<content_width$} │", ""));
387    } else {
388        lines.push(format!("  │ {} │", "─".repeat(content_width).dimmed()));
389    }
390
391    // Category header
392    lines.push(render_category_header(cat, nc, minimal));
393
394    // Scenario rows
395    for status in statuses {
396        lines.push(render_scenario_row(status, nc, minimal));
397    }
398
399    // Bottom border — use │ for interior lines (not └┘)
400    if minimal || nc {
401        lines.push(format!("  │ {:-<content_width$} │", ""));
402    } else {
403        lines.push(format!("  │ {} │", "─".repeat(content_width).dimmed()));
404    }
405
406    lines.join("\n")
407}
408
409/// Render the overall section header.
410fn render_section_header(dl_mbps: f64, nc: bool, minimal: bool) -> String {
411    let title = format!(" USAGE CAPABILITY — {dl_mbps:.0} Mbps ");
412    let left = (LINE_WIDTH.saturating_sub(title.len())) / 2;
413    let right = LINE_WIDTH.saturating_sub(left).saturating_sub(title.len());
414    if minimal || nc {
415        format!("  +{:─<left$}{}{:─<right$}+", "", title, "")
416    } else {
417        format!(
418            "  {}{}{}{}{}",
419            "┌".dimmed(),
420            "─".repeat(left).dimmed(),
421            title.cyan().bold(),
422            "─".repeat(right).dimmed(),
423            "┐".dimmed(),
424        )
425    }
426}
427
428/// Render the section footer.
429fn render_section_footer(nc: bool, minimal: bool) -> String {
430    if minimal || nc {
431        format!("  +{:─<LINE_WIDTH$}+", "")
432    } else {
433        format!("  {}{:─<LINE_WIDTH$}{}", "└".dimmed(), "", "┘".dimmed(),)
434    }
435}
436
437/// Render the summary section.
438fn render_summary(
439    statuses: &[Vec<ScenarioStatus>],
440    dl_mbps: f64,
441    nc: bool,
442    minimal: bool,
443) -> String {
444    let mut lines = Vec::new();
445
446    if minimal || nc {
447        lines.push(String::new());
448        lines.push(
449            "  ---- SUMMARY --------------------------------------------------------".to_string(),
450        );
451    } else {
452        lines.push(String::new());
453        lines.push(format!(
454            "  {}",
455            "──── SUMMARY ────────────────────────────────────────────────".dimmed()
456        ));
457    }
458
459    lines.push(format!("  Your {dl_mbps:.0} Mbps connection supports:"));
460
461    // Find most notable concurrent counts
462    let mut highlights: Vec<(&'static UsageScenario, u32)> = Vec::new();
463    for cat in statuses {
464        for s in cat {
465            if s.concurrent > 0 {
466                highlights.push((s.scenario, s.concurrent));
467            }
468        }
469    }
470    highlights.sort_by_key(|(_, c)| std::cmp::Reverse(*c));
471
472    // Show top items
473    for (scenario, count) in highlights.iter().take(5) {
474        if minimal || nc {
475            lines.push(format!(
476                "    - {:>3}x {} {}",
477                count, scenario.name, scenario.concurrent_label,
478            ));
479        } else {
480            lines.push(format!(
481                "    {} {:>3}x {} {}",
482                "•".cyan(),
483                count.to_string().green().bold(),
484                scenario.name,
485                scenario.concurrent_label.dimmed(),
486            ));
487        }
488    }
489
490    lines.join("\n")
491}
492
493/// Render the recommendation footer.
494fn render_recommendation(
495    statuses: &[Vec<ScenarioStatus>],
496    _dl_mbps: f64,
497    nc: bool,
498    minimal: bool,
499) -> String {
500    // Find the scenario with the worst headroom that is at least partially met
501    let mut worst: Option<&ScenarioStatus> = None;
502    for cat in statuses {
503        for s in cat {
504            if s.is_met {
505                match worst {
506                    None => worst = Some(s),
507                    Some(w) if s.headroom_pct < w.headroom_pct => worst = Some(s),
508                    _ => {}
509                }
510            }
511        }
512    }
513
514    let Some(worst_s) = worst else {
515        // Nothing is met — recommend upgrade
516        let mut lines = Vec::new();
517        lines.push(String::new());
518        if minimal || nc {
519            lines.push("  [!] Your connection speed is insufficient for modern usage.".to_string());
520            lines.push("      Consider upgrading to at least 100 Mbps.".to_string());
521        } else {
522            lines.push(format!(
523                "  {} {}",
524                "⚠️".red().bold(),
525                "Your connection speed is insufficient for modern usage."
526                    .red()
527                    .bold(),
528            ));
529            lines.push(format!(
530                "      {} to at least 100 Mbps.",
531                "Consider upgrading".bright_black(),
532            ));
533        }
534        return lines.join("\n");
535    };
536
537    let s = worst_s.scenario;
538    // Safe: required_mbps is small (≤200), *3 → ≤600, fits u32.
539    let recommended = (s.required_mbps * 3.0)
540        .ceil()
541        .clamp(0.0, f64::from(u32::MAX)) as u32; // 3x headroom target
542
543    let mut lines = Vec::new();
544    lines.push(String::new());
545
546    if minimal || nc {
547        lines.push(format!(
548            "  [!] {} has limited headroom at {:.0}%.",
549            s.name, worst_s.headroom_pct,
550        ));
551        lines.push(format!(
552            "      Consider upgrading to {recommended}+ Mbps for better performance.",
553        ));
554    } else {
555        let warning_icon = if worst_s.headroom_pct < 20.0 {
556            "🔴"
557        } else {
558            "⚠️"
559        };
560        lines.push(format!(
561            "  {} {} {} has limited headroom at {:.0}%.",
562            warning_icon,
563            "Recommendation:".yellow().bold(),
564            s.name.yellow(),
565            worst_s.headroom_pct,
566        ));
567        lines.push(format!(
568            "      {} to {}+ Mbps for better performance.",
569            "Consider upgrading".bright_black(),
570            recommended.to_string().cyan().bold(),
571        ));
572    }
573
574    lines.join("\n")
575}
576
577/// Format the full scenario grid output.
578#[must_use]
579pub fn format_scenario_grid(dl_mbps: f64, nc: bool, minimal: bool) -> String {
580    let statuses = compute_all_statuses(dl_mbps);
581    let mut lines = Vec::new();
582
583    // Opening
584    lines.push(String::new());
585    lines.push(render_section_header(dl_mbps, nc, minimal));
586    lines.push(String::new());
587
588    // Category boxes
589    for (i, cat) in all_categories().iter().enumerate() {
590        if i > 0 {
591            lines.push(String::new());
592        }
593        lines.push(render_category_box(cat, &statuses[i], nc, minimal));
594    }
595
596    lines.push(String::new());
597
598    // Closing
599    lines.push(render_section_footer(nc, minimal));
600
601    // Summary
602    lines.push(render_summary(&statuses, dl_mbps, nc, minimal));
603
604    // Recommendation
605    lines.push(render_recommendation(&statuses, 0.0, nc, minimal));
606
607    lines.push(String::new());
608
609    lines.join("\n")
610}
611
612/// Format the scenario output, printing to stderr.
613pub fn print_scenario_grid(dl_mbps: f64, minimal: bool) {
614    let nc = terminal::no_color() || minimal;
615    let output = format_scenario_grid(dl_mbps, nc, minimal);
616    eprintln!("{output}");
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622
623    #[test]
624    fn test_compute_scenario_status_met() {
625        let s = &CAT_COMMUNICATION.scenarios[0]; // 8 Mbps
626        let status = compute_scenario_status(100.0, s);
627        assert!(status.is_met);
628        assert_eq!(status.concurrent, 12); // 100/8 = 12.5 -> 12
629        assert!((status.headroom_pct - 1150.0).abs() < 1.0); // (100-8)/8*100 = 1150%
630    }
631
632    #[test]
633    fn test_compute_scenario_status_not_met() {
634        let s = &CAT_NEXTGEN.scenarios[2]; // 200 Mbps
635        let status = compute_scenario_status(50.0, s);
636        assert!(!status.is_met);
637        assert_eq!(status.concurrent, 0);
638        assert!((status.headroom_pct - 0.0).abs() < 0.01);
639    }
640
641    #[test]
642    fn test_headroom_level() {
643        assert_eq!(headroom_level(80.0), HeadroomLevel::Green);
644        assert_eq!(headroom_level(50.1), HeadroomLevel::Green);
645        assert_eq!(headroom_level(50.0), HeadroomLevel::Yellow);
646        assert_eq!(headroom_level(20.0), HeadroomLevel::Yellow);
647        assert_eq!(headroom_level(19.9), HeadroomLevel::Red);
648        assert_eq!(headroom_level(0.0), HeadroomLevel::Red);
649    }
650
651    #[test]
652    fn test_worst_headroom_level_all_green() {
653        let statuses = compute_all_statuses(1000.0); // Very fast connection
654        let worst = worst_headroom_level(&statuses);
655        assert_eq!(worst, HeadroomLevel::Green);
656    }
657
658    #[test]
659    fn test_worst_headroom_level_some_red() {
660        let statuses = compute_all_statuses(50.0); // Moderate connection
661        let worst = worst_headroom_level(&statuses);
662        // AI Model Download at 200 Mbps won't be met -> red
663        assert_eq!(worst, HeadroomLevel::Red);
664    }
665
666    #[test]
667    fn test_render_capacity_bar_full() {
668        let bar = render_capacity_bar(100.0, true, false, false);
669        assert!(bar.contains("##########") || bar.contains("██████████"));
670    }
671
672    #[test]
673    fn test_render_capacity_bar_empty() {
674        let bar = render_capacity_bar(0.0, false, false, false);
675        assert!(bar.contains("----------") || bar.contains("░░░░░░░░░░"));
676    }
677
678    #[test]
679    fn test_render_capacity_bar_half() {
680        let bar = render_capacity_bar(50.0, true, false, false);
681        // 50% of 10 = 5 filled
682        assert!(bar.contains("#####") || bar.contains("█████"));
683    }
684
685    #[test]
686    fn test_format_scenario_grid_contains_header() {
687        let output = format_scenario_grid(500.0, true, false);
688        assert!(output.contains("USAGE CAPABILITY"));
689        assert!(output.contains("COMMUNICATION"));
690        assert!(output.contains("STREAMING"));
691    }
692
693    #[test]
694    fn test_format_scenario_grid_contains_summary() {
695        let output = format_scenario_grid(500.0, true, false);
696        assert!(output.contains("SUMMARY"));
697        assert!(output.contains("500 Mbps"));
698    }
699
700    #[test]
701    fn test_all_categories_count() {
702        let cats = all_categories();
703        assert_eq!(cats.len(), 5);
704        let total_scenarios: usize = cats.iter().map(|c| c.scenarios.len()).sum();
705        assert_eq!(total_scenarios, 15);
706    }
707
708    #[test]
709    fn test_recommendation_for_fast_connection() {
710        // At 500 Mbps all scenarios are met with good headroom
711        let output = format_scenario_grid(500.0, true, false);
712        assert!(output.contains("SUMMARY"));
713        assert!(output.contains("500 Mbps"));
714    }
715
716    #[test]
717    fn test_recommendation_for_moderate_connection() {
718        // At 100 Mbps, AI Model Download (200 Mbps) won't be met → triggers recommendation
719        let output = format_scenario_grid(100.0, true, false);
720        assert!(
721            output.contains("Recommendation")
722                || output.contains("recommend")
723                || output.contains("100 Mbps")
724        );
725    }
726
727    #[test]
728    fn test_recommendation_for_slow_connection() {
729        let output = format_scenario_grid(5.0, true, false);
730        assert!(output.contains("insufficient") || output.contains("limited"));
731    }
732
733    // ==================== Rendering Function Tests ====================
734
735    #[test]
736    fn test_render_status_symbol_met_high_headroom() {
737        // is_met=true, headroom>50% -> OK/✅
738        let result = render_status_symbol(75.0, true);
739        assert!(result.contains("OK") || result.contains("✅"));
740    }
741
742    #[test]
743    fn test_render_status_symbol_met_medium_headroom() {
744        // is_met=true, 20<=headroom<=50% -> WARN/⚠️
745        let result = render_status_symbol(35.0, true);
746        assert!(result.contains("WARN") || result.contains("⚠️"));
747    }
748
749    #[test]
750    fn test_render_status_symbol_met_low_headroom() {
751        // is_met=true, headroom<20% -> LOW/🔴
752        let result = render_status_symbol(10.0, true);
753        assert!(result.contains("LOW") || result.contains("🔴"));
754    }
755
756    #[test]
757    fn test_render_status_symbol_not_met() {
758        // is_met=false -> FAIL/❌
759        let result = render_status_symbol(100.0, false);
760        assert!(result.contains("FAIL") || result.contains("❌"));
761    }
762
763    #[test]
764    fn test_render_category_header_colored() {
765        let cat = &CAT_COMMUNICATION;
766        let result = render_category_header(cat, false, false);
767        assert!(result.contains("COMMUNICATION"));
768        assert!(result.contains("│"));
769    }
770
771    #[test]
772    fn test_render_category_header_minimal() {
773        let cat = &CAT_STREAMING;
774        let result = render_category_header(cat, true, true);
775        assert!(result.contains("STREAMING"));
776        assert!(result.contains("│"));
777    }
778
779    #[test]
780    fn test_render_category_box_colored() {
781        let cat = &CAT_SMART_HOME;
782        let statuses = vec![
783            compute_scenario_status(100.0, &cat.scenarios[0]),
784            compute_scenario_status(100.0, &cat.scenarios[1]),
785        ];
786        let result = render_category_box(cat, &statuses, false, false);
787        assert!(result.contains("SMART HOME"));
788        assert!(result.contains("│"));
789        assert!(result.contains("─"));
790    }
791
792    #[test]
793    fn test_render_category_box_minimal() {
794        let cat = &CAT_NEXTGEN;
795        let statuses = vec![compute_scenario_status(1000.0, &cat.scenarios[0])];
796        let result = render_category_box(cat, &statuses, true, true);
797        assert!(result.contains("NEXT-GEN"));
798        assert!(result.contains("│"));
799    }
800
801    #[test]
802    fn test_render_scenario_row_colored_met() {
803        let s = &CAT_COMMUNICATION.scenarios[0]; // 8 Mbps
804        let status = compute_scenario_status(100.0, s);
805        let result = render_scenario_row(&status, false, false);
806        assert!(result.contains("8 Mbps"));
807        assert!(result.contains("12x"));
808    }
809
810    #[test]
811    fn test_render_scenario_row_minimal_not_met() {
812        let s = &CAT_NEXTGEN.scenarios[2]; // 200 Mbps
813        let status = compute_scenario_status(50.0, s);
814        let result = render_scenario_row(&status, true, true);
815        assert!(result.contains("200 Mbps"));
816        // Not met should show FAIL or ❌ depending on emoji mode
817        assert!(result.contains("FAIL") || result.contains("❌"));
818    }
819
820    #[test]
821    fn test_render_section_header_colored() {
822        let result = render_section_header(500.0, false, false);
823        assert!(result.contains("500"));
824        assert!(result.contains("Mbps"));
825        assert!(result.contains("┌"));
826    }
827
828    #[test]
829    fn test_render_section_header_minimal() {
830        let result = render_section_header(100.0, true, true);
831        assert!(result.contains("100"));
832        assert!(result.contains("+"));
833    }
834
835    #[test]
836    fn test_render_section_footer_colored() {
837        let result = render_section_footer(false, false);
838        assert!(result.contains("└"));
839        assert!(result.contains("┘"));
840    }
841
842    #[test]
843    fn test_render_section_footer_minimal() {
844        let result = render_section_footer(true, true);
845        assert!(result.contains("+"));
846    }
847
848    #[test]
849    fn test_render_summary_colored() {
850        let statuses = compute_all_statuses(500.0);
851        let result = render_summary(&statuses, 500.0, false, false);
852        assert!(result.contains("SUMMARY"));
853        assert!(result.contains("500 Mbps"));
854    }
855
856    #[test]
857    fn test_render_summary_minimal() {
858        let statuses = compute_all_statuses(100.0);
859        let result = render_summary(&statuses, 100.0, true, true);
860        assert!(result.contains("SUMMARY"));
861        assert!(result.contains("100"));
862    }
863
864    #[test]
865    fn test_render_recommendation_warning_case() {
866        // At 80 Mbps, most scenarios met but 4K upload (80 Mbps) has limited headroom
867        let statuses = compute_all_statuses(80.0);
868        let result = render_recommendation(&statuses, 80.0, true, true);
869        assert!(result.contains("limited") || result.contains("headroom"));
870    }
871
872    #[test]
873    fn test_render_recommendation_insufficient() {
874        // At 5 Mbps, nothing is met
875        let statuses = compute_all_statuses(5.0);
876        let result = render_recommendation(&statuses, 5.0, true, true);
877        // Should contain some indication of insufficient capacity
878        assert!(!result.is_empty());
879        // Check for the message content (insufficient or upgrading recommendation)
880        assert!(
881            result.contains("insufficient")
882                || result.contains("upgrading")
883                || result.contains("100 Mbps")
884                || result.contains("[!]")
885        );
886    }
887
888    #[test]
889    fn test_render_recommendation_colored_warning() {
890        let statuses = compute_all_statuses(80.0);
891        let result = render_recommendation(&statuses, 80.0, false, false);
892        assert!(!result.is_empty());
893        // Should contain recommendation text
894        assert!(result.contains("headroom") || result.contains("upgrading"));
895    }
896
897    #[test]
898    fn test_render_recommendation_colored_insufficient() {
899        let statuses = compute_all_statuses(5.0);
900        let result = render_recommendation(&statuses, 5.0, false, false);
901        // Should contain recommendation text
902        assert!(!result.is_empty());
903        // Check for warning icon or insufficient/upgrading text
904        assert!(
905            result.contains("insufficient")
906                || result.contains("upgrading")
907                || result.contains("⚠️")
908                || result.contains("🔴")
909        );
910    }
911
912    #[test]
913    fn test_headroom_level_boundary_green_yellow() {
914        // Exactly at 50.0 should be Yellow (>=20 && <=50)
915        assert_eq!(headroom_level(50.0), HeadroomLevel::Yellow);
916    }
917
918    #[test]
919    fn test_headroom_level_boundary_yellow_red() {
920        // Exactly at 20.0 should be Yellow (>=20)
921        assert_eq!(headroom_level(20.0), HeadroomLevel::Yellow);
922    }
923
924    #[test]
925    fn test_headroom_level_just_above_50() {
926        // Just above 50.0 should be Green
927        assert_eq!(headroom_level(50.01), HeadroomLevel::Green);
928    }
929
930    #[test]
931    fn test_headroom_level_just_below_20() {
932        // Just below 20.0 should be Red
933        assert_eq!(headroom_level(19.99), HeadroomLevel::Red);
934    }
935
936    #[test]
937    fn test_compute_all_statuses_empty_connection() {
938        let statuses = compute_all_statuses(0.0);
939        assert_eq!(statuses.len(), 5); // All 5 categories
940        for cat in &statuses {
941            for s in cat {
942                assert!(!s.is_met); // Nothing met at 0 Mbps
943                assert_eq!(s.concurrent, 0);
944            }
945        }
946    }
947
948    #[test]
949    fn test_compute_all_statuses_high_connection() {
950        let statuses = compute_all_statuses(10000.0); // 10 Gbps
951        for cat in &statuses {
952            for s in cat {
953                assert!(s.is_met); // Everything met at 10 Gbps
954                assert!(s.concurrent > 10); // High concurrent count
955            }
956        }
957    }
958
959    #[test]
960    fn test_worst_headroom_level_all_yellow() {
961        // 35 Mbps is just below AI Model Download (200 Mbps) threshold
962        let statuses = compute_all_statuses(35.0);
963        let worst = worst_headroom_level(&statuses);
964        // Should be Red because AI Model Download won't be met
965        assert_eq!(worst, HeadroomLevel::Red);
966    }
967
968    #[test]
969    fn test_worst_headroom_level_mixed() {
970        // 30 Mbps - some met, some not
971        let statuses = compute_all_statuses(30.0);
972        let worst = worst_headroom_level(&statuses);
973        assert_eq!(worst, HeadroomLevel::Red); // AI Model Download won't be met
974    }
975
976    #[test]
977    fn test_format_scenario_grid_all_categories() {
978        let output = format_scenario_grid(200.0, true, false);
979        // All 5 categories should appear
980        assert!(output.contains("COMMUNICATION"));
981        assert!(output.contains("STREAMING"));
982        assert!(output.contains("PRODUCTIVITY"));
983        assert!(output.contains("SMART HOME"));
984        assert!(output.contains("NEXT-GEN"));
985    }
986
987    #[test]
988    fn test_format_scenario_grid_has_summary() {
989        let output = format_scenario_grid(100.0, true, false);
990        assert!(output.contains("SUMMARY"));
991    }
992
993    #[test]
994    fn test_format_scenario_grid_has_recommendation() {
995        let output = format_scenario_grid(100.0, true, false);
996        // Recommendation section should exist
997        assert!(
998            output.contains("Recommendation")
999                || output.contains("recommend")
1000                || output.contains("upgrading")
1001        );
1002    }
1003
1004    #[test]
1005    fn test_print_scenario_grid_runs() {
1006        // Just verify it doesn't panic
1007        print_scenario_grid(100.0, false);
1008    }
1009
1010    #[test]
1011    fn test_print_scenario_grid_minimal() {
1012        // Just verify it doesn't panic
1013        print_scenario_grid(500.0, true);
1014    }
1015}