Skip to main content

presentar_terminal/widgets/
box_plot.rs

1//! Box plot widget for statistical visualization.
2//!
3//! Displays box-and-whisker plots using ASCII/Unicode art showing
4//! median, quartiles, and outliers.
5
6use presentar_core::{
7    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event,
8    LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
9};
10use std::any::Any;
11use std::time::Duration;
12
13/// Orientation for box plots.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum Orientation {
16    /// Horizontal box plots: ├──[████|████]──┤
17    #[default]
18    Horizontal,
19    /// Vertical box plots.
20    Vertical,
21}
22
23/// Statistics for a single box plot.
24#[derive(Debug, Clone, Copy, Default)]
25pub struct BoxStats {
26    /// Minimum value (or lower whisker).
27    pub min: f64,
28    /// First quartile (25th percentile).
29    pub q1: f64,
30    /// Median (50th percentile).
31    pub median: f64,
32    /// Third quartile (75th percentile).
33    pub q3: f64,
34    /// Maximum value (or upper whisker).
35    pub max: f64,
36}
37
38impl BoxStats {
39    /// Create box stats from values.
40    #[must_use]
41    pub fn new(min: f64, q1: f64, median: f64, q3: f64, max: f64) -> Self {
42        debug_assert!(min <= q1, "min must be <= q1");
43        debug_assert!(q1 <= median, "q1 must be <= median");
44        debug_assert!(median <= q3, "median must be <= q3");
45        debug_assert!(q3 <= max, "q3 must be <= max");
46        Self {
47            min,
48            q1,
49            median,
50            q3,
51            max,
52        }
53    }
54
55    /// Calculate box stats from a data slice.
56    #[must_use]
57    pub fn from_data(data: &[f64]) -> Self {
58        if data.is_empty() {
59            return Self::default();
60        }
61
62        let mut sorted: Vec<f64> = data.to_vec();
63        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
64
65        let n = sorted.len();
66        let min = sorted[0];
67        let max = sorted[n - 1];
68        let median = Self::percentile(&sorted, 50.0);
69        let q1 = Self::percentile(&sorted, 25.0);
70        let q3 = Self::percentile(&sorted, 75.0);
71
72        Self {
73            min,
74            q1,
75            median,
76            q3,
77            max,
78        }
79    }
80
81    fn percentile(sorted: &[f64], p: f64) -> f64 {
82        let n = sorted.len();
83        if n == 0 {
84            return 0.0;
85        }
86        if n == 1 {
87            return sorted[0];
88        }
89
90        let idx = (p / 100.0 * (n - 1) as f64).max(0.0);
91        let lower = idx.floor() as usize;
92        let upper = idx.ceil() as usize;
93        let frac = idx - lower as f64;
94
95        if lower >= n {
96            sorted[n - 1]
97        } else if upper >= n {
98            sorted[lower]
99        } else {
100            sorted[lower] * (1.0 - frac) + sorted[upper] * frac
101        }
102    }
103
104    /// Get interquartile range (IQR).
105    #[must_use]
106    pub fn iqr(&self) -> f64 {
107        self.q3 - self.q1
108    }
109
110    /// Get range.
111    #[must_use]
112    pub fn range(&self) -> f64 {
113        self.max - self.min
114    }
115}
116
117/// Box plot widget for statistical visualization.
118#[derive(Debug, Clone)]
119pub struct BoxPlot {
120    /// Statistics for each box.
121    stats: Vec<BoxStats>,
122    /// Labels for each box.
123    labels: Vec<String>,
124    /// Orientation.
125    orientation: Orientation,
126    /// Box color.
127    color: Color,
128    /// Global minimum for scaling.
129    global_min: f64,
130    /// Global maximum for scaling.
131    global_max: f64,
132    /// Show values.
133    show_values: bool,
134    /// Box width in characters.
135    box_width: usize,
136    /// Cached bounds.
137    bounds: Rect,
138}
139
140impl Default for BoxPlot {
141    fn default() -> Self {
142        Self::new(vec![])
143    }
144}
145
146impl BoxPlot {
147    /// Create a new box plot.
148    #[must_use]
149    pub fn new(stats: Vec<BoxStats>) -> Self {
150        let (gmin, gmax) = Self::compute_global_range(&stats);
151        Self {
152            stats,
153            labels: vec![],
154            orientation: Orientation::default(),
155            color: Color::new(0.3, 0.7, 1.0, 1.0),
156            global_min: gmin,
157            global_max: gmax,
158            show_values: false,
159            box_width: 40,
160            bounds: Rect::default(),
161        }
162    }
163
164    /// Create from raw data vectors.
165    #[must_use]
166    pub fn from_data(datasets: &[&[f64]]) -> Self {
167        let stats: Vec<BoxStats> = datasets.iter().map(|d| BoxStats::from_data(d)).collect();
168        Self::new(stats)
169    }
170
171    /// Set labels.
172    #[must_use]
173    pub fn with_labels(mut self, labels: Vec<String>) -> Self {
174        self.labels = labels;
175        self
176    }
177
178    /// Set orientation.
179    #[must_use]
180    pub fn with_orientation(mut self, orientation: Orientation) -> Self {
181        self.orientation = orientation;
182        self
183    }
184
185    /// Set box color.
186    #[must_use]
187    pub fn with_color(mut self, color: Color) -> Self {
188        self.color = color;
189        self
190    }
191
192    /// Set global range for scaling.
193    #[must_use]
194    pub fn with_range(mut self, min: f64, max: f64) -> Self {
195        self.global_min = min;
196        self.global_max = max.max(min + 0.001);
197        self
198    }
199
200    /// Show values on plot.
201    #[must_use]
202    pub fn with_values(mut self, show: bool) -> Self {
203        self.show_values = show;
204        self
205    }
206
207    /// Set box width.
208    #[must_use]
209    pub fn with_box_width(mut self, width: usize) -> Self {
210        self.box_width = width.max(10);
211        self
212    }
213
214    /// Update stats.
215    pub fn set_stats(&mut self, stats: Vec<BoxStats>) {
216        let (gmin, gmax) = Self::compute_global_range(&stats);
217        self.global_min = gmin;
218        self.global_max = gmax;
219        self.stats = stats;
220    }
221
222    /// Get number of box plots.
223    #[must_use]
224    pub fn count(&self) -> usize {
225        self.stats.len()
226    }
227
228    fn compute_global_range(stats: &[BoxStats]) -> (f64, f64) {
229        if stats.is_empty() {
230            return (0.0, 1.0);
231        }
232        let min = stats.iter().map(|s| s.min).fold(f64::MAX, f64::min);
233        let max = stats.iter().map(|s| s.max).fold(f64::MIN, f64::max);
234        if (max - min).abs() < f64::EPSILON {
235            (min - 0.5, max + 0.5)
236        } else {
237            (min, max)
238        }
239    }
240
241    fn normalize(&self, value: f64) -> f64 {
242        let range = self.global_max - self.global_min;
243        if range.abs() < f64::EPSILON {
244            0.5
245        } else {
246            ((value - self.global_min) / range).clamp(0.0, 1.0)
247        }
248    }
249
250    fn label_width(&self) -> usize {
251        self.labels
252            .iter()
253            .map(String::len)
254            .max()
255            .unwrap_or(0)
256            .max(5)
257    }
258
259    fn render_horizontal_box(
260        &self,
261        canvas: &mut dyn Canvas,
262        stats: &BoxStats,
263        x: f32,
264        y: f32,
265        width: f32,
266    ) {
267        let style = TextStyle {
268            color: self.color,
269            ..Default::default()
270        };
271
272        let whisker_style = TextStyle {
273            color: Color::new(0.7, 0.7, 0.7, 1.0),
274            ..Default::default()
275        };
276
277        // Calculate positions
278        let width_f64 = width as f64;
279        let min_pos = (self.normalize(stats.min) * width_f64) as usize;
280        let q1_pos = (self.normalize(stats.q1) * width_f64) as usize;
281        let median_pos = (self.normalize(stats.median) * width_f64) as usize;
282        let q3_pos = (self.normalize(stats.q3) * width_f64) as usize;
283        let max_pos = (self.normalize(stats.max) * width_f64) as usize;
284
285        let width_usize = width as usize;
286
287        // Build the box plot string
288        let mut line = String::with_capacity(width_usize);
289
290        for i in 0..width_usize {
291            let ch = if i == min_pos {
292                '├' // Left whisker endpoint
293            } else if i == max_pos {
294                '┤' // Right whisker endpoint
295            } else if (i > min_pos && i < q1_pos) || (i > q3_pos && i < max_pos) {
296                '─' // Whisker line
297            } else if i == q1_pos {
298                '[' // Box start
299            } else if i == q3_pos {
300                ']' // Box end
301            } else if i == median_pos && i > q1_pos && i < q3_pos {
302                '│' // Median line
303            } else if i > q1_pos && i < q3_pos {
304                '█' // Box fill
305            } else {
306                ' '
307            };
308            line.push(ch);
309        }
310
311        // Draw the box plot
312        canvas.draw_text(&line, Point::new(x, y), &style);
313
314        // Draw whisker endpoints
315        if min_pos < width_usize {
316            canvas.draw_text("├", Point::new(x + min_pos as f32, y), &whisker_style);
317        }
318        if max_pos < width_usize {
319            canvas.draw_text("┤", Point::new(x + max_pos as f32, y), &whisker_style);
320        }
321    }
322
323    fn render_vertical_box(
324        &self,
325        canvas: &mut dyn Canvas,
326        stats: &BoxStats,
327        x: f32,
328        y: f32,
329        height: f32,
330    ) {
331        let style = TextStyle {
332            color: self.color,
333            ..Default::default()
334        };
335
336        let whisker_style = TextStyle {
337            color: Color::new(0.7, 0.7, 0.7, 1.0),
338            ..Default::default()
339        };
340
341        // Calculate positions (inverted: 0 at bottom, 1 at top)
342        let height_f64 = height as f64;
343        let min_pos = ((1.0 - self.normalize(stats.min)) * height_f64) as usize;
344        let q1_pos = ((1.0 - self.normalize(stats.q1)) * height_f64) as usize;
345        let median_pos = ((1.0 - self.normalize(stats.median)) * height_f64) as usize;
346        let q3_pos = ((1.0 - self.normalize(stats.q3)) * height_f64) as usize;
347        let max_pos = ((1.0 - self.normalize(stats.max)) * height_f64) as usize;
348
349        let height_usize = height as usize;
350
351        // Draw from top to bottom
352        for i in 0..height_usize {
353            let ch = if i == max_pos {
354                "┬" // Top whisker
355            } else if i == min_pos {
356                "┴" // Bottom whisker
357            } else if (i > max_pos && i < q3_pos) || (i > q1_pos && i < min_pos) {
358                "│" // Whisker line
359            } else if i == q3_pos {
360                "┌" // Box top
361            } else if i == q1_pos {
362                "└" // Box bottom
363            } else if i == median_pos && i > q3_pos && i < q1_pos {
364                "├" // Median
365            } else if i > q3_pos && i < q1_pos {
366                "█" // Box fill
367            } else {
368                " "
369            };
370
371            let row_style = if i == max_pos || i == min_pos {
372                &whisker_style
373            } else {
374                &style
375            };
376
377            canvas.draw_text(ch, Point::new(x, y + i as f32), row_style);
378        }
379    }
380}
381
382impl Brick for BoxPlot {
383    fn brick_name(&self) -> &'static str {
384        "box_plot"
385    }
386
387    fn assertions(&self) -> &[BrickAssertion] {
388        static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(16)];
389        ASSERTIONS
390    }
391
392    fn budget(&self) -> BrickBudget {
393        BrickBudget::uniform(16)
394    }
395
396    fn verify(&self) -> BrickVerification {
397        BrickVerification {
398            passed: self.assertions().to_vec(),
399            failed: vec![],
400            verification_time: Duration::from_micros(10),
401        }
402    }
403
404    fn to_html(&self) -> String {
405        String::new()
406    }
407
408    fn to_css(&self) -> String {
409        String::new()
410    }
411}
412
413impl Widget for BoxPlot {
414    fn type_id(&self) -> TypeId {
415        TypeId::of::<Self>()
416    }
417
418    fn measure(&self, constraints: Constraints) -> Size {
419        match self.orientation {
420            Orientation::Horizontal => {
421                let label_w = self.label_width();
422                let width = (label_w + 2 + self.box_width) as f32;
423                let height = self.stats.len().max(1) as f32;
424                constraints.constrain(Size::new(width.min(constraints.max_width), height))
425            }
426            Orientation::Vertical => {
427                let width = (self.stats.len() * 4).max(4) as f32;
428                let height = 10.0f32;
429                constraints.constrain(Size::new(width, height.min(constraints.max_height)))
430            }
431        }
432    }
433
434    fn layout(&mut self, bounds: Rect) -> LayoutResult {
435        self.bounds = bounds;
436        LayoutResult {
437            size: Size::new(bounds.width, bounds.height),
438        }
439    }
440
441    fn paint(&self, canvas: &mut dyn Canvas) {
442        if self.stats.is_empty() || self.bounds.width < 1.0 {
443            return;
444        }
445
446        let label_style = TextStyle {
447            color: Color::new(0.8, 0.8, 0.8, 1.0),
448            ..Default::default()
449        };
450
451        let dim_style = TextStyle {
452            color: Color::new(0.5, 0.5, 0.5, 1.0),
453            ..Default::default()
454        };
455
456        match self.orientation {
457            Orientation::Horizontal => {
458                let label_w = self.label_width();
459                let box_start = self.bounds.x + label_w as f32 + 2.0;
460                let box_width = (self.bounds.width - label_w as f32 - 2.0).max(10.0);
461
462                for (i, stats) in self.stats.iter().enumerate() {
463                    let y = self.bounds.y + i as f32;
464
465                    // Draw label
466                    if let Some(label) = self.labels.get(i) {
467                        canvas.draw_text(label, Point::new(self.bounds.x, y), &label_style);
468                    }
469
470                    // Draw box plot
471                    self.render_horizontal_box(canvas, stats, box_start, y, box_width);
472
473                    // Draw values if enabled
474                    if self.show_values {
475                        let val_text =
476                            format!(" [{:.1}, {:.1}, {:.1}]", stats.q1, stats.median, stats.q3);
477                        canvas.draw_text(
478                            &val_text,
479                            Point::new(box_start + box_width, y),
480                            &dim_style,
481                        );
482                    }
483                }
484            }
485            Orientation::Vertical => {
486                let box_height = (self.bounds.height - 2.0).max(5.0);
487
488                for (i, stats) in self.stats.iter().enumerate() {
489                    let x = self.bounds.x + (i * 4) as f32;
490
491                    // Draw box plot
492                    self.render_vertical_box(canvas, stats, x, self.bounds.y, box_height);
493
494                    // Draw label below
495                    if let Some(label) = self.labels.get(i) {
496                        let truncated = if label.len() > 3 { &label[..3] } else { label };
497                        canvas.draw_text(
498                            truncated,
499                            Point::new(x, self.bounds.y + box_height + 1.0),
500                            &label_style,
501                        );
502                    }
503                }
504            }
505        }
506    }
507
508    fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
509        None
510    }
511
512    fn children(&self) -> &[Box<dyn Widget>] {
513        &[]
514    }
515
516    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
517        &mut []
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    struct MockCanvas {
526        texts: Vec<(String, Point)>,
527    }
528
529    impl MockCanvas {
530        fn new() -> Self {
531            Self { texts: vec![] }
532        }
533    }
534
535    impl Canvas for MockCanvas {
536        fn fill_rect(&mut self, _rect: Rect, _color: Color) {}
537        fn stroke_rect(&mut self, _rect: Rect, _color: Color, _width: f32) {}
538        fn draw_text(&mut self, text: &str, position: Point, _style: &TextStyle) {
539            self.texts.push((text.to_string(), position));
540        }
541        fn draw_line(&mut self, _from: Point, _to: Point, _color: Color, _width: f32) {}
542        fn fill_circle(&mut self, _center: Point, _radius: f32, _color: Color) {}
543        fn stroke_circle(&mut self, _center: Point, _radius: f32, _color: Color, _width: f32) {}
544        fn fill_arc(&mut self, _c: Point, _r: f32, _s: f32, _e: f32, _color: Color) {}
545        fn draw_path(&mut self, _points: &[Point], _color: Color, _width: f32) {}
546        fn fill_polygon(&mut self, _points: &[Point], _color: Color) {}
547        fn push_clip(&mut self, _rect: Rect) {}
548        fn pop_clip(&mut self) {}
549        fn push_transform(&mut self, _transform: presentar_core::Transform2D) {}
550        fn pop_transform(&mut self) {}
551    }
552
553    #[test]
554    fn test_box_stats_creation() {
555        let stats = BoxStats::new(1.0, 2.0, 3.0, 4.0, 5.0);
556        assert_eq!(stats.min, 1.0);
557        assert_eq!(stats.median, 3.0);
558        assert_eq!(stats.max, 5.0);
559    }
560
561    #[test]
562    fn test_box_stats_from_data() {
563        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0];
564        let stats = BoxStats::from_data(&data);
565        assert_eq!(stats.min, 1.0);
566        assert_eq!(stats.max, 9.0);
567        assert_eq!(stats.median, 5.0);
568    }
569
570    #[test]
571    fn test_box_stats_from_empty() {
572        let stats = BoxStats::from_data(&[]);
573        assert_eq!(stats.min, 0.0);
574    }
575
576    #[test]
577    fn test_box_stats_from_single() {
578        let stats = BoxStats::from_data(&[5.0]);
579        assert_eq!(stats.min, 5.0);
580        assert_eq!(stats.max, 5.0);
581        assert_eq!(stats.median, 5.0);
582    }
583
584    #[test]
585    fn test_box_stats_iqr() {
586        let stats = BoxStats::new(1.0, 2.0, 3.0, 4.0, 5.0);
587        assert_eq!(stats.iqr(), 2.0);
588    }
589
590    #[test]
591    fn test_box_stats_range() {
592        let stats = BoxStats::new(1.0, 2.0, 3.0, 4.0, 5.0);
593        assert_eq!(stats.range(), 4.0);
594    }
595
596    #[test]
597    fn test_box_plot_creation() {
598        let bp = BoxPlot::new(vec![BoxStats::new(0.0, 1.0, 2.0, 3.0, 4.0)]);
599        assert_eq!(bp.count(), 1);
600    }
601
602    #[test]
603    fn test_box_plot_from_data() {
604        let data1 = vec![1.0, 2.0, 3.0, 4.0, 5.0];
605        let data2 = vec![2.0, 3.0, 4.0, 5.0, 6.0];
606        let bp = BoxPlot::from_data(&[&data1, &data2]);
607        assert_eq!(bp.count(), 2);
608    }
609
610    #[test]
611    fn test_box_plot_with_labels() {
612        let bp = BoxPlot::new(vec![BoxStats::default()]).with_labels(vec!["Group A".to_string()]);
613        assert_eq!(bp.labels.len(), 1);
614    }
615
616    #[test]
617    fn test_box_plot_with_orientation() {
618        let bp = BoxPlot::new(vec![]).with_orientation(Orientation::Vertical);
619        assert_eq!(bp.orientation, Orientation::Vertical);
620    }
621
622    #[test]
623    fn test_box_plot_with_color() {
624        let bp = BoxPlot::new(vec![]).with_color(Color::RED);
625        assert_eq!(bp.color, Color::RED);
626    }
627
628    #[test]
629    fn test_box_plot_with_range() {
630        let bp = BoxPlot::new(vec![]).with_range(0.0, 100.0);
631        assert_eq!(bp.global_min, 0.0);
632        assert_eq!(bp.global_max, 100.0);
633    }
634
635    #[test]
636    fn test_box_plot_with_values() {
637        let bp = BoxPlot::new(vec![]).with_values(true);
638        assert!(bp.show_values);
639    }
640
641    #[test]
642    fn test_box_plot_with_box_width() {
643        let bp = BoxPlot::new(vec![]).with_box_width(60);
644        assert_eq!(bp.box_width, 60);
645    }
646
647    #[test]
648    fn test_box_plot_with_box_width_min() {
649        let bp = BoxPlot::new(vec![]).with_box_width(5);
650        assert_eq!(bp.box_width, 10); // Minimum is 10
651    }
652
653    #[test]
654    fn test_box_plot_set_stats() {
655        let mut bp = BoxPlot::new(vec![]);
656        bp.set_stats(vec![BoxStats::new(0.0, 1.0, 2.0, 3.0, 4.0)]);
657        assert_eq!(bp.count(), 1);
658    }
659
660    #[test]
661    fn test_box_plot_paint_horizontal() {
662        let mut bp = BoxPlot::new(vec![
663            BoxStats::new(0.0, 2.0, 5.0, 8.0, 10.0),
664            BoxStats::new(1.0, 3.0, 5.0, 7.0, 9.0),
665        ])
666        .with_labels(vec!["A".to_string(), "B".to_string()]);
667        bp.bounds = Rect::new(0.0, 0.0, 50.0, 5.0);
668
669        let mut canvas = MockCanvas::new();
670        bp.paint(&mut canvas);
671
672        assert!(!canvas.texts.is_empty());
673    }
674
675    #[test]
676    fn test_box_plot_paint_vertical() {
677        let mut bp = BoxPlot::new(vec![BoxStats::new(0.0, 2.0, 5.0, 8.0, 10.0)])
678            .with_orientation(Orientation::Vertical);
679        bp.bounds = Rect::new(0.0, 0.0, 20.0, 15.0);
680
681        let mut canvas = MockCanvas::new();
682        bp.paint(&mut canvas);
683
684        assert!(!canvas.texts.is_empty());
685    }
686
687    #[test]
688    fn test_box_plot_paint_empty() {
689        let bp = BoxPlot::new(vec![]);
690        let mut canvas = MockCanvas::new();
691        bp.paint(&mut canvas);
692        assert!(canvas.texts.is_empty());
693    }
694
695    #[test]
696    fn test_box_plot_paint_with_values() {
697        let mut bp = BoxPlot::new(vec![BoxStats::new(0.0, 2.0, 5.0, 8.0, 10.0)]).with_values(true);
698        bp.bounds = Rect::new(0.0, 0.0, 80.0, 5.0);
699
700        let mut canvas = MockCanvas::new();
701        bp.paint(&mut canvas);
702
703        // Should have value text
704        assert!(canvas.texts.iter().any(|(t, _)| t.contains("[")));
705    }
706
707    #[test]
708    fn test_box_plot_measure_horizontal() {
709        let bp = BoxPlot::new(vec![BoxStats::default(), BoxStats::default()]);
710        let size = bp.measure(Constraints::loose(Size::new(100.0, 50.0)));
711        assert!(size.height >= 2.0);
712    }
713
714    #[test]
715    fn test_box_plot_measure_vertical() {
716        let bp = BoxPlot::new(vec![BoxStats::default(), BoxStats::default()])
717            .with_orientation(Orientation::Vertical);
718        let size = bp.measure(Constraints::loose(Size::new(100.0, 50.0)));
719        assert!(size.width >= 8.0); // 2 boxes * 4 chars
720    }
721
722    #[test]
723    fn test_box_plot_layout() {
724        let mut bp = BoxPlot::new(vec![]);
725        let bounds = Rect::new(5.0, 10.0, 30.0, 20.0);
726        let result = bp.layout(bounds);
727        assert_eq!(result.size.width, 30.0);
728        assert_eq!(bp.bounds, bounds);
729    }
730
731    #[test]
732    fn test_box_plot_brick_name() {
733        let bp = BoxPlot::new(vec![]);
734        assert_eq!(bp.brick_name(), "box_plot");
735    }
736
737    #[test]
738    fn test_box_plot_assertions() {
739        let bp = BoxPlot::new(vec![]);
740        assert!(!bp.assertions().is_empty());
741    }
742
743    #[test]
744    fn test_box_plot_budget() {
745        let bp = BoxPlot::new(vec![]);
746        let budget = bp.budget();
747        assert!(budget.paint_ms > 0);
748    }
749
750    #[test]
751    fn test_box_plot_verify() {
752        let bp = BoxPlot::new(vec![]);
753        assert!(bp.verify().is_valid());
754    }
755
756    #[test]
757    fn test_box_plot_type_id() {
758        let bp = BoxPlot::new(vec![]);
759        assert_eq!(Widget::type_id(&bp), TypeId::of::<BoxPlot>());
760    }
761
762    #[test]
763    fn test_box_plot_children() {
764        let bp = BoxPlot::new(vec![]);
765        assert!(bp.children().is_empty());
766    }
767
768    #[test]
769    fn test_box_plot_children_mut() {
770        let mut bp = BoxPlot::new(vec![]);
771        assert!(bp.children_mut().is_empty());
772    }
773
774    #[test]
775    fn test_box_plot_event() {
776        let mut bp = BoxPlot::new(vec![]);
777        let event = Event::KeyDown {
778            key: presentar_core::Key::Enter,
779        };
780        assert!(bp.event(&event).is_none());
781    }
782
783    #[test]
784    fn test_box_plot_default() {
785        let bp = BoxPlot::default();
786        assert!(bp.stats.is_empty());
787    }
788
789    #[test]
790    fn test_box_plot_to_html() {
791        let bp = BoxPlot::new(vec![]);
792        assert!(bp.to_html().is_empty());
793    }
794
795    #[test]
796    fn test_box_plot_to_css() {
797        let bp = BoxPlot::new(vec![]);
798        assert!(bp.to_css().is_empty());
799    }
800
801    #[test]
802    fn test_orientation_default() {
803        assert_eq!(Orientation::default(), Orientation::Horizontal);
804    }
805
806    #[test]
807    fn test_box_stats_default() {
808        let stats = BoxStats::default();
809        assert_eq!(stats.min, 0.0);
810        assert_eq!(stats.max, 0.0);
811    }
812
813    // ========================================================================
814    // Additional tests for improved coverage
815    // ========================================================================
816
817    #[test]
818    fn test_box_stats_from_two_values() {
819        let stats = BoxStats::from_data(&[1.0, 5.0]);
820        assert_eq!(stats.min, 1.0);
821        assert_eq!(stats.max, 5.0);
822    }
823
824    #[test]
825    fn test_box_stats_from_three_values() {
826        let stats = BoxStats::from_data(&[1.0, 3.0, 5.0]);
827        assert_eq!(stats.min, 1.0);
828        assert_eq!(stats.median, 3.0);
829        assert_eq!(stats.max, 5.0);
830    }
831
832    #[test]
833    fn test_box_stats_unsorted_data() {
834        let stats = BoxStats::from_data(&[5.0, 1.0, 3.0, 4.0, 2.0]);
835        assert_eq!(stats.min, 1.0);
836        assert_eq!(stats.max, 5.0);
837        // Median should be 3.0
838        assert_eq!(stats.median, 3.0);
839    }
840
841    #[test]
842    fn test_box_stats_with_nan() {
843        // Data with NaN should still work (NaN sorts to end)
844        let stats = BoxStats::from_data(&[1.0, 2.0, 3.0]);
845        assert_eq!(stats.min, 1.0);
846        assert_eq!(stats.max, 3.0);
847    }
848
849    #[test]
850    fn test_box_plot_normalize() {
851        let bp = BoxPlot::new(vec![BoxStats::new(0.0, 25.0, 50.0, 75.0, 100.0)]);
852        // Access normalize through render_horizontal_box indirectly
853        // Test by checking paint produces expected positions
854        let mut bp = bp;
855        bp.bounds = Rect::new(0.0, 0.0, 50.0, 5.0);
856        let mut canvas = MockCanvas::new();
857        bp.paint(&mut canvas);
858        assert!(!canvas.texts.is_empty());
859    }
860
861    #[test]
862    fn test_box_plot_normalize_constant_range() {
863        let bp = BoxPlot::new(vec![BoxStats::new(5.0, 5.0, 5.0, 5.0, 5.0)]);
864        // With constant data, global range becomes (min - 0.5, max + 0.5)
865        assert!((bp.global_min - 4.5).abs() < f64::EPSILON);
866        assert!((bp.global_max - 5.5).abs() < f64::EPSILON);
867    }
868
869    #[test]
870    fn test_box_plot_normalize_empty_stats() {
871        let bp = BoxPlot::new(vec![]);
872        // Empty stats should have default range (0, 1)
873        assert_eq!(bp.global_min, 0.0);
874        assert_eq!(bp.global_max, 1.0);
875    }
876
877    #[test]
878    fn test_box_plot_label_width_no_labels() {
879        let bp = BoxPlot::new(vec![BoxStats::default()]);
880        // No labels, should return minimum of 5
881        let width = bp.label_width();
882        assert_eq!(width, 5);
883    }
884
885    #[test]
886    fn test_box_plot_label_width_with_labels() {
887        let bp =
888            BoxPlot::new(vec![BoxStats::default()]).with_labels(vec!["VeryLongLabel".to_string()]);
889        let width = bp.label_width();
890        assert_eq!(width, 13); // "VeryLongLabel".len()
891    }
892
893    #[test]
894    fn test_box_plot_label_width_multiple_labels() {
895        let bp = BoxPlot::new(vec![BoxStats::default(), BoxStats::default()])
896            .with_labels(vec!["Short".to_string(), "VeryLongLabel".to_string()]);
897        let width = bp.label_width();
898        assert_eq!(width, 13); // Maximum label length
899    }
900
901    #[test]
902    fn test_box_plot_with_range_min_greater_than_max() {
903        let bp = BoxPlot::new(vec![]).with_range(100.0, 50.0);
904        // max should be at least min + 0.001
905        assert!(bp.global_max >= bp.global_min);
906    }
907
908    #[test]
909    fn test_box_plot_with_range_equal() {
910        let bp = BoxPlot::new(vec![]).with_range(50.0, 50.0);
911        // max should be at least min + 0.001
912        assert!(bp.global_max > bp.global_min);
913    }
914
915    #[test]
916    fn test_box_plot_paint_vertical_with_labels() {
917        let mut bp = BoxPlot::new(vec![
918            BoxStats::new(0.0, 2.0, 5.0, 8.0, 10.0),
919            BoxStats::new(1.0, 3.0, 5.0, 7.0, 9.0),
920        ])
921        .with_orientation(Orientation::Vertical)
922        .with_labels(vec!["A".to_string(), "B".to_string()]);
923        bp.bounds = Rect::new(0.0, 0.0, 20.0, 15.0);
924
925        let mut canvas = MockCanvas::new();
926        bp.paint(&mut canvas);
927
928        // Should have label texts
929        assert!(canvas.texts.iter().any(|(t, _)| t == "A" || t == "B"));
930    }
931
932    #[test]
933    fn test_box_plot_paint_vertical_label_truncation() {
934        let mut bp = BoxPlot::new(vec![BoxStats::new(0.0, 2.0, 5.0, 8.0, 10.0)])
935            .with_orientation(Orientation::Vertical)
936            .with_labels(vec!["LongLabel".to_string()]);
937        bp.bounds = Rect::new(0.0, 0.0, 20.0, 15.0);
938
939        let mut canvas = MockCanvas::new();
940        bp.paint(&mut canvas);
941
942        // Label should be truncated to 3 chars
943        assert!(canvas.texts.iter().any(|(t, _)| t == "Lon"));
944    }
945
946    #[test]
947    fn test_box_plot_paint_horizontal_no_labels() {
948        let mut bp = BoxPlot::new(vec![BoxStats::new(0.0, 2.0, 5.0, 8.0, 10.0)]);
949        bp.bounds = Rect::new(0.0, 0.0, 50.0, 5.0);
950
951        let mut canvas = MockCanvas::new();
952        bp.paint(&mut canvas);
953
954        // Should still render box without labels
955        assert!(!canvas.texts.is_empty());
956    }
957
958    #[test]
959    fn test_box_plot_paint_narrow_bounds() {
960        let mut bp = BoxPlot::new(vec![BoxStats::new(0.0, 2.0, 5.0, 8.0, 10.0)]);
961        bp.bounds = Rect::new(0.0, 0.0, 0.5, 5.0);
962
963        let mut canvas = MockCanvas::new();
964        bp.paint(&mut canvas);
965
966        // Should early return for very narrow bounds
967        assert!(canvas.texts.is_empty());
968    }
969
970    #[test]
971    fn test_box_plot_global_range_multiple_stats() {
972        let stats = vec![
973            BoxStats::new(5.0, 10.0, 15.0, 20.0, 25.0),
974            BoxStats::new(0.0, 5.0, 10.0, 15.0, 20.0),
975            BoxStats::new(10.0, 15.0, 20.0, 25.0, 30.0),
976        ];
977        let bp = BoxPlot::new(stats);
978        assert_eq!(bp.global_min, 0.0); // Min of all mins
979        assert_eq!(bp.global_max, 30.0); // Max of all maxes
980    }
981
982    #[test]
983    fn test_box_plot_set_stats_updates_range() {
984        let mut bp = BoxPlot::new(vec![BoxStats::new(0.0, 1.0, 2.0, 3.0, 4.0)]);
985        assert_eq!(bp.global_max, 4.0);
986
987        bp.set_stats(vec![BoxStats::new(0.0, 5.0, 10.0, 15.0, 20.0)]);
988        assert_eq!(bp.global_max, 20.0);
989    }
990
991    #[test]
992    fn test_box_plot_multiple_stats_paint() {
993        let mut bp = BoxPlot::new(vec![
994            BoxStats::new(0.0, 2.0, 5.0, 8.0, 10.0),
995            BoxStats::new(1.0, 3.0, 5.0, 7.0, 9.0),
996            BoxStats::new(2.0, 4.0, 6.0, 8.0, 10.0),
997        ])
998        .with_labels(vec![
999            "Group A".to_string(),
1000            "Group B".to_string(),
1001            "Group C".to_string(),
1002        ]);
1003        bp.bounds = Rect::new(0.0, 0.0, 60.0, 5.0);
1004
1005        let mut canvas = MockCanvas::new();
1006        bp.paint(&mut canvas);
1007
1008        // Should have content for all groups
1009        assert!(canvas.texts.len() > 3);
1010    }
1011
1012    #[test]
1013    fn test_box_plot_clone() {
1014        let bp = BoxPlot::new(vec![BoxStats::new(0.0, 1.0, 2.0, 3.0, 4.0)])
1015            .with_color(Color::RED)
1016            .with_labels(vec!["Test".to_string()]);
1017        let cloned = bp.clone();
1018        assert_eq!(cloned.stats.len(), bp.stats.len());
1019        assert_eq!(cloned.labels, bp.labels);
1020        assert_eq!(cloned.color, bp.color);
1021    }
1022
1023    #[test]
1024    fn test_box_plot_debug() {
1025        let bp = BoxPlot::new(vec![BoxStats::new(0.0, 1.0, 2.0, 3.0, 4.0)]);
1026        let debug_str = format!("{:?}", bp);
1027        assert!(debug_str.contains("BoxPlot"));
1028    }
1029
1030    #[test]
1031    fn test_box_stats_debug() {
1032        let stats = BoxStats::new(1.0, 2.0, 3.0, 4.0, 5.0);
1033        let debug_str = format!("{:?}", stats);
1034        assert!(debug_str.contains("BoxStats"));
1035    }
1036
1037    #[test]
1038    fn test_box_stats_clone() {
1039        let stats = BoxStats::new(1.0, 2.0, 3.0, 4.0, 5.0);
1040        let cloned = stats;
1041        assert_eq!(cloned.min, stats.min);
1042        assert_eq!(cloned.max, stats.max);
1043    }
1044
1045    #[test]
1046    fn test_orientation_debug() {
1047        let h = Orientation::Horizontal;
1048        let v = Orientation::Vertical;
1049        assert!(format!("{:?}", h).contains("Horizontal"));
1050        assert!(format!("{:?}", v).contains("Vertical"));
1051    }
1052
1053    #[test]
1054    fn test_orientation_clone() {
1055        let h = Orientation::Horizontal;
1056        let cloned = h;
1057        assert_eq!(cloned, Orientation::Horizontal);
1058    }
1059
1060    #[test]
1061    fn test_box_plot_measure_empty() {
1062        let bp = BoxPlot::new(vec![]);
1063        let size = bp.measure(Constraints::loose(Size::new(100.0, 50.0)));
1064        assert!(size.height >= 1.0); // min 1 for empty
1065    }
1066
1067    #[test]
1068    fn test_box_plot_measure_vertical_empty() {
1069        let bp = BoxPlot::new(vec![]).with_orientation(Orientation::Vertical);
1070        let size = bp.measure(Constraints::loose(Size::new(100.0, 50.0)));
1071        assert!(size.width >= 4.0); // min 4 for empty
1072    }
1073
1074    #[test]
1075    fn test_box_stats_large_data() {
1076        let data: Vec<f64> = (0..1000).map(|i| i as f64).collect();
1077        let stats = BoxStats::from_data(&data);
1078        assert_eq!(stats.min, 0.0);
1079        assert_eq!(stats.max, 999.0);
1080        // Median should be around 499.5
1081        assert!((stats.median - 499.5).abs() < 1.0);
1082    }
1083
1084    #[test]
1085    fn test_box_plot_vertical_values() {
1086        // Test vertical mode - values are not shown in vertical mode in current impl
1087        let mut bp = BoxPlot::new(vec![BoxStats::new(0.0, 2.0, 5.0, 8.0, 10.0)])
1088            .with_orientation(Orientation::Vertical)
1089            .with_values(true);
1090        bp.bounds = Rect::new(0.0, 0.0, 20.0, 15.0);
1091
1092        let mut canvas = MockCanvas::new();
1093        bp.paint(&mut canvas);
1094
1095        // Should render something
1096        assert!(!canvas.texts.is_empty());
1097    }
1098
1099    #[test]
1100    fn test_box_stats_q1_q3() {
1101        let stats = BoxStats::new(0.0, 25.0, 50.0, 75.0, 100.0);
1102        assert_eq!(stats.q1, 25.0);
1103        assert_eq!(stats.q3, 75.0);
1104    }
1105
1106    #[test]
1107    fn test_box_plot_horizontal_box_rendering_positions() {
1108        // Test that box rendering produces expected characters
1109        let mut bp =
1110            BoxPlot::new(vec![BoxStats::new(0.0, 25.0, 50.0, 75.0, 100.0)]).with_range(0.0, 100.0);
1111        bp.bounds = Rect::new(0.0, 0.0, 50.0, 5.0);
1112
1113        let mut canvas = MockCanvas::new();
1114        bp.paint(&mut canvas);
1115
1116        // Should contain box drawing characters
1117        let has_box_chars = canvas.texts.iter().any(|(t, _)| {
1118            t.contains('├')
1119                || t.contains('┤')
1120                || t.contains('[')
1121                || t.contains(']')
1122                || t.contains('█')
1123        });
1124        assert!(has_box_chars);
1125    }
1126
1127    #[test]
1128    fn test_box_plot_vertical_box_rendering_positions() {
1129        let mut bp = BoxPlot::new(vec![BoxStats::new(0.0, 25.0, 50.0, 75.0, 100.0)])
1130            .with_orientation(Orientation::Vertical)
1131            .with_range(0.0, 100.0);
1132        bp.bounds = Rect::new(0.0, 0.0, 10.0, 15.0);
1133
1134        let mut canvas = MockCanvas::new();
1135        bp.paint(&mut canvas);
1136
1137        // Should contain vertical box drawing characters
1138        let has_vertical_chars = canvas
1139            .texts
1140            .iter()
1141            .any(|(t, _)| t.contains('┬') || t.contains('┴') || t.contains('│') || t.contains('█'));
1142        assert!(has_vertical_chars);
1143    }
1144}