Skip to main content

entrenar/monitor/tui/render/
mod.rs

1//! TUI Rendering - Clean, Simple, Labeled Layout
2//!
3//! Design: Tufte data-ink ratio, clear labels, no chartjunk
4
5mod bars;
6mod charts;
7mod epoch;
8mod format;
9
10pub use bars::{
11    build_block_bar, build_colored_block_bar, pct_color, render_sparkline, trend_arrow,
12};
13pub use charts::{
14    render_braille_chart, render_config_panel, render_gauge, render_history_table,
15    render_sample_panel, BrailleChart,
16};
17pub use epoch::{compute_epoch_summaries, EpochSummary};
18pub use format::{format_bytes, format_duration, format_lr};
19
20use super::color::{ColorMode, Styled, TrainingPalette};
21use super::state::{TrainingSnapshot, TrainingStatus};
22
23// ═══════════════════════════════════════════════════════════════════════════════
24// MAIN LAYOUT - Simple, Clean, Labeled
25// ═══════════════════════════════════════════════════════════════════════════════
26
27pub fn render_layout(snapshot: &TrainingSnapshot, width: usize) -> String {
28    render_layout_colored(snapshot, width, ColorMode::detect())
29}
30
31pub fn render_layout_colored(
32    snapshot: &TrainingSnapshot,
33    width: usize,
34    color_mode: ColorMode,
35) -> String {
36    let mut lines: Vec<String> = Vec::new();
37    let w = width.max(80);
38
39    render_header(&mut lines, snapshot, w, color_mode);
40    render_progress(&mut lines, snapshot, w, color_mode);
41    render_metrics(&mut lines, snapshot, color_mode);
42    render_loss_sparkline(&mut lines, snapshot, w, color_mode);
43    render_gpu_section(&mut lines, snapshot, w, color_mode);
44    render_epoch_table(&mut lines, snapshot, w, color_mode);
45    render_config_footer(&mut lines, snapshot, w, color_mode);
46
47    lines.join("\n")
48}
49
50fn render_header(
51    lines: &mut Vec<String>,
52    snapshot: &TrainingSnapshot,
53    w: usize,
54    color_mode: ColorMode,
55) {
56    let (status_icon, status_text, status_color) = match &snapshot.status {
57        TrainingStatus::Initializing => ("\u{25D0}", "Init", TrainingPalette::INFO),
58        TrainingStatus::Running => ("\u{25CF}", "Running", TrainingPalette::SUCCESS),
59        TrainingStatus::Paused => ("\u{25D0}", "Paused", TrainingPalette::WARNING),
60        TrainingStatus::Completed => ("\u{25CB}", "Done", TrainingPalette::PRIMARY),
61        TrainingStatus::Failed(_) => ("\u{25CF}", "FAIL", TrainingPalette::ERROR),
62    };
63
64    let elapsed = format_duration(snapshot.elapsed());
65    let tps = snapshot.tokens_per_second.max(0.0);
66
67    let model_display = if snapshot.model_name.is_empty() {
68        "N/A".to_string()
69    } else if snapshot.model_name.len() > 30 {
70        format!("{}...", &snapshot.model_name[..27])
71    } else {
72        snapshot.model_name.clone()
73    };
74
75    let header = format!(
76        "ENTRENAR  {}  {} {}  {:.0} tok/s",
77        Styled::new(&format!("{status_icon} {status_text}"), color_mode).fg(status_color),
78        Styled::new(&elapsed, color_mode).fg((150, 150, 150)),
79        Styled::new(&model_display, color_mode).fg((180, 180, 220)),
80        tps
81    );
82    lines.push(format!("\u{2550}{:\u{2550}<w$}\u{2550}", "", w = w - 2));
83    lines.push(header);
84    lines.push(format!("\u{2500}{:\u{2500}<w$}\u{2500}", "", w = w - 2));
85}
86
87fn render_progress(
88    lines: &mut Vec<String>,
89    snapshot: &TrainingSnapshot,
90    _w: usize,
91    color_mode: ColorMode,
92) {
93    let epoch = snapshot.epoch.min(snapshot.total_epochs);
94    let epoch_pct = if snapshot.total_epochs > 0 {
95        // Percentage computed as f64 for precision, then narrowed after clamping
96        let pct = (epoch as f64 / snapshot.total_epochs as f64 * 100.0).clamp(0.0, 100.0);
97        pct as f32
98    } else {
99        0.0
100    };
101
102    let step = snapshot.step.min(snapshot.steps_per_epoch);
103    let step_pct = if snapshot.steps_per_epoch > 0 {
104        let pct = (step as f64 / snapshot.steps_per_epoch as f64 * 100.0).clamp(0.0, 100.0);
105        pct as f32
106    } else {
107        0.0
108    };
109
110    let bar_w = 20;
111    let epoch_bar = build_colored_block_bar(epoch_pct, bar_w, color_mode);
112    let step_bar = build_colored_block_bar(step_pct, bar_w, color_mode);
113
114    lines.push(format!(
115        "Epoch {:>2}/{:<2} {} {:>3.0}%    Step {:>2}/{:<2} {} {:>3.0}%",
116        epoch,
117        snapshot.total_epochs,
118        epoch_bar,
119        epoch_pct,
120        step,
121        snapshot.steps_per_epoch,
122        step_bar,
123        step_pct
124    ));
125}
126
127fn render_metrics(lines: &mut Vec<String>, snapshot: &TrainingSnapshot, color_mode: ColorMode) {
128    let loss_str =
129        if snapshot.loss.is_finite() { format!("{:.4}", snapshot.loss) } else { "???".to_string() };
130    let loss_color = if snapshot.loss.is_finite() {
131        pct_color((snapshot.loss * 10.0).min(100.0))
132    } else {
133        (255, 64, 64)
134    };
135
136    let best = snapshot
137        .loss_history
138        .iter()
139        .copied()
140        .filter(|v| v.is_finite())
141        .fold(f32::INFINITY, f32::min);
142    let best_str = if best.is_finite() { format!("{best:.4}") } else { "---".to_string() };
143
144    let grad = snapshot.gradient_norm.max(0.0);
145    let eta = snapshot.estimated_remaining().map_or("--:--:--".to_string(), format_duration);
146
147    lines.push(format!(
148        "Loss {} {}  Best {}  LR {}  Grad {:.2}  ETA {}",
149        Styled::new(&loss_str, color_mode).fg(loss_color),
150        trend_arrow(&snapshot.loss_history),
151        Styled::new(&best_str, color_mode).fg((100, 200, 100)),
152        format_lr(snapshot.learning_rate),
153        grad,
154        eta
155    ));
156}
157
158fn render_loss_sparkline(
159    lines: &mut Vec<String>,
160    snapshot: &TrainingSnapshot,
161    w: usize,
162    color_mode: ColorMode,
163) {
164    if snapshot.loss_history.is_empty() {
165        return;
166    }
167
168    let spark_w = w.saturating_sub(20);
169    let sparkline = render_sparkline(&snapshot.loss_history, spark_w, color_mode);
170
171    let valid: Vec<f32> = snapshot.loss_history.iter().copied().filter(|v| v.is_finite()).collect();
172    let (min_l, max_l) = if valid.is_empty() {
173        (0.0, 0.0)
174    } else {
175        (
176            valid.iter().copied().fold(f32::INFINITY, f32::min),
177            valid.iter().copied().fold(f32::NEG_INFINITY, f32::max),
178        )
179    };
180
181    lines.push(format!("\u{2500}{:\u{2500}<w$}\u{2500}", "", w = w - 2));
182    lines.push(format!("Loss History: {sparkline} [{min_l:.2} - {max_l:.2}]"));
183}
184
185fn render_gpu_section(
186    lines: &mut Vec<String>,
187    snapshot: &TrainingSnapshot,
188    w: usize,
189    color_mode: ColorMode,
190) {
191    lines.push(format!("\u{2500}{:\u{2500}<w$}\u{2500}", "", w = w - 2));
192    if let Some(gpu) = &snapshot.gpu {
193        let util_bar = build_colored_block_bar(gpu.utilization_percent, 15, color_mode);
194        let vram_pct = gpu.vram_percent().min(100.0);
195        let vram_bar = build_colored_block_bar(vram_pct, 15, color_mode);
196
197        let temp_color = if gpu.temperature_celsius > 80.0 {
198            TrainingPalette::ERROR
199        } else if gpu.temperature_celsius > 70.0 {
200            TrainingPalette::WARNING
201        } else {
202            TrainingPalette::SUCCESS
203        };
204
205        lines.push(format!(
206            "GPU: {}  Util {} {:>3.0}%  Temp {}  Power {:.0}W",
207            gpu.device_name.chars().take(20).collect::<String>(),
208            util_bar,
209            gpu.utilization_percent,
210            Styled::new(&format!("{:.0}\u{00B0}C", gpu.temperature_celsius), color_mode)
211                .fg(temp_color),
212            gpu.power_watts
213        ));
214
215        lines.push(format!(
216            "VRAM: {} {:>3.0}%  {:.1}G / {:.0}G",
217            vram_bar,
218            vram_pct,
219            gpu.vram_used_gb.min(gpu.vram_total_gb),
220            gpu.vram_total_gb
221        ));
222    } else {
223        lines.push("GPU: N/A".to_string());
224    }
225}
226
227fn render_epoch_table(
228    lines: &mut Vec<String>,
229    snapshot: &TrainingSnapshot,
230    w: usize,
231    color_mode: ColorMode,
232) {
233    lines.push(format!("\u{2500}{:\u{2500}<w$}\u{2500}", "", w = w - 2));
234    lines.push(format!(
235        "{:>5}  {:>8}  {:>8}  {:>8}  {:>10}  {:>5}",
236        Styled::new("Epoch", color_mode).fg((150, 150, 150)),
237        Styled::new("Loss", color_mode).fg((150, 150, 150)),
238        Styled::new("Min", color_mode).fg((150, 150, 150)),
239        Styled::new("Max", color_mode).fg((150, 150, 150)),
240        Styled::new("LR", color_mode).fg((150, 150, 150)),
241        Styled::new("Trend", color_mode).fg((150, 150, 150)),
242    ));
243
244    let summaries = compute_epoch_summaries(snapshot);
245    if summaries.is_empty() {
246        lines.push("  (waiting for epoch data...)".to_string());
247        return;
248    }
249
250    let max_rows = 6;
251    let start_idx = summaries.len().saturating_sub(max_rows);
252
253    for (i, summary) in summaries.iter().skip(start_idx).enumerate() {
254        let trend = epoch_trend_arrow(i, start_idx, &summaries, color_mode);
255        let loss_color = pct_color((summary.avg_loss * 8.0).min(100.0));
256
257        lines.push(format!(
258            "{:>5}  {}  {:>8.4}  {:>8.4}  {:>10}  {:>5}",
259            summary.epoch,
260            Styled::new(&format!("{:>8.4}", summary.avg_loss), color_mode).fg(loss_color),
261            summary.min_loss,
262            summary.max_loss,
263            format_lr(summary.lr),
264            trend
265        ));
266    }
267
268    if start_idx > 0 {
269        lines.push(format!(
270            "  ... {} earlier epochs",
271            Styled::new(&format!("{start_idx}"), color_mode).fg((100, 100, 100))
272        ));
273    }
274}
275
276fn epoch_trend_arrow(
277    i: usize,
278    start_idx: usize,
279    summaries: &[EpochSummary],
280    color_mode: ColorMode,
281) -> String {
282    if i == 0 && start_idx == 0 {
283        return " ".to_string();
284    }
285    let prev_idx = if i > 0 { start_idx + i - 1 } else { start_idx.saturating_sub(1) };
286    if let Some(prev) = summaries.get(prev_idx) {
287        let current = &summaries[start_idx + i];
288        let change = (current.avg_loss - prev.avg_loss) / prev.avg_loss.abs().max(0.001);
289        if change < -0.02 {
290            Styled::new("\u{2193}", color_mode).fg((100, 255, 100)).to_string()
291        } else if change > 0.02 {
292            Styled::new("\u{2191}", color_mode).fg((255, 100, 100)).to_string()
293        } else {
294            Styled::new("\u{2192}", color_mode).fg((150, 150, 150)).to_string()
295        }
296    } else {
297        " ".to_string()
298    }
299}
300
301fn render_config_footer(
302    lines: &mut Vec<String>,
303    snapshot: &TrainingSnapshot,
304    w: usize,
305    color_mode: ColorMode,
306) {
307    lines.push(format!("\u{2500}{:\u{2500}<w$}\u{2500}", "", w = w - 2));
308
309    let opt = if snapshot.optimizer_name.is_empty() { "N/A" } else { &snapshot.optimizer_name };
310    let batch = if snapshot.batch_size > 0 {
311        format!("{}", snapshot.batch_size)
312    } else {
313        "N/A".to_string()
314    };
315
316    lines.push(format!(
317        "Config: {}  Batch: {}  Checkpoint: {}",
318        Styled::new(opt, color_mode).fg((150, 255, 150)),
319        batch,
320        if snapshot.checkpoint_path.is_empty() { "N/A" } else { &snapshot.checkpoint_path }
321    ));
322
323    lines.push(format!("\u{2550}{:\u{2550}<w$}\u{2550}", "", w = w - 2));
324}
325
326// ═══════════════════════════════════════════════════════════════════════════════
327// TESTS
328// ═══════════════════════════════════════════════════════════════════════════════
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn test_layout_renders() {
336        let snapshot = TrainingSnapshot {
337            epoch: 5,
338            total_epochs: 10,
339            step: 8,
340            steps_per_epoch: 16,
341            loss: 2.5,
342            loss_history: vec![5.0, 4.5, 4.0, 3.5, 3.0, 2.8, 2.6, 2.5],
343            learning_rate: 0.0001,
344            gradient_norm: 1.5,
345            tokens_per_second: 100.0,
346            model_name: "TestModel".to_string(),
347            optimizer_name: "AdamW".to_string(),
348            batch_size: 4,
349            ..Default::default()
350        };
351
352        let layout = render_layout(&snapshot, 80);
353        assert!(layout.contains("ENTRENAR"));
354        assert!(layout.contains("Epoch"));
355        assert!(layout.contains("Loss"));
356        assert!(layout.contains("Step"));
357    }
358
359    #[test]
360    fn test_render_header_initializing() {
361        let snapshot = TrainingSnapshot {
362            status: TrainingStatus::Initializing,
363            model_name: "TestModel".to_string(),
364            ..Default::default()
365        };
366        let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
367        assert!(layout.contains("Init"));
368    }
369
370    #[test]
371    fn test_render_header_running() {
372        let snapshot = TrainingSnapshot {
373            status: TrainingStatus::Running,
374            model_name: "TestModel".to_string(),
375            ..Default::default()
376        };
377        let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
378        assert!(layout.contains("Running"));
379    }
380
381    #[test]
382    fn test_render_header_paused() {
383        let snapshot = TrainingSnapshot {
384            status: TrainingStatus::Paused,
385            model_name: "TestModel".to_string(),
386            ..Default::default()
387        };
388        let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
389        assert!(layout.contains("Paused"));
390    }
391
392    #[test]
393    fn test_render_header_completed() {
394        let snapshot = TrainingSnapshot {
395            status: TrainingStatus::Completed,
396            model_name: "TestModel".to_string(),
397            ..Default::default()
398        };
399        let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
400        assert!(layout.contains("Done"));
401    }
402
403    #[test]
404    fn test_render_header_failed_arm() {
405        let status = TrainingStatus::Failed("out of memory".to_string());
406        match &status {
407            TrainingStatus::Failed(_) => {}
408            _ => unreachable!(),
409        }
410        let snapshot =
411            TrainingSnapshot { status, model_name: "TestModel".to_string(), ..Default::default() };
412        let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
413        assert!(layout.contains("FAIL"));
414    }
415
416    // ── Additional coverage tests ──
417
418    #[test]
419    fn test_render_layout_colored_mono() {
420        let snapshot = TrainingSnapshot {
421            epoch: 2,
422            total_epochs: 5,
423            step: 3,
424            steps_per_epoch: 10,
425            loss: 1.5,
426            loss_history: vec![3.0, 2.5, 2.0, 1.5],
427            learning_rate: 0.0001,
428            gradient_norm: 0.5,
429            tokens_per_second: 50.0,
430            model_name: "TestModel".to_string(),
431            optimizer_name: "SGD".to_string(),
432            batch_size: 8,
433            status: TrainingStatus::Running,
434            ..Default::default()
435        };
436        let layout = render_layout_colored(&snapshot, 100, ColorMode::Mono);
437        assert!(layout.contains("ENTRENAR"));
438        assert!(layout.contains("Running"));
439        assert!(layout.contains("Epoch"));
440        assert!(layout.contains("Step"));
441        assert!(layout.contains("Loss"));
442        assert!(layout.contains("Config"));
443    }
444
445    #[test]
446    fn test_render_layout_with_gpu() {
447        let snapshot = TrainingSnapshot {
448            epoch: 1,
449            total_epochs: 3,
450            step: 5,
451            steps_per_epoch: 20,
452            loss: 2.0,
453            loss_history: vec![3.0, 2.5, 2.0],
454            learning_rate: 0.001,
455            gradient_norm: 1.0,
456            tokens_per_second: 200.0,
457            model_name: "GPUModel".to_string(),
458            status: TrainingStatus::Running,
459            gpu: Some(super::super::state::GpuTelemetry {
460                device_name: "RTX 4090".to_string(),
461                utilization_percent: 95.0,
462                vram_used_gb: 20.0,
463                vram_total_gb: 24.0,
464                temperature_celsius: 72.0,
465                power_watts: 350.0,
466                power_limit_watts: 400.0,
467                processes: vec![],
468            }),
469            ..Default::default()
470        };
471        let layout = render_layout_colored(&snapshot, 100, ColorMode::Mono);
472        assert!(layout.contains("GPU:"));
473        assert!(layout.contains("RTX 4090"));
474        assert!(layout.contains("VRAM:"));
475    }
476
477    #[test]
478    fn test_render_layout_no_gpu() {
479        let snapshot = TrainingSnapshot {
480            epoch: 1,
481            total_epochs: 1,
482            step: 1,
483            steps_per_epoch: 1,
484            loss: 1.0,
485            model_name: "NoGPU".to_string(),
486            status: TrainingStatus::Running,
487            gpu: None,
488            ..Default::default()
489        };
490        let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
491        assert!(layout.contains("GPU: N/A"));
492    }
493
494    #[test]
495    fn test_render_layout_empty_model_name() {
496        let snapshot = TrainingSnapshot {
497            model_name: String::new(),
498            status: TrainingStatus::Running,
499            ..Default::default()
500        };
501        let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
502        assert!(layout.contains("N/A"));
503    }
504
505    #[test]
506    fn test_render_layout_long_model_name() {
507        let snapshot = TrainingSnapshot {
508            model_name: "A".repeat(50),
509            status: TrainingStatus::Running,
510            ..Default::default()
511        };
512        let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
513        // Long model names should be truncated with ...
514        assert!(layout.contains("..."));
515    }
516
517    #[test]
518    fn test_render_progress_zero_epochs() {
519        let snapshot = TrainingSnapshot {
520            epoch: 0,
521            total_epochs: 0,
522            step: 0,
523            steps_per_epoch: 0,
524            ..Default::default()
525        };
526        let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
527        assert!(layout.contains("Epoch"));
528    }
529
530    #[test]
531    fn test_render_metrics_nan_loss() {
532        let snapshot = TrainingSnapshot {
533            loss: f32::NAN,
534            learning_rate: 0.001,
535            status: TrainingStatus::Running,
536            model_name: "test".to_string(),
537            ..Default::default()
538        };
539        let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
540        assert!(layout.contains("???"));
541    }
542
543    #[test]
544    fn test_render_metrics_infinite_loss() {
545        let snapshot = TrainingSnapshot {
546            loss: f32::INFINITY,
547            learning_rate: 0.001,
548            status: TrainingStatus::Running,
549            model_name: "test".to_string(),
550            ..Default::default()
551        };
552        let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
553        assert!(layout.contains("???"));
554    }
555
556    #[test]
557    fn test_render_loss_sparkline_empty() {
558        let snapshot = TrainingSnapshot {
559            loss_history: vec![],
560            status: TrainingStatus::Running,
561            model_name: "test".to_string(),
562            ..Default::default()
563        };
564        let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
565        // Empty loss_history should not produce a "Loss History" section
566        assert!(!layout.contains("Loss History"));
567    }
568
569    #[test]
570    fn test_render_epoch_table_no_data() {
571        let snapshot = TrainingSnapshot {
572            loss_history: vec![],
573            steps_per_epoch: 0,
574            status: TrainingStatus::Running,
575            model_name: "test".to_string(),
576            ..Default::default()
577        };
578        let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
579        assert!(layout.contains("waiting for epoch data"));
580    }
581
582    #[test]
583    fn test_render_epoch_table_with_epochs() {
584        let snapshot = TrainingSnapshot {
585            steps_per_epoch: 3,
586            loss_history: vec![5.0, 4.0, 3.0, 2.5, 2.0, 1.5],
587            learning_rate: 0.001,
588            tokens_per_second: 100.0,
589            status: TrainingStatus::Running,
590            model_name: "test".to_string(),
591            ..Default::default()
592        };
593        let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
594        assert!(layout.contains("Epoch"));
595        assert!(layout.contains("Loss"));
596    }
597
598    #[test]
599    fn test_render_epoch_table_many_epochs_truncation() {
600        // 20 epochs, max_rows=6 means some should be hidden
601        let snapshot = TrainingSnapshot {
602            steps_per_epoch: 1,
603            loss_history: (0..20).map(|i| 10.0 - (i as f32 * 0.5)).collect(),
604            learning_rate: 0.001,
605            tokens_per_second: 100.0,
606            status: TrainingStatus::Running,
607            model_name: "test".to_string(),
608            ..Default::default()
609        };
610        let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
611        assert!(layout.contains("earlier epochs"));
612    }
613
614    #[test]
615    fn test_render_config_footer_empty_optimizer() {
616        let snapshot = TrainingSnapshot {
617            optimizer_name: String::new(),
618            batch_size: 0,
619            checkpoint_path: String::new(),
620            status: TrainingStatus::Running,
621            model_name: "test".to_string(),
622            ..Default::default()
623        };
624        let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
625        assert!(layout.contains("Config:"));
626    }
627
628    #[test]
629    fn test_render_config_footer_with_values() {
630        let snapshot = TrainingSnapshot {
631            optimizer_name: "AdamW".to_string(),
632            batch_size: 16,
633            checkpoint_path: "/tmp/checkpoints".to_string(),
634            status: TrainingStatus::Running,
635            model_name: "test".to_string(),
636            ..Default::default()
637        };
638        let layout = render_layout_colored(&snapshot, 100, ColorMode::Mono);
639        assert!(layout.contains("AdamW"));
640        assert!(layout.contains("16"));
641        assert!(layout.contains("/tmp/checkpoints"));
642    }
643
644    #[test]
645    fn test_render_gpu_temp_warning() {
646        let snapshot = TrainingSnapshot {
647            gpu: Some(super::super::state::GpuTelemetry {
648                device_name: "RTX 4090".to_string(),
649                utilization_percent: 90.0,
650                vram_used_gb: 20.0,
651                vram_total_gb: 24.0,
652                temperature_celsius: 75.0, // Warning range
653                power_watts: 300.0,
654                power_limit_watts: 400.0,
655                processes: vec![],
656            }),
657            status: TrainingStatus::Running,
658            model_name: "test".to_string(),
659            ..Default::default()
660        };
661        let layout = render_layout_colored(&snapshot, 100, ColorMode::Mono);
662        assert!(layout.contains("75"));
663    }
664
665    #[test]
666    fn test_render_gpu_temp_error() {
667        let snapshot = TrainingSnapshot {
668            gpu: Some(super::super::state::GpuTelemetry {
669                device_name: "RTX 4090".to_string(),
670                utilization_percent: 90.0,
671                vram_used_gb: 20.0,
672                vram_total_gb: 24.0,
673                temperature_celsius: 85.0, // Error range (>80)
674                power_watts: 300.0,
675                power_limit_watts: 400.0,
676                processes: vec![],
677            }),
678            status: TrainingStatus::Running,
679            model_name: "test".to_string(),
680            ..Default::default()
681        };
682        let layout = render_layout_colored(&snapshot, 100, ColorMode::Mono);
683        assert!(layout.contains("85"));
684    }
685
686    #[test]
687    fn test_epoch_trend_arrow_first_epoch_no_start_idx() {
688        let result = epoch_trend_arrow(0, 0, &[], ColorMode::Mono);
689        assert_eq!(result, " ");
690    }
691
692    #[test]
693    fn test_epoch_trend_arrow_with_start_idx() {
694        let summaries = vec![
695            EpochSummary {
696                epoch: 1,
697                avg_loss: 5.0,
698                min_loss: 4.0,
699                max_loss: 6.0,
700                end_loss: 4.5,
701                avg_grad: 1.0,
702                lr: 0.001,
703                tokens_per_sec: 100.0,
704            },
705            EpochSummary {
706                epoch: 2,
707                avg_loss: 3.0,
708                min_loss: 2.5,
709                max_loss: 3.5,
710                end_loss: 2.8,
711                avg_grad: 0.8,
712                lr: 0.001,
713                tokens_per_sec: 100.0,
714            },
715        ];
716        // i=0, start_idx=1 -> should compare with prev
717        let result = epoch_trend_arrow(0, 1, &summaries, ColorMode::Mono);
718        // summaries[1] vs summaries[0]: 3.0 vs 5.0 -> decreasing
719        assert!(result.contains("\u{2193}")); // down arrow
720    }
721
722    #[test]
723    fn test_epoch_trend_arrow_no_prev() {
724        let summaries: Vec<EpochSummary> = vec![];
725        // i=0, start_idx=1, but no element at prev_idx -> space
726        let result = epoch_trend_arrow(0, 1, &summaries, ColorMode::Mono);
727        assert_eq!(result, " ");
728    }
729
730    #[test]
731    fn test_render_layout_minimum_width() {
732        // Width less than 80 should be clamped to 80
733        let snapshot = TrainingSnapshot {
734            model_name: "test".to_string(),
735            status: TrainingStatus::Running,
736            ..Default::default()
737        };
738        let layout = render_layout_colored(&snapshot, 40, ColorMode::Mono);
739        // Should not panic and should contain basic elements
740        assert!(layout.contains("ENTRENAR"));
741    }
742
743    #[test]
744    fn test_render_with_truecolor() {
745        let snapshot = TrainingSnapshot {
746            epoch: 2,
747            total_epochs: 5,
748            step: 3,
749            steps_per_epoch: 10,
750            loss: 1.5,
751            loss_history: vec![3.0, 2.5, 2.0, 1.5],
752            learning_rate: 0.0001,
753            gradient_norm: 0.5,
754            tokens_per_second: 50.0,
755            model_name: "TestModel".to_string(),
756            status: TrainingStatus::Running,
757            ..Default::default()
758        };
759        let layout = render_layout_colored(&snapshot, 100, ColorMode::TrueColor);
760        // Should contain ANSI color codes
761        assert!(layout.contains("\x1b["));
762    }
763
764    #[test]
765    fn test_render_best_loss_all_nan() {
766        let snapshot = TrainingSnapshot {
767            loss: 1.0,
768            loss_history: vec![f32::NAN, f32::NAN, f32::NAN],
769            learning_rate: 0.001,
770            model_name: "test".to_string(),
771            status: TrainingStatus::Running,
772            ..Default::default()
773        };
774        let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
775        assert!(layout.contains("Best ---"));
776    }
777
778    #[test]
779    fn test_render_eta_no_tps() {
780        let snapshot = TrainingSnapshot {
781            tokens_per_second: 0.0,
782            learning_rate: 0.001,
783            model_name: "test".to_string(),
784            status: TrainingStatus::Running,
785            ..Default::default()
786        };
787        let layout = render_layout_colored(&snapshot, 80, ColorMode::Mono);
788        assert!(layout.contains("ETA --:--:--"));
789    }
790}