Skip to main content

presentar_terminal/widgets/
core_stats_dataframe.rs

1//! `CoreStatsDataFrame` widget - Data science view for per-core CPU statistics.
2//!
3//! Grammar of Graphics construct: Core statistics as sortable `DataFrame` with:
4//! - Core ID, Frequency, Temperature columns
5//! - User/System/Idle/IOWait breakdown with stacked bars
6//! - Context switches, interrupts per core
7//! - Load history sparklines
8//!
9//! Implements SPEC-024 Section 27.8 - Framework-First pattern.
10
11use presentar_core::{
12    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event, Key,
13    LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
14};
15use std::any::Any;
16use std::time::Duration;
17
18use super::micro_heat_bar::HeatScheme;
19use super::selection::RowHighlight;
20
21/// Sort column for core stats table.
22#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
23pub enum CoreStatsSortColumn {
24    #[default]
25    CoreId,
26    Frequency,
27    Temperature,
28    User,
29    System,
30    Total,
31    Idle,
32}
33
34impl CoreStatsSortColumn {
35    /// Get column header string.
36    #[must_use]
37    pub fn header(self) -> &'static str {
38        match self {
39            Self::CoreId => "CORE",
40            Self::Frequency => "FREQ",
41            Self::Temperature => "TEMP",
42            Self::User => "USR%",
43            Self::System => "SYS%",
44            Self::Total => "TOT%",
45            Self::Idle => "IDL%",
46        }
47    }
48}
49
50/// Single core row data.
51#[derive(Debug, Clone)]
52pub struct CoreStatsRow {
53    /// Core ID (0-indexed).
54    pub core_id: u32,
55    /// Current frequency in MHz.
56    pub freq_mhz: u32,
57    /// Temperature in Celsius (if available).
58    pub temp_c: Option<f32>,
59    /// User CPU percentage.
60    pub user_pct: f32,
61    /// System CPU percentage.
62    pub system_pct: f32,
63    /// `IOWait` percentage.
64    pub iowait_pct: f32,
65    /// Idle percentage.
66    pub idle_pct: f32,
67    /// Total utilization history (last N samples).
68    pub util_history: Vec<f32>,
69    /// Context switches per second.
70    pub ctx_switches: u64,
71    /// Interrupts per second.
72    pub interrupts: u64,
73}
74
75impl Default for CoreStatsRow {
76    fn default() -> Self {
77        Self {
78            core_id: 0,
79            freq_mhz: 0,
80            temp_c: None,
81            user_pct: 0.0,
82            system_pct: 0.0,
83            iowait_pct: 0.0,
84            idle_pct: 100.0,
85            util_history: Vec::new(),
86            ctx_switches: 0,
87            interrupts: 0,
88        }
89    }
90}
91
92impl CoreStatsRow {
93    /// Total CPU utilization (user + system + iowait).
94    #[must_use]
95    pub fn total_pct(&self) -> f32 {
96        self.user_pct + self.system_pct + self.iowait_pct
97    }
98}
99
100/// Core statistics `DataFrame` widget.
101#[derive(Debug, Clone)]
102pub struct CoreStatsDataFrame {
103    /// Core rows.
104    rows: Vec<CoreStatsRow>,
105    /// Current sort column.
106    sort_column: CoreStatsSortColumn,
107    /// Sort descending.
108    sort_desc: bool,
109    /// Selected row index.
110    selected_row: Option<usize>,
111    /// Scroll offset.
112    scroll_offset: usize,
113    /// Show header row.
114    show_header: bool,
115    /// Show utilization breakdown bars.
116    show_breakdown_bars: bool,
117    /// Primary accent color.
118    accent_color: Color,
119    /// User color.
120    user_color: Color,
121    /// System color.
122    system_color: Color,
123    /// `IOWait` color.
124    iowait_color: Color,
125    /// Cached bounds.
126    bounds: Rect,
127}
128
129impl Default for CoreStatsDataFrame {
130    fn default() -> Self {
131        Self::new()
132    }
133}
134
135impl CoreStatsDataFrame {
136    /// Create a new empty core stats dataframe.
137    #[must_use]
138    pub fn new() -> Self {
139        Self {
140            rows: Vec::new(),
141            sort_column: CoreStatsSortColumn::CoreId,
142            sort_desc: false,
143            selected_row: None,
144            scroll_offset: 0,
145            show_header: true,
146            show_breakdown_bars: true,
147            accent_color: Color::new(0.4, 0.7, 1.0, 1.0),
148            user_color: Color::new(0.3, 0.7, 0.3, 1.0),
149            system_color: Color::new(0.9, 0.5, 0.2, 1.0),
150            iowait_color: Color::new(0.9, 0.2, 0.2, 1.0),
151            bounds: Rect::default(),
152        }
153    }
154
155    /// Update with new core data.
156    pub fn update_cores(&mut self, cores: Vec<CoreStatsRow>) {
157        self.rows = cores;
158        self.sort_rows();
159    }
160
161    /// Set sort column.
162    #[must_use]
163    pub fn with_sort(mut self, column: CoreStatsSortColumn, descending: bool) -> Self {
164        self.sort_column = column;
165        self.sort_desc = descending;
166        self
167    }
168
169    /// Set accent color.
170    #[must_use]
171    pub fn with_accent_color(mut self, color: Color) -> Self {
172        self.accent_color = color;
173        self
174    }
175
176    /// Set breakdown bar visibility.
177    #[must_use]
178    pub fn with_breakdown_bars(mut self, show: bool) -> Self {
179        self.show_breakdown_bars = show;
180        self
181    }
182
183    /// Cycle sort column.
184    pub fn cycle_sort(&mut self) {
185        self.sort_column = match self.sort_column {
186            CoreStatsSortColumn::CoreId => CoreStatsSortColumn::Total,
187            CoreStatsSortColumn::Total => CoreStatsSortColumn::User,
188            CoreStatsSortColumn::User => CoreStatsSortColumn::System,
189            CoreStatsSortColumn::System => CoreStatsSortColumn::Frequency,
190            CoreStatsSortColumn::Frequency => CoreStatsSortColumn::Temperature,
191            CoreStatsSortColumn::Temperature => CoreStatsSortColumn::Idle,
192            CoreStatsSortColumn::Idle => CoreStatsSortColumn::CoreId,
193        };
194        self.sort_rows();
195    }
196
197    /// Toggle sort direction.
198    pub fn toggle_sort_direction(&mut self) {
199        self.sort_desc = !self.sort_desc;
200        self.sort_rows();
201    }
202
203    fn sort_rows(&mut self) {
204        let desc = self.sort_desc;
205        match self.sort_column {
206            CoreStatsSortColumn::CoreId => {
207                self.rows.sort_by(|a, b| {
208                    if desc {
209                        b.core_id.cmp(&a.core_id)
210                    } else {
211                        a.core_id.cmp(&b.core_id)
212                    }
213                });
214            }
215            CoreStatsSortColumn::Frequency => {
216                self.rows.sort_by(|a, b| {
217                    if desc {
218                        b.freq_mhz.cmp(&a.freq_mhz)
219                    } else {
220                        a.freq_mhz.cmp(&b.freq_mhz)
221                    }
222                });
223            }
224            CoreStatsSortColumn::Temperature => {
225                self.rows.sort_by(|a, b| {
226                    let a_temp = a.temp_c.unwrap_or(0.0);
227                    let b_temp = b.temp_c.unwrap_or(0.0);
228                    if desc {
229                        b_temp
230                            .partial_cmp(&a_temp)
231                            .unwrap_or(std::cmp::Ordering::Equal)
232                    } else {
233                        a_temp
234                            .partial_cmp(&b_temp)
235                            .unwrap_or(std::cmp::Ordering::Equal)
236                    }
237                });
238            }
239            CoreStatsSortColumn::User => {
240                self.rows.sort_by(|a, b| {
241                    if desc {
242                        b.user_pct
243                            .partial_cmp(&a.user_pct)
244                            .unwrap_or(std::cmp::Ordering::Equal)
245                    } else {
246                        a.user_pct
247                            .partial_cmp(&b.user_pct)
248                            .unwrap_or(std::cmp::Ordering::Equal)
249                    }
250                });
251            }
252            CoreStatsSortColumn::System => {
253                self.rows.sort_by(|a, b| {
254                    if desc {
255                        b.system_pct
256                            .partial_cmp(&a.system_pct)
257                            .unwrap_or(std::cmp::Ordering::Equal)
258                    } else {
259                        a.system_pct
260                            .partial_cmp(&b.system_pct)
261                            .unwrap_or(std::cmp::Ordering::Equal)
262                    }
263                });
264            }
265            CoreStatsSortColumn::Total => {
266                self.rows.sort_by(|a, b| {
267                    if desc {
268                        b.total_pct()
269                            .partial_cmp(&a.total_pct())
270                            .unwrap_or(std::cmp::Ordering::Equal)
271                    } else {
272                        a.total_pct()
273                            .partial_cmp(&b.total_pct())
274                            .unwrap_or(std::cmp::Ordering::Equal)
275                    }
276                });
277            }
278            CoreStatsSortColumn::Idle => {
279                self.rows.sort_by(|a, b| {
280                    if desc {
281                        b.idle_pct
282                            .partial_cmp(&a.idle_pct)
283                            .unwrap_or(std::cmp::Ordering::Equal)
284                    } else {
285                        a.idle_pct
286                            .partial_cmp(&b.idle_pct)
287                            .unwrap_or(std::cmp::Ordering::Equal)
288                    }
289                });
290            }
291        }
292    }
293
294    fn visible_rows(&self) -> usize {
295        let header_offset = if self.show_header { 2 } else { 0 };
296        (self.bounds.height as usize).saturating_sub(header_offset)
297    }
298
299    fn render_sparkline(values: &[f32], width: usize) -> String {
300        const BARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
301
302        if values.is_empty() {
303            return "─".repeat(width);
304        }
305
306        let sample_width = width.min(values.len());
307        let step = values.len().saturating_sub(1) / sample_width.max(1);
308
309        (0..sample_width)
310            .map(|i| {
311                let idx = (i * step.max(1)).min(values.len().saturating_sub(1));
312                let v = values[idx].clamp(0.0, 100.0);
313                let norm = ((v / 100.0) * 7.0).round() as usize;
314                BARS[norm.min(7)]
315            })
316            .collect()
317    }
318
319    /// Render stacked breakdown bar using thermal heat color encoding.
320    ///
321    /// Grammar of Graphics: Maps CPU category percentages to:
322    /// - Width proportional to percentage (area encoding)
323    /// - Color intensity from thermal scheme (value encoding)
324    ///
325    /// Tufte principle: Double-encoding maximizes data-ink ratio.
326    fn render_stacked_bar(&self, row: &CoreStatsRow, width: usize) -> Vec<(String, Color)> {
327        let total = 100.0f32;
328        let user_chars = ((row.user_pct / total) * width as f32).round() as usize;
329        let sys_chars = ((row.system_pct / total) * width as f32).round() as usize;
330        let io_chars = ((row.iowait_pct / total) * width as f32).round() as usize;
331        let idle_chars = width.saturating_sub(user_chars + sys_chars + io_chars);
332
333        // Thermal heat scheme: color intensity encodes the percentage value
334        // High percentages = warmer colors (red), low = cooler (green)
335        let scheme = HeatScheme::Thermal;
336
337        // Use 8-level gradient blocks for elegant visual encoding
338        let user_block = Self::gradient_block(row.user_pct);
339        let sys_block = Self::gradient_block(row.system_pct);
340        let io_block = Self::gradient_block(row.iowait_pct);
341
342        vec![
343            (
344                user_block.to_string().repeat(user_chars),
345                scheme.color_for_percent(row.user_pct as f64),
346            ),
347            (
348                sys_block.to_string().repeat(sys_chars),
349                scheme.color_for_percent(row.system_pct as f64),
350            ),
351            (
352                io_block.to_string().repeat(io_chars),
353                scheme.color_for_percent(row.iowait_pct as f64),
354            ),
355            ("░".repeat(idle_chars), Color::new(0.2, 0.2, 0.25, 1.0)),
356        ]
357    }
358
359    /// Map percentage to gradient block character (8-level encoding).
360    fn gradient_block(pct: f32) -> char {
361        const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
362        let level = ((pct / 100.0) * 7.0).round() as usize;
363        BLOCKS[level.min(7)]
364    }
365
366    fn format_freq(mhz: u32) -> String {
367        if mhz >= 1000 {
368            format!("{:.1}G", mhz as f32 / 1000.0)
369        } else {
370            format!("{mhz}M")
371        }
372    }
373
374    fn format_temp(temp: Option<f32>) -> String {
375        match temp {
376            Some(t) => format!("{t:.0}°"),
377            None => "─".to_string(),
378        }
379    }
380}
381
382impl Brick for CoreStatsDataFrame {
383    fn brick_name(&self) -> &'static str {
384        "core_stats_dataframe"
385    }
386
387    fn assertions(&self) -> &[BrickAssertion] {
388        static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(16)];
389        ASSERTIONS
390    }
391
392    fn budget(&self) -> BrickBudget {
393        BrickBudget::uniform(16)
394    }
395
396    fn verify(&self) -> BrickVerification {
397        BrickVerification {
398            passed: self.assertions().to_vec(),
399            failed: vec![],
400            verification_time: Duration::from_micros(10),
401        }
402    }
403
404    fn to_html(&self) -> String {
405        format!(
406            r#"<table class="core-stats-dataframe"><thead><tr>{}</tr></thead></table>"#,
407            ["CORE", "FREQ", "TEMP", "USR%", "SYS%", "TOT%"]
408                .iter()
409                .map(|h| format!("<th>{h}</th>"))
410                .collect::<String>()
411        )
412    }
413
414    fn to_css(&self) -> String {
415        ".core-stats-dataframe { sort: core_id; }".to_string()
416    }
417}
418
419impl Widget for CoreStatsDataFrame {
420    fn type_id(&self) -> TypeId {
421        TypeId::of::<Self>()
422    }
423
424    fn measure(&self, constraints: Constraints) -> Size {
425        let header_height = if self.show_header { 2.0 } else { 0.0 };
426        let row_height = self.rows.len().min(64) as f32;
427        let height = (header_height + row_height).min(constraints.max_height);
428        constraints.constrain(Size::new(constraints.max_width, height))
429    }
430
431    fn layout(&mut self, bounds: Rect) -> LayoutResult {
432        self.bounds = bounds;
433        LayoutResult {
434            size: Size::new(bounds.width, bounds.height),
435        }
436    }
437
438    fn paint(&self, canvas: &mut dyn Canvas) {
439        if self.bounds.width < 50.0 || self.bounds.height < 3.0 {
440            return;
441        }
442
443        let mut y = self.bounds.y;
444        let x_start = self.bounds.x;
445        let width = self.bounds.width as usize;
446
447        // Column widths
448        let col_core = 5;
449        let col_freq = 6;
450        let col_temp = 5;
451        let col_user = 6;
452        let col_sys = 6;
453        let col_io = 5;
454        let col_idle = 6;
455        let col_bar = 20.min(width.saturating_sub(
456            col_core + col_freq + col_temp + col_user + col_sys + col_io + col_idle + 10,
457        ));
458        let col_sparkline = 12.min(width.saturating_sub(
459            col_core + col_freq + col_temp + col_user + col_sys + col_io + col_idle + col_bar + 12,
460        ));
461
462        let header_style = TextStyle {
463            color: self.accent_color,
464            ..Default::default()
465        };
466
467        let dim_style = TextStyle {
468            color: Color::new(0.5, 0.5, 0.5, 1.0),
469            ..Default::default()
470        };
471
472        // Header row
473        if self.show_header {
474            let headers = [
475                (CoreStatsSortColumn::CoreId, col_core, "CORE"),
476                (CoreStatsSortColumn::Frequency, col_freq, "FREQ"),
477                (CoreStatsSortColumn::Temperature, col_temp, "TEMP"),
478                (CoreStatsSortColumn::User, col_user, "USR%"),
479                (CoreStatsSortColumn::System, col_sys, "SYS%"),
480                (CoreStatsSortColumn::Total, col_io, "IO%"),
481                (CoreStatsSortColumn::Idle, col_idle, "IDL%"),
482            ];
483
484            let mut hx = x_start;
485            for (col, w, name) in headers {
486                let is_sorted = col == self.sort_column;
487                let arrow = if is_sorted {
488                    if self.sort_desc {
489                        "▼"
490                    } else {
491                        "▲"
492                    }
493                } else {
494                    ""
495                };
496                let text = format!("{name}{arrow}");
497
498                let style = if is_sorted { &header_style } else { &dim_style };
499                canvas.draw_text(&text, Point::new(hx, y), style);
500                hx += w as f32 + 1.0;
501            }
502
503            // Breakdown bar header
504            if self.show_breakdown_bars && col_bar > 0 {
505                canvas.draw_text("BREAKDOWN", Point::new(hx, y), &dim_style);
506                hx += col_bar as f32 + 1.0;
507            }
508
509            // Sparkline header
510            if col_sparkline > 0 {
511                canvas.draw_text("HISTORY", Point::new(hx, y), &dim_style);
512            }
513
514            y += 1.0;
515
516            // Separator
517            let sep = "─".repeat(width.min(120));
518            canvas.draw_text(&sep, Point::new(x_start, y), &dim_style);
519            y += 1.0;
520        }
521
522        // Data rows
523        let visible = self.visible_rows();
524        let end_idx = (self.scroll_offset + visible).min(self.rows.len());
525
526        for (rel_idx, row) in self.rows[self.scroll_offset..end_idx].iter().enumerate() {
527            let abs_idx = self.scroll_offset + rel_idx;
528            let is_selected = self.selected_row == Some(abs_idx);
529
530            // === TUFTE SELECTION HIGHLIGHTING (Framework Widget) ===
531            // Paint strong row background for selected row (immediately visible)
532            let row_bounds = Rect::new(x_start, y, self.bounds.width, 1.0);
533            let row_highlight = RowHighlight::new(row_bounds, is_selected);
534            row_highlight.paint(canvas);
535
536            // Get text style from row highlight (white on blue when selected)
537            let text_style = row_highlight.text_style();
538            let text_color = text_style.color;
539
540            let mut x = x_start;
541
542            // Core ID
543            let core_str = format!("{:>4}", row.core_id);
544            canvas.draw_text(&core_str, Point::new(x, y), &text_style);
545            x += col_core as f32 + 1.0;
546
547            // Frequency
548            let freq_str = format!("{:>5}", Self::format_freq(row.freq_mhz));
549            canvas.draw_text(&freq_str, Point::new(x, y), &text_style);
550            x += col_freq as f32 + 1.0;
551
552            // Temperature with color
553            let temp_str = Self::format_temp(row.temp_c);
554            let temp_color = match row.temp_c {
555                Some(t) if t > 80.0 => Color::new(0.9, 0.2, 0.2, 1.0),
556                Some(t) if t > 60.0 => Color::new(0.9, 0.7, 0.1, 1.0),
557                Some(_) => text_color,
558                None => Color::new(0.5, 0.5, 0.5, 1.0),
559            };
560            canvas.draw_text(
561                &format!("{temp_str:>4}"),
562                Point::new(x, y),
563                &TextStyle {
564                    color: temp_color,
565                    ..Default::default()
566                },
567            );
568            x += col_temp as f32 + 1.0;
569
570            // User %
571            canvas.draw_text(
572                &format!("{:>5.1}", row.user_pct),
573                Point::new(x, y),
574                &TextStyle {
575                    color: self.user_color,
576                    ..Default::default()
577                },
578            );
579            x += col_user as f32 + 1.0;
580
581            // System %
582            canvas.draw_text(
583                &format!("{:>5.1}", row.system_pct),
584                Point::new(x, y),
585                &TextStyle {
586                    color: self.system_color,
587                    ..Default::default()
588                },
589            );
590            x += col_sys as f32 + 1.0;
591
592            // IOWait %
593            canvas.draw_text(
594                &format!("{:>4.1}", row.iowait_pct),
595                Point::new(x, y),
596                &TextStyle {
597                    color: self.iowait_color,
598                    ..Default::default()
599                },
600            );
601            x += col_io as f32 + 1.0;
602
603            // Idle %
604            canvas.draw_text(
605                &format!("{:>5.1}", row.idle_pct),
606                Point::new(x, y),
607                &text_style,
608            );
609            x += col_idle as f32 + 1.0;
610
611            // Stacked breakdown bar
612            if self.show_breakdown_bars && col_bar > 0 {
613                let segments = self.render_stacked_bar(row, col_bar);
614                let mut bx = x;
615                for (chars, color) in segments {
616                    canvas.draw_text(
617                        &chars,
618                        Point::new(bx, y),
619                        &TextStyle {
620                            color,
621                            ..Default::default()
622                        },
623                    );
624                    bx += chars.len() as f32;
625                }
626                x += col_bar as f32 + 1.0;
627            }
628
629            // Sparkline history
630            if col_sparkline > 0 {
631                let sparkline = Self::render_sparkline(&row.util_history, col_sparkline);
632                canvas.draw_text(
633                    &sparkline,
634                    Point::new(x, y),
635                    &TextStyle {
636                        color: self.accent_color,
637                        ..Default::default()
638                    },
639                );
640            }
641
642            y += 1.0;
643        }
644    }
645
646    fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
647        match event {
648            Event::KeyDown {
649                key: Key::Up | Key::K,
650                ..
651            } => {
652                if let Some(sel) = self.selected_row {
653                    if sel > 0 {
654                        self.selected_row = Some(sel - 1);
655                    }
656                } else if !self.rows.is_empty() {
657                    self.selected_row = Some(0);
658                }
659                None
660            }
661            Event::KeyDown {
662                key: Key::Down | Key::J,
663                ..
664            } => {
665                if let Some(sel) = self.selected_row {
666                    if sel < self.rows.len().saturating_sub(1) {
667                        self.selected_row = Some(sel + 1);
668                    }
669                } else if !self.rows.is_empty() {
670                    self.selected_row = Some(0);
671                }
672                None
673            }
674            Event::KeyDown { key: Key::S, .. } => {
675                self.cycle_sort();
676                None
677            }
678            Event::KeyDown { key: Key::R, .. } => {
679                self.toggle_sort_direction();
680                None
681            }
682            _ => None,
683        }
684    }
685
686    fn children(&self) -> &[Box<dyn Widget>] {
687        &[]
688    }
689
690    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
691        &mut []
692    }
693}
694
695#[cfg(test)]
696#[allow(clippy::unwrap_used, clippy::disallowed_methods)]
697mod tests {
698    use super::*;
699    use crate::direct::{CellBuffer, DirectTerminalCanvas};
700
701    fn make_test_cores() -> Vec<CoreStatsRow> {
702        vec![
703            CoreStatsRow {
704                core_id: 0,
705                freq_mhz: 2400,
706                temp_c: Some(45.0),
707                user_pct: 10.0,
708                system_pct: 5.0,
709                iowait_pct: 2.0,
710                idle_pct: 83.0,
711                util_history: vec![10.0, 15.0, 12.0],
712                ..Default::default()
713            },
714            CoreStatsRow {
715                core_id: 1,
716                freq_mhz: 3600,
717                temp_c: Some(65.0),
718                user_pct: 50.0,
719                system_pct: 20.0,
720                iowait_pct: 5.0,
721                idle_pct: 25.0,
722                util_history: vec![60.0, 70.0, 75.0],
723                ..Default::default()
724            },
725            CoreStatsRow {
726                core_id: 2,
727                freq_mhz: 1800,
728                temp_c: Some(85.0),
729                user_pct: 30.0,
730                system_pct: 10.0,
731                iowait_pct: 3.0,
732                idle_pct: 57.0,
733                util_history: vec![35.0, 40.0, 43.0],
734                ..Default::default()
735            },
736        ]
737    }
738
739    #[test]
740    fn test_core_stats_creation() {
741        let df = CoreStatsDataFrame::new()
742            .with_sort(CoreStatsSortColumn::Total, true)
743            .with_breakdown_bars(true);
744
745        assert_eq!(df.sort_column, CoreStatsSortColumn::Total);
746        assert!(df.sort_desc);
747        assert!(df.show_breakdown_bars);
748    }
749
750    #[test]
751    fn test_core_stats_default() {
752        let df = CoreStatsDataFrame::default();
753        assert_eq!(df.sort_column, CoreStatsSortColumn::CoreId);
754        assert!(!df.sort_desc);
755    }
756
757    #[test]
758    fn test_core_row_total() {
759        let row = CoreStatsRow {
760            user_pct: 30.0,
761            system_pct: 10.0,
762            iowait_pct: 5.0,
763            idle_pct: 55.0,
764            ..Default::default()
765        };
766
767        assert!((row.total_pct() - 45.0).abs() < 0.001);
768    }
769
770    #[test]
771    fn test_core_row_default() {
772        let row = CoreStatsRow::default();
773        assert_eq!(row.core_id, 0);
774        assert_eq!(row.idle_pct, 100.0);
775        assert_eq!(row.total_pct(), 0.0);
776    }
777
778    #[test]
779    fn test_format_freq() {
780        assert_eq!(CoreStatsDataFrame::format_freq(2400), "2.4G");
781        assert_eq!(CoreStatsDataFrame::format_freq(800), "800M");
782        assert_eq!(CoreStatsDataFrame::format_freq(3600), "3.6G");
783        assert_eq!(CoreStatsDataFrame::format_freq(999), "999M");
784        assert_eq!(CoreStatsDataFrame::format_freq(1000), "1.0G");
785    }
786
787    #[test]
788    fn test_format_temp() {
789        assert_eq!(CoreStatsDataFrame::format_temp(Some(45.0)), "45°");
790        assert_eq!(CoreStatsDataFrame::format_temp(Some(85.5)), "86°");
791        assert_eq!(CoreStatsDataFrame::format_temp(None), "─");
792    }
793
794    #[test]
795    fn test_sort_by_total() {
796        let mut df = CoreStatsDataFrame::new().with_sort(CoreStatsSortColumn::Total, true);
797        df.update_cores(make_test_cores());
798
799        // Should be sorted descending by total
800        assert_eq!(df.rows[0].core_id, 1); // 75% total
801        assert_eq!(df.rows[1].core_id, 2); // 43% total
802        assert_eq!(df.rows[2].core_id, 0); // 17% total
803    }
804
805    #[test]
806    fn test_sort_by_frequency() {
807        let mut df = CoreStatsDataFrame::new().with_sort(CoreStatsSortColumn::Frequency, true);
808        df.update_cores(make_test_cores());
809
810        assert_eq!(df.rows[0].core_id, 1); // 3600 MHz
811        assert_eq!(df.rows[1].core_id, 0); // 2400 MHz
812        assert_eq!(df.rows[2].core_id, 2); // 1800 MHz
813    }
814
815    #[test]
816    fn test_sort_by_temperature() {
817        let mut df = CoreStatsDataFrame::new().with_sort(CoreStatsSortColumn::Temperature, true);
818        df.update_cores(make_test_cores());
819
820        assert_eq!(df.rows[0].core_id, 2); // 85°C
821        assert_eq!(df.rows[1].core_id, 1); // 65°C
822        assert_eq!(df.rows[2].core_id, 0); // 45°C
823    }
824
825    #[test]
826    fn test_sort_by_user() {
827        let mut df = CoreStatsDataFrame::new().with_sort(CoreStatsSortColumn::User, true);
828        df.update_cores(make_test_cores());
829
830        assert_eq!(df.rows[0].core_id, 1); // 50%
831        assert_eq!(df.rows[1].core_id, 2); // 30%
832        assert_eq!(df.rows[2].core_id, 0); // 10%
833    }
834
835    #[test]
836    fn test_sort_by_system() {
837        let mut df = CoreStatsDataFrame::new().with_sort(CoreStatsSortColumn::System, true);
838        df.update_cores(make_test_cores());
839
840        assert_eq!(df.rows[0].core_id, 1); // 20%
841        assert_eq!(df.rows[1].core_id, 2); // 10%
842        assert_eq!(df.rows[2].core_id, 0); // 5%
843    }
844
845    #[test]
846    fn test_sort_by_idle() {
847        let mut df = CoreStatsDataFrame::new().with_sort(CoreStatsSortColumn::Idle, true);
848        df.update_cores(make_test_cores());
849
850        assert_eq!(df.rows[0].core_id, 0); // 83%
851        assert_eq!(df.rows[1].core_id, 2); // 57%
852        assert_eq!(df.rows[2].core_id, 1); // 25%
853    }
854
855    #[test]
856    fn test_sort_ascending() {
857        let mut df = CoreStatsDataFrame::new().with_sort(CoreStatsSortColumn::Frequency, false);
858        df.update_cores(make_test_cores());
859
860        assert_eq!(df.rows[0].core_id, 2); // 1800 MHz
861        assert_eq!(df.rows[1].core_id, 0); // 2400 MHz
862        assert_eq!(df.rows[2].core_id, 1); // 3600 MHz
863    }
864
865    #[test]
866    fn test_cycle_sort() {
867        let mut df = CoreStatsDataFrame::new();
868        df.update_cores(make_test_cores());
869
870        assert_eq!(df.sort_column, CoreStatsSortColumn::CoreId);
871        df.cycle_sort();
872        assert_eq!(df.sort_column, CoreStatsSortColumn::Total);
873        df.cycle_sort();
874        assert_eq!(df.sort_column, CoreStatsSortColumn::User);
875        df.cycle_sort();
876        assert_eq!(df.sort_column, CoreStatsSortColumn::System);
877        df.cycle_sort();
878        assert_eq!(df.sort_column, CoreStatsSortColumn::Frequency);
879        df.cycle_sort();
880        assert_eq!(df.sort_column, CoreStatsSortColumn::Temperature);
881        df.cycle_sort();
882        assert_eq!(df.sort_column, CoreStatsSortColumn::Idle);
883        df.cycle_sort();
884        assert_eq!(df.sort_column, CoreStatsSortColumn::CoreId);
885    }
886
887    #[test]
888    fn test_toggle_sort_direction() {
889        let mut df = CoreStatsDataFrame::new();
890        assert!(!df.sort_desc);
891        df.toggle_sort_direction();
892        assert!(df.sort_desc);
893        df.toggle_sort_direction();
894        assert!(!df.sort_desc);
895    }
896
897    #[test]
898    fn test_scroll_offset_default() {
899        let df = CoreStatsDataFrame::new();
900        assert_eq!(df.scroll_offset, 0);
901    }
902
903    #[test]
904    fn test_paint() {
905        let mut df = CoreStatsDataFrame::new();
906        df.update_cores(make_test_cores());
907
908        let mut buffer = CellBuffer::new(120, 20);
909        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
910
911        df.layout(Rect::new(0.0, 0.0, 120.0, 20.0));
912        df.paint(&mut canvas);
913
914        // Check that header was drawn
915        let cell = buffer.get(0, 0).unwrap();
916        assert!(!cell.symbol.is_empty());
917    }
918
919    #[test]
920    fn test_paint_small_bounds() {
921        let mut df = CoreStatsDataFrame::new();
922        df.update_cores(make_test_cores());
923
924        let mut buffer = CellBuffer::new(10, 2);
925        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
926
927        df.layout(Rect::new(0.0, 0.0, 10.0, 2.0));
928        df.paint(&mut canvas); // Should return early due to small bounds
929    }
930
931    #[test]
932    fn test_paint_no_header() {
933        let mut df = CoreStatsDataFrame::new();
934        df.show_header = false;
935        df.update_cores(make_test_cores());
936
937        let mut buffer = CellBuffer::new(120, 20);
938        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
939
940        df.layout(Rect::new(0.0, 0.0, 120.0, 20.0));
941        df.paint(&mut canvas);
942    }
943
944    #[test]
945    fn test_paint_no_breakdown_bars() {
946        let mut df = CoreStatsDataFrame::new().with_breakdown_bars(false);
947        df.update_cores(make_test_cores());
948
949        let mut buffer = CellBuffer::new(120, 20);
950        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
951
952        df.layout(Rect::new(0.0, 0.0, 120.0, 20.0));
953        df.paint(&mut canvas);
954    }
955
956    #[test]
957    fn test_paint_selected_row() {
958        let mut df = CoreStatsDataFrame::new();
959        df.update_cores(make_test_cores());
960        df.selected_row = Some(1);
961
962        let mut buffer = CellBuffer::new(120, 20);
963        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
964
965        df.layout(Rect::new(0.0, 0.0, 120.0, 20.0));
966        df.paint(&mut canvas);
967    }
968
969    #[test]
970    fn test_event_key_down() {
971        let mut df = CoreStatsDataFrame::new();
972        df.update_cores(make_test_cores());
973        df.layout(Rect::new(0.0, 0.0, 100.0, 10.0));
974
975        let event = Event::key_down(Key::Down);
976        let _ = df.event(&event);
977    }
978
979    #[test]
980    fn test_event_key_up() {
981        let mut df = CoreStatsDataFrame::new();
982        df.update_cores(make_test_cores());
983        df.layout(Rect::new(0.0, 0.0, 100.0, 10.0));
984
985        let event = Event::key_down(Key::Up);
986        let _ = df.event(&event);
987    }
988
989    #[test]
990    fn test_event_key_s() {
991        let mut df = CoreStatsDataFrame::new();
992        df.update_cores(make_test_cores());
993
994        let event = Event::key_down(Key::S);
995        let _ = df.event(&event);
996        // Sort should have cycled
997        assert_eq!(df.sort_column, CoreStatsSortColumn::Total);
998    }
999
1000    #[test]
1001    fn test_event_key_r() {
1002        let mut df = CoreStatsDataFrame::new();
1003        df.update_cores(make_test_cores());
1004
1005        let event = Event::key_down(Key::R);
1006        let _ = df.event(&event);
1007        // Sort direction should have toggled
1008        assert!(df.sort_desc);
1009    }
1010
1011    #[test]
1012    fn test_measure() {
1013        let df = CoreStatsDataFrame::new();
1014        let constraints = Constraints {
1015            min_width: 0.0,
1016            max_width: 100.0,
1017            min_height: 0.0,
1018            max_height: 50.0,
1019        };
1020        let size = df.measure(constraints);
1021        assert!(size.width > 0.0);
1022        assert!(size.height > 0.0);
1023    }
1024
1025    #[test]
1026    fn test_brick_name() {
1027        let df = CoreStatsDataFrame::new();
1028        assert_eq!(df.brick_name(), "core_stats_dataframe");
1029    }
1030
1031    #[test]
1032    fn test_brick_verify() {
1033        let mut df = CoreStatsDataFrame::new();
1034        df.update_cores(make_test_cores());
1035        let v = df.verify();
1036        assert!(v.failed.is_empty());
1037    }
1038
1039    #[test]
1040    fn test_brick_html() {
1041        let df = CoreStatsDataFrame::new();
1042        let html = df.to_html();
1043        assert!(html.contains("core-stats-dataframe"));
1044        assert!(html.contains("CORE"));
1045    }
1046
1047    #[test]
1048    fn test_brick_css() {
1049        let df = CoreStatsDataFrame::new();
1050        let css = df.to_css();
1051        assert!(css.contains("core_id"));
1052    }
1053
1054    #[test]
1055    fn test_column_headers() {
1056        assert_eq!(CoreStatsSortColumn::CoreId.header(), "CORE");
1057        assert_eq!(CoreStatsSortColumn::Frequency.header(), "FREQ");
1058        assert_eq!(CoreStatsSortColumn::Temperature.header(), "TEMP");
1059        assert_eq!(CoreStatsSortColumn::User.header(), "USR%");
1060        assert_eq!(CoreStatsSortColumn::System.header(), "SYS%");
1061        assert_eq!(CoreStatsSortColumn::Total.header(), "TOT%");
1062        assert_eq!(CoreStatsSortColumn::Idle.header(), "IDL%");
1063    }
1064
1065    #[test]
1066    fn test_accent_color() {
1067        let color = Color::new(0.8, 0.2, 0.5, 1.0);
1068        let df = CoreStatsDataFrame::new().with_accent_color(color);
1069        assert!((df.accent_color.r - 0.8).abs() < 0.001);
1070    }
1071}