⚠️ VeridianOS Kernel Documentation - This is low-level kernel code. All functions are unsafe unless explicitly marked otherwise. no_std

veridian_kernel/devtools/profiler/
gui.rs

1//! Profiling GUI
2//!
3//! Flame graph visualization, CPU/memory timeline, and call tree view.
4//! Reads from the perf::trace ring buffer system.
5
6use alloc::{
7    string::{String, ToString},
8    vec::Vec,
9};
10
11/// Flame graph frame
12#[derive(Debug, Clone)]
13pub(crate) struct FlameFrame {
14    pub(crate) name: String,
15    pub(crate) samples: u64,
16    pub(crate) children: Vec<FlameFrame>,
17}
18
19impl FlameFrame {
20    pub(crate) fn new(name: &str) -> Self {
21        Self {
22            name: name.to_string(),
23            samples: 0,
24            children: Vec::new(),
25        }
26    }
27
28    pub(crate) fn add_child(&mut self, child: FlameFrame) {
29        self.children.push(child);
30    }
31
32    /// Total samples including children
33    pub(crate) fn total_samples(&self) -> u64 {
34        self.samples + self.children.iter().map(|c| c.total_samples()).sum::<u64>()
35    }
36
37    /// Find or create a child with the given name
38    pub(crate) fn get_or_create_child(&mut self, name: &str) -> &mut FlameFrame {
39        let idx = self.children.iter().position(|c| c.name == name);
40        if let Some(i) = idx {
41            &mut self.children[i]
42        } else {
43            self.children.push(FlameFrame::new(name));
44            self.children.last_mut().unwrap()
45        }
46    }
47}
48
49/// Call stack sample
50#[derive(Debug, Clone)]
51pub(crate) struct StackSample {
52    pub(crate) timestamp: u64,
53    pub(crate) frames: Vec<String>,
54    pub(crate) cpu: u32,
55}
56
57/// CPU timeline data point
58#[derive(Debug, Clone, Copy)]
59pub(crate) struct CpuDataPoint {
60    pub(crate) timestamp: u64,
61    pub(crate) usage_percent: u8,
62    pub(crate) cpu_id: u32,
63}
64
65/// Memory timeline data point
66#[derive(Debug, Clone, Copy)]
67pub(crate) struct MemDataPoint {
68    pub(crate) timestamp: u64,
69    pub(crate) used_bytes: u64,
70    pub(crate) total_bytes: u64,
71}
72
73/// Profiler session
74pub(crate) struct ProfilerSession {
75    pub(crate) name: String,
76    pub(crate) samples: Vec<StackSample>,
77    pub(crate) cpu_timeline: Vec<CpuDataPoint>,
78    pub(crate) mem_timeline: Vec<MemDataPoint>,
79    pub(crate) start_time: u64,
80    pub(crate) end_time: u64,
81}
82
83impl ProfilerSession {
84    pub(crate) fn new(name: &str) -> Self {
85        Self {
86            name: name.to_string(),
87            samples: Vec::new(),
88            cpu_timeline: Vec::new(),
89            mem_timeline: Vec::new(),
90            start_time: 0,
91            end_time: 0,
92        }
93    }
94
95    pub(crate) fn add_sample(&mut self, sample: StackSample) {
96        if self.samples.is_empty() {
97            self.start_time = sample.timestamp;
98        }
99        self.end_time = sample.timestamp;
100        self.samples.push(sample);
101    }
102
103    pub(crate) fn add_cpu_point(&mut self, point: CpuDataPoint) {
104        self.cpu_timeline.push(point);
105    }
106
107    pub(crate) fn add_mem_point(&mut self, point: MemDataPoint) {
108        self.mem_timeline.push(point);
109    }
110
111    /// Build flame graph from samples
112    pub(crate) fn build_flame_graph(&self) -> FlameFrame {
113        let mut root = FlameFrame::new("all");
114
115        for sample in &self.samples {
116            let mut current = &mut root;
117            for frame in &sample.frames {
118                current = current.get_or_create_child(frame);
119                current.samples += 1;
120            }
121        }
122
123        root
124    }
125
126    /// Duration in timestamp units
127    pub(crate) fn duration(&self) -> u64 {
128        self.end_time.saturating_sub(self.start_time)
129    }
130
131    pub(crate) fn sample_count(&self) -> usize {
132        self.samples.len()
133    }
134
135    /// Average CPU usage
136    pub(crate) fn avg_cpu_usage(&self) -> u8 {
137        if self.cpu_timeline.is_empty() {
138            return 0;
139        }
140        let sum: u64 = self
141            .cpu_timeline
142            .iter()
143            .map(|p| p.usage_percent as u64)
144            .sum();
145        (sum / self.cpu_timeline.len() as u64) as u8
146    }
147
148    /// Peak memory usage
149    pub(crate) fn peak_memory(&self) -> u64 {
150        self.mem_timeline
151            .iter()
152            .map(|p| p.used_bytes)
153            .max()
154            .unwrap_or(0)
155    }
156}
157
158/// Profiler GUI renderer (renders to a pixel buffer)
159pub(crate) struct ProfilerGui {
160    pub(crate) width: u32,
161    pub(crate) height: u32,
162    pub(crate) view: ProfilerView,
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub(crate) enum ProfilerView {
167    FlameGraph,
168    CpuTimeline,
169    MemoryTimeline,
170    CallTree,
171}
172
173impl ProfilerGui {
174    pub(crate) fn new(width: u32, height: u32) -> Self {
175        Self {
176            width,
177            height,
178            view: ProfilerView::FlameGraph,
179        }
180    }
181
182    pub(crate) fn switch_view(&mut self, view: ProfilerView) {
183        self.view = view;
184    }
185
186    /// Render the flame graph to a color buffer
187    pub(crate) fn render_flame_graph(&self, root: &FlameFrame, buf: &mut [u32]) {
188        let total = root.total_samples();
189        if total == 0 || buf.len() < (self.width * self.height) as usize {
190            return;
191        }
192
193        // Clear buffer
194        for pixel in buf.iter_mut() {
195            *pixel = 0xFF1A1A2E; // Dark background
196        }
197
198        // Render root frame
199        self.render_flame_frame(root, buf, 0, 0, self.width, total, 0);
200    }
201
202    fn render_flame_frame(
203        &self,
204        frame: &FlameFrame,
205        buf: &mut [u32],
206        x: u32,
207        _y: u32,
208        width: u32,
209        total_samples: u64,
210        depth: u32,
211    ) {
212        if width < 2 || depth > 50 {
213            return;
214        }
215
216        let bar_height = 20u32;
217        let bar_y = self.height.saturating_sub((depth + 1) * bar_height);
218
219        // Color based on depth (warm gradient)
220        let color = match depth % 6 {
221            0 => 0xFFE74C3C, // Red
222            1 => 0xFFE67E22, // Orange
223            2 => 0xFFF39C12, // Yellow
224            3 => 0xFF2ECC71, // Green
225            4 => 0xFF3498DB, // Blue
226            _ => 0xFF9B59B6, // Purple
227        };
228
229        // Draw bar
230        for dy in 0..bar_height.saturating_sub(1) {
231            let py = bar_y + dy;
232            if py >= self.height {
233                continue;
234            }
235            for dx in 1..width.saturating_sub(1) {
236                let px = x + dx;
237                if px < self.width {
238                    buf[(py * self.width + px) as usize] = color;
239                }
240            }
241        }
242
243        // Render children
244        let mut child_x = x;
245        for child in &frame.children {
246            let child_total = child.total_samples();
247            let child_width = ((child_total * width as u64) / total_samples.max(1)) as u32;
248            if child_width > 0 {
249                self.render_flame_frame(
250                    child,
251                    buf,
252                    child_x,
253                    _y,
254                    child_width,
255                    total_samples,
256                    depth + 1,
257                );
258                child_x += child_width;
259            }
260        }
261    }
262}
263
264/// Node in an aggregated call tree.
265#[derive(Debug, Clone)]
266pub(crate) struct CallTreeNode {
267    pub(crate) function_name: String,
268    /// Samples where this function is the leaf (self time).
269    pub(crate) self_time: u64,
270    /// Total samples including all descendants.
271    pub(crate) total_time: u64,
272    pub(crate) children: Vec<CallTreeNode>,
273    pub(crate) call_count: u64,
274}
275
276impl CallTreeNode {
277    pub(crate) fn new(name: &str) -> Self {
278        Self {
279            function_name: name.to_string(),
280            self_time: 0,
281            total_time: 0,
282            children: Vec::new(),
283            call_count: 0,
284        }
285    }
286
287    /// Find a child by name, returning its index.
288    fn find_child(&self, name: &str) -> Option<usize> {
289        self.children.iter().position(|c| c.function_name == name)
290    }
291
292    /// Get or create a child node with the given name.
293    fn get_or_create_child(&mut self, name: &str) -> &mut CallTreeNode {
294        let idx = self.find_child(name);
295        if let Some(i) = idx {
296            &mut self.children[i]
297        } else {
298            self.children.push(CallTreeNode::new(name));
299            self.children.last_mut().unwrap()
300        }
301    }
302}
303
304/// Flattened call tree entry for rendering.
305#[derive(Debug, Clone)]
306pub(crate) struct FlatCallEntry {
307    pub(crate) function_name: String,
308    pub(crate) self_time: u64,
309    pub(crate) total_time: u64,
310    pub(crate) call_count: u64,
311    pub(crate) depth: u32,
312}
313
314/// Aggregated call tree built from stack samples.
315pub(crate) struct CallTree {
316    pub(crate) roots: Vec<CallTreeNode>,
317    pub(crate) total_samples: u64,
318}
319
320impl Default for CallTree {
321    fn default() -> Self {
322        Self::new()
323    }
324}
325
326impl CallTree {
327    pub(crate) fn new() -> Self {
328        Self {
329            roots: Vec::new(),
330            total_samples: 0,
331        }
332    }
333
334    /// Build a call tree from a slice of stack samples.
335    ///
336    /// Each sample's frames are walked bottom-up (callers first). The leaf
337    /// frame receives self-time credit.
338    pub(crate) fn build_from_stacks(samples: &[StackSample]) -> Self {
339        let mut tree = CallTree::new();
340        tree.total_samples = samples.len() as u64;
341
342        for sample in samples {
343            if sample.frames.is_empty() {
344                continue;
345            }
346
347            // frames[0] is the bottom (caller), last is the leaf
348            let bottom_name = &sample.frames[0];
349
350            // Find or create root
351            let root_idx = tree
352                .roots
353                .iter()
354                .position(|r| r.function_name == *bottom_name);
355            let root = if let Some(i) = root_idx {
356                &mut tree.roots[i]
357            } else {
358                tree.roots.push(CallTreeNode::new(bottom_name));
359                tree.roots.last_mut().unwrap()
360            };
361
362            root.call_count += 1;
363            root.total_time += 1;
364
365            // Walk down the stack
366            let mut current = root as *mut CallTreeNode;
367            for (i, frame_name) in sample.frames.iter().enumerate().skip(1) {
368                // SAFETY: We only hold one mutable reference at a time,
369                // descending through a tree we own.
370                let node = unsafe { &mut *current };
371                let child = node.get_or_create_child(frame_name);
372                child.call_count += 1;
373                child.total_time += 1;
374
375                // Leaf frame gets self-time
376                if i == sample.frames.len() - 1 {
377                    child.self_time += 1;
378                }
379
380                current = child as *mut CallTreeNode;
381            }
382
383            // If single-frame sample, root is also the leaf
384            if sample.frames.len() == 1 {
385                root.self_time += 1;
386            }
387        }
388
389        tree
390    }
391
392    /// Flatten the tree into a list with depth information for rendering.
393    pub(crate) fn flatten(&self) -> Vec<FlatCallEntry> {
394        let mut result = Vec::new();
395        for root in &self.roots {
396            Self::flatten_node(root, 0, &mut result);
397        }
398        result
399    }
400
401    fn flatten_node(node: &CallTreeNode, depth: u32, result: &mut Vec<FlatCallEntry>) {
402        result.push(FlatCallEntry {
403            function_name: node.function_name.clone(),
404            self_time: node.self_time,
405            total_time: node.total_time,
406            call_count: node.call_count,
407            depth,
408        });
409        for child in &node.children {
410            Self::flatten_node(child, depth + 1, result);
411        }
412    }
413
414    /// Find the top-N hottest paths by total_time across all root entries.
415    pub(crate) fn hottest_paths(&self, top_n: usize) -> Vec<FlatCallEntry> {
416        let mut sorted = self.flatten();
417        // Sort descending by total_time
418        sorted.sort_by(|a, b| b.total_time.cmp(&a.total_time));
419        sorted.truncate(top_n);
420        sorted
421    }
422}
423
424/// Profiler data export utilities.
425pub(crate) struct ProfilerExport;
426
427impl ProfilerExport {
428    /// Generate an SVG-like text representation of a flame graph.
429    ///
430    /// Produces a simplified folded-stack format suitable for text display:
431    /// each line is `stack;path samples\n`.
432    pub(crate) fn export_flamegraph_svg(session: &ProfilerSession) -> String {
433        let mut out = String::new();
434        // Header
435        out.push_str("<!-- VeridianOS Profiler Flame Graph -->\n");
436        out.push_str("<!-- Format: stack;path sample_count -->\n");
437
438        for sample in &session.samples {
439            if sample.frames.is_empty() {
440                continue;
441            }
442            // Build folded stack line
443            for (i, frame) in sample.frames.iter().enumerate() {
444                if i > 0 {
445                    out.push(';');
446                }
447                out.push_str(frame);
448            }
449            out.push_str(" 1\n");
450        }
451        out
452    }
453
454    /// Generate a text summary of a profiling session.
455    pub(crate) fn export_summary(session: &ProfilerSession) -> String {
456        let mut out = String::from("=== Profiler Summary ===\n");
457        out.push_str("Session: ");
458        out.push_str(&session.name);
459        out.push('\n');
460        out.push_str("Samples: ");
461        push_u64_str(&mut out, session.sample_count() as u64);
462        out.push('\n');
463        out.push_str("Duration: ");
464        push_u64_str(&mut out, session.duration());
465        out.push_str(" ticks\n");
466        out.push_str("Avg CPU: ");
467        push_u64_str(&mut out, session.avg_cpu_usage() as u64);
468        out.push_str("%\n");
469        out.push_str("Peak Mem: ");
470        push_u64_str(&mut out, session.peak_memory());
471        out.push_str(" bytes\n");
472
473        // Top functions from call tree
474        let tree = CallTree::build_from_stacks(&session.samples);
475        let hot = tree.hottest_paths(5);
476        if !hot.is_empty() {
477            out.push_str("\nTop functions by total time:\n");
478            for entry in &hot {
479                out.push_str("  ");
480                out.push_str(&entry.function_name);
481                out.push_str(" (total=");
482                push_u64_str(&mut out, entry.total_time);
483                out.push_str(", self=");
484                push_u64_str(&mut out, entry.self_time);
485                out.push_str(")\n");
486            }
487        }
488
489        out
490    }
491}
492
493impl ProfilerGui {
494    /// Render a call tree view into a text buffer (indented with percentages).
495    pub(crate) fn render_call_tree(&self, tree: &CallTree) -> String {
496        let mut out = String::from("Call Tree View\n");
497        out.push_str("==============\n");
498        let total = tree.total_samples.max(1);
499        let flat = tree.flatten();
500        for entry in &flat {
501            // Indent
502            for _ in 0..entry.depth {
503                out.push_str("  ");
504            }
505            out.push_str(&entry.function_name);
506            out.push_str(" [");
507            // Percentage of total: (total_time * 100) / total_samples
508            let pct = (entry.total_time * 100) / total;
509            push_u64_str(&mut out, pct);
510            out.push_str("%, self=");
511            let self_pct = (entry.self_time * 100) / total;
512            push_u64_str(&mut out, self_pct);
513            out.push_str("%, calls=");
514            push_u64_str(&mut out, entry.call_count);
515            out.push_str("]\n");
516        }
517        out
518    }
519
520    /// Render a hotspot list: top functions sorted by self time.
521    pub(crate) fn render_hotspots(&self, tree: &CallTree, top_n: usize) -> String {
522        let mut out = String::from("Hotspot Analysis\n");
523        out.push_str("================\n");
524        let total = tree.total_samples.max(1);
525
526        // Collect all nodes, sort by self_time descending
527        let flat = tree.flatten();
528        let mut by_self: Vec<&FlatCallEntry> = flat.iter().filter(|e| e.self_time > 0).collect();
529        by_self.sort_by(|a, b| b.self_time.cmp(&a.self_time));
530        by_self.truncate(top_n);
531
532        for (i, entry) in by_self.iter().enumerate() {
533            push_u64_str(&mut out, (i + 1) as u64);
534            out.push_str(". ");
535            out.push_str(&entry.function_name);
536            out.push_str("  self=");
537            let pct = (entry.self_time * 100) / total;
538            push_u64_str(&mut out, pct);
539            out.push_str("% (");
540            push_u64_str(&mut out, entry.self_time);
541            out.push_str(" samples)\n");
542        }
543        out
544    }
545}
546
547/// Append a u64 as decimal text (no std formatting needed).
548fn push_u64_str(out: &mut String, mut val: u64) {
549    if val == 0 {
550        out.push('0');
551        return;
552    }
553    let start = out.len();
554    while val > 0 {
555        let digit = (val % 10) as u8 + b'0';
556        out.push(digit as char);
557        val /= 10;
558    }
559    let bytes = unsafe { out.as_bytes_mut() };
560    bytes[start..].reverse();
561}
562
563// ---------------------------------------------------------------------------
564// Tests
565// ---------------------------------------------------------------------------
566
567#[cfg(test)]
568mod tests {
569    #[allow(unused_imports)]
570    use alloc::vec;
571
572    use super::*;
573
574    #[test]
575    fn test_flame_frame_new() {
576        let frame = FlameFrame::new("main");
577        assert_eq!(frame.name, "main");
578        assert_eq!(frame.samples, 0);
579        assert!(frame.children.is_empty());
580    }
581
582    #[test]
583    fn test_flame_frame_total_samples() {
584        let mut root = FlameFrame::new("root");
585        root.samples = 5;
586        let mut child = FlameFrame::new("child");
587        child.samples = 3;
588        root.add_child(child);
589        assert_eq!(root.total_samples(), 8);
590    }
591
592    #[test]
593    fn test_profiler_session() {
594        let mut session = ProfilerSession::new("test");
595        session.add_sample(StackSample {
596            timestamp: 100,
597            frames: vec!["main".to_string(), "foo".to_string()],
598            cpu: 0,
599        });
600        session.add_sample(StackSample {
601            timestamp: 200,
602            frames: vec!["main".to_string(), "bar".to_string()],
603            cpu: 0,
604        });
605
606        assert_eq!(session.sample_count(), 2);
607        assert_eq!(session.duration(), 100);
608    }
609
610    #[test]
611    fn test_build_flame_graph() {
612        let mut session = ProfilerSession::new("test");
613        session.add_sample(StackSample {
614            timestamp: 0,
615            frames: vec!["main".to_string(), "foo".to_string()],
616            cpu: 0,
617        });
618        session.add_sample(StackSample {
619            timestamp: 1,
620            frames: vec!["main".to_string(), "foo".to_string()],
621            cpu: 0,
622        });
623        session.add_sample(StackSample {
624            timestamp: 2,
625            frames: vec!["main".to_string(), "bar".to_string()],
626            cpu: 0,
627        });
628
629        let flame = session.build_flame_graph();
630        assert_eq!(flame.children.len(), 1); // "main"
631        let main_frame = &flame.children[0];
632        assert_eq!(main_frame.name, "main");
633        assert_eq!(main_frame.children.len(), 2); // "foo" and "bar"
634    }
635
636    #[test]
637    fn test_cpu_timeline() {
638        let mut session = ProfilerSession::new("test");
639        session.add_cpu_point(CpuDataPoint {
640            timestamp: 0,
641            usage_percent: 50,
642            cpu_id: 0,
643        });
644        session.add_cpu_point(CpuDataPoint {
645            timestamp: 1,
646            usage_percent: 80,
647            cpu_id: 0,
648        });
649        assert_eq!(session.avg_cpu_usage(), 65);
650    }
651
652    #[test]
653    fn test_mem_timeline() {
654        let mut session = ProfilerSession::new("test");
655        session.add_mem_point(MemDataPoint {
656            timestamp: 0,
657            used_bytes: 1000,
658            total_bytes: 4096,
659        });
660        session.add_mem_point(MemDataPoint {
661            timestamp: 1,
662            used_bytes: 3000,
663            total_bytes: 4096,
664        });
665        assert_eq!(session.peak_memory(), 3000);
666    }
667
668    #[test]
669    fn test_profiler_gui_new() {
670        let gui = ProfilerGui::new(800, 600);
671        assert_eq!(gui.view, ProfilerView::FlameGraph);
672    }
673
674    #[test]
675    fn test_profiler_gui_switch_view() {
676        let mut gui = ProfilerGui::new(800, 600);
677        gui.switch_view(ProfilerView::CpuTimeline);
678        assert_eq!(gui.view, ProfilerView::CpuTimeline);
679    }
680
681    #[test]
682    fn test_profiler_view_eq() {
683        assert_eq!(ProfilerView::FlameGraph, ProfilerView::FlameGraph);
684        assert_ne!(ProfilerView::FlameGraph, ProfilerView::CallTree);
685    }
686
687    #[test]
688    fn test_empty_session_stats() {
689        let session = ProfilerSession::new("empty");
690        assert_eq!(session.avg_cpu_usage(), 0);
691        assert_eq!(session.peak_memory(), 0);
692        assert_eq!(session.duration(), 0);
693    }
694
695    #[test]
696    fn test_call_tree_build() {
697        let samples = vec![
698            StackSample {
699                timestamp: 0,
700                frames: vec!["main".to_string(), "foo".to_string(), "bar".to_string()],
701                cpu: 0,
702            },
703            StackSample {
704                timestamp: 1,
705                frames: vec!["main".to_string(), "foo".to_string()],
706                cpu: 0,
707            },
708        ];
709        let tree = CallTree::build_from_stacks(&samples);
710        assert_eq!(tree.total_samples, 2);
711        assert_eq!(tree.roots.len(), 1);
712        assert_eq!(tree.roots[0].function_name, "main");
713        assert_eq!(tree.roots[0].call_count, 2);
714    }
715
716    #[test]
717    fn test_call_tree_flatten() {
718        let samples = vec![StackSample {
719            timestamp: 0,
720            frames: vec!["main".to_string(), "compute".to_string()],
721            cpu: 0,
722        }];
723        let tree = CallTree::build_from_stacks(&samples);
724        let flat = tree.flatten();
725        assert_eq!(flat.len(), 2);
726        assert_eq!(flat[0].depth, 0);
727        assert_eq!(flat[1].depth, 1);
728        assert_eq!(flat[1].function_name, "compute");
729    }
730
731    #[test]
732    fn test_call_tree_hottest_paths() {
733        let samples = vec![
734            StackSample {
735                timestamp: 0,
736                frames: vec!["main".to_string(), "hot".to_string()],
737                cpu: 0,
738            },
739            StackSample {
740                timestamp: 1,
741                frames: vec!["main".to_string(), "hot".to_string()],
742                cpu: 0,
743            },
744            StackSample {
745                timestamp: 2,
746                frames: vec!["main".to_string(), "cold".to_string()],
747                cpu: 0,
748            },
749        ];
750        let tree = CallTree::build_from_stacks(&samples);
751        let hot = tree.hottest_paths(2);
752        assert!(!hot.is_empty());
753        // "main" has highest total_time (3), then "hot" (2)
754        assert_eq!(hot[0].function_name, "main");
755    }
756
757    #[test]
758    fn test_export_flamegraph_svg() {
759        let mut session = ProfilerSession::new("test");
760        session.add_sample(StackSample {
761            timestamp: 0,
762            frames: vec!["main".to_string(), "foo".to_string()],
763            cpu: 0,
764        });
765        let svg = ProfilerExport::export_flamegraph_svg(&session);
766        assert!(svg.contains("main;foo 1"));
767        assert!(svg.contains("VeridianOS"));
768    }
769
770    #[test]
771    fn test_export_summary() {
772        let mut session = ProfilerSession::new("bench");
773        session.add_sample(StackSample {
774            timestamp: 100,
775            frames: vec!["main".to_string()],
776            cpu: 0,
777        });
778        session.add_sample(StackSample {
779            timestamp: 200,
780            frames: vec!["main".to_string()],
781            cpu: 0,
782        });
783        let summary = ProfilerExport::export_summary(&session);
784        assert!(summary.contains("Session: bench"));
785        assert!(summary.contains("Samples: 2"));
786        assert!(summary.contains("Duration: 100"));
787    }
788
789    #[test]
790    fn test_render_call_tree_view() {
791        let samples = vec![
792            StackSample {
793                timestamp: 0,
794                frames: vec!["main".to_string(), "work".to_string()],
795                cpu: 0,
796            },
797            StackSample {
798                timestamp: 1,
799                frames: vec!["main".to_string(), "work".to_string()],
800                cpu: 0,
801            },
802        ];
803        let tree = CallTree::build_from_stacks(&samples);
804        let gui = ProfilerGui::new(800, 600);
805        let text = gui.render_call_tree(&tree);
806        assert!(text.contains("main"));
807        assert!(text.contains("work"));
808        assert!(text.contains("100%")); // main has 100% total
809    }
810}