Skip to main content

presentar_terminal/widgets/
network_panel.rs

1//! `NetworkPanel` widget for network interface monitoring.
2//!
3//! Displays network interfaces with upload/download sparklines.
4//! Reference: ttop/btop network displays.
5
6use presentar_core::{
7    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event,
8    LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
9};
10use std::any::Any;
11use std::time::Duration;
12
13/// Block characters for sparkline rendering (8 levels).
14const SPARK_CHARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
15
16/// A network interface entry.
17#[derive(Debug, Clone)]
18pub struct NetworkInterface {
19    /// Interface name (e.g., "eth0", "wlan0").
20    pub name: String,
21    /// Download bytes per second history.
22    pub rx_history: Vec<f64>,
23    /// Upload bytes per second history.
24    pub tx_history: Vec<f64>,
25    /// Current download bytes per second.
26    pub rx_bps: f64,
27    /// Current upload bytes per second.
28    pub tx_bps: f64,
29    /// Total bytes received.
30    pub rx_total: u64,
31    /// Total bytes transmitted.
32    pub tx_total: u64,
33    /// Receive errors (cumulative).
34    pub rx_errors: u64,
35    /// Transmit errors (cumulative).
36    pub tx_errors: u64,
37    /// Receive dropped packets (cumulative).
38    pub rx_dropped: u64,
39    /// Transmit dropped packets (cumulative).
40    pub tx_dropped: u64,
41    /// Error rate (errors per second).
42    pub errors_per_sec: f64,
43    /// Drop rate (drops per second).
44    pub drops_per_sec: f64,
45    /// Bandwidth utilization percentage (CB-NET-006).
46    /// None if link speed unknown.
47    pub utilization_percent: Option<f64>,
48}
49
50impl NetworkInterface {
51    /// Create a new network interface entry.
52    #[must_use]
53    pub fn new(name: impl Into<String>) -> Self {
54        Self {
55            name: name.into(),
56            rx_history: Vec::new(),
57            tx_history: Vec::new(),
58            rx_bps: 0.0,
59            tx_bps: 0.0,
60            rx_total: 0,
61            tx_total: 0,
62            rx_errors: 0,
63            tx_errors: 0,
64            rx_dropped: 0,
65            tx_dropped: 0,
66            errors_per_sec: 0.0,
67            drops_per_sec: 0.0,
68            utilization_percent: None,
69        }
70    }
71
72    /// Set error and drop stats.
73    pub fn set_stats(&mut self, rx_errors: u64, tx_errors: u64, rx_dropped: u64, tx_dropped: u64) {
74        self.rx_errors = rx_errors;
75        self.tx_errors = tx_errors;
76        self.rx_dropped = rx_dropped;
77        self.tx_dropped = tx_dropped;
78    }
79
80    /// Set error and drop rates.
81    pub fn set_rates(&mut self, errors_per_sec: f64, drops_per_sec: f64) {
82        self.errors_per_sec = errors_per_sec;
83        self.drops_per_sec = drops_per_sec;
84    }
85
86    /// Set bandwidth utilization percentage (CB-NET-006).
87    pub fn set_utilization(&mut self, utilization_percent: Option<f64>) {
88        self.utilization_percent = utilization_percent;
89    }
90
91    /// Total errors (RX + TX).
92    #[must_use]
93    pub fn total_errors(&self) -> u64 {
94        self.rx_errors + self.tx_errors
95    }
96
97    /// Total dropped packets (RX + TX).
98    #[must_use]
99    pub fn total_dropped(&self) -> u64 {
100        self.rx_dropped + self.tx_dropped
101    }
102
103    /// Update with current bandwidth readings.
104    pub fn update(&mut self, rx_bps: f64, tx_bps: f64) {
105        self.rx_bps = rx_bps;
106        self.tx_bps = tx_bps;
107        self.rx_history.push(rx_bps);
108        self.tx_history.push(tx_bps);
109        // Keep last 60 samples
110        if self.rx_history.len() > 60 {
111            self.rx_history.remove(0);
112        }
113        if self.tx_history.len() > 60 {
114            self.tx_history.remove(0);
115        }
116    }
117
118    /// Set totals.
119    pub fn set_totals(&mut self, rx: u64, tx: u64) {
120        self.rx_total = rx;
121        self.tx_total = tx;
122    }
123}
124
125/// Network panel showing multiple interfaces with sparklines.
126#[derive(Debug, Clone)]
127pub struct NetworkPanel {
128    /// Network interfaces.
129    interfaces: Vec<NetworkInterface>,
130    /// Download color.
131    rx_color: Color,
132    /// Upload color.
133    tx_color: Color,
134    /// Sparkline width.
135    spark_width: usize,
136    /// Show totals.
137    show_totals: bool,
138    /// Compact mode.
139    compact: bool,
140    /// Cached bounds.
141    bounds: Rect,
142}
143
144impl Default for NetworkPanel {
145    fn default() -> Self {
146        Self::new()
147    }
148}
149
150impl NetworkPanel {
151    /// Create a new network panel.
152    #[must_use]
153    pub fn new() -> Self {
154        Self {
155            interfaces: Vec::new(),
156            rx_color: Color::new(0.3, 0.8, 0.3, 1.0), // Green for download
157            tx_color: Color::new(0.8, 0.3, 0.3, 1.0), // Red for upload
158            spark_width: 20,
159            show_totals: true,
160            compact: false,
161            bounds: Rect::default(),
162        }
163    }
164
165    /// Set interfaces.
166    pub fn set_interfaces(&mut self, interfaces: Vec<NetworkInterface>) {
167        self.interfaces = interfaces;
168    }
169
170    /// Add an interface.
171    pub fn add_interface(&mut self, iface: NetworkInterface) {
172        self.interfaces.push(iface);
173    }
174
175    /// Get interface by name.
176    pub fn interface_mut(&mut self, name: &str) -> Option<&mut NetworkInterface> {
177        self.interfaces.iter_mut().find(|i| i.name == name)
178    }
179
180    /// Clear all interfaces.
181    pub fn clear(&mut self) {
182        self.interfaces.clear();
183    }
184
185    /// Set download color.
186    #[must_use]
187    pub fn with_rx_color(mut self, color: Color) -> Self {
188        self.rx_color = color;
189        self
190    }
191
192    /// Set upload color.
193    #[must_use]
194    pub fn with_tx_color(mut self, color: Color) -> Self {
195        self.tx_color = color;
196        self
197    }
198
199    /// Set sparkline width.
200    #[must_use]
201    pub fn with_spark_width(mut self, width: usize) -> Self {
202        self.spark_width = width;
203        self
204    }
205
206    /// Hide totals.
207    #[must_use]
208    pub fn without_totals(mut self) -> Self {
209        self.show_totals = false;
210        self
211    }
212
213    /// Enable compact mode.
214    #[must_use]
215    pub fn compact(mut self) -> Self {
216        self.compact = true;
217        self
218    }
219
220    /// Get interface count.
221    #[must_use]
222    pub fn len(&self) -> usize {
223        self.interfaces.len()
224    }
225
226    /// Check if empty.
227    #[must_use]
228    pub fn is_empty(&self) -> bool {
229        self.interfaces.is_empty()
230    }
231
232    /// Format bytes per second as human-readable string.
233    fn format_bps(bps: f64) -> String {
234        const KB: f64 = 1024.0;
235        const MB: f64 = KB * 1024.0;
236        const GB: f64 = MB * 1024.0;
237
238        if bps >= GB {
239            format!("{:.1}G/s", bps / GB)
240        } else if bps >= MB {
241            format!("{:.1}M/s", bps / MB)
242        } else if bps >= KB {
243            format!("{:.1}K/s", bps / KB)
244        } else {
245            format!("{bps:.0}B/s")
246        }
247    }
248
249    /// Format total bytes as human-readable string.
250    fn format_bytes(bytes: u64) -> String {
251        const KB: u64 = 1024;
252        const MB: u64 = KB * 1024;
253        const GB: u64 = MB * 1024;
254        const TB: u64 = GB * 1024;
255
256        if bytes >= TB {
257            format!("{:.1}T", bytes as f64 / TB as f64)
258        } else if bytes >= GB {
259            format!("{:.1}G", bytes as f64 / GB as f64)
260        } else if bytes >= MB {
261            format!("{:.1}M", bytes as f64 / MB as f64)
262        } else if bytes >= KB {
263            format!("{:.1}K", bytes as f64 / KB as f64)
264        } else {
265            format!("{bytes}B")
266        }
267    }
268
269    /// Render sparkline from data.
270    fn render_sparkline(data: &[f64], width: usize) -> String {
271        if data.is_empty() {
272            return " ".repeat(width);
273        }
274
275        let max_val = data
276            .iter()
277            .copied()
278            .fold(f64::NEG_INFINITY, f64::max)
279            .max(1.0);
280        let start = data.len().saturating_sub(width);
281        let visible = &data[start..];
282
283        let mut result = String::with_capacity(width * 3); // Unicode chars are up to 3 bytes
284        for &val in visible {
285            let normalized = (val / max_val).clamp(0.0, 1.0);
286            let idx = ((normalized * 7.0).round() as usize).min(7);
287            result.push(SPARK_CHARS[idx]);
288        }
289
290        // Pad if needed (use char count, not byte count)
291        let char_count = result.chars().count();
292        if char_count < width {
293            let padding = " ".repeat(width - char_count);
294            result.insert_str(0, &padding);
295        }
296
297        result
298    }
299}
300
301impl Brick for NetworkPanel {
302    fn brick_name(&self) -> &'static str {
303        "network_panel"
304    }
305
306    fn assertions(&self) -> &[BrickAssertion] {
307        static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(8)];
308        ASSERTIONS
309    }
310
311    fn budget(&self) -> BrickBudget {
312        BrickBudget::uniform(8)
313    }
314
315    fn verify(&self) -> BrickVerification {
316        BrickVerification {
317            passed: vec![BrickAssertion::max_latency_ms(8)],
318            failed: vec![],
319            verification_time: Duration::from_micros(5),
320        }
321    }
322
323    fn to_html(&self) -> String {
324        String::new()
325    }
326
327    fn to_css(&self) -> String {
328        String::new()
329    }
330}
331
332impl Widget for NetworkPanel {
333    fn type_id(&self) -> TypeId {
334        TypeId::of::<Self>()
335    }
336
337    fn measure(&self, constraints: Constraints) -> Size {
338        let rows_per_iface = if self.compact { 1 } else { 2 };
339        let height = (self.interfaces.len() * rows_per_iface + 1) as f32;
340        let min_width = if self.compact { 40.0 } else { 60.0 };
341        let width = constraints.max_width.max(min_width);
342        constraints.constrain(Size::new(width, height.max(2.0)))
343    }
344
345    fn layout(&mut self, bounds: Rect) -> LayoutResult {
346        self.bounds = bounds;
347        LayoutResult {
348            size: Size::new(bounds.width, bounds.height),
349        }
350    }
351
352    #[allow(clippy::too_many_lines)]
353    fn paint(&self, canvas: &mut dyn Canvas) {
354        let width = self.bounds.width as usize;
355        let height = self.bounds.height as usize;
356        if width == 0 || height == 0 {
357            return;
358        }
359
360        // Header
361        let header_style = TextStyle {
362            color: Color::new(0.0, 1.0, 1.0, 1.0),
363            weight: presentar_core::FontWeight::Bold,
364            ..Default::default()
365        };
366        canvas.draw_text(
367            "Network",
368            Point::new(self.bounds.x, self.bounds.y),
369            &header_style,
370        );
371
372        let _rows_per_iface = if self.compact { 1 } else { 2 };
373        let mut y = self.bounds.y + 1.0;
374
375        for iface in &self.interfaces {
376            if y >= self.bounds.y + self.bounds.height {
377                break;
378            }
379
380            if self.compact {
381                // Compact: single line per interface
382                // eth0: ▁▂▃▄▅ 125K/s ↓ ▅▄▃▂▁ 50K/s ↑
383                let name_w = 8;
384                let spark_w = self.spark_width.min(width.saturating_sub(30) / 2);
385
386                let mut x = self.bounds.x;
387
388                // Interface name
389                let name = format!("{:name_w$}", iface.name);
390                canvas.draw_text(
391                    &name,
392                    Point::new(x, y),
393                    &TextStyle {
394                        color: Color::new(0.8, 0.8, 0.8, 1.0),
395                        ..Default::default()
396                    },
397                );
398                x += name_w as f32 + 1.0;
399
400                // Download sparkline
401                let rx_spark = Self::render_sparkline(&iface.rx_history, spark_w);
402                canvas.draw_text(
403                    &rx_spark,
404                    Point::new(x, y),
405                    &TextStyle {
406                        color: self.rx_color,
407                        ..Default::default()
408                    },
409                );
410                x += spark_w as f32 + 1.0;
411
412                // Download rate
413                let rx_rate = format!("{:>8}", Self::format_bps(iface.rx_bps));
414                canvas.draw_text(
415                    &rx_rate,
416                    Point::new(x, y),
417                    &TextStyle {
418                        color: self.rx_color,
419                        ..Default::default()
420                    },
421                );
422                x += 9.0;
423
424                // Arrow
425                canvas.draw_text(
426                    "↓",
427                    Point::new(x, y),
428                    &TextStyle {
429                        color: self.rx_color,
430                        ..Default::default()
431                    },
432                );
433                x += 2.0;
434
435                // Upload sparkline
436                let tx_spark = Self::render_sparkline(&iface.tx_history, spark_w);
437                canvas.draw_text(
438                    &tx_spark,
439                    Point::new(x, y),
440                    &TextStyle {
441                        color: self.tx_color,
442                        ..Default::default()
443                    },
444                );
445                x += spark_w as f32 + 1.0;
446
447                // Upload rate
448                let tx_rate = format!("{:>8}", Self::format_bps(iface.tx_bps));
449                canvas.draw_text(
450                    &tx_rate,
451                    Point::new(x, y),
452                    &TextStyle {
453                        color: self.tx_color,
454                        ..Default::default()
455                    },
456                );
457                x += 9.0;
458
459                // Arrow
460                canvas.draw_text(
461                    "↑",
462                    Point::new(x, y),
463                    &TextStyle {
464                        color: self.tx_color,
465                        ..Default::default()
466                    },
467                );
468                x += 2.0;
469
470                // Error/Drop rate highlighting (CB-NET-003/004)
471                // Show warning indicators if error or drop rates are non-zero
472                if iface.errors_per_sec > 0.0 || iface.drops_per_sec > 0.0 {
473                    // Choose color based on severity
474                    let (indicator, color) =
475                        if iface.errors_per_sec > 10.0 || iface.drops_per_sec > 10.0 {
476                            (
477                                "●",
478                                Color {
479                                    r: 1.0,
480                                    g: 0.3,
481                                    b: 0.3,
482                                    a: 1.0,
483                                },
484                            ) // Red - critical
485                        } else if iface.errors_per_sec > 1.0 || iface.drops_per_sec > 1.0 {
486                            (
487                                "◐",
488                                Color {
489                                    r: 1.0,
490                                    g: 0.8,
491                                    b: 0.2,
492                                    a: 1.0,
493                                },
494                            ) // Yellow - warning
495                        } else {
496                            (
497                                "○",
498                                Color {
499                                    r: 0.8,
500                                    g: 0.8,
501                                    b: 0.3,
502                                    a: 1.0,
503                                },
504                            ) // Dim yellow - minor
505                        };
506
507                    // Format: ●E:5/D:2
508                    let err_drop_text = format!(
509                        "{indicator}E:{:.0}/D:{:.0}",
510                        iface.errors_per_sec, iface.drops_per_sec
511                    );
512                    canvas.draw_text(
513                        &err_drop_text,
514                        Point::new(x, y),
515                        &TextStyle {
516                            color,
517                            ..Default::default()
518                        },
519                    );
520                    x += err_drop_text.len() as f32 + 1.0;
521                }
522
523                // Bandwidth utilization display (CB-NET-006)
524                if let Some(util_pct) = iface.utilization_percent {
525                    let capped = util_pct.min(100.0);
526                    let (util_text, util_color) = if util_pct > 80.0 {
527                        (
528                            format!("●{capped:.0}%"),
529                            Color {
530                                r: 1.0,
531                                g: 0.3,
532                                b: 0.3,
533                                a: 1.0,
534                            }, // Red - saturated
535                        )
536                    } else if util_pct > 50.0 {
537                        (
538                            format!("◐{util_pct:.0}%"),
539                            Color {
540                                r: 1.0,
541                                g: 0.8,
542                                b: 0.2,
543                                a: 1.0,
544                            }, // Yellow - high
545                        )
546                    } else {
547                        (
548                            format!("{util_pct:.0}%"),
549                            Color {
550                                r: 0.5,
551                                g: 0.8,
552                                b: 0.5,
553                                a: 1.0,
554                            }, // Green - normal
555                        )
556                    };
557                    canvas.draw_text(
558                        &util_text,
559                        Point::new(x, y),
560                        &TextStyle {
561                            color: util_color,
562                            ..Default::default()
563                        },
564                    );
565                }
566            } else {
567                // Full: two lines per interface
568                // eth0
569                //   ↓ ▁▂▃▄▅▆▇█ 125.3M/s (Total: 1.2G)  ↑ ▅▄▃▂▁▂▃▄ 50.2K/s (Total: 500M)
570
571                // Interface name
572                canvas.draw_text(
573                    &iface.name,
574                    Point::new(self.bounds.x, y),
575                    &TextStyle {
576                        color: Color::new(0.8, 0.8, 1.0, 1.0),
577                        weight: presentar_core::FontWeight::Bold,
578                        ..Default::default()
579                    },
580                );
581                y += 1.0;
582
583                let spark_w = self.spark_width.min(width.saturating_sub(40) / 2);
584                let mut x = self.bounds.x + 2.0;
585
586                // Download
587                canvas.draw_text(
588                    "↓",
589                    Point::new(x, y),
590                    &TextStyle {
591                        color: self.rx_color,
592                        ..Default::default()
593                    },
594                );
595                x += 2.0;
596
597                let rx_spark = Self::render_sparkline(&iface.rx_history, spark_w);
598                canvas.draw_text(
599                    &rx_spark,
600                    Point::new(x, y),
601                    &TextStyle {
602                        color: self.rx_color,
603                        ..Default::default()
604                    },
605                );
606                x += spark_w as f32 + 1.0;
607
608                let rx_rate = Self::format_bps(iface.rx_bps);
609                canvas.draw_text(
610                    &rx_rate,
611                    Point::new(x, y),
612                    &TextStyle {
613                        color: self.rx_color,
614                        ..Default::default()
615                    },
616                );
617                x += 10.0;
618
619                if self.show_totals {
620                    let rx_total = format!("({})", Self::format_bytes(iface.rx_total));
621                    canvas.draw_text(
622                        &rx_total,
623                        Point::new(x, y),
624                        &TextStyle {
625                            color: Color::new(0.5, 0.5, 0.5, 1.0),
626                            ..Default::default()
627                        },
628                    );
629                    x += 10.0;
630                }
631
632                x += 2.0;
633
634                // Upload
635                canvas.draw_text(
636                    "↑",
637                    Point::new(x, y),
638                    &TextStyle {
639                        color: self.tx_color,
640                        ..Default::default()
641                    },
642                );
643                x += 2.0;
644
645                let tx_spark = Self::render_sparkline(&iface.tx_history, spark_w);
646                canvas.draw_text(
647                    &tx_spark,
648                    Point::new(x, y),
649                    &TextStyle {
650                        color: self.tx_color,
651                        ..Default::default()
652                    },
653                );
654                x += spark_w as f32 + 1.0;
655
656                let tx_rate = Self::format_bps(iface.tx_bps);
657                canvas.draw_text(
658                    &tx_rate,
659                    Point::new(x, y),
660                    &TextStyle {
661                        color: self.tx_color,
662                        ..Default::default()
663                    },
664                );
665                x += 10.0;
666
667                if self.show_totals {
668                    let tx_total = format!("({})", Self::format_bytes(iface.tx_total));
669                    canvas.draw_text(
670                        &tx_total,
671                        Point::new(x, y),
672                        &TextStyle {
673                            color: Color::new(0.5, 0.5, 0.5, 1.0),
674                            ..Default::default()
675                        },
676                    );
677                }
678            }
679
680            y += 1.0;
681        }
682
683        // Empty state
684        if self.interfaces.is_empty() && height > 1 {
685            canvas.draw_text(
686                "No interfaces",
687                Point::new(self.bounds.x + 1.0, self.bounds.y + 1.0),
688                &TextStyle {
689                    color: Color::new(0.5, 0.5, 0.5, 1.0),
690                    ..Default::default()
691                },
692            );
693        }
694    }
695
696    fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
697        None
698    }
699
700    fn children(&self) -> &[Box<dyn Widget>] {
701        &[]
702    }
703
704    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
705        &mut []
706    }
707}
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712
713    fn sample_interface() -> NetworkInterface {
714        let mut iface = NetworkInterface::new("eth0");
715        for i in 0..30 {
716            iface.update(i as f64 * 1000.0, i as f64 * 500.0);
717        }
718        iface.set_totals(1024 * 1024 * 1024, 512 * 1024 * 1024);
719        iface
720    }
721
722    #[test]
723    fn test_network_panel_new() {
724        let panel = NetworkPanel::new();
725        assert!(panel.is_empty());
726    }
727
728    #[test]
729    fn test_network_panel_add_interface() {
730        let mut panel = NetworkPanel::new();
731        panel.add_interface(NetworkInterface::new("eth0"));
732        assert_eq!(panel.len(), 1);
733    }
734
735    #[test]
736    fn test_network_panel_set_interfaces() {
737        let mut panel = NetworkPanel::new();
738        panel.set_interfaces(vec![
739            NetworkInterface::new("eth0"),
740            NetworkInterface::new("wlan0"),
741        ]);
742        assert_eq!(panel.len(), 2);
743    }
744
745    #[test]
746    fn test_network_panel_interface_mut() {
747        let mut panel = NetworkPanel::new();
748        panel.add_interface(NetworkInterface::new("eth0"));
749        let iface = panel.interface_mut("eth0").unwrap();
750        iface.update(1000.0, 500.0);
751        assert_eq!(iface.rx_bps, 1000.0);
752    }
753
754    #[test]
755    fn test_network_panel_clear() {
756        let mut panel = NetworkPanel::new();
757        panel.add_interface(NetworkInterface::new("eth0"));
758        panel.clear();
759        assert!(panel.is_empty());
760    }
761
762    #[test]
763    fn test_network_interface_update() {
764        let mut iface = NetworkInterface::new("eth0");
765        iface.update(1000.0, 500.0);
766        assert_eq!(iface.rx_bps, 1000.0);
767        assert_eq!(iface.tx_bps, 500.0);
768        assert_eq!(iface.rx_history.len(), 1);
769        assert_eq!(iface.tx_history.len(), 1);
770    }
771
772    #[test]
773    fn test_network_interface_history_limit() {
774        let mut iface = NetworkInterface::new("eth0");
775        for i in 0..100 {
776            iface.update(i as f64, i as f64);
777        }
778        assert_eq!(iface.rx_history.len(), 60);
779        assert_eq!(iface.tx_history.len(), 60);
780    }
781
782    #[test]
783    fn test_network_panel_with_colors() {
784        let panel = NetworkPanel::new()
785            .with_rx_color(Color::BLUE)
786            .with_tx_color(Color::RED);
787        assert_eq!(panel.rx_color, Color::BLUE);
788        assert_eq!(panel.tx_color, Color::RED);
789    }
790
791    #[test]
792    fn test_network_panel_with_spark_width() {
793        let panel = NetworkPanel::new().with_spark_width(30);
794        assert_eq!(panel.spark_width, 30);
795    }
796
797    #[test]
798    fn test_network_panel_without_totals() {
799        let panel = NetworkPanel::new().without_totals();
800        assert!(!panel.show_totals);
801    }
802
803    #[test]
804    fn test_network_panel_compact() {
805        let panel = NetworkPanel::new().compact();
806        assert!(panel.compact);
807    }
808
809    #[test]
810    fn test_format_bps() {
811        assert_eq!(NetworkPanel::format_bps(500.0), "500B/s");
812        assert_eq!(NetworkPanel::format_bps(1024.0), "1.0K/s");
813        assert_eq!(NetworkPanel::format_bps(1024.0 * 1024.0), "1.0M/s");
814        assert_eq!(NetworkPanel::format_bps(1024.0 * 1024.0 * 1024.0), "1.0G/s");
815    }
816
817    #[test]
818    fn test_format_bytes() {
819        assert_eq!(NetworkPanel::format_bytes(500), "500B");
820        assert_eq!(NetworkPanel::format_bytes(1024), "1.0K");
821        assert_eq!(NetworkPanel::format_bytes(1024 * 1024), "1.0M");
822        assert_eq!(NetworkPanel::format_bytes(1024 * 1024 * 1024), "1.0G");
823        assert_eq!(
824            NetworkPanel::format_bytes(1024u64 * 1024 * 1024 * 1024),
825            "1.0T"
826        );
827    }
828
829    #[test]
830    fn test_render_sparkline() {
831        let data = vec![0.0, 0.5, 1.0];
832        let spark = NetworkPanel::render_sparkline(&data, 5);
833        assert_eq!(spark.chars().count(), 5);
834    }
835
836    #[test]
837    fn test_render_sparkline_empty() {
838        let spark = NetworkPanel::render_sparkline(&[], 5);
839        assert_eq!(spark, "     ");
840    }
841
842    #[test]
843    fn test_network_panel_measure() {
844        let mut panel = NetworkPanel::new();
845        panel.add_interface(NetworkInterface::new("eth0"));
846        let size = panel.measure(Constraints::new(0.0, 100.0, 0.0, 50.0));
847        assert!(size.width >= 60.0);
848        assert!(size.height >= 2.0);
849    }
850
851    #[test]
852    fn test_network_panel_layout() {
853        let mut panel = NetworkPanel::new();
854        let result = panel.layout(Rect::new(0.0, 0.0, 80.0, 20.0));
855        assert_eq!(result.size.width, 80.0);
856    }
857
858    #[test]
859    fn test_network_panel_verify() {
860        let panel = NetworkPanel::new();
861        assert!(panel.verify().is_valid());
862    }
863
864    #[test]
865    fn test_network_panel_brick_name() {
866        let panel = NetworkPanel::new();
867        assert_eq!(panel.brick_name(), "network_panel");
868    }
869
870    #[test]
871    fn test_network_panel_default() {
872        let panel = NetworkPanel::default();
873        assert!(panel.is_empty());
874    }
875
876    #[test]
877    fn test_network_panel_children() {
878        let panel = NetworkPanel::new();
879        assert!(panel.children().is_empty());
880    }
881
882    #[test]
883    fn test_network_panel_children_mut() {
884        let mut panel = NetworkPanel::new();
885        assert!(panel.children_mut().is_empty());
886    }
887
888    #[test]
889    fn test_network_panel_type_id() {
890        let panel = NetworkPanel::new();
891        assert_eq!(Widget::type_id(&panel), TypeId::of::<NetworkPanel>());
892    }
893
894    #[test]
895    fn test_network_panel_to_html() {
896        let panel = NetworkPanel::new();
897        assert!(panel.to_html().is_empty());
898    }
899
900    #[test]
901    fn test_network_panel_to_css() {
902        let panel = NetworkPanel::new();
903        assert!(panel.to_css().is_empty());
904    }
905
906    #[test]
907    fn test_network_interface_set_totals() {
908        let mut iface = NetworkInterface::new("eth0");
909        iface.set_totals(1000, 500);
910        assert_eq!(iface.rx_total, 1000);
911        assert_eq!(iface.tx_total, 500);
912    }
913
914    #[test]
915    fn test_network_panel_paint_with_data() {
916        use crate::direct::{CellBuffer, DirectTerminalCanvas};
917
918        let mut panel = NetworkPanel::new();
919        let mut iface = NetworkInterface::new("eth0");
920        for i in 0..30 {
921            iface.update(i as f64 * 1000.0, i as f64 * 500.0);
922        }
923        iface.set_totals(1024 * 1024 * 1024, 512 * 1024 * 1024);
924        panel.add_interface(iface);
925
926        panel.layout(Rect::new(0.0, 0.0, 80.0, 10.0));
927
928        let mut buffer = CellBuffer::new(80, 10);
929        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
930        panel.paint(&mut canvas);
931    }
932
933    #[test]
934    fn test_network_panel_paint_empty() {
935        use crate::direct::{CellBuffer, DirectTerminalCanvas};
936
937        let mut panel = NetworkPanel::new();
938        panel.layout(Rect::new(0.0, 0.0, 60.0, 10.0));
939
940        let mut buffer = CellBuffer::new(60, 10);
941        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
942        panel.paint(&mut canvas);
943    }
944
945    #[test]
946    fn test_network_panel_paint_small_bounds() {
947        use crate::direct::{CellBuffer, DirectTerminalCanvas};
948
949        let mut panel = NetworkPanel::new();
950        panel.add_interface(NetworkInterface::new("eth0"));
951        panel.layout(Rect::new(0.0, 0.0, 5.0, 0.5)); // Too small
952
953        let mut buffer = CellBuffer::new(5, 1);
954        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
955        panel.paint(&mut canvas); // Should early return
956    }
957
958    #[test]
959    fn test_network_panel_paint_compact() {
960        use crate::direct::{CellBuffer, DirectTerminalCanvas};
961
962        let mut panel = NetworkPanel::new().compact();
963        let mut iface = NetworkInterface::new("wlan0");
964        iface.update(5000.0, 2500.0);
965        panel.add_interface(iface);
966
967        panel.layout(Rect::new(0.0, 0.0, 60.0, 10.0));
968
969        let mut buffer = CellBuffer::new(60, 10);
970        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
971        panel.paint(&mut canvas);
972    }
973
974    #[test]
975    fn test_network_panel_paint_with_totals() {
976        use crate::direct::{CellBuffer, DirectTerminalCanvas};
977
978        let mut panel = NetworkPanel::new(); // show_totals is true by default
979        let mut iface = NetworkInterface::new("eth0");
980        iface.update(1024.0 * 1024.0, 512.0 * 1024.0);
981        iface.set_totals(10 * 1024 * 1024 * 1024, 5 * 1024 * 1024 * 1024);
982        panel.add_interface(iface);
983
984        panel.layout(Rect::new(0.0, 0.0, 80.0, 10.0));
985
986        let mut buffer = CellBuffer::new(80, 10);
987        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
988        panel.paint(&mut canvas);
989    }
990
991    #[test]
992    fn test_network_panel_paint_without_totals() {
993        use crate::direct::{CellBuffer, DirectTerminalCanvas};
994
995        let mut panel = NetworkPanel::new().without_totals();
996        let mut iface = NetworkInterface::new("eth0");
997        iface.update(1024.0, 512.0);
998        panel.add_interface(iface);
999
1000        panel.layout(Rect::new(0.0, 0.0, 60.0, 10.0));
1001
1002        let mut buffer = CellBuffer::new(60, 10);
1003        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
1004        panel.paint(&mut canvas);
1005    }
1006
1007    #[test]
1008    fn test_network_panel_multiple_interfaces() {
1009        use crate::direct::{CellBuffer, DirectTerminalCanvas};
1010
1011        let mut panel = NetworkPanel::new();
1012
1013        let mut eth0 = NetworkInterface::new("eth0");
1014        eth0.update(10000.0, 5000.0);
1015        panel.add_interface(eth0);
1016
1017        let mut wlan0 = NetworkInterface::new("wlan0");
1018        wlan0.update(2000.0, 1000.0);
1019        panel.add_interface(wlan0);
1020
1021        let mut lo = NetworkInterface::new("lo");
1022        lo.update(100.0, 100.0);
1023        panel.add_interface(lo);
1024
1025        panel.layout(Rect::new(0.0, 0.0, 80.0, 10.0));
1026
1027        let mut buffer = CellBuffer::new(80, 10);
1028        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
1029        panel.paint(&mut canvas);
1030    }
1031
1032    #[test]
1033    fn test_network_panel_event() {
1034        let mut panel = NetworkPanel::new();
1035        let result = panel.event(&Event::KeyDown {
1036            key: presentar_core::Key::Enter,
1037        });
1038        assert!(result.is_none());
1039    }
1040
1041    #[test]
1042    fn test_network_panel_assertions() {
1043        let panel = NetworkPanel::new();
1044        assert!(!panel.assertions().is_empty());
1045    }
1046
1047    #[test]
1048    fn test_network_panel_budget() {
1049        let panel = NetworkPanel::new();
1050        assert!(panel.budget().paint_ms > 0);
1051    }
1052
1053    #[test]
1054    fn test_network_interface_long_name() {
1055        use crate::direct::{CellBuffer, DirectTerminalCanvas};
1056
1057        let mut panel = NetworkPanel::new();
1058        let mut iface = NetworkInterface::new("verylonginterfacename0");
1059        iface.update(1024.0, 512.0);
1060        panel.add_interface(iface);
1061
1062        panel.layout(Rect::new(0.0, 0.0, 80.0, 10.0));
1063
1064        let mut buffer = CellBuffer::new(80, 10);
1065        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
1066        panel.paint(&mut canvas);
1067    }
1068
1069    #[test]
1070    fn test_format_bps_edge_cases() {
1071        // Test very large values (displayed as G/s since no T/s support)
1072        assert!(NetworkPanel::format_bps(1024.0 * 1024.0 * 1024.0 * 1024.0).contains("G/s"));
1073        // Test very small values
1074        assert_eq!(NetworkPanel::format_bps(0.5), "0B/s");
1075    }
1076
1077    #[test]
1078    fn test_render_sparkline_single_value() {
1079        let data = vec![0.5];
1080        let spark = NetworkPanel::render_sparkline(&data, 3);
1081        assert_eq!(spark.chars().count(), 3);
1082    }
1083}