Skip to main content

rusticity_term/ui/
monitoring.rs

1use crate::common::render_vertical_scrollbar;
2use crate::ui::format_title;
3use ratatui::prelude::*;
4use ratatui::widgets::{Axis, Block, BorderType, Borders, Chart, Dataset, GraphType, Paragraph};
5
6/// Trait for states that support monitoring with metrics loading
7pub trait MonitoringState {
8    fn is_metrics_loading(&self) -> bool;
9    fn set_metrics_loading(&mut self, loading: bool);
10    fn monitoring_scroll(&self) -> usize;
11    fn set_monitoring_scroll(&mut self, scroll: usize);
12    fn clear_metrics(&mut self);
13}
14
15#[derive(Default)]
16pub struct MetricChart<'a> {
17    pub title: &'a str,
18    pub data: &'a [(i64, f64)],
19    pub y_axis_label: &'a str,
20    pub x_axis_label: Option<String>,
21    /// Optional threshold to draw as a red horizontal line.
22    pub threshold: Option<f64>,
23}
24
25pub struct MultiDatasetChart<'a> {
26    pub title: &'a str,
27    pub datasets: Vec<(&'a str, &'a [(i64, f64)])>,
28    pub y_axis_label: &'a str,
29    pub y_axis_step: u32,
30    pub x_axis_label: Option<String>,
31}
32
33pub struct DualAxisChart<'a> {
34    pub title: &'a str,
35    pub left_dataset: (&'a str, &'a [(i64, f64)]),
36    pub right_dataset: (&'a str, &'a [(i64, f64)]),
37    pub left_y_label: &'a str,
38    pub right_y_label: &'a str,
39    pub x_axis_label: Option<String>,
40}
41
42pub fn render_monitoring_tab(
43    frame: &mut Frame,
44    area: Rect,
45    single_charts: &[MetricChart],
46    multi_charts: &[MultiDatasetChart],
47    dual_charts: &[DualAxisChart],
48    trailing_single_charts: &[MetricChart],
49    scroll_position: usize,
50) {
51    let available_height = area.height as usize;
52    let total_charts =
53        single_charts.len() + multi_charts.len() + dual_charts.len() + trailing_single_charts.len();
54
55    let mut y_offset = 0;
56    let mut chart_idx = 0;
57
58    for chart in single_charts.iter() {
59        if chart_idx < scroll_position {
60            chart_idx += 1;
61            continue;
62        }
63        if y_offset + 14 > available_height {
64            break;
65        }
66        let chart_height = 20.min((available_height - y_offset) as u16);
67        let chart_rect = Rect {
68            x: area.x,
69            y: area.y + y_offset as u16,
70            width: area.width.saturating_sub(1),
71            height: chart_height,
72        };
73        render_chart(frame, chart, chart_rect);
74        y_offset += 20;
75        chart_idx += 1;
76    }
77
78    for chart in multi_charts.iter() {
79        if chart_idx < scroll_position {
80            chart_idx += 1;
81            continue;
82        }
83        if y_offset + 14 > available_height {
84            break;
85        }
86        let chart_height = 20.min((available_height - y_offset) as u16);
87        let chart_rect = Rect {
88            x: area.x,
89            y: area.y + y_offset as u16,
90            width: area.width.saturating_sub(1),
91            height: chart_height,
92        };
93        render_multi_dataset_chart(frame, chart, chart_rect);
94        y_offset += 20;
95        chart_idx += 1;
96    }
97
98    for chart in dual_charts.iter() {
99        if chart_idx < scroll_position {
100            chart_idx += 1;
101            continue;
102        }
103        if y_offset + 14 > available_height {
104            break;
105        }
106        let chart_height = 20.min((available_height - y_offset) as u16);
107        let chart_rect = Rect {
108            x: area.x,
109            y: area.y + y_offset as u16,
110            width: area.width.saturating_sub(1),
111            height: chart_height,
112        };
113        render_dual_axis_chart(frame, chart, chart_rect);
114        y_offset += 20;
115        chart_idx += 1;
116    }
117
118    for chart in trailing_single_charts.iter() {
119        if chart_idx < scroll_position {
120            chart_idx += 1;
121            continue;
122        }
123        if y_offset + 14 > available_height {
124            break;
125        }
126        let chart_height = 20.min((available_height - y_offset) as u16);
127        let chart_rect = Rect {
128            x: area.x,
129            y: area.y + y_offset as u16,
130            width: area.width.saturating_sub(1),
131            height: chart_height,
132        };
133        render_chart(frame, chart, chart_rect);
134        y_offset += 20;
135        chart_idx += 1;
136    }
137
138    let total_height = total_charts * 20;
139    let scroll_offset = scroll_position * 20;
140    // Only show scrollbar when content doesn't fully fit
141    if total_height > available_height {
142        render_vertical_scrollbar(frame, area, total_height, scroll_offset);
143    }
144}
145
146fn x_axis_labels(min_x: f64, max_x: f64) -> Vec<Span<'static>> {
147    let mut labels = Vec::new();
148    let time_range = (max_x - min_x).max(1.0);
149    let target_labels = 6;
150    let raw_step = time_range / target_labels as f64;
151    let step = ((raw_step / 3600.0).ceil() as i64).max(1) * 3600;
152    let mut current = (min_x as i64 / step) * step;
153    while current <= max_x as i64 {
154        let time = chrono::DateTime::from_timestamp(current, 0)
155            .unwrap_or_default()
156            .format("%H:%M")
157            .to_string();
158        labels.push(Span::raw(time));
159        current += step;
160    }
161    labels
162}
163
164fn render_chart(frame: &mut Frame, chart: &MetricChart, area: Rect) {
165    let block = Block::default()
166        .title(format_title(chart.title))
167        .borders(Borders::ALL)
168        .border_type(BorderType::Rounded)
169        .border_style(Style::default().fg(Color::Gray));
170
171    if chart.data.is_empty() {
172        let inner = block.inner(area);
173        frame.render_widget(block, area);
174        let paragraph = Paragraph::new("No data in selected time range")
175            .alignment(Alignment::Center)
176            .style(Style::default().fg(Color::DarkGray));
177        frame.render_widget(paragraph, inner);
178        return;
179    }
180
181    let data: Vec<(f64, f64)> = chart
182        .data
183        .iter()
184        .map(|(timestamp, value)| (*timestamp as f64, *value))
185        .collect();
186
187    let min_x = data.iter().map(|(x, _)| *x).fold(f64::INFINITY, f64::min);
188    let max_x = data
189        .iter()
190        .map(|(x, _)| *x)
191        .fold(f64::NEG_INFINITY, f64::max);
192    let max_y = data
193        .iter()
194        .map(|(_, y)| *y)
195        .fold(0.0_f64, f64::max)
196        .max(chart.threshold.unwrap_or(0.0))
197        .max(1.0);
198
199    let dataset = Dataset::default()
200        .name(chart.title)
201        .marker(symbols::Marker::Braille)
202        .graph_type(GraphType::Line)
203        .style(Style::default().fg(Color::Cyan))
204        .data(&data);
205
206    // Build datasets — metric data + optional red threshold line
207    let threshold_data: Vec<(f64, f64)>;
208    let mut datasets = vec![dataset];
209    if let Some(thresh) = chart.threshold {
210        threshold_data = vec![(min_x, thresh), (max_x, thresh)];
211        datasets.push(
212            Dataset::default()
213                .name(format!("threshold: {}", thresh))
214                .marker(symbols::Marker::Braille)
215                .graph_type(GraphType::Line)
216                .style(Style::default().fg(Color::Red))
217                .data(&threshold_data),
218        );
219    }
220
221    let x_labels = x_axis_labels(min_x, max_x);
222
223    let mut x_axis = Axis::default()
224        .style(Style::default().fg(Color::Gray))
225        .bounds([min_x, max_x])
226        .labels(x_labels);
227
228    if let Some(label) = &chart.x_axis_label {
229        x_axis = x_axis.title(label.as_str());
230    }
231
232    let y_labels: Vec<Span> = {
233        let mut labels = Vec::new();
234        let mut current = 0.0;
235        let max = max_y * 1.1;
236        let step = if max <= 10.0 {
237            0.5
238        } else {
239            (max / 10.0).ceil()
240        };
241        while current <= max {
242            labels.push(Span::raw(format!("{:.1}", current)));
243            current += step;
244        }
245        labels
246    };
247
248    let y_axis = Axis::default()
249        .title(chart.y_axis_label)
250        .style(Style::default().fg(Color::Gray))
251        .bounds([0.0, max_y * 1.1])
252        .labels(y_labels);
253
254    let chart_widget = Chart::new(datasets)
255        .block(block)
256        .x_axis(x_axis)
257        .y_axis(y_axis);
258
259    frame.render_widget(chart_widget, area);
260}
261
262fn render_multi_dataset_chart(frame: &mut Frame, chart: &MultiDatasetChart, area: Rect) {
263    let block = Block::default()
264        .title(format_title(chart.title))
265        .borders(Borders::ALL)
266        .border_type(BorderType::Rounded)
267        .border_style(Style::default().fg(Color::Gray));
268
269    let all_empty = chart.datasets.iter().all(|(_, data)| data.is_empty());
270    if all_empty {
271        let inner = block.inner(area);
272        frame.render_widget(block, area);
273        let paragraph = Paragraph::new("--");
274        frame.render_widget(paragraph, inner);
275        return;
276    }
277
278    let colors = [Color::Cyan, Color::Yellow, Color::Magenta];
279    let mut converted_data: Vec<Vec<(f64, f64)>> = Vec::new();
280    let mut min_x = f64::INFINITY;
281    let mut max_x = f64::NEG_INFINITY;
282    let mut max_y = 0.0_f64;
283
284    for (_, data) in chart.datasets.iter() {
285        if data.is_empty() {
286            converted_data.push(Vec::new());
287            continue;
288        }
289        let converted: Vec<(f64, f64)> = data
290            .iter()
291            .map(|(timestamp, value)| (*timestamp as f64, *value))
292            .collect();
293
294        min_x = min_x.min(
295            converted
296                .iter()
297                .map(|(x, _)| *x)
298                .fold(f64::INFINITY, f64::min),
299        );
300        max_x = max_x.max(
301            converted
302                .iter()
303                .map(|(x, _)| *x)
304                .fold(f64::NEG_INFINITY, f64::max),
305        );
306        max_y = max_y.max(converted.iter().map(|(_, y)| *y).fold(0.0_f64, f64::max));
307
308        converted_data.push(converted);
309    }
310
311    let mut datasets_vec = Vec::new();
312    for (idx, ((name, _), data)) in chart.datasets.iter().zip(converted_data.iter()).enumerate() {
313        if data.is_empty() {
314            continue;
315        }
316        let dataset = Dataset::default()
317            .name(*name)
318            .marker(symbols::Marker::Braille)
319            .graph_type(GraphType::Line)
320            .style(Style::default().fg(colors[idx % colors.len()]))
321            .data(data);
322
323        datasets_vec.push(dataset);
324    }
325
326    max_y = max_y.max(1.0);
327
328    let x_labels = x_axis_labels(min_x, max_x);
329
330    let mut x_axis = Axis::default()
331        .style(Style::default().fg(Color::Gray))
332        .bounds([min_x, max_x])
333        .labels(x_labels);
334
335    if let Some(label) = &chart.x_axis_label {
336        x_axis = x_axis.title(label.as_str());
337    }
338
339    let y_labels: Vec<Span> = {
340        let mut labels = Vec::new();
341        let mut current = 0.0;
342        let max = max_y * 1.1;
343        let step = chart.y_axis_step as f64;
344        while current <= max {
345            let label = if step >= 1000.0 {
346                format!("{}K", (current / 1000.0) as u32)
347            } else {
348                format!("{:.0}", current)
349            };
350            labels.push(Span::raw(label));
351            current += step;
352        }
353        labels
354    };
355
356    let y_axis = Axis::default()
357        .title(chart.y_axis_label)
358        .style(Style::default().fg(Color::Gray))
359        .bounds([0.0, max_y * 1.1])
360        .labels(y_labels);
361
362    let chart_widget = Chart::new(datasets_vec)
363        .block(block)
364        .x_axis(x_axis)
365        .y_axis(y_axis);
366
367    frame.render_widget(chart_widget, area);
368}
369
370fn render_dual_axis_chart(frame: &mut Frame, chart: &DualAxisChart, area: Rect) {
371    let block = Block::default()
372        .title(format_title(chart.title))
373        .borders(Borders::ALL)
374        .border_type(BorderType::Rounded)
375        .border_style(Style::default().fg(Color::Gray));
376
377    let (left_name, left_data) = chart.left_dataset;
378    let (right_name, right_data) = chart.right_dataset;
379
380    if left_data.is_empty() && right_data.is_empty() {
381        let inner = block.inner(area);
382        frame.render_widget(block, area);
383        let paragraph = Paragraph::new("--");
384        frame.render_widget(paragraph, inner);
385        return;
386    }
387
388    let left_converted: Vec<(f64, f64)> = left_data
389        .iter()
390        .map(|(timestamp, value)| (*timestamp as f64, *value))
391        .collect();
392
393    let right_converted: Vec<(f64, f64)> = right_data
394        .iter()
395        .map(|(timestamp, value)| (*timestamp as f64, *value))
396        .collect();
397
398    let mut min_x = f64::INFINITY;
399    let mut max_x = f64::NEG_INFINITY;
400    let mut max_left_y = 0.0_f64;
401    let max_right_y = 100.0;
402
403    if !left_converted.is_empty() {
404        min_x = min_x.min(
405            left_converted
406                .iter()
407                .map(|(x, _)| *x)
408                .fold(f64::INFINITY, f64::min),
409        );
410        max_x = max_x.max(
411            left_converted
412                .iter()
413                .map(|(x, _)| *x)
414                .fold(f64::NEG_INFINITY, f64::max),
415        );
416        max_left_y = left_converted
417            .iter()
418            .map(|(_, y)| *y)
419            .fold(0.0_f64, f64::max);
420    }
421
422    if !right_converted.is_empty() {
423        min_x = min_x.min(
424            right_converted
425                .iter()
426                .map(|(x, _)| *x)
427                .fold(f64::INFINITY, f64::min),
428        );
429        max_x = max_x.max(
430            right_converted
431                .iter()
432                .map(|(x, _)| *x)
433                .fold(f64::NEG_INFINITY, f64::max),
434        );
435    }
436
437    max_left_y = max_left_y.max(1.0);
438
439    let normalized_right: Vec<(f64, f64)> = right_converted
440        .iter()
441        .map(|(x, y)| (*x, y * max_left_y / max_right_y))
442        .collect();
443
444    let left_dataset = Dataset::default()
445        .name(left_name)
446        .marker(symbols::Marker::Braille)
447        .graph_type(GraphType::Line)
448        .style(Style::default().fg(Color::Red))
449        .data(&left_converted);
450
451    let right_dataset = Dataset::default()
452        .name(right_name)
453        .marker(symbols::Marker::Braille)
454        .graph_type(GraphType::Line)
455        .style(Style::default().fg(Color::Green))
456        .data(&normalized_right);
457
458    let x_labels = x_axis_labels(min_x, max_x);
459
460    let mut x_axis = Axis::default()
461        .style(Style::default().fg(Color::Gray))
462        .bounds([min_x, max_x])
463        .labels(x_labels);
464
465    if let Some(label) = &chart.x_axis_label {
466        x_axis = x_axis.title(label.as_str());
467    }
468
469    let y_labels: Vec<Span> = {
470        let mut labels = Vec::new();
471        let mut current = 0.0;
472        let max = max_left_y * 1.1;
473        let step = if max <= 10.0 {
474            0.5
475        } else {
476            (max / 10.0).ceil()
477        };
478        while current <= max {
479            labels.push(Span::raw(format!("{:.0}", current)));
480            current += step;
481        }
482        labels
483    };
484
485    let y_axis = Axis::default()
486        .title(chart.left_y_label)
487        .style(Style::default().fg(Color::Gray))
488        .bounds([0.0, max_left_y * 1.1])
489        .labels(y_labels);
490
491    let chart_widget = Chart::new(vec![left_dataset, right_dataset])
492        .block(block)
493        .x_axis(x_axis)
494        .y_axis(y_axis);
495
496    frame.render_widget(chart_widget, area);
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    #[test]
504    fn test_metric_chart_creation() {
505        let data = vec![(1700000000, 5.0), (1700000060, 10.0)];
506        let chart = MetricChart {
507            title: "Test Metric",
508            data: &data,
509            y_axis_label: "Count",
510            x_axis_label: None,
511            ..Default::default()
512        };
513        assert_eq!(chart.title, "Test Metric");
514        assert_eq!(chart.data.len(), 2);
515        assert_eq!(chart.y_axis_label, "Count");
516        assert_eq!(chart.x_axis_label, None);
517    }
518
519    #[test]
520    fn test_empty_chart_data() {
521        let data: Vec<(i64, f64)> = vec![];
522        let chart = MetricChart {
523            title: "Empty Chart",
524            data: &data,
525            y_axis_label: "Value",
526            x_axis_label: None,
527            ..Default::default()
528        };
529        assert!(chart.data.is_empty());
530    }
531
532    #[test]
533    fn test_metric_chart_with_x_axis_label() {
534        let data = vec![(1700000000, 5.0), (1700000060, 10.0)];
535        let chart = MetricChart {
536            title: "Invocations",
537            data: &data,
538            y_axis_label: "Count",
539            x_axis_label: Some("Invocations [sum: 15]".to_string()),
540            ..Default::default()
541        };
542        assert_eq!(
543            chart.x_axis_label,
544            Some("Invocations [sum: 15]".to_string())
545        );
546    }
547
548    #[test]
549    fn test_metric_chart_threshold_field() {
550        // MetricChart accepts an optional threshold for the red alarm line.
551        let data = vec![(1700000000, 50.0), (1700000060, 95.0)];
552
553        // No threshold
554        let chart_no_thresh = MetricChart {
555            title: "StorageCapacityUtilization",
556            data: &data,
557            y_axis_label: "%",
558            x_axis_label: None,
559            ..Default::default()
560        };
561        assert!(chart_no_thresh.threshold.is_none());
562
563        // With threshold
564        let chart_with_thresh = MetricChart {
565            title: "StorageCapacityUtilization",
566            data: &data,
567            y_axis_label: "%",
568            x_axis_label: None,
569            threshold: Some(90.0),
570        };
571        assert_eq!(chart_with_thresh.threshold, Some(90.0));
572    }
573
574    #[test]
575    fn test_metric_chart_threshold_affects_y_axis_scale() {
576        // When threshold > max data value, the y-axis must scale to include the threshold.
577        // We verify this by checking threshold is stored correctly — actual rendering
578        // uses max(data_max, threshold) for y bounds.
579        let data = vec![(1700000000, 30.0)]; // data max = 30
580        let chart = MetricChart {
581            title: "Test",
582            data: &data,
583            y_axis_label: "",
584            x_axis_label: None,
585            threshold: Some(90.0), // threshold > data max
586        };
587        assert_eq!(chart.threshold, Some(90.0));
588        // The render_chart function uses: max_y = data_max.max(threshold).max(1.0)
589        // so y-axis extends to at least 90.0 * 1.1
590        let effective_max = data
591            .iter()
592            .map(|(_, y)| *y)
593            .fold(0.0_f64, f64::max)
594            .max(chart.threshold.unwrap_or(0.0))
595            .max(1.0);
596        assert!(effective_max >= 90.0);
597    }
598
599    #[test]
600    fn test_multi_dataset_chart_creation() {
601        let min_data = vec![(1700000000, 100.0), (1700000060, 150.0)];
602        let avg_data = vec![(1700000000, 200.0), (1700000060, 250.0)];
603        let max_data = vec![(1700000000, 300.0), (1700000060, 350.0)];
604
605        let chart = MultiDatasetChart {
606            title: "Duration",
607            datasets: vec![
608                ("Minimum", &min_data),
609                ("Average", &avg_data),
610                ("Maximum", &max_data),
611            ],
612            y_axis_label: "Milliseconds",
613            y_axis_step: 1000,
614            x_axis_label: Some("Minimum [100], Average [200], Maximum [300]".to_string()),
615        };
616
617        assert_eq!(chart.title, "Duration");
618        assert_eq!(chart.datasets.len(), 3);
619        assert_eq!(chart.y_axis_label, "Milliseconds");
620        assert_eq!(chart.y_axis_step, 1000);
621    }
622
623    #[test]
624    fn test_multi_dataset_chart_empty() {
625        let empty: Vec<(i64, f64)> = vec![];
626        let chart = MultiDatasetChart {
627            title: "Empty Duration",
628            datasets: vec![
629                ("Minimum", &empty),
630                ("Average", &empty),
631                ("Maximum", &empty),
632            ],
633            y_axis_label: "Milliseconds",
634            y_axis_step: 1000,
635            x_axis_label: None,
636        };
637
638        assert!(chart.datasets.iter().all(|(_, data)| data.is_empty()));
639    }
640
641    #[test]
642    fn test_duration_label_format() {
643        let min = 100.0;
644        let avg = 200.5;
645        let max = 350.0;
646        let label = format!(
647            "Minimum [{:.0}], Average [{:.0}], Maximum [{:.0}]",
648            min, avg, max
649        );
650        assert_eq!(label, "Minimum [100], Average [200], Maximum [350]");
651    }
652
653    #[test]
654    fn test_y_axis_label_formatting_1k() {
655        let value = 1000.0;
656        let label = format!("{}K", (value / 1000.0) as u32);
657        assert_eq!(label, "1K");
658    }
659
660    #[test]
661    fn test_y_axis_label_formatting_5k() {
662        let value = 5000.0;
663        let label = format!("{}K", (value / 1000.0) as u32);
664        assert_eq!(label, "5K");
665    }
666
667    #[test]
668    fn test_y_axis_step_1000() {
669        let step = 1000;
670        assert_eq!(step, 1000);
671        let values = [0, 1000, 2000, 3000, 4000, 5000];
672        for (i, val) in values.iter().enumerate() {
673            assert_eq!(*val, i * step);
674        }
675    }
676
677    #[test]
678    fn test_duration_min_calculation() {
679        let data = [(1700000000, 100.0), (1700000060, 50.0), (1700000120, 75.0)];
680        let min: f64 = data
681            .iter()
682            .map(|(_, v)| v)
683            .fold(f64::INFINITY, |a, &b| a.min(b));
684        assert_eq!(min, 50.0);
685    }
686
687    #[test]
688    fn test_duration_avg_calculation() {
689        let data = [
690            (1700000000, 100.0),
691            (1700000060, 200.0),
692            (1700000120, 300.0),
693        ];
694        let avg: f64 = data.iter().map(|(_, v)| v).sum::<f64>() / data.len() as f64;
695        assert_eq!(avg, 200.0);
696    }
697
698    #[test]
699    fn test_duration_max_calculation() {
700        let data = [
701            (1700000000, 100.0),
702            (1700000060, 350.0),
703            (1700000120, 200.0),
704        ];
705        let max: f64 = data
706            .iter()
707            .map(|(_, v)| v)
708            .fold(f64::NEG_INFINITY, |a, &b| a.max(b));
709        assert_eq!(max, 350.0);
710    }
711
712    #[test]
713    fn test_duration_empty_data_min() {
714        let data: Vec<(i64, f64)> = vec![];
715        let min: f64 = data
716            .iter()
717            .map(|(_, v)| v)
718            .fold(f64::INFINITY, |a, &b| a.min(b));
719        assert!(min.is_infinite() && min.is_sign_positive());
720    }
721
722    #[test]
723    fn test_duration_empty_data_avg() {
724        let data: Vec<(i64, f64)> = vec![];
725        let avg: f64 = if !data.is_empty() {
726            data.iter().map(|(_, v)| v).sum::<f64>() / data.len() as f64
727        } else {
728            0.0
729        };
730        assert_eq!(avg, 0.0);
731    }
732
733    #[test]
734    fn test_dual_axis_chart_creation() {
735        let errors = vec![(1700000000, 5.0), (1700000060, 10.0)];
736        let success_rate = vec![(1700000000, 95.0), (1700000060, 90.0)];
737
738        let chart = DualAxisChart {
739            title: "Error count and success rate",
740            left_dataset: ("Errors", &errors),
741            right_dataset: ("Success rate", &success_rate),
742            left_y_label: "Count",
743            right_y_label: "%",
744            x_axis_label: Some("Errors [max: 10] and Success rate [min: 90%]".to_string()),
745        };
746
747        assert_eq!(chart.title, "Error count and success rate");
748        assert_eq!(chart.left_y_label, "Count");
749        assert_eq!(chart.right_y_label, "%");
750    }
751
752    #[test]
753    fn test_dual_axis_normalization() {
754        let max_left_y = 10.0;
755        let max_right_y = 100.0;
756        let right_value = 95.0;
757        let normalized = right_value * max_left_y / max_right_y;
758        assert_eq!(normalized, 9.5);
759    }
760
761    #[test]
762    fn test_chart_renders_with_14_lines_available() {
763        // Test that a chart will render when exactly 14 lines are available
764        let available_height = 34; // 20 for first chart + 14 for second
765        let y_offset = 20; // After first chart
766
767        // Should render: y_offset (20) + 14 = 34, which is NOT > 34
768        assert!(y_offset + 14 <= available_height);
769    }
770
771    #[test]
772    fn test_chart_skips_with_13_lines_available() {
773        // Test that a chart will NOT render when only 13 lines are available
774        let available_height = 33; // 20 for first chart + 13 remaining
775        let y_offset = 20; // After first chart
776
777        // Should NOT render: y_offset (20) + 14 = 34, which is > 33
778        assert!(y_offset + 14 > available_height);
779    }
780}