use ggplot_rs::data::Value;
use ggplot_rs::prelude::*;
use ggplot_rs::stat::boxplot::StatBoxplot;
use ggplot_rs::stat::identity::StatIdentity;
use ggplot_rs::stat::Stat;
fn floats(v: &[f64]) -> Vec<Value> {
v.iter().map(|&f| Value::Float(f)).collect()
}
fn strs(v: &[&str]) -> Vec<Value> {
v.iter().map(|s| Value::Str(s.to_string())).collect()
}
fn frame(cols: Vec<(&str, Vec<Value>)>) -> Vec<(String, Vec<Value>)> {
cols.into_iter().map(|(k, v)| (k.to_string(), v)).collect()
}
fn raw_group_a() -> Vec<f64> {
vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]
}
#[test]
fn identity_path_matches_the_stat_paths_pixels() {
let y = raw_group_a();
let default_stat_svg = GGPlot::new(frame(vec![("x", strs(&["A"; 10])), ("y", floats(&y))]))
.aes(Aes::new().x("x").y("y"))
.geom_boxplot()
.render_svg_native_with_size(300, 300)
.unwrap();
let mut df = ggplot_rs::data::DataFrame::new();
df.add_column("x".into(), vec![Value::Str("A".into()); 10]);
df.add_column("y".into(), floats(&y));
let computed = StatBoxplot.compute_group(&df, &ggplot_rs::scale::ScaleSet::new());
let get = |col: &str| computed.column(col).unwrap()[0].as_f64().unwrap();
let (lower, middle, upper, ymin, ymax) = (
get("lower"),
get("middle"),
get("upper"),
get("ymin"),
get("ymax"),
);
let identity_svg = GGPlot::new(frame(vec![
("x", strs(&["A"])),
("lower", floats(&[lower])),
("middle", floats(&[middle])),
("upper", floats(&[upper])),
("ymin", floats(&[ymin])),
("ymax", floats(&[ymax])),
]))
.aes(
Aes::new()
.x("x")
.lower("lower")
.middle("middle")
.upper("upper")
.ymin("ymin")
.ymax("ymax"),
)
.geom_boxplot()
.stat(StatIdentity)
.render_svg_native_with_size(300, 300)
.unwrap();
assert_eq!(default_stat_svg, identity_svg);
}
#[test]
fn outliers_draw_and_widen_y() {
let (svg, domain) = render_with_ydomain(
GGPlot::new(frame(vec![
("x", strs(&["A"])),
("lower", floats(&[3.0])),
("middle", floats(&[5.0])),
("upper", floats(&[7.0])),
("ymin", floats(&[1.0])),
("ymax", floats(&[8.0])),
("outliers", strs(&["7, 9"])),
]))
.aes(
Aes::new()
.x("x")
.lower("lower")
.middle("middle")
.upper("upper")
.ymin("ymin")
.ymax("ymax")
.outliers("outliers"),
)
.geom_boxplot()
.stat(StatIdentity),
300,
300,
);
assert_eq!(svg.matches("<circle ").count(), 2, "{svg}");
assert!(
domain.1 >= 9.0 - 1e-6,
"y domain should widen to 9: {domain:?}"
);
}
#[test]
fn outlier_extent_is_the_confirmed_fix() {
let (_, domain) = render_with_ydomain(
GGPlot::new(frame(vec![
("x", strs(&["A"])),
("lower", floats(&[3.0])),
("middle", floats(&[5.0])),
("upper", floats(&[7.0])),
("ymin", floats(&[1.0])),
("ymax", floats(&[8.0])),
("outliers", strs(&["100"])),
]))
.aes(
Aes::new()
.x("x")
.lower("lower")
.middle("middle")
.upper("upper")
.ymin("ymin")
.ymax("ymax")
.outliers("outliers"),
)
.geom_boxplot()
.stat(StatIdentity),
300,
300,
);
assert!(
domain.1 >= 100.0 - 1e-6,
"the y domain must widen to the far outlier: {domain:?}"
);
}
#[test]
fn raw_unmapped_lower_under_identity_is_a_validation_error() {
let result = GGPlot::new(frame(vec![
("x", strs(&["A"])),
("lower", floats(&[3.0])), ("middle", floats(&[5.0])),
("upper", floats(&[7.0])),
]))
.aes(Aes::new().x("x"))
.geom_boxplot()
.stat(StatIdentity)
.render_svg_native_with_size(300, 300);
assert!(
matches!(result, Err(GGError::ValidationError(_))),
"{result:?}"
);
}
#[test]
fn raw_outliers_under_the_default_stat_is_ignored() {
let svg = GGPlot::new(frame(vec![
("x", strs(&["A"; 10])),
("y", floats(&raw_group_a())),
("outliers", strs(&["not,real,data"; 10])),
]))
.aes(Aes::new().x("x").y("y"))
.geom_boxplot()
.render_svg_native_with_size(300, 300)
.unwrap();
assert!(svg.starts_with("<svg"));
assert!(!svg.contains("NaN"));
}
#[test]
fn na_lower_row_is_dropped_with_the_standard_warning() {
let (svg, warnings) = GGPlot::new(frame(vec![
("x", strs(&["A", "B"])),
("lower", vec![Value::Float(f64::NAN), Value::Float(3.0)]),
("middle", floats(&[5.0, 5.0])),
("upper", floats(&[7.0, 7.0])),
("ymin", floats(&[1.0, 1.0])),
("ymax", floats(&[8.0, 8.0])),
]))
.aes(
Aes::new()
.x("x")
.lower("lower")
.middle("middle")
.upper("upper")
.ymin("ymin")
.ymax("ymax"),
)
.geom_boxplot()
.stat(StatIdentity)
.render_svg_native_with_warnings(300, 300)
.unwrap();
assert!(!svg.contains("NaN"));
assert!(
warnings
.iter()
.any(|w| w.contains("geom_boxplot") && w.contains("non-finite values")),
"{warnings:?}"
);
}
#[test]
fn na_lower_row_sql_null_is_dropped_not_drawn_at_zero() {
let (svg, warnings) = GGPlot::new(frame(vec![
("x", strs(&["A", "B"])),
("lower", vec![Value::Float(3.0), Value::Na]),
("middle", floats(&[5.0, 4.0])),
("upper", floats(&[7.0, 5.0])),
("ymin", floats(&[1.0, 1.0])),
("ymax", floats(&[8.0, 8.0])),
]))
.aes(
Aes::new()
.x("x")
.lower("lower")
.middle("middle")
.upper("upper")
.ymin("ymin")
.ymax("ymax"),
)
.geom_boxplot()
.stat(StatIdentity)
.render_svg_native_with_warnings(300, 300)
.unwrap();
assert!(
!svg.contains("median 4 (0"),
"row B must not silently render from 0: {svg}"
);
assert!(
!svg.contains(">B<"),
"row B's category must not appear: {svg}"
);
assert!(
warnings
.iter()
.any(|w| w.contains("geom_boxplot") && w.contains("non-finite values")),
"{warnings:?}"
);
}
#[test]
fn na_middle_and_upper_rows_are_also_dropped() {
let (svg, warnings) = GGPlot::new(frame(vec![
("x", strs(&["A", "B", "C"])),
("lower", floats(&[3.0, 3.0, 3.0])),
(
"middle",
vec![Value::Float(5.0), Value::Na, Value::Float(5.0)],
),
(
"upper",
vec![Value::Float(7.0), Value::Float(7.0), Value::Na],
),
("ymin", floats(&[1.0, 1.0, 1.0])),
("ymax", floats(&[8.0, 8.0, 8.0])),
]))
.aes(
Aes::new()
.x("x")
.lower("lower")
.middle("middle")
.upper("upper")
.ymin("ymin")
.ymax("ymax"),
)
.geom_boxplot()
.stat(StatIdentity)
.render_svg_native_with_warnings(300, 300)
.unwrap();
assert!(svg.contains(">A<"), "row A (fully numeric) must survive");
assert!(!svg.contains(">B<"), "row B (Na middle) must be dropped");
assert!(!svg.contains(">C<"), "row C (Na upper) must be dropped");
assert!(
warnings
.iter()
.any(|w| w.contains("geom_boxplot") && w.contains("non-finite values")),
"{warnings:?}"
);
}
#[test]
fn tooltip_and_data_attr_reach_the_box() {
let svg = GGPlot::new(frame(vec![
("x", strs(&["A"])),
("lower", floats(&[3.0])),
("middle", floats(&[5.0])),
("upper", floats(&[7.0])),
("ymin", floats(&[1.0])),
("ymax", floats(&[8.0])),
("grade", strs(&["B"])),
]))
.aes(
Aes::new()
.x("x")
.lower("lower")
.middle("middle")
.upper("upper")
.ymin("ymin")
.ymax("ymax")
.data_attr("grade", "grade"),
)
.geom_boxplot()
.stat(StatIdentity)
.tooltip("box {x}: {middle}")
.render_svg_native_with_size(300, 300)
.unwrap();
assert!(svg.contains("<title>box A: 5</title>"), "{svg}");
assert!(svg.contains("data-grade=\"B\""), "{svg}");
}
#[test]
fn five_number_template_formats_through_the_y_scale_including_log10() {
let plain = GGPlot::new(frame(vec![
("x", strs(&["A"])),
("lower", floats(&[10.0])),
("middle", floats(&[100.0])),
("upper", floats(&[1000.0])),
("ymin", floats(&[1.0])),
("ymax", floats(&[10000.0])),
]))
.aes(
Aes::new()
.x("x")
.lower("lower")
.middle("middle")
.upper("upper")
.ymin("ymin")
.ymax("ymax"),
)
.geom_boxplot()
.stat(StatIdentity)
.tooltip("{ymin}/{lower}/{middle}/{upper}/{ymax}")
.render_svg_native_with_size(300, 300)
.unwrap();
assert!(
plain.contains("<title>1/10/100/1000/10000</title>"),
"{plain}"
);
let log10 = GGPlot::new(frame(vec![
("x", strs(&["A"])),
("lower", floats(&[10.0])),
("middle", floats(&[100.0])),
("upper", floats(&[1000.0])),
("ymin", floats(&[1.0])),
("ymax", floats(&[10000.0])),
]))
.aes(
Aes::new()
.x("x")
.lower("lower")
.middle("middle")
.upper("upper")
.ymin("ymin")
.ymax("ymax"),
)
.geom_boxplot()
.stat(StatIdentity)
.scale_y_log10()
.tooltip("{ymin}/{lower}/{middle}/{upper}/{ymax}")
.render_svg_native_with_size(300, 300)
.unwrap();
assert!(
log10.contains("<title>1/10/100/1000/10000</title>"),
"{log10}"
);
}
#[test]
fn missing_ymin_ymax_under_identity_is_a_validation_error() {
let result = GGPlot::new(frame(vec![
("x", strs(&["A"])),
("lower", floats(&[3.0])),
("middle", floats(&[5.0])),
("upper", floats(&[7.0])),
]))
.aes(
Aes::new()
.x("x")
.lower("lower")
.middle("middle")
.upper("upper"),
)
.geom_boxplot()
.stat(StatIdentity)
.render_svg_native_with_size(300, 300);
assert!(
matches!(result, Err(GGError::ValidationError(_))),
"{result:?}"
);
}
#[test]
fn raw_unmapped_ymin_ymax_under_identity_is_a_validation_error() {
let result = GGPlot::new(frame(vec![
("x", strs(&["A"])),
("lower", floats(&[3.0])),
("middle", floats(&[5.0])),
("upper", floats(&[7.0])),
("ymin", floats(&[1.0])), ("ymax", floats(&[9.0])), ]))
.aes(
Aes::new()
.x("x")
.lower("lower")
.middle("middle")
.upper("upper"),
)
.geom_boxplot()
.stat(StatIdentity)
.render_svg_native_with_size(300, 300);
assert!(
matches!(result, Err(GGError::ValidationError(_))),
"{result:?}"
);
}
#[test]
fn numeric_outliers_cell_is_treated_as_a_single_outlier() {
let (svg, domain) = render_with_ydomain(
GGPlot::new(frame(vec![
("x", strs(&["A"])),
("lower", floats(&[3.0])),
("middle", floats(&[5.0])),
("upper", floats(&[7.0])),
("ymin", floats(&[1.0])),
("ymax", floats(&[8.0])),
("outliers", floats(&[50.0])),
]))
.aes(
Aes::new()
.x("x")
.lower("lower")
.middle("middle")
.upper("upper")
.ymin("ymin")
.ymax("ymax")
.outliers("outliers"),
)
.geom_boxplot()
.stat(StatIdentity),
300,
300,
);
assert_eq!(svg.matches("<circle ").count(), 1, "{svg}");
assert!(
domain.1 >= 50.0 - 1e-6,
"a numeric outliers cell must still widen y: {domain:?}"
);
}
#[test]
fn raw_ymin_final_column_does_not_leak_into_an_unrelated_geoms_y_domain() {
let (_, domain) = render_with_ydomain(
GGPlot::new(frame(vec![
("x", floats(&[1.0, 2.0, 3.0])),
("y", floats(&[10.0, 20.0, 30.0])),
("ymin_final", floats(&[-500.0, -500.0, -500.0])),
]))
.aes(Aes::new().x("x").y("y"))
.geom_point(),
300,
300,
);
assert!(
domain.0 > -100.0,
"a raw, unmapped `ymin_final` column must not widen geom_point's y domain: {domain:?}"
);
}
#[test]
fn forecast_shaped_unmapped_lower_upper_columns_do_not_affect_a_plain_line() {
let y = [10.0, 9.9, 11.2, 10.5, 12.1, 11.8];
let n = y.len();
let (svg, warnings) = GGPlot::new(frame(vec![
("x", floats(&(0..n).map(|i| i as f64).collect::<Vec<_>>())),
("y", floats(&y)),
("lower", vec![Value::Na; n]),
("upper", vec![Value::Na; n]),
]))
.aes(Aes::new().x("x").y("y"))
.geom_line()
.geom_point()
.render_svg_native_with_warnings(300, 200)
.unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(
svg.matches("<circle ").count(),
n,
"all {n} history points must survive: {svg}"
);
let dom = svg
.split("data-ydomain=\"")
.nth(1)
.and_then(|r| r.split('"').next())
.unwrap();
let hi: f64 = dom.split(' ').nth(1).unwrap().parse().unwrap();
assert!(
hi < 20.0,
"the y domain must reflect only the mapped y data (9.9-12.1-ish), not a stretch: {dom}"
);
}
#[test]
fn forecast_shaped_unmapped_wide_lower_upper_values_do_not_stretch_y() {
let y = [10.0, 10.5, 11.0, 11.5, 12.0];
let n = y.len();
let (_, domain) = render_with_ydomain(
GGPlot::new(frame(vec![
("x", floats(&(0..n).map(|i| i as f64).collect::<Vec<_>>())),
("y", floats(&y)),
("lower", floats(&vec![-100.0; n])),
("upper", floats(&vec![500.0; n])),
]))
.aes(Aes::new().x("x").y("y"))
.geom_line(),
300,
200,
);
assert!(
domain.0 > -20.0 && domain.1 < 30.0,
"unmapped lower=-100/upper=500 must not stretch y from ~9.9-12.1: {domain:?}"
);
}
#[test]
fn facet_wrap_on_an_unmapped_upper_column_still_splits_into_two_panels() {
let built = GGPlot::new(frame(vec![
("x", floats(&[0.0, 1.0, 2.0, 3.0])),
("y", floats(&[10.0, 11.0, 10.5, 11.5])),
("upper", strs(&["A", "A", "B", "B"])),
]))
.aes(Aes::new().x("x").y("y"))
.geom_line()
.geom_point()
.facet_wrap("upper", None)
.build();
assert_eq!(
built.panels.len(),
2,
"facet_wrap(\"upper\") must still split into 2 panels: {:?}",
built.panels.iter().map(|p| &p.label).collect::<Vec<_>>()
);
}
fn render_with_ydomain(plot: GGPlot, w: u32, h: u32) -> (String, (f64, f64)) {
let svg = plot.render_svg_native_with_size(w, h).unwrap();
let start = svg.find("data-ydomain=\"").unwrap() + "data-ydomain=\"".len();
let rest = &svg[start..];
let end = rest.find('"').unwrap();
let mut it = rest[..end]
.split_whitespace()
.map(|s| s.parse::<f64>().unwrap());
let domain = (it.next().unwrap(), it.next().unwrap());
(svg, domain)
}