use polars::frame::DataFrame;
use crate::charts::Chart;
use crate::charts::scatter::data_range;
use crate::dtype::{classify_column, VizDtype};
use crate::error::{CharcoalError, CharcoalWarning};
use crate::normalize::{to_f64, to_string};
use crate::render::{
SvgCanvas, Margin,
axes::{
AxisOrientation, LinearScale, TickMark,
build_tick_marks, compute_axis,
nice_ticks, tick_labels_numeric, categorical_scale,
},
geometry,
};
use crate::theme::{Theme, ThemeConfig};
const CANVAS_WIDTH: u32 = 800;
const CANVAS_HEIGHT: u32 = 500;
const DEFAULT_ROW_LIMIT: usize = 1_000_000;
const BOX_WIDTH_FRAC: f64 = 0.5;
const WHISKER_CAP_FRAC: f64 = 0.4;
const POINT_RADIUS: f64 = 3.0;
const JITTER_MAX_PX: f64 = 6.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PointDisplay {
#[default]
Outliers,
All,
None,
}
#[derive(Clone)]
pub(crate) struct BoxPlotConfig {
pub x_col: Option<String>,
pub y_col: Option<String>,
pub title: Option<String>,
pub x_label: Option<String>,
pub y_label: Option<String>,
pub theme: Theme,
pub notched: bool,
pub points: PointDisplay,
pub row_limit: usize,
pub palette: Option<Vec<String>>,
}
impl Default for BoxPlotConfig {
fn default() -> Self {
Self {
x_col: None,
y_col: None,
title: None,
x_label: None,
y_label: None,
theme: Theme::Default,
notched: false,
points: PointDisplay::Outliers,
row_limit: DEFAULT_ROW_LIMIT,
palette: None,
}
}
}
pub struct BoxPlotBuilder<'df> {
pub(crate) df: &'df DataFrame,
pub(crate) config: BoxPlotConfig,
}
pub struct BoxPlotWithX<'df> {
pub(crate) df: &'df DataFrame,
pub(crate) config: BoxPlotConfig,
}
pub struct BoxPlotWithXY<'df> {
pub(crate) df: &'df DataFrame,
pub(crate) config: BoxPlotConfig,
}
impl<'df> BoxPlotBuilder<'df> {
pub(crate) fn new(df: &'df DataFrame) -> Self {
Self { df, config: BoxPlotConfig::default() }
}
}
macro_rules! impl_box_plot_optional_setters {
($t:ty) => {
impl<'df> $t {
pub fn title(mut self, title: &str) -> Self {
self.config.title = Some(title.to_string());
self
}
pub fn x_label(mut self, label: &str) -> Self {
self.config.x_label = Some(label.to_string());
self
}
pub fn y_label(mut self, label: &str) -> Self {
self.config.y_label = Some(label.to_string());
self
}
pub fn theme(mut self, theme: Theme) -> Self {
self.config.theme = theme;
self
}
pub fn notched(mut self, notched: bool) -> Self {
self.config.notched = notched;
self
}
pub fn points(mut self, points: PointDisplay) -> Self {
self.config.points = points;
self
}
pub fn row_limit(mut self, limit: usize) -> Self {
self.config.row_limit = limit;
self
}
pub fn palette(mut self, colors: Vec<&str>) -> Self {
self.config.palette = Some(colors.into_iter().map(str::to_string).collect());
self
}
}
};
}
impl_box_plot_optional_setters!(BoxPlotBuilder<'df>);
impl_box_plot_optional_setters!(BoxPlotWithX<'df>);
impl_box_plot_optional_setters!(BoxPlotWithXY<'df>);
impl<'df> BoxPlotBuilder<'df> {
pub fn x(mut self, col: &str) -> BoxPlotWithX<'df> {
self.config.x_col = Some(col.to_string());
BoxPlotWithX { df: self.df, config: self.config }
}
}
impl<'df> BoxPlotWithX<'df> {
pub fn y(mut self, col: &str) -> BoxPlotWithXY<'df> {
self.config.y_col = Some(col.to_string());
BoxPlotWithXY { df: self.df, config: self.config }
}
}
impl<'df> BoxPlotWithXY<'df> {
pub fn build(self) -> Result<Chart, CharcoalError> {
let df = self.df;
let config = self.config;
let mut warnings: Vec<CharcoalWarning> = Vec::new();
let n_rows = df.height();
if n_rows > config.row_limit {
return Err(CharcoalError::DataTooLarge {
rows: n_rows,
limit: config.row_limit,
message: format!(
"DataFrame exceeds the {} row render limit. \
Consider df.sample({}) or an aggregation before charting.",
config.row_limit,
config.row_limit / 2,
),
});
}
let x_col = config.x_col.as_deref().unwrap(); let x_viz = classify_column(df, x_col, None)?;
if x_viz == VizDtype::Numeric || x_viz == VizDtype::Temporal {
let dtype = df.schema().get(x_col).unwrap().clone();
return Err(CharcoalError::UnsupportedColumn {
col: x_col.to_string(),
dtype,
message: format!(
"The x column of a box plot must be Categorical (String or Boolean), \
not {:?}. Each unique x value defines one box.",
df.schema().get(x_col).unwrap()
),
});
}
if x_viz == VizDtype::Unsupported {
let dtype = df.schema().get(x_col).unwrap().clone();
return Err(CharcoalError::UnsupportedColumn {
col: x_col.to_string(),
dtype,
message: "The x column of a box plot must be Categorical (String or Boolean)."
.to_string(),
});
}
let (x_raw, x_warnings) = to_string(df, x_col)?;
warnings.extend(x_warnings);
let x_vals: Vec<String> = x_raw
.into_iter()
.map(|v| v.unwrap_or_else(|| "null".to_string()))
.collect();
let y_col = config.y_col.as_deref().unwrap();
let y_viz = classify_column(df, y_col, None)?;
if y_viz != VizDtype::Numeric {
let dtype = df.schema().get(y_col).unwrap().clone();
return Err(CharcoalError::UnsupportedColumn {
col: y_col.to_string(),
dtype,
message: "The y column of a box plot must be Numeric.".to_string(),
});
}
let (y_vals_raw, _) = to_f64(df, y_col)?;
let mut null_y_count = 0usize;
let y_vals: Vec<Option<f64>> = y_vals_raw
.into_iter()
.inspect(|v| { if v.is_none() { null_y_count += 1; } })
.collect();
if null_y_count > 0 {
warnings.push(CharcoalWarning::NullsSkipped {
col: y_col.to_string(),
count: null_y_count,
});
}
let mut categories: Vec<String> = Vec::new();
for v in &x_vals {
if !categories.contains(v) {
categories.push(v.clone());
}
}
let mut group_values: Vec<Vec<f64>> = vec![Vec::new(); categories.len()];
for row in 0..n_rows {
if let Some(y) = y_vals[row] {
let ci = categories.iter().position(|c| c == &x_vals[row]).unwrap();
group_values[ci].push(y);
}
}
let mut group_stats: Vec<Option<GroupStats>> = Vec::with_capacity(categories.len());
for (ci, vals) in group_values.iter().enumerate() {
if vals.is_empty() {
group_stats.push(None);
continue;
}
let mut stats = compute_stats(vals);
if config.notched {
let n = vals.len() as f64;
let iqr = stats.q3 - stats.q1;
let half_notch = 1.58 * iqr / n.sqrt();
let mut notch_lo = stats.median - half_notch;
let mut notch_hi = stats.median + half_notch;
let mut clamped = false;
if notch_lo < stats.q1 {
notch_lo = stats.q1;
clamped = true;
}
if notch_hi > stats.q3 {
notch_hi = stats.q3;
clamped = true;
}
if clamped {
warnings.push(CharcoalWarning::NotchClamped {
group: categories[ci].clone(),
});
}
stats.notch_lo = Some(notch_lo);
stats.notch_hi = Some(notch_hi);
}
group_stats.push(Some(stats));
}
let all_y: Vec<f64> = group_values.iter().flatten().copied().collect();
let (y_data_min, y_data_max) = data_range(all_y.iter().copied());
let y_tick_vals = nice_ticks(y_data_min, y_data_max, 6);
let theme_cfg = ThemeConfig::from(&config.theme);
let canvas = SvgCanvas::new(
CANVAS_WIDTH,
CANVAS_HEIGHT,
Margin::default_chart(),
theme_cfg.clone(),
);
let ox = canvas.plot_origin_x();
let oy = canvas.plot_origin_y();
let pw = canvas.plot_width();
let ph = canvas.plot_height();
let cat_positions = categorical_scale(&categories, 0.0, 1.0);
let band_px = pw / categories.len() as f64;
let y_scale = LinearScale::new(
*y_tick_vals.first().unwrap(),
*y_tick_vals.last().unwrap(),
oy + ph, oy, );
let active_palette: Vec<&str> = match &config.palette {
Some(p) if !p.is_empty() => p.iter().map(|s| s.as_str()).collect(),
Some(_) => {
warnings.push(CharcoalWarning::EmptyPalette);
theme_cfg.palette.to_vec()
}
None => theme_cfg.palette.to_vec(),
};
let fill_color = active_palette[0];
let stroke_color = theme_cfg.axis_color;
let mut elements: Vec<String> = Vec::new();
for (ci, (_cat_label, cat_norm)) in cat_positions.iter().enumerate() {
let center_px = ox + cat_norm * pw;
let box_half = band_px * BOX_WIDTH_FRAC / 2.0;
let cap_half = box_half * WHISKER_CAP_FRAC;
let stats = match &group_stats[ci] {
None => continue,
Some(s) => s,
};
let py_q1 = y_scale.map(stats.q1);
let py_q3 = y_scale.map(stats.q3);
let py_med = y_scale.map(stats.median);
let py_whi_lo = y_scale.map(stats.whisker_lo);
let py_whi_hi = y_scale.map(stats.whisker_hi);
let box_top = py_q3.min(py_q1);
let box_h = (py_q1 - py_q3).abs().max(0.5);
if config.notched {
let (nl, nh) = (
y_scale.map(stats.notch_lo.unwrap_or(stats.median)),
y_scale.map(stats.notch_hi.unwrap_or(stats.median)),
);
let box_left = center_px - box_half;
let box_right = center_px + box_half;
let notch_indent = box_half * 0.35; let nl_px = nh.min(py_med); let nh_px = nl.max(py_med);
let poly_pts = vec![
(box_left, box_top), (box_right, box_top), (box_right, nl_px), (center_px + notch_indent, py_med), (box_right, nh_px), (box_right, box_top + box_h), (box_left, box_top + box_h), (box_left, nh_px), (center_px - notch_indent, py_med), (box_left, nl_px), ];
elements.push(geometry::polygon(&poly_pts, fill_color, stroke_color, 0.7));
} else {
elements.push(geometry::rect(
center_px - box_half, box_top,
box_half * 2.0, box_h,
fill_color, 0.0,
));
elements.push(format!(
r#"<rect x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" fill="none" stroke="{}" stroke-width="1.50"/>"#,
center_px - box_half, box_top,
box_half * 2.0, box_h,
stroke_color,
));
}
elements.push(geometry::line(
center_px - box_half, py_med,
center_px + box_half, py_med,
stroke_color, 2.0,
));
elements.push(geometry::line(
center_px, py_q3,
center_px, py_whi_hi,
stroke_color, 1.5,
));
elements.push(geometry::line(
center_px - cap_half, py_whi_hi,
center_px + cap_half, py_whi_hi,
stroke_color, 1.5,
));
elements.push(geometry::line(
center_px, py_q1,
center_px, py_whi_lo,
stroke_color, 1.5,
));
elements.push(geometry::line(
center_px - cap_half, py_whi_lo,
center_px + cap_half, py_whi_lo,
stroke_color, 1.5,
));
match config.points {
PointDisplay::None => {}
PointDisplay::Outliers => {
for &ov in &stats.outliers {
let py = y_scale.map(ov);
elements.push(geometry::circle(
center_px, py, POINT_RADIUS, stroke_color, 0.8,
));
}
}
PointDisplay::All => {
let jitter_vals = &group_values[ci];
for (vi, &yv) in jitter_vals.iter().enumerate() {
let jitter = deterministic_jitter(ci, vi, box_half);
let py = y_scale.map(yv);
elements.push(geometry::circle(
center_px + jitter, py, POINT_RADIUS, stroke_color, 0.6,
));
}
}
}
}
let y_labels = tick_labels_numeric(&y_tick_vals);
let val_ticks = build_tick_marks(&y_tick_vals, &y_labels, &y_scale);
let cat_tick_marks: Vec<TickMark> = cat_positions
.iter()
.map(|(cat_label, norm)| TickMark {
data_value: *norm,
pixel_pos: ox + norm * pw,
label: cat_label.clone(),
})
.collect();
let cat_scale = LinearScale::new(0.0, 1.0, 0.0, 1.0);
let x_axis = compute_axis(
&cat_scale, &cat_tick_marks, AxisOrientation::Horizontal,
ox, oy, pw, ph, &theme_cfg,
);
let y_axis = compute_axis(
&y_scale, &val_ticks, AxisOrientation::Vertical,
ox, oy, pw, ph, &theme_cfg,
);
let title = config.title.as_deref().unwrap_or("");
let x_label = config.x_label.as_deref().unwrap_or(x_col);
let y_label = config.y_label.as_deref().unwrap_or(y_col);
let svg = canvas.render(elements, x_axis, y_axis, title, x_label, y_label, None);
Ok(Chart {
svg,
warnings,
title: title.to_string(),
width: CANVAS_WIDTH,
height: CANVAS_HEIGHT,
})
}
}
#[derive(Debug, Clone)]
struct GroupStats {
whisker_lo: f64,
q1: f64,
median: f64,
q3: f64,
whisker_hi: f64,
outliers: Vec<f64>,
notch_lo: Option<f64>,
notch_hi: Option<f64>,
}
fn compute_stats(vals: &[f64]) -> GroupStats {
debug_assert!(!vals.is_empty());
let mut sorted = vals.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let q1 = percentile(&sorted, 0.25);
let median = percentile(&sorted, 0.50);
let q3 = percentile(&sorted, 0.75);
let iqr = q3 - q1;
let lo_fence = q1 - 1.5 * iqr;
let hi_fence = q3 + 1.5 * iqr;
let mut outliers: Vec<f64> = Vec::new();
let mut whisker_lo = q1; let mut whisker_hi = q3;
for &v in &sorted {
if v < lo_fence || v > hi_fence {
outliers.push(v);
} else {
if v < whisker_lo { whisker_lo = v; }
if v > whisker_hi { whisker_hi = v; }
}
}
GroupStats {
whisker_lo,
q1,
median,
q3,
whisker_hi,
outliers,
notch_lo: None,
notch_hi: None,
}
}
fn percentile(sorted: &[f64], p: f64) -> f64 {
let n = sorted.len();
debug_assert!(n > 0);
if n == 1 {
return sorted[0];
}
let idx = (n - 1) as f64 * p;
let lo = idx.floor() as usize;
let hi = idx.ceil() as usize;
let frac = idx - lo as f64;
sorted[lo] + frac * (sorted[hi] - sorted[lo])
}
fn deterministic_jitter(cat_idx: usize, val_idx: usize, box_half: f64) -> f64 {
let seed = (cat_idx as u64).wrapping_mul(6364136223846793005)
.wrapping_add(val_idx as u64)
.wrapping_add(1442695040888963407);
let lcg = seed
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
let t = (lcg as f64) / (u64::MAX as f64); let max = JITTER_MAX_PX.min(box_half * 0.8);
(t - 0.5) * 2.0 * max
}
#[cfg(test)]
mod tests {
use super::*;
use polars::prelude::*;
fn make_df(cats: &[&str], vals: &[f64]) -> DataFrame {
DataFrame::new(vec![
Series::new("group", cats),
Series::new("value", vals),
])
.unwrap()
}
fn make_df_opt(cats: &[&str], vals: &[Option<f64>]) -> DataFrame {
DataFrame::new(vec![
Series::new("group", cats),
Series::new("value", vals),
])
.unwrap()
}
#[test]
fn percentile_single_value_returns_that_value() {
assert!((percentile(&[42.0], 0.25) - 42.0).abs() < 1e-9);
assert!((percentile(&[42.0], 0.50) - 42.0).abs() < 1e-9);
assert!((percentile(&[42.0], 0.75) - 42.0).abs() < 1e-9);
}
#[test]
fn percentile_two_values_median_is_midpoint() {
let s = vec![0.0, 10.0];
assert!((percentile(&s, 0.50) - 5.0).abs() < 1e-9);
}
#[test]
fn five_number_summary_known_dataset() {
let data = vec![2.0_f64, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
let stats = compute_stats(&data);
assert!((stats.q1 - 4.0).abs() < 1e-9, "Q1: {}", stats.q1);
assert!((stats.median - 4.5).abs() < 1e-9, "median: {}", stats.median);
assert!((stats.q3 - 5.5).abs() < 1e-9, "Q3: {}", stats.q3);
}
#[test]
fn outliers_identified_correctly() {
let data: Vec<f64> = vec![1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0,100.0];
let stats = compute_stats(&data);
assert_eq!(stats.outliers.len(), 1, "expected 1 outlier, got {:?}", stats.outliers);
assert!((stats.outliers[0] - 100.0).abs() < 1e-9, "outlier should be 100");
}
#[test]
fn no_outliers_when_all_data_within_fence() {
let data: Vec<f64> = (1..=10).map(|i| i as f64).collect();
let stats = compute_stats(&data);
assert!(stats.outliers.is_empty(), "expected no outliers");
}
#[test]
fn zero_iqr_group_has_identical_q1_median_q3() {
let data = vec![5.0_f64; 10];
let stats = compute_stats(&data);
assert!((stats.q1 - 5.0).abs() < 1e-9);
assert!((stats.median - 5.0).abs() < 1e-9);
assert!((stats.q3 - 5.0).abs() < 1e-9);
assert!((stats.whisker_lo - 5.0).abs() < 1e-9);
assert!((stats.whisker_hi - 5.0).abs() < 1e-9);
assert!(stats.outliers.is_empty());
}
#[test]
fn build_produces_svg_with_correct_structure() {
let cats = ["A","A","A","B","B","B","C","C","C"];
let vals = [1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0];
let df = make_df(&cats, &vals);
let chart = BoxPlotBuilder::new(&df)
.x("group").y("value")
.build()
.expect("build should succeed");
let svg = chart.svg();
assert!(svg.contains("<svg"), "output must be SVG");
assert!(svg.contains("<rect"), "must contain rect elements for boxes");
assert!(svg.contains("<line"), "must contain line elements for whiskers/median");
}
#[test]
fn build_single_value_group_does_not_panic() {
let df = make_df(&["A"], &[7.0]);
BoxPlotBuilder::new(&df)
.x("group").y("value")
.build()
.expect("single-value group must build without panic");
}
#[test]
fn build_zero_iqr_group_renders_flat_box() {
let df = make_df(&["A","A","A","A","A"], &[5.0,5.0,5.0,5.0,5.0]);
let chart = BoxPlotBuilder::new(&df)
.x("group").y("value")
.build()
.expect("zero-IQR group must build");
assert!(chart.svg().contains("<line"));
}
#[test]
fn build_excludes_null_y_and_emits_warning() {
let df = make_df_opt(
&["A","A","A","A"],
&[Some(1.0), None, Some(3.0), Some(5.0)],
);
let chart = BoxPlotBuilder::new(&df)
.x("group").y("value")
.build()
.expect("null y should be skipped, not error");
let has_warning = chart.warnings().iter().any(|w| matches!(
w, CharcoalWarning::NullsSkipped { col, .. } if col == "value"
));
assert!(has_warning, "expected NullsSkipped warning");
}
#[test]
fn notch_bounds_clamped_when_exceed_q1_q3_emits_warning() {
let df = make_df(&["A","A"], &[4.9, 5.1]);
let chart = BoxPlotBuilder::new(&df)
.x("group").y("value")
.notched(true)
.build()
.expect("notched build must succeed");
let has_clamp_warn = chart.warnings().iter().any(|w| matches!(
w, CharcoalWarning::NotchClamped { group } if group == "A"
));
assert!(has_clamp_warn, "expected NotchClamped warning for group A");
}
#[test]
fn notch_not_clamped_for_large_n() {
let cats: Vec<&str> = vec!["A"; 50];
let vals: Vec<f64> = (0..50).map(|i| i as f64).collect();
let df = make_df(&cats, &vals);
let chart = BoxPlotBuilder::new(&df)
.x("group").y("value")
.notched(true)
.build()
.expect("large-N notched build must succeed");
let has_clamp_warn = chart.warnings().iter().any(|w| matches!(
w, CharcoalWarning::NotchClamped { .. }
));
assert!(!has_clamp_warn, "no clamping expected for large N");
}
#[test]
fn point_display_all_renders_more_circles_than_outliers() {
let cats: Vec<&str> = vec!["A"; 11];
let mut vals: Vec<f64> = (1..=10).map(|i| i as f64).collect();
vals.push(1000.0); let df = make_df(&cats, &vals);
let chart_outliers = BoxPlotBuilder::new(&df)
.x("group").y("value")
.points(PointDisplay::Outliers)
.build()
.unwrap();
let chart_all = BoxPlotBuilder::new(&df)
.x("group").y("value")
.points(PointDisplay::All)
.build()
.unwrap();
let count_circles = |svg: &str| svg.matches("<circle").count();
assert!(
count_circles(chart_all.svg()) > count_circles(chart_outliers.svg()),
"All mode must render more circles than Outliers mode"
);
}
#[test]
fn point_display_none_renders_no_circles() {
let cats = ["A","A","A","A","A","A"];
let vals = [1.0, 2.0, 3.0, 4.0, 5.0, 1000.0]; let df = make_df(&cats, &vals);
let chart = BoxPlotBuilder::new(&df)
.x("group").y("value")
.points(PointDisplay::None)
.build()
.unwrap();
assert_eq!(chart.svg().matches("<circle").count(), 0,
"PointDisplay::None must render zero circles");
}
#[test]
fn point_display_outliers_renders_only_outliers() {
let cats = ["A","A","A","A","A","A"];
let vals = [1.0,2.0,3.0,4.0,5.0,1000.0];
let df = make_df(&cats, &vals);
let chart = BoxPlotBuilder::new(&df)
.x("group").y("value")
.points(PointDisplay::Outliers)
.build()
.unwrap();
assert_eq!(chart.svg().matches("<circle").count(), 1,
"PointDisplay::Outliers must render exactly 1 circle for 1 outlier");
}
#[test]
fn build_produces_correct_dimensions() {
let df = make_df(&["A","A","B","B"], &[1.0,2.0,3.0,4.0]);
let chart = BoxPlotBuilder::new(&df)
.x("group").y("value")
.build()
.unwrap();
assert_eq!(chart.width(), CANVAS_WIDTH);
assert_eq!(chart.height(), CANVAS_HEIGHT);
}
#[test]
fn build_numeric_x_returns_unsupported_column() {
let df = DataFrame::new(vec![
Series::new("x", &[1.0f64, 2.0]),
Series::new("y", &[3.0f64, 4.0]),
]).unwrap();
let err = BoxPlotBuilder::new(&df).x("x").y("y").build().unwrap_err();
match err {
CharcoalError::UnsupportedColumn { col, .. } => assert_eq!(col, "x"),
other => panic!("expected UnsupportedColumn, got {other:?}"),
}
}
#[test]
fn build_categorical_y_returns_unsupported_column() {
let df = DataFrame::new(vec![
Series::new("cat", &["A","B"]),
Series::new("label",&["X","Y"]),
]).unwrap();
let err = BoxPlotBuilder::new(&df).x("cat").y("label").build().unwrap_err();
match err {
CharcoalError::UnsupportedColumn { col, .. } => assert_eq!(col, "label"),
other => panic!("expected UnsupportedColumn, got {other:?}"),
}
}
#[test]
fn build_exceeding_row_limit_returns_data_too_large() {
let cats: Vec<&str> = vec!["A"; 10];
let vals: Vec<f64> = vec![1.0; 10];
let df = make_df(&cats, &vals);
let err = BoxPlotBuilder::new(&df)
.x("group").y("value")
.row_limit(5)
.build()
.unwrap_err();
match err {
CharcoalError::DataTooLarge { rows, limit, .. } => {
assert_eq!(rows, 10);
assert_eq!(limit, 5);
}
other => panic!("expected DataTooLarge, got {other:?}"),
}
}
#[test]
fn title_setter_stores_string() {
let df = DataFrame::empty();
let b = BoxPlotBuilder::new(&df).title("My Chart");
assert_eq!(b.config.title.as_deref(), Some("My Chart"));
}
#[test]
fn notched_setter_stores_true() {
let df = DataFrame::empty();
let b = BoxPlotBuilder::new(&df).notched(true);
assert!(b.config.notched);
}
#[test]
fn points_setter_stores_variant() {
let df = DataFrame::empty();
let b = BoxPlotBuilder::new(&df).points(PointDisplay::All);
assert!(matches!(b.config.points, PointDisplay::All));
}
#[test]
fn row_limit_setter_stores_value() {
let df = DataFrame::empty();
let b = BoxPlotBuilder::new(&df).row_limit(500);
assert_eq!(b.config.row_limit, 500);
}
#[test]
fn jitter_is_deterministic_same_inputs() {
let j1 = deterministic_jitter(2, 5, 20.0);
let j2 = deterministic_jitter(2, 5, 20.0);
assert_eq!(j1, j2, "jitter must be deterministic");
}
#[test]
fn jitter_differs_between_values_in_same_group() {
let j0 = deterministic_jitter(0, 0, 20.0);
let j1 = deterministic_jitter(0, 1, 20.0);
assert_ne!(j0, j1, "different value indices should yield different jitter");
}
#[test]
fn jitter_magnitude_bounded_by_box_half() {
let box_half = 15.0;
for vi in 0..50 {
let j = deterministic_jitter(0, vi, box_half);
assert!(j.abs() <= box_half * 0.8 + 1e-9,
"jitter {j} out of bounds for box_half={box_half}");
}
}
}