Skip to main content

entrenar/monitor/tui/
dashboard.rs

1//! Presentar-based Training Dashboard Widget (ALB-047/048/057)
2//!
3//! Composes sovereign-stack presentar-terminal widgets:
4//! - Layout for flex-based arrangement
5//! - Border for section panels
6//! - Meter for progress bar
7//! - GpuPanel for GPU telemetry
8//! - Sparkline for loss history
9//! - Text for information lines
10//!
11//! ALB-057: Replaces monolithic draw_text() rendering with composable widget tree.
12//! Each dashboard section is a standalone widget composed via Layout::rows().
13
14use super::state::{TrainingSnapshot, TrainingState, TrainingStatus};
15use presentar_core::{
16    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event,
17    LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
18};
19use presentar_terminal::widgets::{
20    Border, GpuDevice, GpuPanel as PresentarGpuPanel, GpuProcess as PresentarGpuProcess, GpuVendor,
21    Layout, LayoutItem, Meter, Sparkline, Text,
22};
23use std::any::Any;
24use std::path::PathBuf;
25use std::time::Duration;
26
27/// Training dashboard widget for `apr monitor`.
28///
29/// Composes presentar-terminal widgets (Layout, Border, Meter, GpuPanel,
30/// Sparkline) via a widget tree rebuilt each frame from `training_state.json`.
31/// Delegates layout/paint to the composed tree, getting responsive arrangement
32/// and consistent theming from the sovereign stack.
33pub struct TrainingDashboard {
34    /// Latest snapshot (updated each layout pass)
35    snapshot: Option<TrainingSnapshot>,
36    /// Experiment directory (contains training_state.json)
37    experiment_dir: PathBuf,
38    /// Cached bounds from layout
39    bounds: Rect,
40    /// Composed widget tree — rebuilt each frame from snapshot
41    widget_tree: Option<Layout>,
42}
43
44#[allow(clippy::missing_fields_in_debug)]
45impl std::fmt::Debug for TrainingDashboard {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        f.debug_struct("TrainingDashboard")
48            .field("experiment_dir", &self.experiment_dir)
49            .field("has_snapshot", &self.snapshot.is_some())
50            .field("has_widget_tree", &self.widget_tree.is_some())
51            .finish()
52    }
53}
54
55impl TrainingDashboard {
56    /// Create a new training dashboard for an experiment directory.
57    #[must_use]
58    pub fn new(experiment_dir: PathBuf) -> Self {
59        Self { snapshot: None, experiment_dir, bounds: Rect::default(), widget_tree: None }
60    }
61
62    /// Refresh the snapshot from training_state.json.
63    pub fn refresh(&mut self) {
64        let mut state = TrainingState::new(&self.experiment_dir);
65        if let Ok(Some(snap)) = state.read() {
66            self.snapshot = Some(snap);
67        }
68    }
69
70    /// Check if training is done (for app quit logic).
71    pub fn is_finished(&self) -> bool {
72        self.snapshot.as_ref().is_some_and(|s| {
73            matches!(s.status, TrainingStatus::Completed | TrainingStatus::Failed(_))
74        })
75    }
76
77    /// Rebuild composed widget tree from current snapshot.
78    ///
79    /// Called each frame after refresh(). Constructs a Layout::rows tree
80    /// of bordered section panels from the latest training data.
81    fn rebuild_widgets(&mut self) {
82        let Some(snap) = &self.snapshot else {
83            self.widget_tree = None;
84            return;
85        };
86
87        let mut items = Vec::with_capacity(5);
88
89        // Header: title + status badge + model info (2 lines)
90        items.push(build_header(snap).into_item().fixed(2.0));
91
92        // Training metrics panel: progress, loss, timing (4 content + 2 border)
93        items.push(LayoutItem::new(build_metrics_panel(snap)).fixed(6.0));
94
95        // Shows device, utilization, VRAM, thermal/power (3 content + 2 border)
96        if snap.gpu.is_some() {
97            items.push(LayoutItem::new(build_gpu_panel(snap)).fixed(5.0));
98        }
99
100        // Loss history sparkline (2 content + 2 border)
101        if !snap.loss_history.is_empty() {
102            items.push(LayoutItem::new(build_loss_panel(snap)).fixed(4.0));
103        }
104
105        // Error message if failed
106        if let TrainingStatus::Failed(msg) = &snap.status {
107            let err = Text::new(format!("ERROR: {msg}")).with_color(Color::new(1.0, 0.3, 0.3, 1.0));
108            items.push(LayoutItem::new(err).fixed(1.0));
109        }
110
111        self.widget_tree = Some(Layout::rows(items));
112    }
113}
114
115// =============================================================================
116// Widget builders — each returns a composed widget for one dashboard section
117// =============================================================================
118
119/// Build header section: title + status badge + model info.
120fn build_header(snap: &TrainingSnapshot) -> Layout {
121    let (status_str, status_color) = status_display(&snap.status);
122
123    let title = Text::new(format!("apr monitor — {}", truncate_str(&snap.experiment_id, 30)))
124        .with_color(Color::WHITE)
125        .bold();
126
127    let status = Text::new(status_str).with_color(status_color).right();
128
129    let title_row =
130        Layout::columns([LayoutItem::new(title).expanded(), LayoutItem::new(status).fixed(10.0)]);
131
132    let info = Text::new(format!(
133        "Model: {} | Opt: {} | Batch: {}",
134        truncate_str(&snap.model_name, 30),
135        snap.optimizer_name,
136        snap.batch_size
137    ))
138    .with_color(Color::new(0.6, 0.6, 0.6, 1.0));
139
140    Layout::rows([title_row.into_item().fixed(1.0), LayoutItem::new(info).fixed(1.0)])
141}
142
143/// Build metrics panel: progress bar, loss, timing — inside a rounded border.
144fn build_metrics_panel(snap: &TrainingSnapshot) -> Border {
145    let progress = snap.progress_percent();
146    let trend = snap.loss_trend();
147
148    let progress_text = Text::new(format!(
149        "Epoch {}/{}  Step {}/{}",
150        snap.epoch, snap.total_epochs, snap.step, snap.steps_per_epoch
151    ))
152    .with_color(Color::WHITE);
153
154    let meter = Meter::percentage(f64::from(progress))
155        .with_label(format!("{progress:.1}%"))
156        .with_color(Color::new(0.3, 0.9, 0.3, 1.0));
157
158    let loss_text = Text::new(format!(
159        "Loss: {:.6} {}  LR: {:.2e}  Tok/s: {:.0}  Grad: {:.2}",
160        snap.loss,
161        trend.arrow(),
162        snap.learning_rate,
163        snap.tokens_per_second,
164        snap.gradient_norm
165    ))
166    .with_color(Color::WHITE);
167
168    let elapsed = snap.elapsed();
169    let eta = snap
170        .estimated_remaining()
171        .map(|r| format!("ETA: {}", format_duration(r)))
172        .unwrap_or_default();
173    let time_text = Text::new(format!("Elapsed: {}  {}", format_duration(elapsed), eta))
174        .with_color(Color::new(1.0, 0.9, 0.3, 1.0));
175
176    let content = Layout::rows([
177        LayoutItem::new(progress_text).fixed(1.0),
178        LayoutItem::new(meter).fixed(1.0),
179        LayoutItem::new(loss_text).fixed(1.0),
180        LayoutItem::new(time_text).fixed(1.0),
181    ]);
182
183    Border::rounded("Training").child(content)
184}
185
186/// Build GPU panel: utilization, VRAM, temp/power — inside a rounded border.
187fn build_gpu_panel(snap: &TrainingSnapshot) -> Border {
188    let Some(gpu) = &snap.gpu else {
189        return Border::rounded("GPU")
190            .child(Text::new("N/A (CPU training)").with_color(Color::new(0.5, 0.5, 0.5, 1.0)));
191    };
192
193    let device = convert_gpu_device(gpu);
194    let processes = convert_gpu_processes(&gpu.processes);
195
196    let gpu_widget = PresentarGpuPanel::new()
197        .with_device(device)
198        .with_processes(processes)
199        .show_processes(false);
200
201    Border::rounded(format!("GPU: {}", truncate_str(&gpu.device_name, 20))).child(gpu_widget)
202}
203
204/// Build loss history panel: sparkline + range — inside a rounded border.
205fn build_loss_panel(snap: &TrainingSnapshot) -> Border {
206    let values: Vec<f64> = snap.loss_history.iter().map(|v| f64::from(*v)).collect();
207    let first = snap.loss_history.first().copied().unwrap_or(0.0);
208    let last = snap.loss_history.last().copied().unwrap_or(0.0);
209
210    let sparkline =
211        Sparkline::new(values).with_color(Color::new(0.3, 0.7, 1.0, 1.0)).with_trend(true);
212
213    let range =
214        Text::new(format!("{first:.4} → {last:.4}")).with_color(Color::new(0.5, 0.5, 0.5, 1.0));
215
216    let content =
217        Layout::rows([LayoutItem::new(sparkline).fixed(1.0), LayoutItem::new(range).fixed(1.0)]);
218
219    Border::rounded("Loss History").child(content)
220}
221
222// =============================================================================
223// Helpers
224// =============================================================================
225
226/// Convert entrenar `GpuTelemetry` to presentar `GpuDevice`.
227fn convert_gpu_device(gpu: &super::state::GpuTelemetry) -> GpuDevice {
228    GpuDevice::new(&gpu.device_name)
229        .with_vendor(GpuVendor::Nvidia)
230        .with_utilization(gpu.utilization_percent)
231        .with_temperature(gpu.temperature_celsius)
232        .with_vram(
233            (gpu.vram_used_gb.max(0.0) * 1_073_741_824.0) as u64,
234            (gpu.vram_total_gb.max(0.0) * 1_073_741_824.0) as u64,
235        )
236        .with_power(gpu.power_watts, Some(gpu.power_limit_watts))
237}
238
239/// Convert entrenar `GpuProcessInfo` to presentar `GpuProcess`.
240fn convert_gpu_processes(processes: &[super::state::GpuProcessInfo]) -> Vec<PresentarGpuProcess> {
241    processes
242        .iter()
243        .map(|p| {
244            let name = p.exe_path.rsplit('/').next().unwrap_or(&p.exe_path);
245            PresentarGpuProcess::new(name, p.pid, p.gpu_memory_mb * 1024 * 1024)
246        })
247        .collect()
248}
249
250/// Get display string and color for training status.
251fn status_display(status: &TrainingStatus) -> (&'static str, Color) {
252    match status {
253        TrainingStatus::Initializing => ("INIT", Color::new(0.7, 0.7, 0.7, 1.0)),
254        TrainingStatus::Running => ("RUNNING", Color::new(0.3, 0.9, 0.3, 1.0)),
255        TrainingStatus::Paused => ("PAUSED", Color::new(0.7, 0.7, 0.7, 1.0)),
256        TrainingStatus::Completed => ("DONE", Color::new(0.3, 0.7, 1.0, 1.0)),
257        TrainingStatus::Failed(_) => ("FAILED", Color::new(1.0, 0.3, 0.3, 1.0)),
258    }
259}
260
261/// Truncate a string with no-alloc slicing.
262fn truncate_str(s: &str, max: usize) -> &str {
263    if s.len() <= max {
264        s
265    } else {
266        &s[..max]
267    }
268}
269
270/// Format a Duration as human-readable.
271fn format_duration(d: Duration) -> String {
272    let secs = d.as_secs();
273    if secs > 3600 {
274        format!("{}h {}m {}s", secs / 3600, (secs % 3600) / 60, secs % 60)
275    } else if secs > 60 {
276        format!("{}m {}s", secs / 60, secs % 60)
277    } else {
278        format!("{secs}s")
279    }
280}
281
282// =============================================================================
283// Brick trait implementation (PROBAR-SPEC-009)
284// =============================================================================
285
286impl Brick for TrainingDashboard {
287    fn brick_name(&self) -> &'static str {
288        "training_dashboard"
289    }
290
291    fn assertions(&self) -> &[BrickAssertion] {
292        static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(16)];
293        ASSERTIONS
294    }
295
296    fn budget(&self) -> BrickBudget {
297        BrickBudget::uniform(16) // 60fps
298    }
299
300    fn verify(&self) -> BrickVerification {
301        // Wire panel verification system into Brick verify (ALB-057).
302        // Uses panel::layout_can_render() to validate snapshot data before rendering.
303        let start = std::time::Instant::now();
304        if let Some(snap) = &self.snapshot {
305            if !super::panel::layout_can_render(snap) {
306                return BrickVerification {
307                    passed: vec![],
308                    failed: vec![(
309                        BrickAssertion::max_latency_ms(16),
310                        "snapshot data failed panel verification".to_string(),
311                    )],
312                    verification_time: start.elapsed(),
313                };
314            }
315        }
316        BrickVerification {
317            passed: vec![BrickAssertion::max_latency_ms(16)],
318            failed: vec![],
319            verification_time: start.elapsed(),
320        }
321    }
322
323    fn to_html(&self) -> String {
324        String::new() // Terminal-only widget
325    }
326
327    fn to_css(&self) -> String {
328        String::new() // Terminal-only widget
329    }
330}
331
332// =============================================================================
333// Widget trait implementation
334// =============================================================================
335
336impl Widget for TrainingDashboard {
337    fn type_id(&self) -> TypeId {
338        TypeId::of::<Self>()
339    }
340
341    fn measure(&self, constraints: Constraints) -> Size {
342        Size { width: constraints.max_width, height: constraints.max_height }
343    }
344
345    fn layout(&mut self, bounds: Rect) -> LayoutResult {
346        self.bounds = bounds;
347        // Refresh data and rebuild widget tree each frame
348        self.refresh();
349        self.rebuild_widgets();
350
351        // Delegate layout to composed widget tree
352        if let Some(tree) = &mut self.widget_tree {
353            tree.layout(bounds);
354        }
355
356        LayoutResult { size: Size { width: bounds.width, height: bounds.height } }
357    }
358
359    fn paint(&self, canvas: &mut dyn Canvas) {
360        // Delegate to composed widget tree
361        if let Some(tree) = &self.widget_tree {
362            tree.paint(canvas);
363            return;
364        }
365
366        // Fallback: waiting state
367        let dim = TextStyle { color: Color::new(0.5, 0.5, 0.5, 1.0), ..Default::default() };
368        canvas.draw_text("Waiting for training data...", Point { x: 1.0, y: 1.0 }, &dim);
369    }
370
371    fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
372        if let Some(tree) = &mut self.widget_tree {
373            return tree.event(event);
374        }
375        None
376    }
377
378    fn children(&self) -> &[Box<dyn Widget>] {
379        &[]
380    }
381
382    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
383        &mut []
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    #![allow(clippy::unwrap_used)]
390    use super::*;
391    use std::path::PathBuf;
392
393    fn make_snapshot() -> TrainingSnapshot {
394        TrainingSnapshot {
395            timestamp_ms: 2000,
396            epoch: 2,
397            total_epochs: 10,
398            step: 50,
399            steps_per_epoch: 100,
400            loss: 0.123456,
401            loss_history: vec![0.5, 0.4, 0.35, 0.3, 0.25, 0.2, 0.15],
402            learning_rate: 1e-4,
403            lr_history: vec![],
404            gradient_norm: 1.5,
405            accuracy: 0.85,
406            tokens_per_second: 1234.0,
407            samples_per_second: 10.0,
408            start_timestamp_ms: 1000,
409            gpu: None,
410            sample: None,
411            status: TrainingStatus::Running,
412            experiment_id: "exp-001".to_string(),
413            model_name: "test-model".to_string(),
414            model_path: String::new(),
415            optimizer_name: "AdamW".to_string(),
416            batch_size: 32,
417            checkpoint_path: String::new(),
418            executable_path: String::new(),
419        }
420    }
421
422    fn make_gpu() -> super::super::state::GpuTelemetry {
423        super::super::state::GpuTelemetry {
424            device_name: "RTX 4090".to_string(),
425            utilization_percent: 95.0,
426            vram_used_gb: 20.0,
427            vram_total_gb: 24.0,
428            temperature_celsius: 72.0,
429            power_watts: 350.0,
430            power_limit_watts: 450.0,
431            processes: vec![],
432        }
433    }
434
435    // ── format_duration tests ──────────────────────────────────────────
436
437    #[test]
438    fn test_format_duration_seconds() {
439        assert_eq!(format_duration(Duration::from_secs(42)), "42s");
440    }
441
442    #[test]
443    fn test_format_duration_minutes() {
444        assert_eq!(format_duration(Duration::from_secs(125)), "2m 5s");
445    }
446
447    #[test]
448    fn test_format_duration_hours() {
449        assert_eq!(format_duration(Duration::from_secs(3723)), "1h 2m 3s");
450    }
451
452    #[test]
453    fn test_format_duration_zero() {
454        assert_eq!(format_duration(Duration::from_secs(0)), "0s");
455    }
456
457    #[test]
458    fn test_format_duration_exact_minute() {
459        // 60s uses `> 60` branch which is false, so falls to else → "60s"
460        assert_eq!(format_duration(Duration::from_secs(60)), "60s");
461    }
462
463    #[test]
464    fn test_format_duration_exact_hour() {
465        // 3600s uses `> 3600` which is false, falls to `> 60` → "60m 0s"
466        assert_eq!(format_duration(Duration::from_secs(3600)), "60m 0s");
467    }
468
469    // ── truncate_str tests ─────────────────────────────────────────────
470
471    #[test]
472    fn test_truncate_str_short() {
473        assert_eq!(truncate_str("hello", 10), "hello");
474    }
475
476    #[test]
477    fn test_truncate_str_exact() {
478        assert_eq!(truncate_str("hello", 5), "hello");
479    }
480
481    #[test]
482    fn test_truncate_str_long() {
483        assert_eq!(truncate_str("hello world", 5), "hello");
484    }
485
486    #[test]
487    fn test_truncate_str_empty() {
488        assert_eq!(truncate_str("", 5), "");
489    }
490
491    #[test]
492    fn test_truncate_str_zero_max() {
493        assert_eq!(truncate_str("abc", 0), "");
494    }
495
496    // ── status_display tests ───────────────────────────────────────────
497
498    #[test]
499    fn test_status_display_running() {
500        let (text, color) = status_display(&TrainingStatus::Running);
501        assert_eq!(text, "RUNNING");
502        assert!(color.g > 0.5); // green-ish
503    }
504
505    #[test]
506    fn test_status_display_init() {
507        let (text, _) = status_display(&TrainingStatus::Initializing);
508        assert_eq!(text, "INIT");
509    }
510
511    #[test]
512    fn test_status_display_paused() {
513        let (text, _) = status_display(&TrainingStatus::Paused);
514        assert_eq!(text, "PAUSED");
515    }
516
517    #[test]
518    fn test_status_display_completed() {
519        let (text, _) = status_display(&TrainingStatus::Completed);
520        assert_eq!(text, "DONE");
521    }
522
523    #[test]
524    fn test_status_display_failed() {
525        let (text, color) = status_display(&TrainingStatus::Failed("oops".into()));
526        assert_eq!(text, "FAILED");
527        assert!(color.r > 0.5); // red-ish
528    }
529
530    // ── convert_gpu_device tests ───────────────────────────────────────
531
532    #[test]
533    fn test_convert_gpu_device_basic() {
534        let gpu = make_gpu();
535        let _dev = convert_gpu_device(&gpu);
536        // No panic = success; GpuDevice is opaque
537    }
538
539    #[test]
540    fn test_convert_gpu_device_zero_vram() {
541        let mut gpu = make_gpu();
542        gpu.vram_used_gb = 0.0;
543        gpu.vram_total_gb = 0.0;
544        let _dev = convert_gpu_device(&gpu);
545    }
546
547    #[test]
548    fn test_convert_gpu_device_negative_vram_clamped() {
549        let mut gpu = make_gpu();
550        gpu.vram_used_gb = -1.0;
551        gpu.vram_total_gb = -1.0;
552        // Should not panic — max(0.0) clamp handles negative
553        let _dev = convert_gpu_device(&gpu);
554    }
555
556    // ── convert_gpu_processes tests ────────────────────────────────────
557
558    #[test]
559    fn test_convert_gpu_processes_empty() {
560        let procs = convert_gpu_processes(&[]);
561        assert!(procs.is_empty());
562    }
563
564    #[test]
565    fn test_convert_gpu_processes_single() {
566        let procs = convert_gpu_processes(&[super::super::state::GpuProcessInfo {
567            pid: 1234,
568            exe_path: "/usr/bin/python3".to_string(),
569            gpu_memory_mb: 4096,
570            cpu_percent: 50.0,
571            rss_mb: 2048,
572        }]);
573        assert_eq!(procs.len(), 1);
574    }
575
576    #[test]
577    fn test_convert_gpu_processes_basename_extraction() {
578        let procs = convert_gpu_processes(&[super::super::state::GpuProcessInfo {
579            pid: 42,
580            exe_path: "/very/long/path/to/trainer".to_string(),
581            gpu_memory_mb: 100,
582            cpu_percent: 10.0,
583            rss_mb: 100,
584        }]);
585        assert_eq!(procs.len(), 1);
586    }
587
588    #[test]
589    fn test_convert_gpu_processes_no_slash() {
590        let procs = convert_gpu_processes(&[super::super::state::GpuProcessInfo {
591            pid: 1,
592            exe_path: "python3".to_string(),
593            gpu_memory_mb: 50,
594            cpu_percent: 5.0,
595            rss_mb: 50,
596        }]);
597        assert_eq!(procs.len(), 1);
598    }
599
600    // ── TrainingDashboard tests ────────────────────────────────────────
601
602    #[test]
603    fn test_dashboard_new() {
604        let dash = TrainingDashboard::new(PathBuf::from("/tmp/exp"));
605        assert!(dash.snapshot.is_none());
606        assert!(dash.widget_tree.is_none());
607    }
608
609    #[test]
610    fn test_dashboard_is_finished_no_snapshot() {
611        let dash = TrainingDashboard::new(PathBuf::from("/tmp/exp"));
612        assert!(!dash.is_finished());
613    }
614
615    #[test]
616    fn test_dashboard_is_finished_running() {
617        let mut dash = TrainingDashboard::new(PathBuf::from("/tmp/exp"));
618        dash.snapshot = Some(make_snapshot());
619        assert!(!dash.is_finished());
620    }
621
622    #[test]
623    fn test_dashboard_is_finished_completed() {
624        let mut dash = TrainingDashboard::new(PathBuf::from("/tmp/exp"));
625        let mut snap = make_snapshot();
626        snap.status = TrainingStatus::Completed;
627        dash.snapshot = Some(snap);
628        assert!(dash.is_finished());
629    }
630
631    #[test]
632    fn test_dashboard_is_finished_failed() {
633        let mut dash = TrainingDashboard::new(PathBuf::from("/tmp/exp"));
634        let mut snap = make_snapshot();
635        snap.status = TrainingStatus::Failed("boom".into());
636        dash.snapshot = Some(snap);
637        assert!(dash.is_finished());
638    }
639
640    #[test]
641    fn test_dashboard_is_finished_init() {
642        let mut dash = TrainingDashboard::new(PathBuf::from("/tmp/exp"));
643        let mut snap = make_snapshot();
644        snap.status = TrainingStatus::Initializing;
645        dash.snapshot = Some(snap);
646        assert!(!dash.is_finished());
647    }
648
649    #[test]
650    fn test_dashboard_is_finished_paused() {
651        let mut dash = TrainingDashboard::new(PathBuf::from("/tmp/exp"));
652        let mut snap = make_snapshot();
653        snap.status = TrainingStatus::Paused;
654        dash.snapshot = Some(snap);
655        assert!(!dash.is_finished());
656    }
657
658    // ── Debug impl test ────────────────────────────────────────────────
659
660    #[test]
661    fn test_dashboard_debug() {
662        let dash = TrainingDashboard::new(PathBuf::from("/tmp/exp"));
663        let dbg = format!("{dash:?}");
664        assert!(dbg.contains("TrainingDashboard"));
665        assert!(dbg.contains("/tmp/exp"));
666        assert!(dbg.contains("has_snapshot"));
667    }
668
669    // ── rebuild_widgets tests ──────────────────────────────────────────
670
671    #[test]
672    fn test_rebuild_widgets_no_snapshot() {
673        let mut dash = TrainingDashboard::new(PathBuf::from("/tmp/exp"));
674        dash.rebuild_widgets();
675        assert!(dash.widget_tree.is_none());
676    }
677
678    #[test]
679    fn test_rebuild_widgets_basic() {
680        let mut dash = TrainingDashboard::new(PathBuf::from("/tmp/exp"));
681        dash.snapshot = Some(make_snapshot());
682        dash.rebuild_widgets();
683        assert!(dash.widget_tree.is_some());
684    }
685
686    #[test]
687    fn test_rebuild_widgets_with_gpu() {
688        let mut dash = TrainingDashboard::new(PathBuf::from("/tmp/exp"));
689        let mut snap = make_snapshot();
690        snap.gpu = Some(make_gpu());
691        dash.snapshot = Some(snap);
692        dash.rebuild_widgets();
693        assert!(dash.widget_tree.is_some());
694    }
695
696    #[test]
697    fn test_rebuild_widgets_no_loss_history() {
698        let mut dash = TrainingDashboard::new(PathBuf::from("/tmp/exp"));
699        let mut snap = make_snapshot();
700        snap.loss_history = vec![];
701        dash.snapshot = Some(snap);
702        dash.rebuild_widgets();
703        assert!(dash.widget_tree.is_some());
704    }
705
706    #[test]
707    fn test_rebuild_widgets_failed_status() {
708        let mut dash = TrainingDashboard::new(PathBuf::from("/tmp/exp"));
709        let mut snap = make_snapshot();
710        snap.status = TrainingStatus::Failed("critical error".into());
711        dash.snapshot = Some(snap);
712        dash.rebuild_widgets();
713        assert!(dash.widget_tree.is_some());
714    }
715
716    #[test]
717    fn test_rebuild_widgets_completed() {
718        let mut dash = TrainingDashboard::new(PathBuf::from("/tmp/exp"));
719        let mut snap = make_snapshot();
720        snap.status = TrainingStatus::Completed;
721        dash.snapshot = Some(snap);
722        dash.rebuild_widgets();
723        assert!(dash.widget_tree.is_some());
724    }
725
726    // ── build_* function tests ─────────────────────────────────────────
727
728    #[test]
729    fn test_build_header() {
730        let snap = make_snapshot();
731        let _header = build_header(&snap);
732        // No panic = success
733    }
734
735    #[test]
736    fn test_build_header_long_experiment_id() {
737        let mut snap = make_snapshot();
738        snap.experiment_id = "a".repeat(100);
739        let _header = build_header(&snap);
740    }
741
742    #[test]
743    fn test_build_metrics_panel() {
744        let snap = make_snapshot();
745        let _panel = build_metrics_panel(&snap);
746    }
747
748    #[test]
749    fn test_build_metrics_panel_zero_progress() {
750        let mut snap = make_snapshot();
751        snap.epoch = 0;
752        snap.step = 0;
753        snap.total_epochs = 0;
754        snap.steps_per_epoch = 0;
755        let _panel = build_metrics_panel(&snap);
756    }
757
758    #[test]
759    fn test_build_gpu_panel_with_gpu() {
760        let mut snap = make_snapshot();
761        snap.gpu = Some(make_gpu());
762        let _panel = build_gpu_panel(&snap);
763    }
764
765    #[test]
766    fn test_build_gpu_panel_no_gpu() {
767        let snap = make_snapshot();
768        let _panel = build_gpu_panel(&snap);
769    }
770
771    #[test]
772    fn test_build_gpu_panel_with_processes() {
773        let mut snap = make_snapshot();
774        let mut gpu = make_gpu();
775        gpu.processes = vec![
776            super::super::state::GpuProcessInfo {
777                pid: 100,
778                exe_path: "/usr/bin/python3".to_string(),
779                gpu_memory_mb: 4096,
780                cpu_percent: 50.0,
781                rss_mb: 2048,
782            },
783            super::super::state::GpuProcessInfo {
784                pid: 200,
785                exe_path: "trainer".to_string(),
786                gpu_memory_mb: 2048,
787                cpu_percent: 25.0,
788                rss_mb: 1024,
789            },
790        ];
791        snap.gpu = Some(gpu);
792        let _panel = build_gpu_panel(&snap);
793    }
794
795    #[test]
796    fn test_build_loss_panel() {
797        let snap = make_snapshot();
798        let _panel = build_loss_panel(&snap);
799    }
800
801    #[test]
802    fn test_build_loss_panel_single_value() {
803        let mut snap = make_snapshot();
804        snap.loss_history = vec![0.5];
805        let _panel = build_loss_panel(&snap);
806    }
807
808    // ── Brick trait tests ──────────────────────────────────────────────
809
810    #[test]
811    fn test_brick_name() {
812        let dash = TrainingDashboard::new(PathBuf::from("/tmp/exp"));
813        assert_eq!(dash.brick_name(), "training_dashboard");
814    }
815
816    #[test]
817    fn test_brick_assertions() {
818        let dash = TrainingDashboard::new(PathBuf::from("/tmp/exp"));
819        assert!(!dash.assertions().is_empty());
820    }
821
822    #[test]
823    fn test_brick_budget() {
824        let dash = TrainingDashboard::new(PathBuf::from("/tmp/exp"));
825        let _budget = dash.budget();
826    }
827
828    #[test]
829    fn test_brick_verify_no_snapshot() {
830        let dash = TrainingDashboard::new(PathBuf::from("/tmp/exp"));
831        let v = dash.verify();
832        assert!(v.failed.is_empty());
833    }
834
835    #[test]
836    fn test_brick_verify_with_snapshot() {
837        let mut dash = TrainingDashboard::new(PathBuf::from("/tmp/exp"));
838        dash.snapshot = Some(make_snapshot());
839        let v = dash.verify();
840        // Should pass for valid snapshot
841        assert!(!v.passed.is_empty() || !v.failed.is_empty());
842    }
843
844    #[test]
845    fn test_brick_to_html() {
846        let dash = TrainingDashboard::new(PathBuf::from("/tmp/exp"));
847        assert!(dash.to_html().is_empty());
848    }
849
850    #[test]
851    fn test_brick_to_css() {
852        let dash = TrainingDashboard::new(PathBuf::from("/tmp/exp"));
853        assert!(dash.to_css().is_empty());
854    }
855
856    // ── Widget trait tests ─────────────────────────────────────────────
857
858    #[test]
859    fn test_widget_measure() {
860        let dash = TrainingDashboard::new(PathBuf::from("/tmp/exp"));
861        let size = dash.measure(Constraints {
862            min_width: 0.0,
863            min_height: 0.0,
864            max_width: 80.0,
865            max_height: 24.0,
866        });
867        assert_eq!(size.width, 80.0);
868        assert_eq!(size.height, 24.0);
869    }
870
871    #[test]
872    fn test_widget_children_empty() {
873        let dash = TrainingDashboard::new(PathBuf::from("/tmp/exp"));
874        assert!(dash.children().is_empty());
875    }
876
877    #[test]
878    fn test_widget_children_mut_empty() {
879        let mut dash = TrainingDashboard::new(PathBuf::from("/tmp/exp"));
880        assert!(dash.children_mut().is_empty());
881    }
882}