use ggplot_rs::data::Value;
use ggplot_rs::prelude::*;
type Cols = Vec<(String, Vec<Value>)>;
fn data() -> Cols {
let n = 40;
let x: Vec<Value> = (0..n).map(|i| Value::Float(i as f64 * 0.25)).collect();
let y: Vec<Value> = (0..n)
.map(|i| Value::Float((i as f64 * 0.3).sin() * 3.0 + i as f64 * 0.1))
.collect();
let g: Vec<Value> = (0..n)
.map(|i| Value::Str(["a", "b", "c"][i % 3].to_string()))
.collect();
let w: Vec<Value> = (0..n).map(|i| Value::Float(1.0 + (i % 5) as f64)).collect();
vec![
("x".into(), x),
("y".into(), y),
("g".into(), g),
("w".into(), w),
]
}
fn check(plot: GGPlot, what: &str) -> String {
let svg = plot
.render_svg_native_with_size(480, 320)
.unwrap_or_else(|e| panic!("{what}: {e}"));
assert!(svg.starts_with("<svg"), "{what}: root element");
assert!(
svg.trim_end().ends_with("</svg>"),
"{what}: closed document"
);
assert!(!svg.contains("NaN"), "{what}: NaN leaked into SVG");
svg
}
fn xy() -> GGPlot {
GGPlot::new(data()).aes(Aes::new().x("x").y("y"))
}
fn gy() -> GGPlot {
GGPlot::new(data()).aes(Aes::new().x("g").y("y"))
}
#[test]
fn xy_geoms_render() {
let cases: Vec<(&str, GGPlot)> = vec![
("point", xy().geom_point()),
("line", xy().geom_line()),
("path", xy().geom_path()),
("step", xy().geom_step()),
("area", xy().geom_area()),
("smooth", xy().geom_smooth()),
("jitter", xy().geom_jitter()),
("rug", xy().geom_point().geom_rug()),
("bin2d", xy().geom_bin2d()),
("hex", xy().geom_hex()),
("density2d", xy().geom_density2d()),
("count", xy().geom_count()),
("blank", xy().geom_blank()),
];
for (name, p) in cases {
check(p, name);
}
}
#[test]
fn distribution_geoms_render() {
let x = || GGPlot::new(data()).aes(Aes::new().x("y"));
check(x().geom_histogram(), "histogram");
check(x().geom_density(), "density");
check(x().geom_freqpoly(), "freqpoly");
check(x().geom_dotplot(), "dotplot");
check(GGPlot::new(data()).aes(Aes::new().y("y")).geom_qq(), "qq");
check(gy().geom_boxplot(), "boxplot");
check(gy().geom_violin(), "violin");
check(GGPlot::new(data()).aes(Aes::new().x("g")).geom_bar(), "bar");
check(gy().geom_col(), "col");
}
#[test]
fn grouped_and_coloured_layers_render_legends() {
let svg = check(
GGPlot::new(data())
.aes(Aes::new().x("x").y("y").color("g"))
.geom_point()
.geom_line()
.title("Grouped"),
"grouped",
);
assert!(svg.contains("Grouped"));
for level in ["a", "b", "c"] {
assert!(svg.contains(&format!(">{level}<")), "legend label {level}");
}
}
#[test]
fn coords_facets_and_themes_render() {
check(xy().geom_point().coord_flip(), "coord_flip");
check(xy().geom_point().coord_polar(), "coord_polar");
check(xy().geom_point().coord_fixed(1.0), "coord_fixed");
check(xy().geom_point().facet_wrap("g", Some(2)), "facet_wrap");
check(xy().geom_point().facet_grid(Some("g"), None), "facet_grid");
check(xy().geom_point().scale_y_log10(), "log10");
check(xy().geom_point().theme_minimal(), "theme_minimal");
check(xy().geom_point().theme_dark(), "theme_dark");
check(xy().geom_point().theme_void(), "theme_void");
}
#[test]
fn manual_scales_render() {
let svg = check(
GGPlot::new(data())
.aes(Aes::new().x("x").y("y").color("g"))
.geom_point()
.scale_color_manual(vec![
("a", RGBAColor::new(255, 0, 0)),
("b", RGBAColor::new(0, 128, 0)),
("c", RGBAColor::new(0, 0, 255)),
]),
"manual",
);
assert!(svg.contains("255,0,0") || svg.contains("#ff0000") || svg.contains("#FF0000"));
}
#[test]
fn empty_manual_scales_fall_back_instead_of_panicking() {
let gl = || {
GGPlot::new(data())
.aes(Aes::new().x("x").y("y").color("g").shape("g").linetype("g"))
.geom_point()
.geom_line()
};
check(gl().scale_color_manual(vec![]), "empty color manual");
check(gl().scale_fill_manual(vec![]), "empty fill manual");
check(gl().scale_shape_manual(vec![]), "empty shape manual");
check(gl().scale_linetype_manual(vec![]), "empty linetype manual");
}
#[test]
fn mismatched_column_lengths_are_a_validation_error_not_a_panic() {
let cols: Cols = vec![
("x".into(), vec![Value::Float(1.0), Value::Float(2.0)]),
("y".into(), vec![Value::Float(1.0)]),
];
let plot = || {
GGPlot::new(cols.clone())
.aes(Aes::new().x("x").y("y"))
.geom_point()
};
match plot().render_svg_native() {
Err(GGError::ValidationError(msg)) => assert!(msg.contains("'y'"), "{msg}"),
other => panic!("expected a validation error, got {other:?}"),
}
assert!(plot().try_build().is_err());
let ok: Cols = vec![
("x".into(), vec![Value::Float(1.0)]),
("y".into(), vec![Value::Float(2.0)]),
];
let r = GGPlot::new(ok)
.aes(Aes::new().x("x").y("y"))
.geom_point()
.layer_data(cols.clone())
.render_svg_native();
assert!(matches!(r, Err(GGError::ValidationError(_))), "{r:?}");
}
fn tags<'a>(svg: &'a str, tag: &str) -> Vec<&'a str> {
let open = format!("<{tag} ");
svg.match_indices(&open)
.map(|(i, _)| {
let rest = &svg[i..];
&rest[..rest.find('>').map_or(rest.len(), |e| e + 1)]
})
.collect()
}
#[test]
fn data_marks_carry_a_stable_class() {
let cases: Vec<(&str, GGPlot, &str, &str)> = vec![
("point", xy().geom_point(), "circle", "gg-point"),
("line", xy().geom_line(), "polyline", "gg-line"),
("path", xy().geom_path(), "polyline", "gg-line"),
("step", xy().geom_step(), "polyline", "gg-line"),
("area", xy().geom_area(), "polygon", "gg-band"),
("smooth", xy().geom_smooth(), "polygon", "gg-band"),
("col", gy().geom_col(), "rect", "gg-bar"),
(
"bar",
GGPlot::new(data()).aes(Aes::new().x("g")).geom_bar(),
"rect",
"gg-bar",
),
(
"text",
GGPlot::new(data())
.aes(Aes::new().x("x").y("y").label("g"))
.geom_text(),
"text",
"gg-text",
),
];
for (geom, plot, tag, class) in cases {
let svg = check(plot, geom);
let want = format!("class=\"gg-mark {class}\"");
assert!(
tags(&svg, tag).iter().any(|t| t.contains(&want)),
"{geom}: no <{tag}> carries {want}"
);
assert!(
svg.contains(&format!("data-geom=\"{geom}\"")),
"{geom}: data-geom missing"
);
}
}
#[test]
fn grid_axes_and_legends_carry_no_mark_class() {
for plot in [
xy().geom_line(),
GGPlot::new(data())
.aes(Aes::new().x("x").y("y").color("g"))
.geom_line(),
xy().geom_line().facet_wrap("g", None),
] {
let svg = check(plot, "line");
let lines = tags(&svg, "polyline");
let bare: Vec<_> = lines.iter().filter(|t| !t.contains("gg-mark")).collect();
assert!(!bare.is_empty(), "grid lines are drawn");
assert!(
lines
.iter()
.any(|t| t.contains("class=\"gg-mark gg-line\"")),
"the data line is classed"
);
assert!(!svg.contains("gg-text"), "axis/legend text is unclassed");
assert!(tags(&svg, "rect").iter().all(|t| !t.contains("gg-mark")));
}
}
#[test]
fn arrow_heads_carry_gg_arrow_not_the_parents_kind() {
let svg = GGPlot::new(vec![
("x".into(), vec![Value::Float(0.0), Value::Float(0.2)]),
("y".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
("xend".into(), vec![Value::Float(1.0), Value::Float(0.8)]),
("yend".into(), vec![Value::Float(0.5), Value::Float(0.3)]),
])
.aes(Aes::new().x("x").y("y").xend("xend").yend("yend"))
.geom_segment_with(GeomSegment::default().arrow(arrow().closed()))
.render_svg_native_with_size(200, 200)
.unwrap();
let heads: Vec<&str> = tags(&svg, "polygon")
.into_iter()
.filter(|t| t.contains("data-part=\"arrow\""))
.collect();
assert_eq!(heads.len(), 2, "one arrowhead per row: {svg}");
for head in &heads {
assert!(
head.contains("class=\"gg-mark gg-arrow\""),
"arrowhead should carry gg-arrow, not the parent segment's kind: {head}"
);
assert!(
head.contains("data-geom=\"segment\""),
"arrowhead keeps the parent layer's data-geom: {head}"
);
}
}
#[test]
fn no_element_ever_carries_two_class_attributes() {
let plots: Vec<(&str, String)> = vec![
(
"segment+arrow",
GGPlot::new(vec![
("x".into(), vec![Value::Float(0.0), Value::Float(0.2)]),
("y".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
("xend".into(), vec![Value::Float(1.0), Value::Float(0.8)]),
("yend".into(), vec![Value::Float(0.5), Value::Float(0.3)]),
])
.aes(Aes::new().x("x").y("y").xend("xend").yend("yend"))
.geom_segment_with(GeomSegment::default().arrow(arrow().closed()))
.render_svg_native_with_size(200, 200)
.unwrap(),
),
(
"colour legend",
GGPlot::new(data())
.aes(Aes::new().x("x").y("y").color("g"))
.geom_point()
.render_svg_native_with_size(400, 300)
.unwrap(),
),
(
"facets",
xy().geom_line()
.facet_wrap("g", None)
.render_svg_native_with_size(400, 300)
.unwrap(),
),
];
for (name, svg) in plots {
for tag in ["circle", "polyline", "polygon", "rect", "path", "text"] {
for t in tags(&svg, tag) {
let n = t.matches("class=\"").count();
assert!(n <= 1, "{name}: {tag} has {n} class attributes: {t}");
}
}
}
}
#[test]
fn plotgrid_marks_are_classed() {
let p = || GGPlot::new(data()).aes(Aes::new().x("x").y("y"));
let svg = (p().geom_point() | p().geom_line())
.render_svg_native_with_size(600, 300)
.unwrap();
assert!(!svg.contains("NaN"));
assert!(
tags(&svg, "circle")
.iter()
.any(|t| t.contains("class=\"gg-mark gg-point\"")),
"left cell's points are classed: {svg}"
);
assert!(
tags(&svg, "polyline")
.iter()
.any(|t| t.contains("class=\"gg-mark gg-line\"")),
"right cell's line is classed"
);
}
#[test]
fn reflines_carry_their_specific_data_geom() {
let base = || {
GGPlot::new(data())
.aes(Aes::new().x("x").y("y"))
.geom_point()
};
let hline = base()
.geom_hline(1.0)
.render_svg_native_with_size(200, 200)
.unwrap();
assert!(hline.contains("data-geom=\"hline\""));
let vline = base()
.geom_vline(1.0)
.render_svg_native_with_size(200, 200)
.unwrap();
assert!(vline.contains("data-geom=\"vline\""));
let abline = base()
.geom_abline(1.0, 0.0)
.render_svg_native_with_size(200, 200)
.unwrap();
assert!(abline.contains("data-geom=\"abline\""));
}
#[test]
fn default_opacity_is_omitted_but_a_set_alpha_still_writes_it() {
let default_svg = GGPlot::new(data())
.aes(Aes::new().x("x").y("y"))
.geom_point()
.geom_line()
.render_svg_native_with_size(300, 200)
.unwrap();
assert!(!default_svg.contains("-opacity=\"1.000\""), "{default_svg}");
assert!(!default_svg.contains("-opacity="), "{default_svg}");
let faded_svg = GGPlot::new(data())
.aes(Aes::new().x("x").y("y"))
.geom_point_with(GeomPoint {
alpha: 0.4,
..Default::default()
})
.render_svg_native_with_size(300, 200)
.unwrap();
assert!(faded_svg.contains("fill-opacity=\"0.400\""), "{faded_svg}");
}