1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum Orientation {
16 #[default]
18 Horizontal,
19 Vertical,
21}
22
23#[derive(Debug, Clone, Copy, Default)]
25pub struct BoxStats {
26 pub min: f64,
28 pub q1: f64,
30 pub median: f64,
32 pub q3: f64,
34 pub max: f64,
36}
37
38impl BoxStats {
39 #[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 #[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 #[must_use]
106 pub fn iqr(&self) -> f64 {
107 self.q3 - self.q1
108 }
109
110 #[must_use]
112 pub fn range(&self) -> f64 {
113 self.max - self.min
114 }
115}
116
117#[derive(Debug, Clone)]
119pub struct BoxPlot {
120 stats: Vec<BoxStats>,
122 labels: Vec<String>,
124 orientation: Orientation,
126 color: Color,
128 global_min: f64,
130 global_max: f64,
132 show_values: bool,
134 box_width: usize,
136 bounds: Rect,
138}
139
140impl Default for BoxPlot {
141 fn default() -> Self {
142 Self::new(vec![])
143 }
144}
145
146impl BoxPlot {
147 #[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 #[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 #[must_use]
173 pub fn with_labels(mut self, labels: Vec<String>) -> Self {
174 self.labels = labels;
175 self
176 }
177
178 #[must_use]
180 pub fn with_orientation(mut self, orientation: Orientation) -> Self {
181 self.orientation = orientation;
182 self
183 }
184
185 #[must_use]
187 pub fn with_color(mut self, color: Color) -> Self {
188 self.color = color;
189 self
190 }
191
192 #[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 #[must_use]
202 pub fn with_values(mut self, show: bool) -> Self {
203 self.show_values = show;
204 self
205 }
206
207 #[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 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 #[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 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 let mut line = String::with_capacity(width_usize);
289
290 for i in 0..width_usize {
291 let ch = if i == min_pos {
292 '├' } else if i == max_pos {
294 '┤' } else if (i > min_pos && i < q1_pos) || (i > q3_pos && i < max_pos) {
296 '─' } else if i == q1_pos {
298 '[' } else if i == q3_pos {
300 ']' } else if i == median_pos && i > q1_pos && i < q3_pos {
302 '│' } else if i > q1_pos && i < q3_pos {
304 '█' } else {
306 ' '
307 };
308 line.push(ch);
309 }
310
311 canvas.draw_text(&line, Point::new(x, y), &style);
313
314 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 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 for i in 0..height_usize {
353 let ch = if i == max_pos {
354 "┬" } else if i == min_pos {
356 "┴" } else if (i > max_pos && i < q3_pos) || (i > q1_pos && i < min_pos) {
358 "│" } else if i == q3_pos {
360 "┌" } else if i == q1_pos {
362 "└" } else if i == median_pos && i > q3_pos && i < q1_pos {
364 "├" } else if i > q3_pos && i < q1_pos {
366 "█" } 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 if let Some(label) = self.labels.get(i) {
467 canvas.draw_text(label, Point::new(self.bounds.x, y), &label_style);
468 }
469
470 self.render_horizontal_box(canvas, stats, box_start, y, box_width);
472
473 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 self.render_vertical_box(canvas, stats, x, self.bounds.y, box_height);
493
494 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); }
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 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); }
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::key_down(presentar_core::Key::Enter);
778 assert!(bp.event(&event).is_none());
779 }
780
781 #[test]
782 fn test_box_plot_default() {
783 let bp = BoxPlot::default();
784 assert!(bp.stats.is_empty());
785 }
786
787 #[test]
788 fn test_box_plot_to_html() {
789 let bp = BoxPlot::new(vec![]);
790 assert!(bp.to_html().is_empty());
791 }
792
793 #[test]
794 fn test_box_plot_to_css() {
795 let bp = BoxPlot::new(vec![]);
796 assert!(bp.to_css().is_empty());
797 }
798
799 #[test]
800 fn test_orientation_default() {
801 assert_eq!(Orientation::default(), Orientation::Horizontal);
802 }
803
804 #[test]
805 fn test_box_stats_default() {
806 let stats = BoxStats::default();
807 assert_eq!(stats.min, 0.0);
808 assert_eq!(stats.max, 0.0);
809 }
810
811 #[test]
816 fn test_box_stats_from_two_values() {
817 let stats = BoxStats::from_data(&[1.0, 5.0]);
818 assert_eq!(stats.min, 1.0);
819 assert_eq!(stats.max, 5.0);
820 }
821
822 #[test]
823 fn test_box_stats_from_three_values() {
824 let stats = BoxStats::from_data(&[1.0, 3.0, 5.0]);
825 assert_eq!(stats.min, 1.0);
826 assert_eq!(stats.median, 3.0);
827 assert_eq!(stats.max, 5.0);
828 }
829
830 #[test]
831 fn test_box_stats_unsorted_data() {
832 let stats = BoxStats::from_data(&[5.0, 1.0, 3.0, 4.0, 2.0]);
833 assert_eq!(stats.min, 1.0);
834 assert_eq!(stats.max, 5.0);
835 assert_eq!(stats.median, 3.0);
837 }
838
839 #[test]
840 fn test_box_stats_with_nan() {
841 let stats = BoxStats::from_data(&[1.0, 2.0, 3.0]);
843 assert_eq!(stats.min, 1.0);
844 assert_eq!(stats.max, 3.0);
845 }
846
847 #[test]
848 fn test_box_plot_normalize() {
849 let bp = BoxPlot::new(vec![BoxStats::new(0.0, 25.0, 50.0, 75.0, 100.0)]);
850 let mut bp = bp;
853 bp.bounds = Rect::new(0.0, 0.0, 50.0, 5.0);
854 let mut canvas = MockCanvas::new();
855 bp.paint(&mut canvas);
856 assert!(!canvas.texts.is_empty());
857 }
858
859 #[test]
860 fn test_box_plot_normalize_constant_range() {
861 let bp = BoxPlot::new(vec![BoxStats::new(5.0, 5.0, 5.0, 5.0, 5.0)]);
862 assert!((bp.global_min - 4.5).abs() < f64::EPSILON);
864 assert!((bp.global_max - 5.5).abs() < f64::EPSILON);
865 }
866
867 #[test]
868 fn test_box_plot_normalize_empty_stats() {
869 let bp = BoxPlot::new(vec![]);
870 assert_eq!(bp.global_min, 0.0);
872 assert_eq!(bp.global_max, 1.0);
873 }
874
875 #[test]
876 fn test_box_plot_label_width_no_labels() {
877 let bp = BoxPlot::new(vec![BoxStats::default()]);
878 let width = bp.label_width();
880 assert_eq!(width, 5);
881 }
882
883 #[test]
884 fn test_box_plot_label_width_with_labels() {
885 let bp =
886 BoxPlot::new(vec![BoxStats::default()]).with_labels(vec!["VeryLongLabel".to_string()]);
887 let width = bp.label_width();
888 assert_eq!(width, 13); }
890
891 #[test]
892 fn test_box_plot_label_width_multiple_labels() {
893 let bp = BoxPlot::new(vec![BoxStats::default(), BoxStats::default()])
894 .with_labels(vec!["Short".to_string(), "VeryLongLabel".to_string()]);
895 let width = bp.label_width();
896 assert_eq!(width, 13); }
898
899 #[test]
900 fn test_box_plot_with_range_min_greater_than_max() {
901 let bp = BoxPlot::new(vec![]).with_range(100.0, 50.0);
902 assert!(bp.global_max >= bp.global_min);
904 }
905
906 #[test]
907 fn test_box_plot_with_range_equal() {
908 let bp = BoxPlot::new(vec![]).with_range(50.0, 50.0);
909 assert!(bp.global_max > bp.global_min);
911 }
912
913 #[test]
914 fn test_box_plot_paint_vertical_with_labels() {
915 let mut bp = BoxPlot::new(vec![
916 BoxStats::new(0.0, 2.0, 5.0, 8.0, 10.0),
917 BoxStats::new(1.0, 3.0, 5.0, 7.0, 9.0),
918 ])
919 .with_orientation(Orientation::Vertical)
920 .with_labels(vec!["A".to_string(), "B".to_string()]);
921 bp.bounds = Rect::new(0.0, 0.0, 20.0, 15.0);
922
923 let mut canvas = MockCanvas::new();
924 bp.paint(&mut canvas);
925
926 assert!(canvas.texts.iter().any(|(t, _)| t == "A" || t == "B"));
928 }
929
930 #[test]
931 fn test_box_plot_paint_vertical_label_truncation() {
932 let mut bp = BoxPlot::new(vec![BoxStats::new(0.0, 2.0, 5.0, 8.0, 10.0)])
933 .with_orientation(Orientation::Vertical)
934 .with_labels(vec!["LongLabel".to_string()]);
935 bp.bounds = Rect::new(0.0, 0.0, 20.0, 15.0);
936
937 let mut canvas = MockCanvas::new();
938 bp.paint(&mut canvas);
939
940 assert!(canvas.texts.iter().any(|(t, _)| t == "Lon"));
942 }
943
944 #[test]
945 fn test_box_plot_paint_horizontal_no_labels() {
946 let mut bp = BoxPlot::new(vec![BoxStats::new(0.0, 2.0, 5.0, 8.0, 10.0)]);
947 bp.bounds = Rect::new(0.0, 0.0, 50.0, 5.0);
948
949 let mut canvas = MockCanvas::new();
950 bp.paint(&mut canvas);
951
952 assert!(!canvas.texts.is_empty());
954 }
955
956 #[test]
957 fn test_box_plot_paint_narrow_bounds() {
958 let mut bp = BoxPlot::new(vec![BoxStats::new(0.0, 2.0, 5.0, 8.0, 10.0)]);
959 bp.bounds = Rect::new(0.0, 0.0, 0.5, 5.0);
960
961 let mut canvas = MockCanvas::new();
962 bp.paint(&mut canvas);
963
964 assert!(canvas.texts.is_empty());
966 }
967
968 #[test]
969 fn test_box_plot_global_range_multiple_stats() {
970 let stats = vec![
971 BoxStats::new(5.0, 10.0, 15.0, 20.0, 25.0),
972 BoxStats::new(0.0, 5.0, 10.0, 15.0, 20.0),
973 BoxStats::new(10.0, 15.0, 20.0, 25.0, 30.0),
974 ];
975 let bp = BoxPlot::new(stats);
976 assert_eq!(bp.global_min, 0.0); assert_eq!(bp.global_max, 30.0); }
979
980 #[test]
981 fn test_box_plot_set_stats_updates_range() {
982 let mut bp = BoxPlot::new(vec![BoxStats::new(0.0, 1.0, 2.0, 3.0, 4.0)]);
983 assert_eq!(bp.global_max, 4.0);
984
985 bp.set_stats(vec![BoxStats::new(0.0, 5.0, 10.0, 15.0, 20.0)]);
986 assert_eq!(bp.global_max, 20.0);
987 }
988
989 #[test]
990 fn test_box_plot_multiple_stats_paint() {
991 let mut bp = BoxPlot::new(vec![
992 BoxStats::new(0.0, 2.0, 5.0, 8.0, 10.0),
993 BoxStats::new(1.0, 3.0, 5.0, 7.0, 9.0),
994 BoxStats::new(2.0, 4.0, 6.0, 8.0, 10.0),
995 ])
996 .with_labels(vec![
997 "Group A".to_string(),
998 "Group B".to_string(),
999 "Group C".to_string(),
1000 ]);
1001 bp.bounds = Rect::new(0.0, 0.0, 60.0, 5.0);
1002
1003 let mut canvas = MockCanvas::new();
1004 bp.paint(&mut canvas);
1005
1006 assert!(canvas.texts.len() > 3);
1008 }
1009
1010 #[test]
1011 fn test_box_plot_clone() {
1012 let bp = BoxPlot::new(vec![BoxStats::new(0.0, 1.0, 2.0, 3.0, 4.0)])
1013 .with_color(Color::RED)
1014 .with_labels(vec!["Test".to_string()]);
1015 let cloned = bp.clone();
1016 assert_eq!(cloned.stats.len(), bp.stats.len());
1017 assert_eq!(cloned.labels, bp.labels);
1018 assert_eq!(cloned.color, bp.color);
1019 }
1020
1021 #[test]
1022 fn test_box_plot_debug() {
1023 let bp = BoxPlot::new(vec![BoxStats::new(0.0, 1.0, 2.0, 3.0, 4.0)]);
1024 let debug_str = format!("{:?}", bp);
1025 assert!(debug_str.contains("BoxPlot"));
1026 }
1027
1028 #[test]
1029 fn test_box_stats_debug() {
1030 let stats = BoxStats::new(1.0, 2.0, 3.0, 4.0, 5.0);
1031 let debug_str = format!("{:?}", stats);
1032 assert!(debug_str.contains("BoxStats"));
1033 }
1034
1035 #[test]
1036 fn test_box_stats_clone() {
1037 let stats = BoxStats::new(1.0, 2.0, 3.0, 4.0, 5.0);
1038 let cloned = stats;
1039 assert_eq!(cloned.min, stats.min);
1040 assert_eq!(cloned.max, stats.max);
1041 }
1042
1043 #[test]
1044 fn test_orientation_debug() {
1045 let h = Orientation::Horizontal;
1046 let v = Orientation::Vertical;
1047 assert!(format!("{:?}", h).contains("Horizontal"));
1048 assert!(format!("{:?}", v).contains("Vertical"));
1049 }
1050
1051 #[test]
1052 fn test_orientation_clone() {
1053 let h = Orientation::Horizontal;
1054 let cloned = h;
1055 assert_eq!(cloned, Orientation::Horizontal);
1056 }
1057
1058 #[test]
1059 fn test_box_plot_measure_empty() {
1060 let bp = BoxPlot::new(vec![]);
1061 let size = bp.measure(Constraints::loose(Size::new(100.0, 50.0)));
1062 assert!(size.height >= 1.0); }
1064
1065 #[test]
1066 fn test_box_plot_measure_vertical_empty() {
1067 let bp = BoxPlot::new(vec![]).with_orientation(Orientation::Vertical);
1068 let size = bp.measure(Constraints::loose(Size::new(100.0, 50.0)));
1069 assert!(size.width >= 4.0); }
1071
1072 #[test]
1073 fn test_box_stats_large_data() {
1074 let data: Vec<f64> = (0..1000).map(|i| i as f64).collect();
1075 let stats = BoxStats::from_data(&data);
1076 assert_eq!(stats.min, 0.0);
1077 assert_eq!(stats.max, 999.0);
1078 assert!((stats.median - 499.5).abs() < 1.0);
1080 }
1081
1082 #[test]
1083 fn test_box_plot_vertical_values() {
1084 let mut bp = BoxPlot::new(vec![BoxStats::new(0.0, 2.0, 5.0, 8.0, 10.0)])
1086 .with_orientation(Orientation::Vertical)
1087 .with_values(true);
1088 bp.bounds = Rect::new(0.0, 0.0, 20.0, 15.0);
1089
1090 let mut canvas = MockCanvas::new();
1091 bp.paint(&mut canvas);
1092
1093 assert!(!canvas.texts.is_empty());
1095 }
1096
1097 #[test]
1098 fn test_box_stats_q1_q3() {
1099 let stats = BoxStats::new(0.0, 25.0, 50.0, 75.0, 100.0);
1100 assert_eq!(stats.q1, 25.0);
1101 assert_eq!(stats.q3, 75.0);
1102 }
1103
1104 #[test]
1105 fn test_box_plot_horizontal_box_rendering_positions() {
1106 let mut bp =
1108 BoxPlot::new(vec![BoxStats::new(0.0, 25.0, 50.0, 75.0, 100.0)]).with_range(0.0, 100.0);
1109 bp.bounds = Rect::new(0.0, 0.0, 50.0, 5.0);
1110
1111 let mut canvas = MockCanvas::new();
1112 bp.paint(&mut canvas);
1113
1114 let has_box_chars = canvas.texts.iter().any(|(t, _)| {
1116 t.contains('├')
1117 || t.contains('┤')
1118 || t.contains('[')
1119 || t.contains(']')
1120 || t.contains('█')
1121 });
1122 assert!(has_box_chars);
1123 }
1124
1125 #[test]
1126 fn test_box_plot_vertical_box_rendering_positions() {
1127 let mut bp = BoxPlot::new(vec![BoxStats::new(0.0, 25.0, 50.0, 75.0, 100.0)])
1128 .with_orientation(Orientation::Vertical)
1129 .with_range(0.0, 100.0);
1130 bp.bounds = Rect::new(0.0, 0.0, 10.0, 15.0);
1131
1132 let mut canvas = MockCanvas::new();
1133 bp.paint(&mut canvas);
1134
1135 let has_vertical_chars = canvas
1137 .texts
1138 .iter()
1139 .any(|(t, _)| t.contains('┬') || t.contains('┴') || t.contains('│') || t.contains('█'));
1140 assert!(has_vertical_chars);
1141 }
1142}