Skip to main content

presentar_terminal/widgets/
process_dataframe.rs

1//! `ProcessDataFrame` widget - Data science view for process monitoring.
2//!
3//! Grammar of Graphics construct: Process data as sortable `DataFrame` with:
4//! - PID, Name, State columns
5//! - CPU% with inline sparkline history
6//! - MEM% with micro bar
7//! - Threads, Priority, User columns
8//! - Sort by any column (click or keyboard)
9//!
10//! Implements SPEC-024 Section 27.8 - Framework-First pattern.
11
12use compact_str::CompactString;
13use presentar_core::{
14    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event, Key,
15    LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
16};
17use std::any::Any;
18use std::collections::HashMap;
19use std::time::Duration;
20
21use super::micro_heat_bar::HeatScheme;
22use super::selection::RowHighlight;
23
24/// Process state for display.
25#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
26pub enum ProcessDisplayState {
27    #[default]
28    Running,
29    Sleeping,
30    Idle,
31    Zombie,
32    Stopped,
33    Unknown,
34}
35
36impl ProcessDisplayState {
37    /// Get display character and color.
38    #[must_use]
39    pub fn render(self) -> (char, Color) {
40        match self {
41            Self::Running => ('R', Color::new(0.2, 0.9, 0.2, 1.0)),
42            Self::Sleeping => ('S', Color::new(0.6, 0.6, 0.6, 1.0)),
43            Self::Idle => ('I', Color::new(0.5, 0.5, 0.5, 1.0)),
44            Self::Zombie => ('Z', Color::new(0.9, 0.2, 0.2, 1.0)),
45            Self::Stopped => ('T', Color::new(0.9, 0.7, 0.1, 1.0)),
46            Self::Unknown => ('?', Color::new(0.4, 0.4, 0.4, 1.0)),
47        }
48    }
49}
50
51/// Sort column for process table.
52#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
53pub enum ProcessSortColumn {
54    Pid,
55    Name,
56    #[default]
57    Cpu,
58    Mem,
59    Threads,
60    Priority,
61    User,
62    Time,
63}
64
65impl ProcessSortColumn {
66    /// Get column header string.
67    #[must_use]
68    pub fn header(self) -> &'static str {
69        match self {
70            Self::Pid => "PID",
71            Self::Name => "COMMAND",
72            Self::Cpu => "CPU%",
73            Self::Mem => "MEM%",
74            Self::Threads => "THR",
75            Self::Priority => "PRI",
76            Self::User => "USER",
77            Self::Time => "TIME+",
78        }
79    }
80}
81
82/// Single process row data.
83#[derive(Debug, Clone)]
84pub struct ProcessRow {
85    /// Process ID.
86    pub pid: u32,
87    /// Process name/command.
88    pub name: CompactString,
89    /// Current CPU percentage.
90    pub cpu_percent: f32,
91    /// CPU history (last N samples for sparkline).
92    pub cpu_history: Vec<f32>,
93    /// Memory percentage.
94    pub mem_percent: f32,
95    /// Process state.
96    pub state: ProcessDisplayState,
97    /// Number of threads.
98    pub threads: u32,
99    /// Priority/nice value.
100    pub priority: i32,
101    /// User name.
102    pub user: CompactString,
103    /// CPU time in seconds.
104    pub cpu_time_secs: u64,
105}
106
107impl Default for ProcessRow {
108    fn default() -> Self {
109        Self {
110            pid: 0,
111            name: CompactString::new(""),
112            cpu_percent: 0.0,
113            cpu_history: Vec::new(),
114            mem_percent: 0.0,
115            state: ProcessDisplayState::default(),
116            threads: 1,
117            priority: 0,
118            user: CompactString::new(""),
119            cpu_time_secs: 0,
120        }
121    }
122}
123
124/// Process `DataFrame` widget for data science view.
125#[derive(Debug, Clone)]
126pub struct ProcessDataFrame {
127    /// Process rows.
128    rows: Vec<ProcessRow>,
129    /// CPU history per PID (ring buffer).
130    cpu_histories: HashMap<u32, Vec<f32>>,
131    /// History length for sparklines.
132    history_len: usize,
133    /// Current sort column.
134    sort_column: ProcessSortColumn,
135    /// Sort descending.
136    sort_desc: bool,
137    /// Selected row index.
138    selected_row: Option<usize>,
139    /// Scroll offset.
140    scroll_offset: usize,
141    /// Show header row.
142    show_header: bool,
143    /// Column widths.
144    col_widths: ProcessColumnWidths,
145    /// Primary accent color.
146    accent_color: Color,
147    /// Cached bounds.
148    bounds: Rect,
149}
150
151/// Column width configuration.
152#[derive(Debug, Clone, Copy)]
153pub struct ProcessColumnWidths {
154    pub pid: usize,
155    pub name: usize,
156    pub cpu: usize,
157    pub sparkline: usize,
158    pub mem: usize,
159    pub state: usize,
160    pub threads: usize,
161    pub priority: usize,
162    pub user: usize,
163    pub time: usize,
164}
165
166impl Default for ProcessColumnWidths {
167    fn default() -> Self {
168        Self {
169            pid: 7,
170            name: 20,
171            cpu: 6,
172            sparkline: 12,
173            mem: 6,
174            state: 3,
175            threads: 4,
176            priority: 4,
177            user: 10,
178            time: 10,
179        }
180    }
181}
182
183impl Default for ProcessDataFrame {
184    fn default() -> Self {
185        Self::new()
186    }
187}
188
189impl ProcessDataFrame {
190    /// Create a new empty process dataframe.
191    #[must_use]
192    pub fn new() -> Self {
193        Self {
194            rows: Vec::new(),
195            cpu_histories: HashMap::new(),
196            history_len: 60, // 60 samples = 60s at 1s refresh
197            sort_column: ProcessSortColumn::Cpu,
198            sort_desc: true,
199            selected_row: None,
200            scroll_offset: 0,
201            show_header: true,
202            col_widths: ProcessColumnWidths::default(),
203            accent_color: Color::new(0.4, 0.7, 1.0, 1.0),
204            bounds: Rect::default(),
205        }
206    }
207
208    /// Update with new process data.
209    pub fn update_processes(&mut self, processes: Vec<ProcessRow>) {
210        // Update CPU histories
211        for proc in &processes {
212            let history = self.cpu_histories.entry(proc.pid).or_default();
213            history.push(proc.cpu_percent);
214            if history.len() > self.history_len {
215                history.remove(0);
216            }
217        }
218
219        // Clean up histories for dead processes
220        let live_pids: std::collections::HashSet<u32> = processes.iter().map(|p| p.pid).collect();
221        self.cpu_histories.retain(|pid, _| live_pids.contains(pid));
222
223        // Update rows with histories
224        self.rows = processes
225            .into_iter()
226            .map(|mut proc| {
227                if let Some(history) = self.cpu_histories.get(&proc.pid) {
228                    proc.cpu_history = history.clone();
229                }
230                proc
231            })
232            .collect();
233
234        self.sort_rows();
235    }
236
237    /// Set sort column.
238    #[must_use]
239    pub fn with_sort(mut self, column: ProcessSortColumn, descending: bool) -> Self {
240        self.sort_column = column;
241        self.sort_desc = descending;
242        self
243    }
244
245    /// Set accent color.
246    #[must_use]
247    pub fn with_accent_color(mut self, color: Color) -> Self {
248        self.accent_color = color;
249        self
250    }
251
252    /// Set column widths.
253    #[must_use]
254    pub fn with_column_widths(mut self, widths: ProcessColumnWidths) -> Self {
255        self.col_widths = widths;
256        self
257    }
258
259    /// Set history length.
260    #[must_use]
261    pub fn with_history_len(mut self, len: usize) -> Self {
262        self.history_len = len;
263        self
264    }
265
266    /// Get selected process PID (if any).
267    #[must_use]
268    pub fn selected_pid(&self) -> Option<u32> {
269        self.selected_row
270            .and_then(|idx| self.rows.get(idx).map(|r| r.pid))
271    }
272
273    /// Scroll up one row.
274    pub fn scroll_up(&mut self) {
275        if let Some(sel) = self.selected_row {
276            if sel > 0 {
277                self.selected_row = Some(sel - 1);
278                if sel - 1 < self.scroll_offset {
279                    self.scroll_offset = sel - 1;
280                }
281            }
282        } else if !self.rows.is_empty() {
283            self.selected_row = Some(0);
284        }
285    }
286
287    /// Scroll down one row.
288    pub fn scroll_down(&mut self) {
289        let max_visible = self.visible_rows();
290        if let Some(sel) = self.selected_row {
291            if sel < self.rows.len().saturating_sub(1) {
292                self.selected_row = Some(sel + 1);
293                if sel + 1 >= self.scroll_offset + max_visible {
294                    self.scroll_offset = (sel + 2).saturating_sub(max_visible);
295                }
296            }
297        } else if !self.rows.is_empty() {
298            self.selected_row = Some(0);
299        }
300    }
301
302    /// Cycle sort column.
303    pub fn cycle_sort(&mut self) {
304        self.sort_column = match self.sort_column {
305            ProcessSortColumn::Cpu => ProcessSortColumn::Mem,
306            ProcessSortColumn::Mem => ProcessSortColumn::Pid,
307            ProcessSortColumn::Pid => ProcessSortColumn::Name,
308            ProcessSortColumn::Name => ProcessSortColumn::Threads,
309            ProcessSortColumn::Threads => ProcessSortColumn::Time,
310            ProcessSortColumn::Time => ProcessSortColumn::Cpu,
311            _ => ProcessSortColumn::Cpu,
312        };
313        self.sort_rows();
314    }
315
316    /// Toggle sort direction.
317    pub fn toggle_sort_direction(&mut self) {
318        self.sort_desc = !self.sort_desc;
319        self.sort_rows();
320    }
321
322    fn sort_rows(&mut self) {
323        let desc = self.sort_desc;
324        match self.sort_column {
325            ProcessSortColumn::Pid => {
326                self.rows.sort_by(|a, b| {
327                    if desc {
328                        b.pid.cmp(&a.pid)
329                    } else {
330                        a.pid.cmp(&b.pid)
331                    }
332                });
333            }
334            ProcessSortColumn::Name => {
335                self.rows.sort_by(|a, b| {
336                    if desc {
337                        b.name.cmp(&a.name)
338                    } else {
339                        a.name.cmp(&b.name)
340                    }
341                });
342            }
343            ProcessSortColumn::Cpu => {
344                self.rows.sort_by(|a, b| {
345                    if desc {
346                        b.cpu_percent
347                            .partial_cmp(&a.cpu_percent)
348                            .unwrap_or(std::cmp::Ordering::Equal)
349                    } else {
350                        a.cpu_percent
351                            .partial_cmp(&b.cpu_percent)
352                            .unwrap_or(std::cmp::Ordering::Equal)
353                    }
354                });
355            }
356            ProcessSortColumn::Mem => {
357                self.rows.sort_by(|a, b| {
358                    if desc {
359                        b.mem_percent
360                            .partial_cmp(&a.mem_percent)
361                            .unwrap_or(std::cmp::Ordering::Equal)
362                    } else {
363                        a.mem_percent
364                            .partial_cmp(&b.mem_percent)
365                            .unwrap_or(std::cmp::Ordering::Equal)
366                    }
367                });
368            }
369            ProcessSortColumn::Threads => {
370                self.rows.sort_by(|a, b| {
371                    if desc {
372                        b.threads.cmp(&a.threads)
373                    } else {
374                        a.threads.cmp(&b.threads)
375                    }
376                });
377            }
378            ProcessSortColumn::Priority => {
379                self.rows.sort_by(|a, b| {
380                    if desc {
381                        b.priority.cmp(&a.priority)
382                    } else {
383                        a.priority.cmp(&b.priority)
384                    }
385                });
386            }
387            ProcessSortColumn::User => {
388                self.rows.sort_by(|a, b| {
389                    if desc {
390                        b.user.cmp(&a.user)
391                    } else {
392                        a.user.cmp(&b.user)
393                    }
394                });
395            }
396            ProcessSortColumn::Time => {
397                self.rows.sort_by(|a, b| {
398                    if desc {
399                        b.cpu_time_secs.cmp(&a.cpu_time_secs)
400                    } else {
401                        a.cpu_time_secs.cmp(&b.cpu_time_secs)
402                    }
403                });
404            }
405        }
406    }
407
408    fn visible_rows(&self) -> usize {
409        let header_offset = if self.show_header { 2 } else { 0 };
410        (self.bounds.height as usize).saturating_sub(header_offset)
411    }
412
413    fn render_sparkline(values: &[f32], width: usize) -> String {
414        const BARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
415
416        if values.is_empty() {
417            return "─".repeat(width);
418        }
419
420        // Scale to 0-100% range
421        let sample_width = width.min(values.len());
422        let step = values.len().saturating_sub(1) / sample_width.max(1);
423
424        (0..sample_width)
425            .map(|i| {
426                let idx = (i * step.max(1)).min(values.len().saturating_sub(1));
427                let v = values[idx].clamp(0.0, 100.0);
428                let norm = ((v / 100.0) * 7.0).round() as usize;
429                BARS[norm.min(7)]
430            })
431            .collect()
432    }
433
434    fn render_microbar(value: f32, width: usize) -> String {
435        let pct = (value / 100.0).clamp(0.0, 1.0);
436        let filled = ((width as f32) * pct).round() as usize;
437        let empty = width.saturating_sub(filled);
438        format!("{}{}", "█".repeat(filled), "░".repeat(empty))
439    }
440
441    fn format_time(secs: u64) -> String {
442        let hours = secs / 3600;
443        let mins = (secs % 3600) / 60;
444        let secs = secs % 60;
445        if hours > 99 {
446            format!("{hours}h")
447        } else if hours > 0 {
448            format!("{hours}:{mins:02}:{secs:02}")
449        } else {
450            format!("{mins}:{secs:02}")
451        }
452    }
453}
454
455impl Brick for ProcessDataFrame {
456    fn brick_name(&self) -> &'static str {
457        "process_dataframe"
458    }
459
460    fn assertions(&self) -> &[BrickAssertion] {
461        static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(16)];
462        ASSERTIONS
463    }
464
465    fn budget(&self) -> BrickBudget {
466        BrickBudget::uniform(16)
467    }
468
469    fn verify(&self) -> BrickVerification {
470        BrickVerification {
471            passed: self.assertions().to_vec(),
472            failed: vec![],
473            verification_time: Duration::from_micros(10),
474        }
475    }
476
477    fn to_html(&self) -> String {
478        format!(
479            r#"<table class="process-dataframe"><thead><tr>{}</tr></thead></table>"#,
480            ["PID", "COMMAND", "CPU%", "MEM%", "THR", "USER"]
481                .iter()
482                .map(|h| format!("<th>{h}</th>"))
483                .collect::<String>()
484        )
485    }
486
487    fn to_css(&self) -> String {
488        ".process-dataframe { sort: cpu; }".to_string()
489    }
490}
491
492impl Widget for ProcessDataFrame {
493    fn type_id(&self) -> TypeId {
494        TypeId::of::<Self>()
495    }
496
497    fn measure(&self, constraints: Constraints) -> Size {
498        let header_height = if self.show_header { 2.0 } else { 0.0 };
499        let row_height = self.rows.len().min(30) as f32;
500        let height = (header_height + row_height).min(constraints.max_height);
501        constraints.constrain(Size::new(constraints.max_width, height))
502    }
503
504    fn layout(&mut self, bounds: Rect) -> LayoutResult {
505        self.bounds = bounds;
506        LayoutResult {
507            size: Size::new(bounds.width, bounds.height),
508        }
509    }
510
511    fn paint(&self, canvas: &mut dyn Canvas) {
512        if self.bounds.width < 40.0 || self.bounds.height < 3.0 {
513            return;
514        }
515
516        let mut y = self.bounds.y;
517        let x_start = self.bounds.x;
518        let w = &self.col_widths;
519
520        // Calculate column positions
521        let col_positions = [
522            0usize,                                                                      // PID
523            w.pid,                                                                       // Name
524            w.pid + w.name,                                                              // CPU%
525            w.pid + w.name + w.cpu,                       // Sparkline
526            w.pid + w.name + w.cpu + w.sparkline,         // MEM%
527            w.pid + w.name + w.cpu + w.sparkline + w.mem, // State
528            w.pid + w.name + w.cpu + w.sparkline + w.mem + w.state, // Threads
529            w.pid + w.name + w.cpu + w.sparkline + w.mem + w.state + w.threads, // User
530            w.pid + w.name + w.cpu + w.sparkline + w.mem + w.state + w.threads + w.user, // Time
531        ];
532
533        let header_style = TextStyle {
534            color: self.accent_color,
535            ..Default::default()
536        };
537
538        let dim_style = TextStyle {
539            color: Color::new(0.5, 0.5, 0.5, 1.0),
540            ..Default::default()
541        };
542
543        // Header row
544        if self.show_header {
545            let headers = [
546                (ProcessSortColumn::Pid, w.pid),
547                (ProcessSortColumn::Name, w.name),
548                (ProcessSortColumn::Cpu, w.cpu + w.sparkline),
549                (ProcessSortColumn::Mem, w.mem),
550                (ProcessSortColumn::Threads, w.state + w.threads),
551                (ProcessSortColumn::User, w.user),
552                (ProcessSortColumn::Time, w.time),
553            ];
554
555            let mut hx = x_start;
556            for (col, width) in headers {
557                let is_sorted = col == self.sort_column;
558                let arrow = if is_sorted {
559                    if self.sort_desc {
560                        "▼"
561                    } else {
562                        "▲"
563                    }
564                } else {
565                    ""
566                };
567                let text = format!("{}{}", col.header(), arrow);
568                let text = if text.len() > width {
569                    text[..width].to_string()
570                } else {
571                    text
572                };
573
574                let style = if is_sorted { &header_style } else { &dim_style };
575                canvas.draw_text(&text, Point::new(hx, y), style);
576                hx += width as f32 + 1.0;
577            }
578
579            y += 1.0;
580
581            // Separator line
582            let sep = "─".repeat((self.bounds.width as usize).min(120));
583            canvas.draw_text(&sep, Point::new(x_start, y), &dim_style);
584            y += 1.0;
585        }
586
587        // Data rows
588        let visible_rows = self.visible_rows();
589        let end_idx = (self.scroll_offset + visible_rows).min(self.rows.len());
590
591        for (rel_idx, row) in self.rows[self.scroll_offset..end_idx].iter().enumerate() {
592            let abs_idx = self.scroll_offset + rel_idx;
593            let is_selected = self.selected_row == Some(abs_idx);
594
595            // === TUFTE SELECTION HIGHLIGHTING (Framework Widget) ===
596            // Strong row background + gutter indicator for selected row
597            let row_bounds = Rect::new(x_start, y, self.bounds.width, 1.0);
598            let row_highlight = RowHighlight::new(row_bounds, is_selected);
599            row_highlight.paint(canvas);
600
601            // Get text style from row highlight (white on blue when selected)
602            let text_style = row_highlight.text_style();
603
604            // PID
605            let pid_str = format!("{:>width$}", row.pid, width = w.pid);
606            canvas.draw_text(
607                &pid_str,
608                Point::new(x_start + col_positions[0] as f32, y),
609                &text_style,
610            );
611
612            // Name (truncated)
613            let name = if row.name.len() > w.name {
614                format!("{}…", &row.name[..w.name - 1])
615            } else {
616                format!("{:<width$}", row.name, width = w.name)
617            };
618            canvas.draw_text(
619                &name,
620                Point::new(x_start + col_positions[1] as f32, y),
621                &text_style,
622            );
623
624            // CPU% with thermal heat color encoding (Grammar of Graphics)
625            // High CPU = warmer (red), low CPU = cooler (green)
626            let cpu_color = HeatScheme::Thermal.color_for_percent(row.cpu_percent as f64);
627            let cpu_str = format!("{:>5.1}", row.cpu_percent);
628            canvas.draw_text(
629                &cpu_str,
630                Point::new(x_start + col_positions[2] as f32, y),
631                &TextStyle {
632                    color: cpu_color,
633                    ..Default::default()
634                },
635            );
636
637            // CPU sparkline
638            let sparkline = Self::render_sparkline(&row.cpu_history, w.sparkline);
639            canvas.draw_text(
640                &sparkline,
641                Point::new(x_start + col_positions[3] as f32, y),
642                &TextStyle {
643                    color: self.accent_color,
644                    ..Default::default()
645                },
646            );
647
648            // MEM% with micro bar
649            let mem_bar = Self::render_microbar(row.mem_percent, 4);
650            let mem_str = format!("{:>4.1}", row.mem_percent);
651            canvas.draw_text(
652                &format!("{mem_bar}{mem_str}"),
653                Point::new(x_start + col_positions[4] as f32, y),
654                &TextStyle {
655                    color: Color::new(0.7, 0.5, 0.9, 1.0),
656                    ..Default::default()
657                },
658            );
659
660            // State
661            let (state_ch, state_color) = row.state.render();
662            canvas.draw_text(
663                &state_ch.to_string(),
664                Point::new(x_start + col_positions[5] as f32, y),
665                &TextStyle {
666                    color: state_color,
667                    ..Default::default()
668                },
669            );
670
671            // Threads
672            let thr_str = format!("{:>3}", row.threads);
673            canvas.draw_text(
674                &thr_str,
675                Point::new(x_start + col_positions[6] as f32 + 1.0, y),
676                &text_style,
677            );
678
679            // User (truncated)
680            let user = if row.user.len() > w.user {
681                format!("{}…", &row.user[..w.user - 1])
682            } else {
683                format!("{:<width$}", row.user, width = w.user)
684            };
685            canvas.draw_text(
686                &user,
687                Point::new(x_start + col_positions[7] as f32, y),
688                &text_style,
689            );
690
691            // Time
692            let time_str = Self::format_time(row.cpu_time_secs);
693            canvas.draw_text(
694                &time_str,
695                Point::new(x_start + col_positions[8] as f32, y),
696                &text_style,
697            );
698
699            y += 1.0;
700        }
701
702        // Scroll indicator if needed
703        if self.rows.len() > visible_rows {
704            let scroll_pct = self.scroll_offset as f32 / (self.rows.len() - visible_rows) as f32;
705            let indicator_y = self.bounds.y + 2.0 + (scroll_pct * (visible_rows - 1) as f32);
706            canvas.draw_text(
707                "│",
708                Point::new(self.bounds.x + self.bounds.width - 1.0, indicator_y),
709                &dim_style,
710            );
711        }
712    }
713
714    fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
715        match event {
716            Event::KeyDown {
717                key: Key::Up | Key::K,
718                ..
719            } => {
720                self.scroll_up();
721                None
722            }
723            Event::KeyDown {
724                key: Key::Down | Key::J,
725                ..
726            } => {
727                self.scroll_down();
728                None
729            }
730            Event::KeyDown { key: Key::S, .. } => {
731                self.cycle_sort();
732                None
733            }
734            Event::KeyDown { key: Key::R, .. } => {
735                self.toggle_sort_direction();
736                None
737            }
738            _ => None,
739        }
740    }
741
742    fn children(&self) -> &[Box<dyn Widget>] {
743        &[]
744    }
745
746    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
747        &mut []
748    }
749}
750
751#[cfg(test)]
752#[allow(clippy::unwrap_used, clippy::disallowed_methods)]
753mod tests {
754    use super::*;
755    use crate::direct::{CellBuffer, DirectTerminalCanvas};
756
757    // ProcessDisplayState tests
758    #[test]
759    fn test_display_state_running() {
760        let (ch, color) = ProcessDisplayState::Running.render();
761        assert_eq!(ch, 'R');
762        assert!(color.g > 0.8, "Running should be green");
763    }
764
765    #[test]
766    fn test_display_state_sleeping() {
767        let (ch, color) = ProcessDisplayState::Sleeping.render();
768        assert_eq!(ch, 'S');
769        assert!(color.r > 0.5 && color.g > 0.5, "Sleeping should be gray");
770    }
771
772    #[test]
773    fn test_display_state_idle() {
774        let (ch, color) = ProcessDisplayState::Idle.render();
775        assert_eq!(ch, 'I');
776        assert!(color.r > 0.4, "Idle should be grayish");
777    }
778
779    #[test]
780    fn test_display_state_zombie() {
781        let (ch, color) = ProcessDisplayState::Zombie.render();
782        assert_eq!(ch, 'Z');
783        assert!(color.r > 0.8 && color.g < 0.3, "Zombie should be red");
784    }
785
786    #[test]
787    fn test_display_state_stopped() {
788        let (ch, color) = ProcessDisplayState::Stopped.render();
789        assert_eq!(ch, 'T');
790        assert!(
791            color.r > 0.8 && color.g > 0.6,
792            "Stopped should be yellow/orange"
793        );
794    }
795
796    #[test]
797    fn test_display_state_unknown() {
798        let (ch, color) = ProcessDisplayState::Unknown.render();
799        assert_eq!(ch, '?');
800        assert!(color.r < 0.5, "Unknown should be dim");
801    }
802
803    #[test]
804    fn test_display_state_default() {
805        assert_eq!(ProcessDisplayState::default(), ProcessDisplayState::Running);
806    }
807
808    // ProcessSortColumn tests
809    #[test]
810    fn test_sort_column_headers() {
811        assert_eq!(ProcessSortColumn::Pid.header(), "PID");
812        assert_eq!(ProcessSortColumn::Name.header(), "COMMAND");
813        assert_eq!(ProcessSortColumn::Cpu.header(), "CPU%");
814        assert_eq!(ProcessSortColumn::Mem.header(), "MEM%");
815        assert_eq!(ProcessSortColumn::Threads.header(), "THR");
816        assert_eq!(ProcessSortColumn::Priority.header(), "PRI");
817        assert_eq!(ProcessSortColumn::User.header(), "USER");
818        assert_eq!(ProcessSortColumn::Time.header(), "TIME+");
819    }
820
821    #[test]
822    fn test_sort_column_default() {
823        assert_eq!(ProcessSortColumn::default(), ProcessSortColumn::Cpu);
824    }
825
826    // ProcessRow tests
827    #[test]
828    fn test_process_row_default() {
829        let row = ProcessRow::default();
830        assert_eq!(row.pid, 0);
831        assert!(row.name.is_empty());
832        assert_eq!(row.cpu_percent, 0.0);
833        assert!(row.cpu_history.is_empty());
834        assert_eq!(row.mem_percent, 0.0);
835        assert_eq!(row.state, ProcessDisplayState::Running);
836        assert_eq!(row.threads, 1);
837        assert_eq!(row.priority, 0);
838        assert!(row.user.is_empty());
839        assert_eq!(row.cpu_time_secs, 0);
840    }
841
842    // ProcessColumnWidths tests
843    #[test]
844    fn test_column_widths_default() {
845        let w = ProcessColumnWidths::default();
846        assert_eq!(w.pid, 7);
847        assert_eq!(w.name, 20);
848        assert_eq!(w.cpu, 6);
849        assert_eq!(w.sparkline, 12);
850        assert_eq!(w.mem, 6);
851        assert_eq!(w.state, 3);
852        assert_eq!(w.threads, 4);
853        assert_eq!(w.priority, 4);
854        assert_eq!(w.user, 10);
855        assert_eq!(w.time, 10);
856    }
857
858    // ProcessDataFrame creation tests
859    #[test]
860    fn test_process_dataframe_creation() {
861        let df = ProcessDataFrame::new()
862            .with_sort(ProcessSortColumn::Mem, true)
863            .with_history_len(30);
864
865        assert_eq!(df.sort_column, ProcessSortColumn::Mem);
866        assert!(df.sort_desc);
867        assert_eq!(df.history_len, 30);
868    }
869
870    #[test]
871    fn test_process_dataframe_default() {
872        let df = ProcessDataFrame::default();
873        assert_eq!(df.sort_column, ProcessSortColumn::Cpu);
874        assert!(df.sort_desc);
875        assert_eq!(df.history_len, 60);
876    }
877
878    #[test]
879    fn test_with_accent_color() {
880        let color = Color::new(1.0, 0.0, 0.0, 1.0);
881        let df = ProcessDataFrame::new().with_accent_color(color);
882        assert_eq!(df.accent_color.r, 1.0);
883    }
884
885    #[test]
886    fn test_with_column_widths() {
887        let widths = ProcessColumnWidths {
888            pid: 10,
889            name: 30,
890            ..Default::default()
891        };
892        let df = ProcessDataFrame::new().with_column_widths(widths);
893        assert_eq!(df.col_widths.pid, 10);
894        assert_eq!(df.col_widths.name, 30);
895    }
896
897    // Update and sort tests
898    #[test]
899    fn test_process_row_update() {
900        let mut df = ProcessDataFrame::new();
901
902        let rows = vec![
903            ProcessRow {
904                pid: 1,
905                name: "systemd".into(),
906                cpu_percent: 0.5,
907                mem_percent: 1.2,
908                state: ProcessDisplayState::Running,
909                threads: 1,
910                priority: 0,
911                user: "root".into(),
912                cpu_time_secs: 3600,
913                ..Default::default()
914            },
915            ProcessRow {
916                pid: 100,
917                name: "firefox".into(),
918                cpu_percent: 25.0,
919                mem_percent: 15.0,
920                state: ProcessDisplayState::Sleeping,
921                threads: 120,
922                priority: 20,
923                user: "noah".into(),
924                cpu_time_secs: 7200,
925                ..Default::default()
926            },
927        ];
928
929        df.update_processes(rows);
930
931        // Should be sorted by CPU desc by default
932        assert_eq!(df.rows[0].pid, 100); // firefox has higher CPU
933        assert_eq!(df.rows[1].pid, 1);
934    }
935
936    #[test]
937    fn test_update_clears_dead_processes() {
938        let mut df = ProcessDataFrame::new();
939
940        // First update with two processes
941        df.update_processes(vec![
942            ProcessRow {
943                pid: 1,
944                cpu_percent: 10.0,
945                ..Default::default()
946            },
947            ProcessRow {
948                pid: 2,
949                cpu_percent: 20.0,
950                ..Default::default()
951            },
952        ]);
953        assert_eq!(df.cpu_histories.len(), 2);
954
955        // Second update with only one process
956        df.update_processes(vec![ProcessRow {
957            pid: 1,
958            cpu_percent: 15.0,
959            ..Default::default()
960        }]);
961        assert_eq!(df.cpu_histories.len(), 1);
962        assert!(df.cpu_histories.contains_key(&1));
963        assert!(!df.cpu_histories.contains_key(&2));
964    }
965
966    #[test]
967    fn test_history_length_limit() {
968        let mut df = ProcessDataFrame::new().with_history_len(3);
969
970        for i in 0..5 {
971            df.update_processes(vec![ProcessRow {
972                pid: 1,
973                cpu_percent: i as f32,
974                ..Default::default()
975            }]);
976        }
977
978        let history = df.cpu_histories.get(&1).unwrap();
979        assert_eq!(history.len(), 3);
980        assert_eq!(history, &vec![2.0, 3.0, 4.0]);
981    }
982
983    #[test]
984    fn test_selected_pid() {
985        let mut df = ProcessDataFrame::new();
986        assert!(df.selected_pid().is_none());
987
988        df.update_processes(vec![ProcessRow {
989            pid: 42,
990            cpu_percent: 10.0,
991            ..Default::default()
992        }]);
993        df.selected_row = Some(0);
994        assert_eq!(df.selected_pid(), Some(42));
995    }
996
997    #[test]
998    fn test_selected_pid_out_of_range() {
999        let mut df = ProcessDataFrame::new();
1000        df.selected_row = Some(999);
1001        assert!(df.selected_pid().is_none());
1002    }
1003
1004    // Sort tests for all columns
1005    #[test]
1006    fn test_sort_by_pid() {
1007        let mut df = ProcessDataFrame::new().with_sort(ProcessSortColumn::Pid, false);
1008        df.update_processes(vec![
1009            ProcessRow {
1010                pid: 100,
1011                ..Default::default()
1012            },
1013            ProcessRow {
1014                pid: 1,
1015                ..Default::default()
1016            },
1017            ProcessRow {
1018                pid: 50,
1019                ..Default::default()
1020            },
1021        ]);
1022        assert_eq!(df.rows[0].pid, 1);
1023        assert_eq!(df.rows[1].pid, 50);
1024        assert_eq!(df.rows[2].pid, 100);
1025    }
1026
1027    #[test]
1028    fn test_sort_by_pid_desc() {
1029        let mut df = ProcessDataFrame::new().with_sort(ProcessSortColumn::Pid, true);
1030        df.update_processes(vec![
1031            ProcessRow {
1032                pid: 100,
1033                ..Default::default()
1034            },
1035            ProcessRow {
1036                pid: 1,
1037                ..Default::default()
1038            },
1039            ProcessRow {
1040                pid: 50,
1041                ..Default::default()
1042            },
1043        ]);
1044        assert_eq!(df.rows[0].pid, 100);
1045        assert_eq!(df.rows[1].pid, 50);
1046        assert_eq!(df.rows[2].pid, 1);
1047    }
1048
1049    #[test]
1050    fn test_sort_by_name() {
1051        let mut df = ProcessDataFrame::new().with_sort(ProcessSortColumn::Name, false);
1052        df.update_processes(vec![
1053            ProcessRow {
1054                pid: 1,
1055                name: "zsh".into(),
1056                ..Default::default()
1057            },
1058            ProcessRow {
1059                pid: 2,
1060                name: "bash".into(),
1061                ..Default::default()
1062            },
1063            ProcessRow {
1064                pid: 3,
1065                name: "fish".into(),
1066                ..Default::default()
1067            },
1068        ]);
1069        assert_eq!(df.rows[0].name.as_str(), "bash");
1070        assert_eq!(df.rows[1].name.as_str(), "fish");
1071        assert_eq!(df.rows[2].name.as_str(), "zsh");
1072    }
1073
1074    #[test]
1075    fn test_sort_by_mem() {
1076        let mut df = ProcessDataFrame::new().with_sort(ProcessSortColumn::Mem, true);
1077        df.update_processes(vec![
1078            ProcessRow {
1079                pid: 1,
1080                mem_percent: 10.0,
1081                ..Default::default()
1082            },
1083            ProcessRow {
1084                pid: 2,
1085                mem_percent: 50.0,
1086                ..Default::default()
1087            },
1088            ProcessRow {
1089                pid: 3,
1090                mem_percent: 25.0,
1091                ..Default::default()
1092            },
1093        ]);
1094        assert_eq!(df.rows[0].pid, 2);
1095        assert_eq!(df.rows[1].pid, 3);
1096        assert_eq!(df.rows[2].pid, 1);
1097    }
1098
1099    #[test]
1100    fn test_sort_by_threads() {
1101        let mut df = ProcessDataFrame::new().with_sort(ProcessSortColumn::Threads, true);
1102        df.update_processes(vec![
1103            ProcessRow {
1104                pid: 1,
1105                threads: 1,
1106                ..Default::default()
1107            },
1108            ProcessRow {
1109                pid: 2,
1110                threads: 100,
1111                ..Default::default()
1112            },
1113            ProcessRow {
1114                pid: 3,
1115                threads: 10,
1116                ..Default::default()
1117            },
1118        ]);
1119        assert_eq!(df.rows[0].pid, 2);
1120        assert_eq!(df.rows[1].pid, 3);
1121        assert_eq!(df.rows[2].pid, 1);
1122    }
1123
1124    #[test]
1125    fn test_sort_by_priority() {
1126        let mut df = ProcessDataFrame::new().with_sort(ProcessSortColumn::Priority, false);
1127        df.update_processes(vec![
1128            ProcessRow {
1129                pid: 1,
1130                priority: 20,
1131                ..Default::default()
1132            },
1133            ProcessRow {
1134                pid: 2,
1135                priority: -10,
1136                ..Default::default()
1137            },
1138            ProcessRow {
1139                pid: 3,
1140                priority: 0,
1141                ..Default::default()
1142            },
1143        ]);
1144        assert_eq!(df.rows[0].pid, 2);
1145        assert_eq!(df.rows[1].pid, 3);
1146        assert_eq!(df.rows[2].pid, 1);
1147    }
1148
1149    #[test]
1150    fn test_sort_by_user() {
1151        let mut df = ProcessDataFrame::new().with_sort(ProcessSortColumn::User, false);
1152        df.update_processes(vec![
1153            ProcessRow {
1154                pid: 1,
1155                user: "root".into(),
1156                ..Default::default()
1157            },
1158            ProcessRow {
1159                pid: 2,
1160                user: "alice".into(),
1161                ..Default::default()
1162            },
1163            ProcessRow {
1164                pid: 3,
1165                user: "bob".into(),
1166                ..Default::default()
1167            },
1168        ]);
1169        assert_eq!(df.rows[0].user.as_str(), "alice");
1170        assert_eq!(df.rows[1].user.as_str(), "bob");
1171        assert_eq!(df.rows[2].user.as_str(), "root");
1172    }
1173
1174    #[test]
1175    fn test_sort_by_time() {
1176        let mut df = ProcessDataFrame::new().with_sort(ProcessSortColumn::Time, true);
1177        df.update_processes(vec![
1178            ProcessRow {
1179                pid: 1,
1180                cpu_time_secs: 100,
1181                ..Default::default()
1182            },
1183            ProcessRow {
1184                pid: 2,
1185                cpu_time_secs: 10000,
1186                ..Default::default()
1187            },
1188            ProcessRow {
1189                pid: 3,
1190                cpu_time_secs: 1000,
1191                ..Default::default()
1192            },
1193        ]);
1194        assert_eq!(df.rows[0].pid, 2);
1195        assert_eq!(df.rows[1].pid, 3);
1196        assert_eq!(df.rows[2].pid, 1);
1197    }
1198
1199    #[test]
1200    fn test_cycle_sort() {
1201        let mut df = ProcessDataFrame::new();
1202        assert_eq!(df.sort_column, ProcessSortColumn::Cpu);
1203
1204        df.cycle_sort();
1205        assert_eq!(df.sort_column, ProcessSortColumn::Mem);
1206
1207        df.cycle_sort();
1208        assert_eq!(df.sort_column, ProcessSortColumn::Pid);
1209
1210        df.cycle_sort();
1211        assert_eq!(df.sort_column, ProcessSortColumn::Name);
1212
1213        df.cycle_sort();
1214        assert_eq!(df.sort_column, ProcessSortColumn::Threads);
1215
1216        df.cycle_sort();
1217        assert_eq!(df.sort_column, ProcessSortColumn::Time);
1218
1219        df.cycle_sort();
1220        assert_eq!(df.sort_column, ProcessSortColumn::Cpu);
1221    }
1222
1223    #[test]
1224    fn test_cycle_sort_from_priority() {
1225        let mut df = ProcessDataFrame::new().with_sort(ProcessSortColumn::Priority, true);
1226        df.cycle_sort();
1227        assert_eq!(df.sort_column, ProcessSortColumn::Cpu);
1228    }
1229
1230    #[test]
1231    fn test_toggle_sort_direction() {
1232        let mut df = ProcessDataFrame::new();
1233        assert!(df.sort_desc);
1234
1235        df.toggle_sort_direction();
1236        assert!(!df.sort_desc);
1237
1238        df.toggle_sort_direction();
1239        assert!(df.sort_desc);
1240    }
1241
1242    // Rendering tests
1243    #[test]
1244    fn test_sparkline_rendering() {
1245        let values = vec![10.0, 20.0, 50.0, 80.0, 100.0];
1246        let sparkline = ProcessDataFrame::render_sparkline(&values, 5);
1247        assert_eq!(sparkline.chars().count(), 5);
1248    }
1249
1250    #[test]
1251    fn test_sparkline_empty() {
1252        let sparkline = ProcessDataFrame::render_sparkline(&[], 5);
1253        assert_eq!(sparkline, "─────");
1254    }
1255
1256    #[test]
1257    fn test_sparkline_single_value() {
1258        // With 1 value and width 3, sample_width = min(3, 1) = 1
1259        let sparkline = ProcessDataFrame::render_sparkline(&[50.0], 3);
1260        assert_eq!(sparkline.chars().count(), 1);
1261    }
1262
1263    #[test]
1264    fn test_microbar_rendering() {
1265        let bar = ProcessDataFrame::render_microbar(50.0, 10);
1266        assert_eq!(bar.chars().count(), 10);
1267        assert!(bar.contains('█'));
1268        assert!(bar.contains('░'));
1269    }
1270
1271    #[test]
1272    fn test_microbar_zero() {
1273        let bar = ProcessDataFrame::render_microbar(0.0, 5);
1274        assert_eq!(bar, "░░░░░");
1275    }
1276
1277    #[test]
1278    fn test_microbar_full() {
1279        let bar = ProcessDataFrame::render_microbar(100.0, 5);
1280        assert_eq!(bar, "█████");
1281    }
1282
1283    #[test]
1284    fn test_microbar_clamped() {
1285        let bar_over = ProcessDataFrame::render_microbar(150.0, 5);
1286        assert_eq!(bar_over, "█████");
1287
1288        let bar_under = ProcessDataFrame::render_microbar(-10.0, 5);
1289        assert_eq!(bar_under, "░░░░░");
1290    }
1291
1292    #[test]
1293    fn test_format_time() {
1294        assert_eq!(ProcessDataFrame::format_time(59), "0:59");
1295        assert_eq!(ProcessDataFrame::format_time(3661), "1:01:01");
1296        assert_eq!(ProcessDataFrame::format_time(360000), "100h");
1297    }
1298
1299    #[test]
1300    fn test_format_time_zero() {
1301        assert_eq!(ProcessDataFrame::format_time(0), "0:00");
1302    }
1303
1304    #[test]
1305    fn test_format_time_exact_hour() {
1306        assert_eq!(ProcessDataFrame::format_time(3600), "1:00:00");
1307    }
1308
1309    #[test]
1310    fn test_format_time_99_hours() {
1311        assert_eq!(ProcessDataFrame::format_time(99 * 3600), "99:00:00");
1312    }
1313
1314    // Scroll tests
1315    #[test]
1316    fn test_scroll() {
1317        let mut df = ProcessDataFrame::new();
1318        df.bounds = Rect::new(0.0, 0.0, 100.0, 10.0);
1319
1320        let rows: Vec<ProcessRow> = (0..20)
1321            .map(|i| ProcessRow {
1322                pid: i,
1323                name: format!("proc{i}").into(),
1324                cpu_percent: i as f32,
1325                ..Default::default()
1326            })
1327            .collect();
1328
1329        df.update_processes(rows);
1330
1331        df.scroll_down();
1332        assert_eq!(df.selected_row, Some(0));
1333
1334        df.scroll_down();
1335        assert_eq!(df.selected_row, Some(1));
1336
1337        df.scroll_up();
1338        assert_eq!(df.selected_row, Some(0));
1339    }
1340
1341    #[test]
1342    fn test_scroll_up_at_top() {
1343        let mut df = ProcessDataFrame::new();
1344        df.update_processes(vec![ProcessRow {
1345            pid: 1,
1346            ..Default::default()
1347        }]);
1348        df.selected_row = Some(0);
1349
1350        df.scroll_up();
1351        assert_eq!(df.selected_row, Some(0));
1352    }
1353
1354    #[test]
1355    fn test_scroll_down_at_bottom() {
1356        let mut df = ProcessDataFrame::new();
1357        df.bounds = Rect::new(0.0, 0.0, 100.0, 100.0);
1358        df.update_processes(vec![ProcessRow {
1359            pid: 1,
1360            ..Default::default()
1361        }]);
1362        df.selected_row = Some(0);
1363
1364        df.scroll_down();
1365        assert_eq!(df.selected_row, Some(0));
1366    }
1367
1368    #[test]
1369    fn test_scroll_empty() {
1370        let mut df = ProcessDataFrame::new();
1371        df.scroll_up();
1372        assert!(df.selected_row.is_none());
1373
1374        df.scroll_down();
1375        assert!(df.selected_row.is_none());
1376    }
1377
1378    #[test]
1379    fn test_scroll_triggers_offset_adjustment() {
1380        let mut df = ProcessDataFrame::new();
1381        df.bounds = Rect::new(0.0, 0.0, 100.0, 5.0); // Small height
1382        df.show_header = false;
1383
1384        let rows: Vec<ProcessRow> = (0..20)
1385            .map(|i| ProcessRow {
1386                pid: i,
1387                cpu_percent: (20 - i) as f32,
1388                ..Default::default()
1389            })
1390            .collect();
1391        df.update_processes(rows);
1392
1393        // Scroll down past visible area
1394        for _ in 0..10 {
1395            df.scroll_down();
1396        }
1397        assert!(df.scroll_offset > 0);
1398    }
1399
1400    // Visible rows test
1401    #[test]
1402    fn test_visible_rows_with_header() {
1403        let mut df = ProcessDataFrame::new();
1404        df.bounds = Rect::new(0.0, 0.0, 100.0, 10.0);
1405        df.show_header = true;
1406        assert_eq!(df.visible_rows(), 8); // 10 - 2 for header
1407    }
1408
1409    #[test]
1410    fn test_visible_rows_without_header() {
1411        let mut df = ProcessDataFrame::new();
1412        df.bounds = Rect::new(0.0, 0.0, 100.0, 10.0);
1413        df.show_header = false;
1414        assert_eq!(df.visible_rows(), 10);
1415    }
1416
1417    // Brick trait tests
1418    #[test]
1419    fn test_brick_name() {
1420        let df = ProcessDataFrame::new();
1421        assert_eq!(df.brick_name(), "process_dataframe");
1422    }
1423
1424    #[test]
1425    fn test_brick_assertions() {
1426        let df = ProcessDataFrame::new();
1427        let assertions = df.assertions();
1428        assert!(!assertions.is_empty());
1429    }
1430
1431    #[test]
1432    fn test_brick_budget() {
1433        let df = ProcessDataFrame::new();
1434        let budget = df.budget();
1435        assert!(budget.total_ms > 0);
1436    }
1437
1438    #[test]
1439    fn test_brick_verify() {
1440        let df = ProcessDataFrame::new();
1441        let verification = df.verify();
1442        assert!(verification.failed.is_empty());
1443    }
1444
1445    #[test]
1446    fn test_to_html() {
1447        let df = ProcessDataFrame::new();
1448        let html = df.to_html();
1449        assert!(html.contains("process-dataframe"));
1450        assert!(html.contains("PID"));
1451        assert!(html.contains("COMMAND"));
1452    }
1453
1454    #[test]
1455    fn test_to_css() {
1456        let df = ProcessDataFrame::new();
1457        let css = df.to_css();
1458        assert!(css.contains("process-dataframe"));
1459        assert!(css.contains("sort"));
1460    }
1461
1462    // Widget trait tests
1463    #[test]
1464    fn test_type_id() {
1465        let df = ProcessDataFrame::new();
1466        let id = Widget::type_id(&df);
1467        assert_eq!(id, TypeId::of::<ProcessDataFrame>());
1468    }
1469
1470    #[test]
1471    fn test_measure() {
1472        let df = ProcessDataFrame::new();
1473        let constraints = Constraints::tight(Size::new(100.0, 50.0));
1474        let size = df.measure(constraints);
1475        assert!(size.width <= 100.0);
1476        assert!(size.height <= 50.0);
1477    }
1478
1479    #[test]
1480    fn test_measure_with_rows() {
1481        let mut df = ProcessDataFrame::new();
1482        df.update_processes(vec![
1483            ProcessRow {
1484                pid: 1,
1485                ..Default::default()
1486            },
1487            ProcessRow {
1488                pid: 2,
1489                ..Default::default()
1490            },
1491        ]);
1492        let constraints = Constraints::tight(Size::new(100.0, 50.0));
1493        let size = df.measure(constraints);
1494        assert!(size.height >= 3.0); // header + 2 rows
1495    }
1496
1497    #[test]
1498    fn test_layout() {
1499        let mut df = ProcessDataFrame::new();
1500        let bounds = Rect::new(10.0, 20.0, 200.0, 100.0);
1501        let result = df.layout(bounds);
1502        assert_eq!(result.size.width, 200.0);
1503        assert_eq!(result.size.height, 100.0);
1504        assert_eq!(df.bounds, bounds);
1505    }
1506
1507    #[test]
1508    fn test_paint_too_small() {
1509        let mut df = ProcessDataFrame::new();
1510        df.bounds = Rect::new(0.0, 0.0, 30.0, 2.0); // Too small
1511
1512        let mut buffer = CellBuffer::new(30, 2);
1513        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
1514        df.paint(&mut canvas);
1515        // Should not crash, just skip painting
1516    }
1517
1518    #[test]
1519    fn test_paint_with_data() {
1520        let mut df = ProcessDataFrame::new();
1521        df.bounds = Rect::new(0.0, 0.0, 100.0, 20.0);
1522        df.update_processes(vec![ProcessRow {
1523            pid: 1,
1524            name: "test".into(),
1525            cpu_percent: 50.0,
1526            mem_percent: 25.0,
1527            state: ProcessDisplayState::Running,
1528            threads: 4,
1529            user: "root".into(),
1530            cpu_time_secs: 3600,
1531            ..Default::default()
1532        }]);
1533
1534        let mut buffer = CellBuffer::new(100, 20);
1535        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
1536        df.paint(&mut canvas);
1537        // Should render without errors
1538    }
1539
1540    #[test]
1541    fn test_paint_with_scroll_indicator() {
1542        let mut df = ProcessDataFrame::new();
1543        df.bounds = Rect::new(0.0, 0.0, 100.0, 5.0);
1544        df.show_header = false;
1545
1546        let rows: Vec<ProcessRow> = (0..20)
1547            .map(|i| ProcessRow {
1548                pid: i,
1549                cpu_percent: i as f32,
1550                ..Default::default()
1551            })
1552            .collect();
1553        df.update_processes(rows);
1554
1555        let mut buffer = CellBuffer::new(100, 5);
1556        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
1557        df.paint(&mut canvas);
1558    }
1559
1560    #[test]
1561    fn test_paint_with_selection() {
1562        let mut df = ProcessDataFrame::new();
1563        df.bounds = Rect::new(0.0, 0.0, 100.0, 10.0);
1564        df.update_processes(vec![
1565            ProcessRow {
1566                pid: 1,
1567                cpu_percent: 50.0,
1568                ..Default::default()
1569            },
1570            ProcessRow {
1571                pid: 2,
1572                cpu_percent: 25.0,
1573                ..Default::default()
1574            },
1575        ]);
1576        df.selected_row = Some(0);
1577
1578        let mut buffer = CellBuffer::new(100, 10);
1579        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
1580        df.paint(&mut canvas);
1581    }
1582
1583    // Event tests
1584    #[test]
1585    fn test_event_key_up() {
1586        let mut df = ProcessDataFrame::new();
1587        df.update_processes(vec![
1588            ProcessRow {
1589                pid: 1,
1590                ..Default::default()
1591            },
1592            ProcessRow {
1593                pid: 2,
1594                ..Default::default()
1595            },
1596        ]);
1597        df.selected_row = Some(1);
1598
1599        let result = df.event(&Event::key_down(Key::Up));
1600        assert!(result.is_none());
1601        assert_eq!(df.selected_row, Some(0));
1602    }
1603
1604    #[test]
1605    fn test_event_key_k() {
1606        let mut df = ProcessDataFrame::new();
1607        df.update_processes(vec![
1608            ProcessRow {
1609                pid: 1,
1610                ..Default::default()
1611            },
1612            ProcessRow {
1613                pid: 2,
1614                ..Default::default()
1615            },
1616        ]);
1617        df.selected_row = Some(1);
1618
1619        let result = df.event(&Event::key_down(Key::K));
1620        assert!(result.is_none());
1621        assert_eq!(df.selected_row, Some(0));
1622    }
1623
1624    #[test]
1625    fn test_event_key_down() {
1626        let mut df = ProcessDataFrame::new();
1627        df.bounds = Rect::new(0.0, 0.0, 100.0, 100.0);
1628        df.update_processes(vec![
1629            ProcessRow {
1630                pid: 1,
1631                ..Default::default()
1632            },
1633            ProcessRow {
1634                pid: 2,
1635                ..Default::default()
1636            },
1637        ]);
1638
1639        let result = df.event(&Event::key_down(Key::Down));
1640        assert!(result.is_none());
1641        assert_eq!(df.selected_row, Some(0));
1642    }
1643
1644    #[test]
1645    fn test_event_key_j() {
1646        let mut df = ProcessDataFrame::new();
1647        df.bounds = Rect::new(0.0, 0.0, 100.0, 100.0);
1648        df.update_processes(vec![
1649            ProcessRow {
1650                pid: 1,
1651                ..Default::default()
1652            },
1653            ProcessRow {
1654                pid: 2,
1655                ..Default::default()
1656            },
1657        ]);
1658
1659        let result = df.event(&Event::key_down(Key::J));
1660        assert!(result.is_none());
1661        assert_eq!(df.selected_row, Some(0));
1662    }
1663
1664    #[test]
1665    fn test_event_key_s_cycles_sort() {
1666        let mut df = ProcessDataFrame::new();
1667        assert_eq!(df.sort_column, ProcessSortColumn::Cpu);
1668
1669        df.event(&Event::key_down(Key::S));
1670        assert_eq!(df.sort_column, ProcessSortColumn::Mem);
1671    }
1672
1673    #[test]
1674    fn test_event_key_r_toggles_direction() {
1675        let mut df = ProcessDataFrame::new();
1676        assert!(df.sort_desc);
1677
1678        df.event(&Event::key_down(Key::R));
1679        assert!(!df.sort_desc);
1680    }
1681
1682    #[test]
1683    fn test_event_unhandled() {
1684        let mut df = ProcessDataFrame::new();
1685        let result = df.event(&Event::key_down(Key::Escape));
1686        assert!(result.is_none());
1687    }
1688
1689    #[test]
1690    fn test_children_empty() {
1691        let df = ProcessDataFrame::new();
1692        assert!(df.children().is_empty());
1693    }
1694
1695    #[test]
1696    fn test_children_mut_empty() {
1697        let mut df = ProcessDataFrame::new();
1698        assert!(df.children_mut().is_empty());
1699    }
1700}