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::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 #[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 assert_eq!(stats.median, 3.0);
839 }
840
841 #[test]
842 fn test_box_stats_with_nan() {
843 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 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 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 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 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); }
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); }
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 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 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 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 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 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 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); assert_eq!(bp.global_max, 30.0); }
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 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); }
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); }
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 assert!((stats.median - 499.5).abs() < 1.0);
1082 }
1083
1084 #[test]
1085 fn test_box_plot_vertical_values() {
1086 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 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 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 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 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}