Skip to main content

presentar_terminal/widgets/
process_table.rs

1//! `ProcessTable` widget for system process monitoring.
2//!
3//! Displays running processes with CPU/Memory usage in a ttop/btop style.
4//! Reference: ttop/btop process displays.
5
6use crate::theme::Gradient;
7use presentar_core::{
8    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event, Key,
9    LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
10};
11use std::any::Any;
12use std::cmp::Ordering;
13use std::fmt::Write as _;
14use std::time::Duration;
15
16/// Process state (from /proc/[pid]/stat)
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum ProcessState {
19    /// Running (R)
20    Running,
21    /// Sleeping (S)
22    #[default]
23    Sleeping,
24    /// Disk sleep/waiting (D)
25    DiskWait,
26    /// Zombie (Z)
27    Zombie,
28    /// Stopped (T)
29    Stopped,
30    /// Idle (I)
31    Idle,
32}
33
34impl ProcessState {
35    /// Get the single-character representation
36    #[must_use]
37    pub fn char(&self) -> char {
38        match self {
39            Self::Running => 'R',
40            Self::Sleeping => 'S',
41            Self::DiskWait => 'D',
42            Self::Zombie => 'Z',
43            Self::Stopped => 'T',
44            Self::Idle => 'I',
45        }
46    }
47
48    /// Get the color for this state
49    #[must_use]
50    pub fn color(&self) -> Color {
51        match self {
52            Self::Running => Color::new(0.3, 0.9, 0.3, 1.0), // Green
53            Self::Sleeping => Color::new(0.5, 0.5, 0.5, 1.0), // Gray
54            Self::DiskWait => Color::new(1.0, 0.7, 0.2, 1.0), // Orange
55            Self::Zombie => Color::new(1.0, 0.3, 0.3, 1.0),  // Red
56            Self::Stopped => Color::new(0.9, 0.9, 0.3, 1.0), // Yellow
57            Self::Idle => Color::new(0.4, 0.4, 0.4, 1.0),    // Dark gray
58        }
59    }
60}
61
62/// A running process entry.
63#[derive(Debug, Clone)]
64pub struct ProcessEntry {
65    /// Process ID.
66    pub pid: u32,
67    /// User running the process.
68    pub user: String,
69    /// CPU usage percentage (0-100).
70    pub cpu_percent: f32,
71    /// Memory usage percentage (0-100).
72    pub mem_percent: f32,
73    /// Command name.
74    pub command: String,
75    /// Full command line (optional).
76    pub cmdline: Option<String>,
77    /// Process state.
78    pub state: ProcessState,
79    /// OOM score (0-1000, higher = more likely to be killed).
80    pub oom_score: Option<i32>,
81    /// cgroup path (short form).
82    pub cgroup: Option<String>,
83    /// Nice value (-20 to +19).
84    pub nice: Option<i32>,
85    /// Thread count (CB-PROC-006).
86    pub threads: Option<u32>,
87    /// Parent process ID (CB-PROC-001 tree view).
88    pub parent_pid: Option<u32>,
89    /// Tree depth level for indentation (CB-PROC-001).
90    pub tree_depth: usize,
91    /// Whether this is the last child at its level (CB-PROC-001).
92    pub is_last_child: bool,
93    /// Tree prefix string (e.g., "│ └─") for display (CB-PROC-001).
94    pub tree_prefix: String,
95}
96
97impl ProcessEntry {
98    /// Create a new process entry.
99    #[must_use]
100    pub fn new(
101        pid: u32,
102        user: impl Into<String>,
103        cpu: f32,
104        mem: f32,
105        command: impl Into<String>,
106    ) -> Self {
107        Self {
108            pid,
109            user: user.into(),
110            cpu_percent: cpu,
111            mem_percent: mem,
112            command: command.into(),
113            cmdline: None,
114            state: ProcessState::default(),
115            oom_score: None,
116            cgroup: None,
117            nice: None,
118            threads: None,
119            parent_pid: None,
120            tree_depth: 0,
121            is_last_child: false,
122            tree_prefix: String::new(),
123        }
124    }
125
126    /// Set full command line.
127    #[must_use]
128    pub fn with_cmdline(mut self, cmdline: impl Into<String>) -> Self {
129        self.cmdline = Some(cmdline.into());
130        self
131    }
132
133    /// Set process state.
134    #[must_use]
135    pub fn with_state(mut self, state: ProcessState) -> Self {
136        self.state = state;
137        self
138    }
139
140    /// Set OOM score (0-1000).
141    #[must_use]
142    pub fn with_oom_score(mut self, score: i32) -> Self {
143        self.oom_score = Some(score);
144        self
145    }
146
147    /// Set cgroup path.
148    #[must_use]
149    pub fn with_cgroup(mut self, cgroup: impl Into<String>) -> Self {
150        self.cgroup = Some(cgroup.into());
151        self
152    }
153
154    /// Set nice value.
155    #[must_use]
156    pub fn with_nice(mut self, nice: i32) -> Self {
157        self.nice = Some(nice);
158        self
159    }
160
161    /// Set thread count (CB-PROC-006).
162    #[must_use]
163    pub fn with_threads(mut self, threads: u32) -> Self {
164        self.threads = Some(threads);
165        self
166    }
167
168    /// Set parent PID (CB-PROC-001 tree view).
169    #[must_use]
170    pub fn with_parent_pid(mut self, ppid: u32) -> Self {
171        self.parent_pid = Some(ppid);
172        self
173    }
174
175    /// Set tree display info (CB-PROC-001 tree view).
176    pub fn set_tree_info(&mut self, depth: usize, is_last: bool, prefix: String) {
177        self.tree_depth = depth;
178        self.is_last_child = is_last;
179        self.tree_prefix = prefix;
180    }
181}
182
183/// Sort column for process table.
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub enum ProcessSort {
186    Pid,
187    User,
188    Cpu,
189    Memory,
190    Command,
191    Oom,
192}
193
194/// Process table widget with color-coded CPU/Memory bars.
195#[derive(Debug, Clone)]
196#[allow(clippy::struct_excessive_bools)]
197pub struct ProcessTable {
198    /// Process entries.
199    processes: Vec<ProcessEntry>,
200    /// Selected row index.
201    selected: usize,
202    /// Scroll offset.
203    scroll_offset: usize,
204    /// Current sort column.
205    sort_by: ProcessSort,
206    /// Sort ascending.
207    sort_ascending: bool,
208    /// CPU gradient (low → high).
209    cpu_gradient: Gradient,
210    /// Memory gradient (low → high).
211    mem_gradient: Gradient,
212    /// Show command line instead of command name.
213    show_cmdline: bool,
214    /// Compact mode (fewer columns).
215    compact: bool,
216    /// Show OOM score column.
217    show_oom: bool,
218    /// Show nice value column.
219    show_nice: bool,
220    /// Show thread count column (CB-PROC-006).
221    show_threads: bool,
222    /// Tree view mode (CB-PROC-001).
223    tree_view: bool,
224    /// Cached bounds.
225    bounds: Rect,
226}
227
228impl Default for ProcessTable {
229    fn default() -> Self {
230        Self::new()
231    }
232}
233
234impl ProcessTable {
235    /// Create a new process table.
236    #[must_use]
237    pub fn new() -> Self {
238        Self {
239            processes: Vec::new(),
240            selected: 0,
241            scroll_offset: 0,
242            sort_by: ProcessSort::Cpu,
243            sort_ascending: false, // Default: highest CPU first
244            cpu_gradient: Gradient::from_hex(&["#7aa2f7", "#e0af68", "#f7768e"]),
245            mem_gradient: Gradient::from_hex(&["#9ece6a", "#e0af68", "#f7768e"]),
246            show_cmdline: false,
247            compact: false,
248            show_oom: false,
249            show_nice: false,
250            show_threads: false,
251            tree_view: false,
252            bounds: Rect::default(),
253        }
254    }
255
256    /// Set processes.
257    pub fn set_processes(&mut self, processes: Vec<ProcessEntry>) {
258        self.processes = processes;
259        // Tree view (CB-PROC-001) takes precedence over sorting
260        if self.tree_view {
261            self.build_tree();
262        } else {
263            self.sort_processes();
264        }
265        // Clamp selection
266        if !self.processes.is_empty() && self.selected >= self.processes.len() {
267            self.selected = self.processes.len() - 1;
268        }
269    }
270
271    /// Add a process.
272    pub fn add_process(&mut self, process: ProcessEntry) {
273        self.processes.push(process);
274    }
275
276    /// Clear all processes.
277    pub fn clear(&mut self) {
278        self.processes.clear();
279        self.selected = 0;
280        self.scroll_offset = 0;
281    }
282
283    /// Set CPU gradient.
284    #[must_use]
285    pub fn with_cpu_gradient(mut self, gradient: Gradient) -> Self {
286        self.cpu_gradient = gradient;
287        self
288    }
289
290    /// Set memory gradient.
291    #[must_use]
292    pub fn with_mem_gradient(mut self, gradient: Gradient) -> Self {
293        self.mem_gradient = gradient;
294        self
295    }
296
297    /// Enable compact mode.
298    #[must_use]
299    pub fn compact(mut self) -> Self {
300        self.compact = true;
301        self
302    }
303
304    /// Show full command line.
305    #[must_use]
306    pub fn with_cmdline(mut self) -> Self {
307        self.show_cmdline = true;
308        self
309    }
310
311    /// Show OOM score column.
312    #[must_use]
313    pub fn with_oom(mut self) -> Self {
314        self.show_oom = true;
315        self
316    }
317
318    /// Show nice value column.
319    #[must_use]
320    pub fn with_nice_column(mut self) -> Self {
321        self.show_nice = true;
322        self
323    }
324
325    /// Show thread count column (CB-PROC-006).
326    #[must_use]
327    pub fn with_threads_column(mut self) -> Self {
328        self.show_threads = true;
329        self
330    }
331
332    /// Enable tree view mode (CB-PROC-001).
333    #[must_use]
334    pub fn with_tree_view(mut self) -> Self {
335        self.tree_view = true;
336        self
337    }
338
339    /// Toggle tree view mode (CB-PROC-001).
340    pub fn toggle_tree_view(&mut self) {
341        self.tree_view = !self.tree_view;
342        if self.tree_view {
343            self.build_tree();
344        }
345    }
346
347    /// Check if tree view is enabled (CB-PROC-001).
348    #[must_use]
349    pub fn is_tree_view(&self) -> bool {
350        self.tree_view
351    }
352
353    /// Set sort column.
354    pub fn sort_by(&mut self, column: ProcessSort) {
355        if self.sort_by == column {
356            self.sort_ascending = !self.sort_ascending;
357        } else {
358            self.sort_by = column;
359            // Default directions (CPU/Memory/OOM default to descending)
360            self.sort_ascending = !matches!(
361                column,
362                ProcessSort::Cpu | ProcessSort::Memory | ProcessSort::Oom
363            );
364        }
365        self.sort_processes();
366    }
367
368    /// Get current sort column.
369    #[must_use]
370    pub fn current_sort(&self) -> ProcessSort {
371        self.sort_by
372    }
373
374    /// Get selected index.
375    #[must_use]
376    pub fn selected(&self) -> usize {
377        self.selected
378    }
379
380    /// Get selected process.
381    #[must_use]
382    pub fn selected_process(&self) -> Option<&ProcessEntry> {
383        self.processes.get(self.selected)
384    }
385
386    /// Select a row.
387    pub fn select(&mut self, row: usize) {
388        if !self.processes.is_empty() {
389            self.selected = row.min(self.processes.len() - 1);
390            self.ensure_visible();
391        }
392    }
393
394    /// Move selection up.
395    pub fn select_prev(&mut self) {
396        if self.selected > 0 {
397            self.selected -= 1;
398            self.ensure_visible();
399        }
400    }
401
402    /// Move selection down.
403    pub fn select_next(&mut self) {
404        if !self.processes.is_empty() && self.selected < self.processes.len() - 1 {
405            self.selected += 1;
406            self.ensure_visible();
407        }
408    }
409
410    /// Get process count.
411    #[must_use]
412    pub fn len(&self) -> usize {
413        self.processes.len()
414    }
415
416    /// Check if empty.
417    #[must_use]
418    pub fn is_empty(&self) -> bool {
419        self.processes.is_empty()
420    }
421
422    /// Build process tree structure (CB-PROC-001).
423    ///
424    /// Reorganizes processes into a tree by parent-child relationships.
425    /// Uses ASCII art prefixes: └─ (last child), ├─ (middle child), │ (continuation).
426    fn build_tree(&mut self) {
427        use std::collections::HashMap;
428
429        if self.processes.is_empty() {
430            return;
431        }
432
433        // Build PID -> index map
434        let pid_to_idx: HashMap<u32, usize> = self
435            .processes
436            .iter()
437            .enumerate()
438            .map(|(i, p)| (p.pid, i))
439            .collect();
440
441        // Build PPID -> children map
442        let mut children: HashMap<u32, Vec<usize>> = HashMap::new();
443        let mut roots: Vec<usize> = Vec::new();
444
445        for (idx, proc) in self.processes.iter().enumerate() {
446            if let Some(ppid) = proc.parent_pid {
447                if pid_to_idx.contains_key(&ppid) {
448                    children.entry(ppid).or_default().push(idx);
449                } else {
450                    // Parent not in list, treat as root
451                    roots.push(idx);
452                }
453            } else {
454                roots.push(idx);
455            }
456        }
457
458        // Sort children by CPU descending at each level
459        for children_list in children.values_mut() {
460            children_list.sort_by(|&a, &b| {
461                self.processes[b]
462                    .cpu_percent
463                    .partial_cmp(&self.processes[a].cpu_percent)
464                    .unwrap_or(std::cmp::Ordering::Equal)
465            });
466        }
467
468        // Sort roots by CPU descending
469        roots.sort_by(|&a, &b| {
470            self.processes[b]
471                .cpu_percent
472                .partial_cmp(&self.processes[a].cpu_percent)
473                .unwrap_or(std::cmp::Ordering::Equal)
474        });
475
476        // DFS walk to build tree order
477        let mut tree_order: Vec<(usize, usize, bool, String)> = Vec::new();
478
479        let roots_len = roots.len();
480        for (i, &root_idx) in roots.iter().enumerate() {
481            let is_last = i == roots_len - 1;
482            Self::build_tree_dfs(
483                root_idx,
484                0,
485                "",
486                is_last,
487                &self.processes,
488                &children,
489                &mut tree_order,
490            );
491        }
492
493        // Reorder processes and apply tree info
494        let old_processes = std::mem::take(&mut self.processes);
495        self.processes.reserve(tree_order.len());
496
497        for (idx, depth, is_last, prefix) in tree_order {
498            let mut proc = old_processes[idx].clone();
499            proc.set_tree_info(depth, is_last, prefix);
500            self.processes.push(proc);
501        }
502    }
503
504    fn build_tree_dfs(
505        idx: usize,
506        depth: usize,
507        prefix: &str,
508        is_last: bool,
509        processes: &[ProcessEntry],
510        children: &std::collections::HashMap<u32, Vec<usize>>,
511        tree_order: &mut Vec<(usize, usize, bool, String)>,
512    ) {
513        let proc = &processes[idx];
514        let current_prefix = if depth == 0 {
515            String::new()
516        } else if is_last {
517            format!("{prefix}└─")
518        } else {
519            format!("{prefix}├─")
520        };
521
522        tree_order.push((idx, depth, is_last, current_prefix));
523
524        // Calculate next prefix for children
525        let next_prefix = if depth == 0 {
526            String::new()
527        } else if is_last {
528            format!("{prefix}  ")
529        } else {
530            format!("{prefix}│ ")
531        };
532
533        if let Some(child_indices) = children.get(&proc.pid) {
534            let len = child_indices.len();
535            for (i, &child_idx) in child_indices.iter().enumerate() {
536                let child_is_last = i == len - 1;
537                Self::build_tree_dfs(
538                    child_idx,
539                    depth + 1,
540                    &next_prefix,
541                    child_is_last,
542                    processes,
543                    children,
544                    tree_order,
545                );
546            }
547        }
548    }
549
550    fn sort_processes(&mut self) {
551        let ascending = self.sort_ascending;
552        match self.sort_by {
553            ProcessSort::Pid => {
554                self.processes.sort_by(|a, b| {
555                    if ascending {
556                        a.pid.cmp(&b.pid)
557                    } else {
558                        b.pid.cmp(&a.pid)
559                    }
560                });
561            }
562            ProcessSort::User => {
563                self.processes.sort_by(|a, b| {
564                    if ascending {
565                        a.user.cmp(&b.user)
566                    } else {
567                        b.user.cmp(&a.user)
568                    }
569                });
570            }
571            ProcessSort::Cpu => {
572                self.processes.sort_by(|a, b| {
573                    let cmp = a
574                        .cpu_percent
575                        .partial_cmp(&b.cpu_percent)
576                        .unwrap_or(std::cmp::Ordering::Equal);
577                    if ascending {
578                        cmp
579                    } else {
580                        cmp.reverse()
581                    }
582                });
583            }
584            ProcessSort::Memory => {
585                self.processes.sort_by(|a, b| {
586                    let cmp = a
587                        .mem_percent
588                        .partial_cmp(&b.mem_percent)
589                        .unwrap_or(std::cmp::Ordering::Equal);
590                    if ascending {
591                        cmp
592                    } else {
593                        cmp.reverse()
594                    }
595                });
596            }
597            ProcessSort::Command => {
598                self.processes.sort_by(|a, b| {
599                    if ascending {
600                        a.command.cmp(&b.command)
601                    } else {
602                        b.command.cmp(&a.command)
603                    }
604                });
605            }
606            ProcessSort::Oom => {
607                self.processes.sort_by(|a, b| {
608                    let a_oom = a.oom_score.unwrap_or(0);
609                    let b_oom = b.oom_score.unwrap_or(0);
610                    if ascending {
611                        a_oom.cmp(&b_oom)
612                    } else {
613                        b_oom.cmp(&a_oom)
614                    }
615                });
616            }
617        }
618    }
619
620    fn ensure_visible(&mut self) {
621        let visible_rows = (self.bounds.height as usize).saturating_sub(2);
622        if visible_rows == 0 {
623            return;
624        }
625
626        if self.selected < self.scroll_offset {
627            self.scroll_offset = self.selected;
628        } else if self.selected >= self.scroll_offset + visible_rows {
629            self.scroll_offset = self.selected - visible_rows + 1;
630        }
631    }
632
633    fn truncate(s: &str, width: usize) -> String {
634        if s.chars().count() <= width {
635            format!("{s:width$}")
636        } else if width > 1 {
637            // Use proper ellipsis character "…" instead of "..."
638            let chars: String = s.chars().take(width - 1).collect();
639            format!("{chars}…")
640        } else {
641            s.chars().take(width).collect()
642        }
643    }
644
645    /// Get OOM score color based on risk level.
646    fn oom_color(oom: i32) -> Color {
647        if oom > 500 {
648            Color::new(1.0, 0.3, 0.3, 1.0) // Red - high risk
649        } else if oom > 200 {
650            Color::new(1.0, 0.8, 0.2, 1.0) // Yellow - medium risk
651        } else {
652            Color::new(0.5, 0.8, 0.5, 1.0) // Green - low risk
653        }
654    }
655
656    /// Get nice value color based on priority.
657    fn nice_color(ni: i32) -> Color {
658        match ni.cmp(&0) {
659            Ordering::Less => Color::new(0.3, 0.9, 0.9, 1.0), // Cyan - high priority
660            Ordering::Greater => Color::new(0.6, 0.6, 0.6, 1.0), // Gray - low priority
661            Ordering::Equal => Color::new(0.8, 0.8, 0.8, 1.0), // White - normal
662        }
663    }
664
665    /// Get thread count color based on count.
666    fn threads_color(th: u32) -> Color {
667        if th > 50 {
668            Color::new(0.3, 0.9, 0.9, 1.0) // Cyan - many threads
669        } else if th > 10 {
670            Color::new(1.0, 0.8, 0.2, 1.0) // Yellow - moderate
671        } else {
672            Color::new(0.8, 0.8, 0.8, 1.0) // White - normal
673        }
674    }
675
676    /// Build header string for the table.
677    fn build_header(&self, cols: &ColumnWidths) -> String {
678        let sep = if self.compact { " " } else { " │ " };
679        let mut header = String::new();
680        let _ = write!(header, "{:>w$}", "PID", w = cols.pid);
681        if self.compact {
682            header.push(' ');
683            let _ = write!(header, "{:>1}", "S");
684        } else {
685            header.push_str(sep);
686            let _ = write!(header, "{:w$}", "USER", w = cols.user);
687        }
688        if self.show_oom {
689            header.push_str(sep);
690            let _ = write!(header, "{:>3}", "OOM");
691        }
692        if self.show_nice {
693            header.push_str(sep);
694            let _ = write!(header, "{:>3}", "NI");
695        }
696        if self.show_threads {
697            header.push_str(sep);
698            let _ = write!(header, "{:>3}", "TH");
699        }
700        header.push_str(sep);
701        let _ = write!(
702            header,
703            "{:>w$}",
704            if self.compact { "C%" } else { "CPU%" },
705            w = cols.cpu
706        );
707        header.push_str(sep);
708        let _ = write!(
709            header,
710            "{:>w$}",
711            if self.compact { "M%" } else { "MEM%" },
712            w = cols.mem
713        );
714        header.push_str(sep);
715        let _ = write!(header, "{:w$}", "COMMAND", w = cols.cmd);
716        header
717    }
718
719    /// Draw a single process row.
720    #[allow(clippy::too_many_arguments)]
721    fn draw_row(
722        &self,
723        canvas: &mut dyn Canvas,
724        proc: &ProcessEntry,
725        y: f32,
726        is_selected: bool,
727        cols: &ColumnWidths,
728        default_style: &TextStyle,
729    ) {
730        let sep = if self.compact { 1.0 } else { 3.0 };
731        let mut x = self.bounds.x;
732        // PID
733        canvas.draw_text(
734            &format!("{:>w$}", proc.pid, w = cols.pid),
735            Point::new(x, y),
736            default_style,
737        );
738        x += cols.pid as f32;
739        // State or User
740        if self.compact {
741            x += 1.0;
742            canvas.draw_text(
743                &proc.state.char().to_string(),
744                Point::new(x, y),
745                &TextStyle {
746                    color: proc.state.color(),
747                    ..Default::default()
748                },
749            );
750            x += 1.0;
751        } else {
752            x += sep;
753            canvas.draw_text(
754                &Self::truncate(&proc.user, cols.user),
755                Point::new(x, y),
756                default_style,
757            );
758            x += cols.user as f32;
759        }
760        // OOM
761        if self.show_oom {
762            x += sep;
763            let oom = proc.oom_score.unwrap_or(0);
764            canvas.draw_text(
765                &format!("{oom:>3}"),
766                Point::new(x, y),
767                &TextStyle {
768                    color: Self::oom_color(oom),
769                    ..Default::default()
770                },
771            );
772            x += 3.0;
773        }
774        // Nice
775        if self.show_nice {
776            x += sep;
777            let ni = proc.nice.unwrap_or(0);
778            canvas.draw_text(
779                &format!("{ni:>3}"),
780                Point::new(x, y),
781                &TextStyle {
782                    color: Self::nice_color(ni),
783                    ..Default::default()
784                },
785            );
786            x += 3.0;
787        }
788        // Threads
789        if self.show_threads {
790            x += sep;
791            let th = proc.threads.unwrap_or(1);
792            canvas.draw_text(
793                &format!("{th:>3}"),
794                Point::new(x, y),
795                &TextStyle {
796                    color: Self::threads_color(th),
797                    ..Default::default()
798                },
799            );
800            x += 3.0;
801        }
802        // CPU
803        x += sep;
804        canvas.draw_text(
805            &format!("{:>5.1}%", proc.cpu_percent),
806            Point::new(x, y),
807            &TextStyle {
808                color: self.cpu_gradient.for_percent(proc.cpu_percent as f64),
809                ..Default::default()
810            },
811        );
812        x += cols.cpu as f32;
813        // Mem
814        x += sep;
815        canvas.draw_text(
816            &format!("{:>5.1}%", proc.mem_percent),
817            Point::new(x, y),
818            &TextStyle {
819                color: self.mem_gradient.for_percent(proc.mem_percent as f64),
820                ..Default::default()
821            },
822        );
823        x += cols.mem as f32;
824        // Command
825        x += sep;
826        self.draw_command(canvas, proc, x, y, is_selected, cols.cmd, default_style);
827    }
828
829    /// Draw command column with optional tree prefix.
830    #[allow(clippy::too_many_arguments)]
831    fn draw_command(
832        &self,
833        canvas: &mut dyn Canvas,
834        proc: &ProcessEntry,
835        x: f32,
836        y: f32,
837        is_selected: bool,
838        cmd_w: usize,
839        default_style: &TextStyle,
840    ) {
841        let cmd = if self.show_cmdline {
842            proc.cmdline.as_deref().unwrap_or(&proc.command)
843        } else {
844            &proc.command
845        };
846        let cmd_style = if is_selected {
847            TextStyle {
848                color: Color::new(1.0, 1.0, 1.0, 1.0),
849                ..Default::default()
850            }
851        } else {
852            default_style.clone()
853        };
854        if self.tree_view && !proc.tree_prefix.is_empty() {
855            let prefix_len = proc.tree_prefix.chars().count();
856            canvas.draw_text(
857                &proc.tree_prefix,
858                Point::new(x, y),
859                &TextStyle {
860                    color: Color::new(0.4, 0.5, 0.6, 1.0),
861                    ..Default::default()
862                },
863            );
864            canvas.draw_text(
865                &Self::truncate(cmd, cmd_w.saturating_sub(prefix_len)),
866                Point::new(x + prefix_len as f32, y),
867                &cmd_style,
868            );
869        } else {
870            canvas.draw_text(&Self::truncate(cmd, cmd_w), Point::new(x, y), &cmd_style);
871        }
872    }
873}
874
875/// Column widths for process table layout.
876#[allow(dead_code)]
877struct ColumnWidths {
878    pid: usize,
879    state: usize,
880    oom: usize,
881    nice: usize,
882    threads: usize,
883    user: usize,
884    cpu: usize,
885    mem: usize,
886    sep: usize,
887    cmd: usize,
888    num_seps: usize,
889}
890
891impl ColumnWidths {
892    fn new(table: &ProcessTable, width: usize) -> Self {
893        let pid = 7;
894        let state = if table.compact { 2 } else { 0 };
895        let oom = if table.show_oom { 4 } else { 0 };
896        let nice = if table.show_nice { 4 } else { 0 };
897        let threads = if table.show_threads { 4 } else { 0 };
898        let user = if table.compact { 0 } else { 8 };
899        let cpu = 6;
900        let mem = 6;
901        let sep = if table.compact { 1 } else { 3 };
902        let extra_cols = usize::from(table.show_oom)
903            + usize::from(table.show_nice)
904            + usize::from(table.show_threads);
905        let num_seps = if table.compact { 3 } else { 4 } + extra_cols;
906        let fixed = pid + state + oom + nice + threads + user + cpu + mem + sep * num_seps;
907        let cmd = width.saturating_sub(fixed);
908        Self {
909            pid,
910            state,
911            oom,
912            nice,
913            threads,
914            user,
915            cpu,
916            mem,
917            sep,
918            cmd,
919            num_seps,
920        }
921    }
922}
923
924impl Brick for ProcessTable {
925    fn brick_name(&self) -> &'static str {
926        "process_table"
927    }
928
929    fn assertions(&self) -> &[BrickAssertion] {
930        static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(16)];
931        ASSERTIONS
932    }
933
934    fn budget(&self) -> BrickBudget {
935        BrickBudget::uniform(16)
936    }
937
938    fn verify(&self) -> BrickVerification {
939        let passed = if self.processes.is_empty() || self.selected < self.processes.len() {
940            vec![BrickAssertion::max_latency_ms(16)]
941        } else {
942            vec![]
943        };
944        let failed = if !self.processes.is_empty() && self.selected >= self.processes.len() {
945            vec![(
946                BrickAssertion::max_latency_ms(16),
947                format!(
948                    "Selected {} >= process count {}",
949                    self.selected,
950                    self.processes.len()
951                ),
952            )]
953        } else {
954            vec![]
955        };
956
957        BrickVerification {
958            passed,
959            failed,
960            verification_time: Duration::from_micros(10),
961        }
962    }
963
964    fn to_html(&self) -> String {
965        String::new()
966    }
967
968    fn to_css(&self) -> String {
969        String::new()
970    }
971}
972
973impl Widget for ProcessTable {
974    fn type_id(&self) -> TypeId {
975        TypeId::of::<Self>()
976    }
977
978    fn measure(&self, constraints: Constraints) -> Size {
979        let min_width = if self.compact { 40.0 } else { 60.0 };
980        let width = constraints.max_width.max(min_width);
981        let height = (self.processes.len() + 2).min(30) as f32;
982        constraints.constrain(Size::new(width, height.max(3.0)))
983    }
984
985    fn layout(&mut self, bounds: Rect) -> LayoutResult {
986        self.bounds = bounds;
987        self.ensure_visible();
988        LayoutResult {
989            size: Size::new(bounds.width, bounds.height),
990        }
991    }
992
993    fn paint(&self, canvas: &mut dyn Canvas) {
994        let width = self.bounds.width as usize;
995        let height = self.bounds.height as usize;
996        if width == 0 || height == 0 {
997            return;
998        }
999
1000        let cols = ColumnWidths::new(self, width);
1001
1002        // Draw header
1003        let header_style = TextStyle {
1004            color: Color::new(0.0, 1.0, 1.0, 1.0),
1005            weight: presentar_core::FontWeight::Bold,
1006            ..Default::default()
1007        };
1008        canvas.draw_text(
1009            &self.build_header(&cols),
1010            Point::new(self.bounds.x, self.bounds.y),
1011            &header_style,
1012        );
1013
1014        // Draw separator
1015        if height > 1 {
1016            canvas.draw_text(
1017                &"─".repeat(width),
1018                Point::new(self.bounds.x, self.bounds.y + 1.0),
1019                &TextStyle {
1020                    color: Color::new(0.3, 0.3, 0.4, 1.0),
1021                    ..Default::default()
1022                },
1023            );
1024        }
1025
1026        // Draw rows
1027        let default_style = TextStyle {
1028            color: Color::new(0.8, 0.8, 0.8, 1.0),
1029            ..Default::default()
1030        };
1031        let visible_rows = height.saturating_sub(2);
1032        for (i, proc_idx) in (self.scroll_offset..self.processes.len())
1033            .take(visible_rows)
1034            .enumerate()
1035        {
1036            let proc = &self.processes[proc_idx];
1037            let y = self.bounds.y + 2.0 + i as f32;
1038            let is_selected = proc_idx == self.selected;
1039            if is_selected {
1040                canvas.fill_rect(
1041                    Rect::new(self.bounds.x, y, self.bounds.width, 1.0),
1042                    Color::new(0.2, 0.2, 0.4, 0.5),
1043                );
1044            }
1045            self.draw_row(canvas, proc, y, is_selected, &cols, &default_style);
1046        }
1047
1048        // Empty state
1049        if self.processes.is_empty() && height > 2 {
1050            canvas.draw_text(
1051                "No processes",
1052                Point::new(self.bounds.x + 1.0, self.bounds.y + 2.0),
1053                &TextStyle {
1054                    color: Color::new(0.5, 0.5, 0.5, 1.0),
1055                    ..Default::default()
1056                },
1057            );
1058        }
1059    }
1060
1061    fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
1062        match event {
1063            Event::KeyDown { key } => {
1064                match key {
1065                    Key::Up | Key::K => self.select_prev(),
1066                    Key::Down | Key::J => self.select_next(),
1067                    Key::C => self.sort_by(ProcessSort::Cpu),
1068                    Key::M => self.sort_by(ProcessSort::Memory),
1069                    Key::P => self.sort_by(ProcessSort::Pid),
1070                    Key::N => self.sort_by(ProcessSort::Command),
1071                    Key::O => self.sort_by(ProcessSort::Oom),
1072                    Key::T => self.toggle_tree_view(), // CB-PROC-001
1073                    _ => {}
1074                }
1075                None
1076            }
1077            _ => None,
1078        }
1079    }
1080
1081    fn children(&self) -> &[Box<dyn Widget>] {
1082        &[]
1083    }
1084
1085    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
1086        &mut []
1087    }
1088}
1089
1090#[cfg(test)]
1091mod tests {
1092    use super::*;
1093
1094    fn sample_processes() -> Vec<ProcessEntry> {
1095        vec![
1096            ProcessEntry::new(1, "root", 0.5, 0.1, "systemd"),
1097            ProcessEntry::new(1234, "noah", 25.0, 5.5, "firefox"),
1098            ProcessEntry::new(5678, "noah", 80.0, 12.3, "rustc"),
1099        ]
1100    }
1101
1102    #[test]
1103    fn test_process_table_new() {
1104        let table = ProcessTable::new();
1105        assert!(table.is_empty());
1106    }
1107
1108    #[test]
1109    fn test_process_table_set_processes() {
1110        let mut table = ProcessTable::new();
1111        table.set_processes(sample_processes());
1112        assert_eq!(table.len(), 3);
1113    }
1114
1115    #[test]
1116    fn test_process_table_add_process() {
1117        let mut table = ProcessTable::new();
1118        table.add_process(ProcessEntry::new(1, "root", 0.0, 0.0, "init"));
1119        assert_eq!(table.len(), 1);
1120    }
1121
1122    #[test]
1123    fn test_process_table_clear() {
1124        let mut table = ProcessTable::new();
1125        table.set_processes(sample_processes());
1126        table.select(1);
1127        table.clear();
1128        assert!(table.is_empty());
1129        assert_eq!(table.selected(), 0);
1130    }
1131
1132    #[test]
1133    fn test_process_table_selection() {
1134        let mut table = ProcessTable::new();
1135        table.set_processes(sample_processes());
1136        assert_eq!(table.selected(), 0);
1137
1138        table.select_next();
1139        assert_eq!(table.selected(), 1);
1140
1141        table.select_prev();
1142        assert_eq!(table.selected(), 0);
1143    }
1144
1145    #[test]
1146    fn test_process_table_select_bounds() {
1147        let mut table = ProcessTable::new();
1148        table.set_processes(sample_processes());
1149
1150        table.select(100);
1151        assert_eq!(table.selected(), 2);
1152
1153        table.select_prev();
1154        table.select_prev();
1155        table.select_prev();
1156        assert_eq!(table.selected(), 0);
1157    }
1158
1159    #[test]
1160    fn test_process_table_sort_cpu() {
1161        let mut table = ProcessTable::new();
1162        table.set_processes(sample_processes());
1163        // Default sort is CPU descending
1164        assert_eq!(table.processes[0].command, "rustc");
1165    }
1166
1167    #[test]
1168    fn test_process_table_sort_toggle() {
1169        let mut table = ProcessTable::new();
1170        table.set_processes(sample_processes());
1171        table.sort_by(ProcessSort::Cpu); // Toggle to ascending
1172        assert_eq!(table.processes[0].command, "systemd");
1173    }
1174
1175    #[test]
1176    fn test_process_table_sort_pid() {
1177        let mut table = ProcessTable::new();
1178        table.set_processes(sample_processes());
1179        table.sort_by(ProcessSort::Pid);
1180        assert_eq!(table.processes[0].pid, 1);
1181    }
1182
1183    #[test]
1184    fn test_process_table_sort_memory() {
1185        let mut table = ProcessTable::new();
1186        table.set_processes(sample_processes());
1187        table.sort_by(ProcessSort::Memory);
1188        assert_eq!(table.processes[0].command, "rustc");
1189    }
1190
1191    #[test]
1192    fn test_process_table_selected_process() {
1193        let mut table = ProcessTable::new();
1194        table.set_processes(sample_processes());
1195        let proc = table.selected_process().unwrap();
1196        assert_eq!(proc.command, "rustc"); // Highest CPU
1197    }
1198
1199    #[test]
1200    fn test_process_entry_with_cmdline() {
1201        let proc = ProcessEntry::new(1, "root", 0.0, 0.0, "bash").with_cmdline("/bin/bash --login");
1202        assert_eq!(proc.cmdline.as_deref(), Some("/bin/bash --login"));
1203    }
1204
1205    #[test]
1206    fn test_process_table_compact() {
1207        let table = ProcessTable::new().compact();
1208        assert!(table.compact);
1209    }
1210
1211    #[test]
1212    fn test_process_table_with_cmdline() {
1213        let table = ProcessTable::new().with_cmdline();
1214        assert!(table.show_cmdline);
1215    }
1216
1217    #[test]
1218    fn test_process_table_verify() {
1219        let mut table = ProcessTable::new();
1220        table.set_processes(sample_processes());
1221        assert!(table.verify().is_valid());
1222    }
1223
1224    #[test]
1225    fn test_process_table_verify_invalid() {
1226        let mut table = ProcessTable::new();
1227        table.set_processes(sample_processes());
1228        table.selected = 100;
1229        assert!(!table.verify().is_valid());
1230    }
1231
1232    #[test]
1233    fn test_process_table_measure() {
1234        let mut table = ProcessTable::new();
1235        table.set_processes(sample_processes());
1236        let size = table.measure(Constraints::new(0.0, 100.0, 0.0, 50.0));
1237        assert!(size.width >= 60.0);
1238        assert!(size.height >= 3.0);
1239    }
1240
1241    #[test]
1242    fn test_process_table_layout() {
1243        let mut table = ProcessTable::new();
1244        table.set_processes(sample_processes());
1245        let result = table.layout(Rect::new(0.0, 0.0, 80.0, 20.0));
1246        assert_eq!(result.size.width, 80.0);
1247    }
1248
1249    #[test]
1250    fn test_process_table_event_keys() {
1251        let mut table = ProcessTable::new();
1252        table.set_processes(sample_processes());
1253
1254        table.event(&Event::KeyDown { key: Key::J });
1255        assert_eq!(table.selected(), 1);
1256
1257        table.event(&Event::KeyDown { key: Key::K });
1258        assert_eq!(table.selected(), 0);
1259
1260        table.event(&Event::KeyDown { key: Key::P });
1261        assert_eq!(table.current_sort(), ProcessSort::Pid);
1262    }
1263
1264    #[test]
1265    fn test_process_table_brick_name() {
1266        let table = ProcessTable::new();
1267        assert_eq!(table.brick_name(), "process_table");
1268    }
1269
1270    #[test]
1271    fn test_process_table_default() {
1272        let table = ProcessTable::default();
1273        assert!(table.is_empty());
1274    }
1275
1276    #[test]
1277    fn test_process_table_children() {
1278        let table = ProcessTable::new();
1279        assert!(table.children().is_empty());
1280    }
1281
1282    #[test]
1283    fn test_process_table_children_mut() {
1284        let mut table = ProcessTable::new();
1285        assert!(table.children_mut().is_empty());
1286    }
1287
1288    #[test]
1289    fn test_process_table_truncate() {
1290        assert_eq!(ProcessTable::truncate("hello", 10), "hello     ");
1291        assert_eq!(ProcessTable::truncate("hello world", 8), "hello w…");
1292        assert_eq!(ProcessTable::truncate("hi", 2), "hi");
1293        // Ensure proper ellipsis character is used
1294        assert!(ProcessTable::truncate("long text here", 6).ends_with('…'));
1295    }
1296
1297    #[test]
1298    fn test_process_table_type_id() {
1299        let table = ProcessTable::new();
1300        assert_eq!(Widget::type_id(&table), TypeId::of::<ProcessTable>());
1301    }
1302
1303    #[test]
1304    fn test_process_table_to_html() {
1305        let table = ProcessTable::new();
1306        assert!(table.to_html().is_empty());
1307    }
1308
1309    #[test]
1310    fn test_process_table_to_css() {
1311        let table = ProcessTable::new();
1312        assert!(table.to_css().is_empty());
1313    }
1314
1315    #[test]
1316    fn test_process_table_sort_command() {
1317        let mut table = ProcessTable::new();
1318        table.set_processes(sample_processes());
1319        table.sort_by(ProcessSort::Command);
1320        assert_eq!(table.processes[0].command, "firefox");
1321    }
1322
1323    #[test]
1324    fn test_process_table_sort_user() {
1325        let mut table = ProcessTable::new();
1326        table.set_processes(sample_processes());
1327        table.sort_by(ProcessSort::User);
1328        assert_eq!(table.processes[0].user, "noah");
1329    }
1330
1331    #[test]
1332    fn test_process_table_sort_oom() {
1333        let mut table = ProcessTable::new();
1334        // Create processes with different OOM scores
1335        let entries = vec![
1336            ProcessEntry::new(1, "user", 10.0, 5.0, "low_oom").with_oom_score(100),
1337            ProcessEntry::new(2, "user", 10.0, 5.0, "high_oom").with_oom_score(800),
1338            ProcessEntry::new(3, "user", 10.0, 5.0, "med_oom").with_oom_score(400),
1339        ];
1340        table.set_processes(entries);
1341
1342        // Sort by OOM (default descending - highest first)
1343        table.sort_by(ProcessSort::Oom);
1344
1345        // Verify order: high (800) -> med (400) -> low (100)
1346        assert_eq!(table.processes[0].command, "high_oom");
1347        assert_eq!(table.processes[1].command, "med_oom");
1348        assert_eq!(table.processes[2].command, "low_oom");
1349    }
1350
1351    #[test]
1352    fn test_process_table_sort_oom_toggle_ascending() {
1353        let mut table = ProcessTable::new();
1354        let entries = vec![
1355            ProcessEntry::new(1, "user", 10.0, 5.0, "low_oom").with_oom_score(100),
1356            ProcessEntry::new(2, "user", 10.0, 5.0, "high_oom").with_oom_score(800),
1357        ];
1358        table.set_processes(entries);
1359
1360        // Sort by OOM twice to toggle to ascending
1361        table.sort_by(ProcessSort::Oom);
1362        table.sort_by(ProcessSort::Oom);
1363
1364        // Verify order is now ascending: low (100) -> high (800)
1365        assert_eq!(table.processes[0].command, "low_oom");
1366        assert_eq!(table.processes[1].command, "high_oom");
1367    }
1368
1369    // ========================================================================
1370    // Additional tests for paint() paths and better coverage
1371    // ========================================================================
1372
1373    struct MockCanvas {
1374        texts: Vec<(String, Point)>,
1375        rects: Vec<Rect>,
1376    }
1377
1378    impl MockCanvas {
1379        fn new() -> Self {
1380            Self {
1381                texts: vec![],
1382                rects: vec![],
1383            }
1384        }
1385    }
1386
1387    impl Canvas for MockCanvas {
1388        fn fill_rect(&mut self, rect: Rect, _color: Color) {
1389            self.rects.push(rect);
1390        }
1391        fn stroke_rect(&mut self, _rect: Rect, _color: Color, _width: f32) {}
1392        fn draw_text(&mut self, text: &str, position: Point, _style: &TextStyle) {
1393            self.texts.push((text.to_string(), position));
1394        }
1395        fn draw_line(&mut self, _from: Point, _to: Point, _color: Color, _width: f32) {}
1396        fn fill_circle(&mut self, _center: Point, _radius: f32, _color: Color) {}
1397        fn stroke_circle(&mut self, _center: Point, _radius: f32, _color: Color, _width: f32) {}
1398        fn fill_arc(&mut self, _c: Point, _r: f32, _s: f32, _e: f32, _color: Color) {}
1399        fn draw_path(&mut self, _points: &[Point], _color: Color, _width: f32) {}
1400        fn fill_polygon(&mut self, _points: &[Point], _color: Color) {}
1401        fn push_clip(&mut self, _rect: Rect) {}
1402        fn pop_clip(&mut self) {}
1403        fn push_transform(&mut self, _transform: presentar_core::Transform2D) {}
1404        fn pop_transform(&mut self) {}
1405    }
1406
1407    #[test]
1408    fn test_process_table_paint_basic() {
1409        let mut table = ProcessTable::new();
1410        table.set_processes(sample_processes());
1411        table.bounds = Rect::new(0.0, 0.0, 80.0, 20.0);
1412
1413        let mut canvas = MockCanvas::new();
1414        table.paint(&mut canvas);
1415
1416        // Should have rendered header, separator, and rows
1417        assert!(!canvas.texts.is_empty());
1418        // Check header contains "PID"
1419        assert!(canvas.texts.iter().any(|(t, _)| t.contains("PID")));
1420    }
1421
1422    #[test]
1423    fn test_process_table_paint_compact() {
1424        let mut table = ProcessTable::new().compact();
1425        table.set_processes(sample_processes());
1426        table.bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
1427
1428        let mut canvas = MockCanvas::new();
1429        table.paint(&mut canvas);
1430
1431        // Check compact header has "C%" and "M%" instead of "CPU%" and "MEM%"
1432        assert!(canvas.texts.iter().any(|(t, _)| t.contains("C%")));
1433        assert!(canvas.texts.iter().any(|(t, _)| t.contains("M%")));
1434    }
1435
1436    #[test]
1437    fn test_process_table_paint_with_oom() {
1438        let mut table = ProcessTable::new().with_oom();
1439        let entries = vec![
1440            ProcessEntry::new(1, "user", 10.0, 5.0, "low_oom").with_oom_score(100),
1441            ProcessEntry::new(2, "user", 10.0, 5.0, "high_oom").with_oom_score(800),
1442            ProcessEntry::new(3, "user", 10.0, 5.0, "med_oom").with_oom_score(400),
1443        ];
1444        table.set_processes(entries);
1445        table.bounds = Rect::new(0.0, 0.0, 100.0, 20.0);
1446
1447        let mut canvas = MockCanvas::new();
1448        table.paint(&mut canvas);
1449
1450        // Should have OOM header
1451        assert!(canvas.texts.iter().any(|(t, _)| t.contains("OOM")));
1452        // Should have OOM values rendered
1453        assert!(canvas.texts.iter().any(|(t, _)| t.contains("100")));
1454        assert!(canvas.texts.iter().any(|(t, _)| t.contains("800")));
1455    }
1456
1457    #[test]
1458    fn test_process_table_paint_with_nice() {
1459        let mut table = ProcessTable::new().with_nice_column();
1460        let entries = vec![
1461            ProcessEntry::new(1, "user", 10.0, 5.0, "high_pri").with_nice(-10),
1462            ProcessEntry::new(2, "user", 10.0, 5.0, "low_pri").with_nice(10),
1463            ProcessEntry::new(3, "user", 10.0, 5.0, "normal").with_nice(0),
1464        ];
1465        table.set_processes(entries);
1466        table.bounds = Rect::new(0.0, 0.0, 100.0, 20.0);
1467
1468        let mut canvas = MockCanvas::new();
1469        table.paint(&mut canvas);
1470
1471        // Should have NI header
1472        assert!(canvas.texts.iter().any(|(t, _)| t.contains("NI")));
1473    }
1474
1475    #[test]
1476    fn test_process_table_paint_with_selection() {
1477        let mut table = ProcessTable::new();
1478        table.set_processes(sample_processes());
1479        table.select(1);
1480        table.bounds = Rect::new(0.0, 0.0, 80.0, 20.0);
1481
1482        let mut canvas = MockCanvas::new();
1483        table.paint(&mut canvas);
1484
1485        // Should have a selection rect
1486        assert!(!canvas.rects.is_empty());
1487    }
1488
1489    #[test]
1490    fn test_process_table_paint_empty() {
1491        let mut table = ProcessTable::new();
1492        table.bounds = Rect::new(0.0, 0.0, 80.0, 20.0);
1493
1494        let mut canvas = MockCanvas::new();
1495        table.paint(&mut canvas);
1496
1497        // Should show "No processes" message
1498        assert!(canvas.texts.iter().any(|(t, _)| t.contains("No processes")));
1499    }
1500
1501    #[test]
1502    fn test_process_table_paint_zero_bounds() {
1503        let mut table = ProcessTable::new();
1504        table.set_processes(sample_processes());
1505        table.bounds = Rect::new(0.0, 0.0, 0.0, 0.0);
1506
1507        let mut canvas = MockCanvas::new();
1508        table.paint(&mut canvas);
1509
1510        // Should return early, no output
1511        assert!(canvas.texts.is_empty());
1512    }
1513
1514    #[test]
1515    fn test_process_table_paint_with_cmdline() {
1516        let mut table = ProcessTable::new().with_cmdline();
1517        let entries = vec![
1518            ProcessEntry::new(1, "root", 0.5, 0.1, "bash").with_cmdline("/bin/bash --login -i")
1519        ];
1520        table.set_processes(entries);
1521        table.bounds = Rect::new(0.0, 0.0, 100.0, 20.0);
1522
1523        let mut canvas = MockCanvas::new();
1524        table.paint(&mut canvas);
1525
1526        // Should show cmdline instead of command
1527        assert!(canvas
1528            .texts
1529            .iter()
1530            .any(|(t, _)| t.contains("/bin/bash") || t.contains("--login")));
1531    }
1532
1533    #[test]
1534    fn test_process_table_paint_compact_with_state() {
1535        let mut table = ProcessTable::new().compact();
1536        let entries = vec![
1537            ProcessEntry::new(1, "root", 50.0, 10.0, "running").with_state(ProcessState::Running),
1538            ProcessEntry::new(2, "user", 0.0, 0.5, "sleeping").with_state(ProcessState::Sleeping),
1539            ProcessEntry::new(3, "user", 0.0, 0.1, "zombie").with_state(ProcessState::Zombie),
1540        ];
1541        table.set_processes(entries);
1542        table.bounds = Rect::new(0.0, 0.0, 60.0, 20.0);
1543
1544        let mut canvas = MockCanvas::new();
1545        table.paint(&mut canvas);
1546
1547        // Should have state characters
1548        assert!(canvas.texts.iter().any(|(t, _)| t == "R")); // Running
1549        assert!(canvas.texts.iter().any(|(t, _)| t == "S")); // Sleeping
1550    }
1551
1552    #[test]
1553    fn test_process_state_char() {
1554        assert_eq!(ProcessState::Running.char(), 'R');
1555        assert_eq!(ProcessState::Sleeping.char(), 'S');
1556        assert_eq!(ProcessState::DiskWait.char(), 'D');
1557        assert_eq!(ProcessState::Zombie.char(), 'Z');
1558        assert_eq!(ProcessState::Stopped.char(), 'T');
1559        assert_eq!(ProcessState::Idle.char(), 'I');
1560    }
1561
1562    #[test]
1563    fn test_process_state_color() {
1564        // Each state should have a unique color
1565        let running = ProcessState::Running.color();
1566        let sleeping = ProcessState::Sleeping.color();
1567        let zombie = ProcessState::Zombie.color();
1568        assert_ne!(running, sleeping);
1569        assert_ne!(running, zombie);
1570    }
1571
1572    #[test]
1573    fn test_process_state_default() {
1574        assert_eq!(ProcessState::default(), ProcessState::Sleeping);
1575    }
1576
1577    #[test]
1578    fn test_process_entry_with_state() {
1579        let proc = ProcessEntry::new(1, "root", 0.0, 0.0, "test").with_state(ProcessState::Running);
1580        assert_eq!(proc.state, ProcessState::Running);
1581    }
1582
1583    #[test]
1584    fn test_process_entry_with_cgroup() {
1585        let proc =
1586            ProcessEntry::new(1, "root", 0.0, 0.0, "test").with_cgroup("/user.slice/user-1000");
1587        assert_eq!(proc.cgroup.as_deref(), Some("/user.slice/user-1000"));
1588    }
1589
1590    #[test]
1591    fn test_process_entry_with_nice() {
1592        let proc = ProcessEntry::new(1, "root", 0.0, 0.0, "test").with_nice(-5);
1593        assert_eq!(proc.nice, Some(-5));
1594    }
1595
1596    #[test]
1597    fn test_process_entry_with_oom_score() {
1598        let proc = ProcessEntry::new(1, "root", 0.0, 0.0, "test").with_oom_score(500);
1599        assert_eq!(proc.oom_score, Some(500));
1600    }
1601
1602    #[test]
1603    fn test_process_entry_with_threads() {
1604        let proc = ProcessEntry::new(1, "root", 0.0, 0.0, "test").with_threads(42);
1605        assert_eq!(proc.threads, Some(42));
1606    }
1607
1608    #[test]
1609    fn test_process_table_with_threads_column() {
1610        let table = ProcessTable::new().with_threads_column();
1611        assert!(table.show_threads);
1612    }
1613
1614    #[test]
1615    fn test_process_table_scroll() {
1616        let mut table = ProcessTable::new();
1617        // Create many processes to trigger scrolling
1618        let entries: Vec<ProcessEntry> = (0..50)
1619            .map(|i| ProcessEntry::new(i, "user", i as f32, 0.0, format!("proc{i}")))
1620            .collect();
1621        table.set_processes(entries);
1622        table.bounds = Rect::new(0.0, 0.0, 80.0, 10.0); // Only 8 visible rows
1623        table.layout(table.bounds);
1624
1625        // Select a process beyond the visible area
1626        table.select(45);
1627        // scroll_offset should have been updated
1628        assert!(table.scroll_offset > 0);
1629    }
1630
1631    #[test]
1632    fn test_process_table_ensure_visible_up() {
1633        let mut table = ProcessTable::new();
1634        let entries: Vec<ProcessEntry> = (0..20)
1635            .map(|i| ProcessEntry::new(i, "user", 0.0, 0.0, format!("proc{i}")))
1636            .collect();
1637        table.set_processes(entries);
1638        table.bounds = Rect::new(0.0, 0.0, 80.0, 10.0);
1639        table.scroll_offset = 10;
1640        table.selected = 5; // Above visible area
1641
1642        table.ensure_visible();
1643        assert!(table.scroll_offset <= table.selected);
1644    }
1645
1646    #[test]
1647    fn test_process_table_select_empty() {
1648        let mut table = ProcessTable::new();
1649        // Should not panic on empty table
1650        table.select(5);
1651        table.select_next();
1652        table.select_prev();
1653        assert_eq!(table.selected(), 0);
1654    }
1655
1656    #[test]
1657    fn test_process_table_selected_process_empty() {
1658        let table = ProcessTable::new();
1659        assert!(table.selected_process().is_none());
1660    }
1661
1662    #[test]
1663    fn test_process_table_budget() {
1664        let table = ProcessTable::new();
1665        let budget = table.budget();
1666        assert!(budget.paint_ms > 0);
1667    }
1668
1669    #[test]
1670    fn test_process_table_assertions() {
1671        let table = ProcessTable::new();
1672        assert!(!table.assertions().is_empty());
1673    }
1674
1675    #[test]
1676    fn test_process_table_set_processes_clamp_selection() {
1677        let mut table = ProcessTable::new();
1678        table.set_processes(sample_processes());
1679        table.selected = 2; // Last item
1680                            // Set fewer processes
1681        table.set_processes(vec![ProcessEntry::new(1, "root", 0.0, 0.0, "test")]);
1682        // Selection should be clamped
1683        assert_eq!(table.selected(), 0);
1684    }
1685
1686    #[test]
1687    fn test_process_table_event_down() {
1688        let mut table = ProcessTable::new();
1689        table.set_processes(sample_processes());
1690
1691        table.event(&Event::KeyDown { key: Key::Down });
1692        assert_eq!(table.selected(), 1);
1693    }
1694
1695    #[test]
1696    fn test_process_table_event_up() {
1697        let mut table = ProcessTable::new();
1698        table.set_processes(sample_processes());
1699        table.select(2);
1700
1701        table.event(&Event::KeyDown { key: Key::Up });
1702        assert_eq!(table.selected(), 1);
1703    }
1704
1705    #[test]
1706    fn test_process_table_event_c() {
1707        let mut table = ProcessTable::new();
1708        table.set_processes(sample_processes());
1709        // First sort by something else
1710        table.sort_by(ProcessSort::Pid);
1711
1712        table.event(&Event::KeyDown { key: Key::C });
1713        assert_eq!(table.current_sort(), ProcessSort::Cpu);
1714    }
1715
1716    #[test]
1717    fn test_process_table_event_m() {
1718        let mut table = ProcessTable::new();
1719        table.set_processes(sample_processes());
1720
1721        table.event(&Event::KeyDown { key: Key::M });
1722        assert_eq!(table.current_sort(), ProcessSort::Memory);
1723    }
1724
1725    #[test]
1726    fn test_process_table_event_n() {
1727        let mut table = ProcessTable::new();
1728        table.set_processes(sample_processes());
1729
1730        table.event(&Event::KeyDown { key: Key::N });
1731        assert_eq!(table.current_sort(), ProcessSort::Command);
1732    }
1733
1734    #[test]
1735    fn test_process_table_event_o() {
1736        let mut table = ProcessTable::new();
1737        table.set_processes(sample_processes());
1738
1739        table.event(&Event::KeyDown { key: Key::O });
1740        assert_eq!(table.current_sort(), ProcessSort::Oom);
1741    }
1742
1743    #[test]
1744    fn test_process_table_event_other() {
1745        let mut table = ProcessTable::new();
1746        table.set_processes(sample_processes());
1747        let prev_selected = table.selected();
1748
1749        // Event that doesn't match any key
1750        table.event(&Event::KeyDown { key: Key::A });
1751        assert_eq!(table.selected(), prev_selected);
1752    }
1753
1754    #[test]
1755    fn test_process_table_event_non_keydown() {
1756        let mut table = ProcessTable::new();
1757        table.set_processes(sample_processes());
1758
1759        // Non-keydown event
1760        let result = table.event(&Event::Resize {
1761            width: 100.0,
1762            height: 50.0,
1763        });
1764        assert!(result.is_none());
1765    }
1766
1767    #[test]
1768    fn test_process_table_with_cpu_gradient() {
1769        let gradient = Gradient::from_hex(&["#0000FF", "#FF0000"]);
1770        let table = ProcessTable::new().with_cpu_gradient(gradient);
1771        // Just verify it compiles and doesn't panic
1772        assert!(!table.is_empty() || table.is_empty());
1773    }
1774
1775    #[test]
1776    fn test_process_table_with_mem_gradient() {
1777        let gradient = Gradient::from_hex(&["#00FF00", "#FF0000"]);
1778        let table = ProcessTable::new().with_mem_gradient(gradient);
1779        assert!(!table.is_empty() || table.is_empty());
1780    }
1781
1782    #[test]
1783    fn test_process_table_measure_compact() {
1784        let table = ProcessTable::new().compact();
1785        let size = table.measure(Constraints::new(0.0, 100.0, 0.0, 50.0));
1786        assert!(size.width >= 40.0); // Compact mode has smaller min width
1787    }
1788
1789    #[test]
1790    fn test_process_table_truncate_exact() {
1791        assert_eq!(ProcessTable::truncate("exact", 5), "exact");
1792    }
1793
1794    #[test]
1795    fn test_process_table_truncate_width_1() {
1796        assert_eq!(ProcessTable::truncate("hello", 1), "h");
1797    }
1798
1799    #[test]
1800    fn test_process_table_paint_all_columns() {
1801        // Test paint with all optional columns enabled
1802        let mut table = ProcessTable::new()
1803            .compact()
1804            .with_oom()
1805            .with_nice_column()
1806            .with_cmdline();
1807
1808        let entries = vec![
1809            ProcessEntry::new(1, "root", 50.0, 10.0, "bash")
1810                .with_state(ProcessState::Running)
1811                .with_oom_score(100)
1812                .with_nice(-5)
1813                .with_cmdline("/bin/bash"),
1814            ProcessEntry::new(2, "user", 30.0, 5.0, "vim")
1815                .with_state(ProcessState::Sleeping)
1816                .with_oom_score(600)
1817                .with_nice(10)
1818                .with_cmdline("/usr/bin/vim"),
1819        ];
1820        table.set_processes(entries);
1821        table.bounds = Rect::new(0.0, 0.0, 120.0, 20.0);
1822
1823        let mut canvas = MockCanvas::new();
1824        table.paint(&mut canvas);
1825
1826        // All columns should be rendered
1827        assert!(canvas.texts.iter().any(|(t, _)| t.contains("PID")));
1828        assert!(canvas.texts.iter().any(|(t, _)| t.contains("OOM")));
1829        assert!(canvas.texts.iter().any(|(t, _)| t.contains("NI")));
1830    }
1831
1832    #[test]
1833    fn test_process_entry_clone() {
1834        let proc = ProcessEntry::new(1, "root", 50.0, 10.0, "test")
1835            .with_state(ProcessState::Running)
1836            .with_oom_score(100);
1837        let cloned = proc.clone();
1838        assert_eq!(cloned.pid, proc.pid);
1839        assert_eq!(cloned.state, proc.state);
1840    }
1841
1842    #[test]
1843    fn test_process_entry_debug() {
1844        let proc = ProcessEntry::new(1, "root", 0.0, 0.0, "test");
1845        let debug = format!("{:?}", proc);
1846        assert!(debug.contains("ProcessEntry"));
1847    }
1848
1849    #[test]
1850    fn test_process_sort_debug() {
1851        let sort = ProcessSort::Cpu;
1852        let debug = format!("{:?}", sort);
1853        assert!(debug.contains("Cpu"));
1854    }
1855
1856    #[test]
1857    fn test_process_table_clone() {
1858        let mut table = ProcessTable::new();
1859        table.set_processes(sample_processes());
1860        let cloned = table.clone();
1861        assert_eq!(cloned.len(), table.len());
1862    }
1863
1864    #[test]
1865    fn test_process_table_debug() {
1866        let table = ProcessTable::new();
1867        let debug = format!("{:?}", table);
1868        assert!(debug.contains("ProcessTable"));
1869    }
1870
1871    #[test]
1872    fn test_process_state_debug() {
1873        let state = ProcessState::Running;
1874        let debug = format!("{:?}", state);
1875        assert!(debug.contains("Running"));
1876    }
1877
1878    // ========================================================================
1879    // Tree view tests (CB-PROC-001)
1880    // ========================================================================
1881
1882    #[test]
1883    fn test_process_entry_with_parent_pid() {
1884        let proc = ProcessEntry::new(100, "user", 10.0, 5.0, "child").with_parent_pid(1);
1885        assert_eq!(proc.parent_pid, Some(1));
1886    }
1887
1888    #[test]
1889    fn test_process_entry_set_tree_info() {
1890        let mut proc = ProcessEntry::new(100, "user", 10.0, 5.0, "child");
1891        proc.set_tree_info(2, true, "│ └─".to_string());
1892        assert_eq!(proc.tree_depth, 2);
1893        assert!(proc.is_last_child);
1894        assert_eq!(proc.tree_prefix, "│ └─");
1895    }
1896
1897    #[test]
1898    fn test_process_table_with_tree_view() {
1899        let table = ProcessTable::new().with_tree_view();
1900        assert!(table.is_tree_view());
1901    }
1902
1903    #[test]
1904    fn test_process_table_toggle_tree_view() {
1905        let mut table = ProcessTable::new();
1906        assert!(!table.is_tree_view());
1907
1908        table.toggle_tree_view();
1909        assert!(table.is_tree_view());
1910
1911        table.toggle_tree_view();
1912        assert!(!table.is_tree_view());
1913    }
1914
1915    #[test]
1916    fn test_process_table_tree_view_builds_tree() {
1917        let mut table = ProcessTable::new().with_tree_view();
1918
1919        // Create parent-child hierarchy:
1920        // 1 (systemd) -> 100 (bash) -> 200 (vim)
1921        //             -> 101 (sshd)
1922        let entries = vec![
1923            ProcessEntry::new(200, "user", 5.0, 1.0, "vim").with_parent_pid(100),
1924            ProcessEntry::new(100, "user", 10.0, 2.0, "bash").with_parent_pid(1),
1925            ProcessEntry::new(101, "root", 1.0, 0.5, "sshd").with_parent_pid(1),
1926            ProcessEntry::new(1, "root", 0.5, 0.1, "systemd"),
1927        ];
1928        table.set_processes(entries);
1929
1930        // After tree building, systemd should be first (root)
1931        assert_eq!(table.processes[0].command, "systemd");
1932
1933        // Check tree prefixes
1934        // systemd (root) has no prefix
1935        assert_eq!(table.processes[0].tree_prefix, "");
1936        // bash is child of systemd, and has higher CPU than sshd
1937        // So bash should come before sshd
1938    }
1939
1940    #[test]
1941    fn test_process_table_tree_view_prefix_chars() {
1942        let mut table = ProcessTable::new().with_tree_view();
1943
1944        // Create: 1 -> 2 -> 3
1945        let entries = vec![
1946            ProcessEntry::new(3, "user", 5.0, 1.0, "grandchild").with_parent_pid(2),
1947            ProcessEntry::new(2, "user", 10.0, 2.0, "child").with_parent_pid(1),
1948            ProcessEntry::new(1, "root", 0.5, 0.1, "parent"),
1949        ];
1950        table.set_processes(entries);
1951
1952        // Parent should have no prefix
1953        assert_eq!(table.processes[0].tree_prefix, "");
1954        // Child should have └─ (last child of parent)
1955        assert!(
1956            table.processes[1].tree_prefix.contains('└')
1957                || table.processes[1].tree_prefix.contains('├')
1958        );
1959    }
1960
1961    #[test]
1962    fn test_process_table_event_t_toggles_tree() {
1963        let mut table = ProcessTable::new();
1964        table.set_processes(sample_processes());
1965        assert!(!table.is_tree_view());
1966
1967        table.event(&Event::KeyDown { key: Key::T });
1968        assert!(table.is_tree_view());
1969
1970        table.event(&Event::KeyDown { key: Key::T });
1971        assert!(!table.is_tree_view());
1972    }
1973
1974    #[test]
1975    fn test_process_table_tree_view_paint() {
1976        let mut table = ProcessTable::new().with_tree_view();
1977
1978        let entries = vec![
1979            ProcessEntry::new(2, "user", 10.0, 2.0, "child").with_parent_pid(1),
1980            ProcessEntry::new(1, "root", 0.5, 0.1, "parent"),
1981        ];
1982        table.set_processes(entries);
1983        table.bounds = Rect::new(0.0, 0.0, 80.0, 10.0);
1984
1985        let mut canvas = MockCanvas::new();
1986        table.paint(&mut canvas);
1987
1988        // Should have tree prefix in output
1989        assert!(canvas
1990            .texts
1991            .iter()
1992            .any(|(t, _)| t.contains("└") || t.contains("├")));
1993    }
1994
1995    #[test]
1996    fn test_process_table_tree_empty() {
1997        let mut table = ProcessTable::new().with_tree_view();
1998        // Should not panic on empty
1999        table.set_processes(vec![]);
2000        assert!(table.is_empty());
2001    }
2002
2003    // ========================================================================
2004    // Falsification Tests for CB-PROC-001 (Phase 7 QA Gate)
2005    // ========================================================================
2006
2007    /// F-TREE-001: "Orphaned Child" Test
2008    /// Hierarchy MUST override sorting. Children MUST appear immediately below parent.
2009    #[test]
2010    fn test_f_tree_001_hierarchy_overrides_sorting() {
2011        let mut table = ProcessTable::new().with_tree_view();
2012
2013        // sh (PID 100) with two sleep children (PIDs 200, 201)
2014        // Higher CPU processes elsewhere should NOT split the hierarchy
2015        let entries = vec![
2016            ProcessEntry::new(999, "root", 99.0, 50.0, "chrome"), // High CPU unrelated
2017            ProcessEntry::new(200, "user", 0.1, 0.1, "sleep").with_parent_pid(100),
2018            ProcessEntry::new(201, "user", 0.1, 0.1, "sleep").with_parent_pid(100),
2019            ProcessEntry::new(100, "user", 1.0, 0.5, "sh"),
2020            ProcessEntry::new(1, "root", 0.5, 0.1, "systemd"),
2021        ];
2022        table.set_processes(entries);
2023
2024        // Find sh in the tree
2025        let sh_idx = table
2026            .processes
2027            .iter()
2028            .position(|p| p.command == "sh")
2029            .expect("sh not found");
2030
2031        // Both sleep processes MUST be immediately after sh
2032        let sleep1_idx = table
2033            .processes
2034            .iter()
2035            .position(|p| p.command == "sleep" && p.pid == 200)
2036            .expect("sleep 200 not found");
2037        let sleep2_idx = table
2038            .processes
2039            .iter()
2040            .position(|p| p.command == "sleep" && p.pid == 201)
2041            .expect("sleep 201 not found");
2042
2043        // Children must appear IMMEDIATELY after parent (next indices)
2044        assert!(
2045            sleep1_idx > sh_idx && sleep1_idx <= sh_idx + 2,
2046            "sleep 200 (idx {}) should be immediately after sh (idx {})",
2047            sleep1_idx,
2048            sh_idx
2049        );
2050        assert!(
2051            sleep2_idx > sh_idx && sleep2_idx <= sh_idx + 2,
2052            "sleep 201 (idx {}) should be immediately after sh (idx {})",
2053            sleep2_idx,
2054            sh_idx
2055        );
2056
2057        // Unrelated high-CPU process should NOT be between sh and its children
2058        let chrome_idx = table
2059            .processes
2060            .iter()
2061            .position(|p| p.command == "chrome")
2062            .expect("chrome not found");
2063        assert!(
2064            !(chrome_idx > sh_idx && chrome_idx < sleep1_idx.max(sleep2_idx)),
2065            "Unrelated process should not split parent-child hierarchy"
2066        );
2067    }
2068
2069    /// F-TREE-002: "Live Re-Parenting" - Orphan handling
2070    /// When parent is killed, orphans should gracefully become roots
2071    #[test]
2072    fn test_f_tree_002_orphan_handling() {
2073        let mut table = ProcessTable::new().with_tree_view();
2074
2075        // Child processes whose parent (PID 100) is NOT in the list
2076        let entries = vec![
2077            ProcessEntry::new(200, "user", 5.0, 1.0, "orphan1").with_parent_pid(100), // Parent missing
2078            ProcessEntry::new(201, "user", 3.0, 1.0, "orphan2").with_parent_pid(100), // Parent missing
2079            ProcessEntry::new(1, "root", 0.5, 0.1, "systemd"),
2080        ];
2081        table.set_processes(entries);
2082
2083        // Should not panic - orphans become roots
2084        assert_eq!(table.len(), 3);
2085
2086        // Orphans should have depth 0 (root level) since parent not found
2087        let orphan1 = table
2088            .processes
2089            .iter()
2090            .find(|p| p.command == "orphan1")
2091            .unwrap();
2092        let orphan2 = table
2093            .processes
2094            .iter()
2095            .find(|p| p.command == "orphan2")
2096            .unwrap();
2097
2098        // Orphans treated as roots have no tree prefix
2099        assert_eq!(orphan1.tree_depth, 0);
2100        assert_eq!(orphan2.tree_depth, 0);
2101    }
2102
2103    /// F-TREE-003: "Deep Nesting" Boundary (15 levels)
2104    /// Tree must handle deep hierarchies without overflow or crash
2105    #[test]
2106    fn test_f_tree_003_deep_nesting_15_levels() {
2107        let mut table = ProcessTable::new().with_tree_view();
2108
2109        // Create 15-level deep hierarchy
2110        let mut entries = vec![ProcessEntry::new(1, "root", 0.5, 0.1, "init")];
2111
2112        for depth in 1..=15 {
2113            let pid = (depth + 1) as u32;
2114            let ppid = depth as u32;
2115            entries.push(
2116                ProcessEntry::new(pid, "user", 0.1, 0.1, format!("level{depth}"))
2117                    .with_parent_pid(ppid),
2118            );
2119        }
2120
2121        table.set_processes(entries);
2122
2123        // Should not panic
2124        assert_eq!(table.len(), 16); // 1 root + 15 children
2125
2126        // Verify deepest process has depth 15
2127        let deepest = table
2128            .processes
2129            .iter()
2130            .find(|p| p.command == "level15")
2131            .unwrap();
2132        assert_eq!(deepest.tree_depth, 15);
2133
2134        // Verify prefix has correct structure (should have 14 "│ " or "  " segments)
2135        let prefix_segments =
2136            deepest.tree_prefix.matches("│").count() + deepest.tree_prefix.matches("  ").count();
2137        // At depth 15, prefix should have accumulated continuation chars
2138        assert!(
2139            deepest.tree_prefix.len() > 20,
2140            "Deep prefix should be substantial: '{}'",
2141            deepest.tree_prefix
2142        );
2143    }
2144
2145    /// F-TREE-004: Verify DFS traversal order
2146    /// Tree order must be parent, then all descendants, then next sibling
2147    #[test]
2148    fn test_f_tree_004_dfs_traversal_order() {
2149        let mut table = ProcessTable::new().with_tree_view();
2150
2151        // Tree: A -> B -> D
2152        //           -> E
2153        //       -> C
2154        let entries = vec![
2155            ProcessEntry::new(5, "user", 1.0, 1.0, "E").with_parent_pid(2),
2156            ProcessEntry::new(4, "user", 1.0, 1.0, "D").with_parent_pid(2),
2157            ProcessEntry::new(3, "user", 1.0, 1.0, "C").with_parent_pid(1),
2158            ProcessEntry::new(2, "user", 2.0, 1.0, "B").with_parent_pid(1), // Higher CPU
2159            ProcessEntry::new(1, "root", 0.5, 0.1, "A"),
2160        ];
2161        table.set_processes(entries);
2162
2163        // Expected DFS order (sorted by CPU within siblings): A, B, D, E, C
2164        // B comes before C because B has higher CPU
2165        let commands: Vec<&str> = table.processes.iter().map(|p| p.command.as_str()).collect();
2166
2167        assert_eq!(commands[0], "A", "Root should be first");
2168        assert_eq!(commands[1], "B", "B (higher CPU child) should be second");
2169        // D and E are B's children
2170        assert!(
2171            commands[2] == "D" || commands[2] == "E",
2172            "B's children should follow B"
2173        );
2174        assert!(
2175            commands[3] == "D" || commands[3] == "E",
2176            "B's children should follow B"
2177        );
2178        assert_eq!(commands[4], "C", "C should be after B's subtree");
2179    }
2180}