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