use ggplot_rs::prelude::*;
fn plot_with_y_magnitude(magnitude: f64, x_lo: f64, x_hi: f64) -> GGPlot {
let n = 20;
let x: Vec<Value> = (0..n)
.map(|i| Value::Float(x_lo + (x_hi - x_lo) * i as f64 / (n - 1) as f64))
.collect();
let y: Vec<Value> = (0..n)
.map(|i| Value::Float(magnitude + (i as f64 - n as f64 / 2.0)))
.collect();
GGPlot::new(vec![("x".to_string(), x), ("y".to_string(), y)])
.aes(Aes::new().x("x").y("y"))
.geom_line()
}
fn plot_rects(svg: &str) -> Vec<[f64; 4]> {
svg.match_indices("data-plot=\"")
.map(|(at, m)| {
let start = at + m.len();
let rest = &svg[start..];
let end = rest.find('"').unwrap();
let v: Vec<f64> = rest[..end]
.split_whitespace()
.map(|t| t.parse().unwrap())
.collect();
v.try_into().unwrap()
})
.collect()
}
fn xdomains(svg: &str) -> Vec<(f64, f64)> {
svg.match_indices("data-xdomain=\"")
.map(|(at, m)| {
let start = at + m.len();
let rest = &svg[start..];
let end = rest.find('"').unwrap();
let v: Vec<f64> = rest[..end]
.split_whitespace()
.map(|t| t.parse().unwrap())
.collect();
(v[0], v[1])
})
.collect()
}
fn three_plots() -> Vec<GGPlot> {
vec![
plot_with_y_magnitude(1.0, 0.0, 100.0),
plot_with_y_magnitude(1_234_567.0, 10.0, 90.0),
plot_with_y_magnitude(42.0, 0.0, 50.0),
]
}
#[test]
fn collect_x_aligns_plot_areas_and_shares_one_x_domain() {
let svg = PlotGrid::new()
.add_all(three_plots())
.ncol(1)
.axis_collect(AxisCollect::CollectX)
.render_svg_native_with_size(800, 600)
.expect("render");
let rects = plot_rects(&svg);
assert_eq!(rects.len(), 3, "expected 3 panels: {svg}");
let (x0, _, w0, _) = (rects[0][0], rects[0][1], rects[0][2], rects[0][3]);
for (i, r) in rects.iter().enumerate() {
assert!(
(r[0] - x0).abs() < 1e-6 && (r[2] - w0).abs() < 1e-6,
"panel {i} plot-area x/width {r:?} does not match panel 0's ({x0}, {w0})"
);
}
let domains = xdomains(&svg);
assert_eq!(domains.len(), 3);
let d0 = domains[0];
for (i, d) in domains.iter().enumerate() {
assert!(
(d.0 - d0.0).abs() < 1e-6 && (d.1 - d0.1).abs() < 1e-6,
"panel {i} x domain {d:?} does not match panel 0's {d0:?}"
);
}
assert!(
d0.0 <= 0.5,
"shared domain should cover the widest range: {d0:?}"
);
assert!(
d0.1 >= 99.5,
"shared domain should cover the widest range: {d0:?}"
);
let keep_svg = PlotGrid::new()
.add_all(three_plots())
.ncol(1)
.render_svg_native_with_size(800, 600)
.expect("render");
let collected = panel_fragments(&svg);
let kept = panel_fragments(&keep_svg);
assert_eq!(collected.len(), 3);
assert_eq!(kept.len(), 3);
for i in 0..2 {
assert!(
count_text_elements(collected[i]) < count_text_elements(kept[i]),
"panel {i} should lose its x tick labels under CollectX"
);
}
assert!(
count_text_elements(collected[2]) > 0,
"the bottom panel should still draw its own x tick labels under CollectX"
);
}
fn panel_fragments(svg: &str) -> Vec<&str> {
svg.split("<svg ").skip(2).collect()
}
fn count_text_elements(svg: &str) -> usize {
svg.matches("<text ").count()
}
#[test]
fn keep_is_the_default_and_leaves_panels_unaligned() {
let svg = PlotGrid::new()
.add_all(three_plots())
.ncol(1)
.render_svg_native_with_size(800, 600)
.expect("render");
let rects = plot_rects(&svg);
assert_eq!(rects.len(), 3);
let lefts: Vec<f64> = rects.iter().map(|r| r[0]).collect();
assert!(
(lefts[0] - lefts[1]).abs() > 1.0,
"unaligned panels should have different left edges (1-digit vs 7-digit y labels): {lefts:?}"
);
let domains = xdomains(&svg);
assert_eq!(domains.len(), 3);
assert!(
(domains[0].0 - domains[1].0).abs() > 1.0 || (domains[0].1 - domains[1].1).abs() > 1.0,
"unaligned panels should keep their own, different x domains: {domains:?}"
);
}
#[test]
fn align_alone_equalises_plot_areas_but_keeps_each_panels_own_x_domain() {
let svg = PlotGrid::new()
.add_all(three_plots())
.ncol(1)
.axis_collect(AxisCollect::Align)
.render_svg_native_with_size(800, 600)
.expect("render");
let rects = plot_rects(&svg);
assert_eq!(rects.len(), 3);
let (x0, w0) = (rects[0][0], rects[0][2]);
for (i, r) in rects.iter().enumerate() {
assert!(
(r[0] - x0).abs() < 1e-6 && (r[2] - w0).abs() < 1e-6,
"panel {i} plot-area {r:?} should align under `Align` too"
);
}
let domains = xdomains(&svg);
assert!(
(domains[0].0 - domains[1].0).abs() > 1.0 || (domains[0].1 - domains[1].1).abs() > 1.0,
"`Align` alone must not collect the x domain: {domains:?}"
);
}
fn fragment_sizes(svg: &str) -> Vec<(f64, f64)> {
panel_fragments(svg)
.into_iter()
.map(|frag| {
let tag = &frag[..frag.find('>').unwrap_or(frag.len())];
let attr = |n: &str| -> f64 {
tag.split(&format!(" {n}=\""))
.nth(1)
.and_then(|r| r.split('"').next())
.and_then(|v| v.parse().ok())
.unwrap_or_else(|| panic!("no {n} attr in {tag}"))
};
(attr("width"), attr("height"))
})
.collect()
}
#[test]
fn collect_x_keeps_thin_strip_cells_at_their_requested_height_and_still_aligns_them() {
let chart = plot_with_y_magnitude(1_000.0, 0.0, 100.0);
let strip_a = plot_with_y_magnitude(1.0, 0.0, 100.0);
let strip_b = plot_with_y_magnitude(1_234_567.0, 0.0, 100.0);
let svg = PlotGrid::new()
.add(chart)
.add(strip_a)
.add(strip_b)
.ncol(1)
.heights(&[300.0, 24.0, 24.0])
.axis_collect(AxisCollect::CollectX)
.render_svg_native_with_size(500, 348)
.expect("render");
let rects = plot_rects(&svg);
assert_eq!(rects.len(), 3, "expected 3 panels: {svg}");
let (x0, w0) = (rects[0][0], rects[0][2]);
for (i, r) in rects.iter().enumerate() {
assert!(
(r[0] - x0).abs() < 1e-6 && (r[2] - w0).abs() < 1e-6,
"panel {i} plot-area x/width {r:?} does not match panel 0's ({x0}, {w0})"
);
}
let domains = xdomains(&svg);
assert_eq!(domains.len(), 3);
let d0 = domains[0];
for (i, d) in domains.iter().enumerate() {
assert!(
(d.0 - d0.0).abs() < 1e-6 && (d.1 - d0.1).abs() < 1e-6,
"panel {i} x domain {d:?} does not match panel 0's {d0:?}"
);
}
let sizes = fragment_sizes(&svg);
assert_eq!(sizes.len(), 3);
for (i, (_, h)) in sizes.iter().enumerate().skip(1) {
assert!(
(h - 24.0).abs() <= 1.0,
"strip panel {i} canvas height {h} should be within 1px of the requested 24px"
);
}
for (i, (r, (_, canvas_h))) in rects.iter().zip(&sizes).enumerate().skip(1) {
let (y, h) = (r[1], r[3]);
assert!(
y + h <= canvas_h + 0.5,
"strip panel {i}'s plot area (y={y}, h={h}) overflows its {canvas_h}px canvas"
);
assert!(
h < 49.0,
"strip panel {i} panel height {h} looks like the unfixed 50px floor, not the aspect-ratio correction"
);
}
}
#[test]
fn collect_x_on_a_discrete_x_scale_warns_and_skips_the_domain_only() {
let discrete = |cat: &str| {
GGPlot::new(vec![
("x".to_string(), vec![Value::Str(cat.to_string())]),
("y".to_string(), vec![Value::Float(1.0)]),
])
.aes(Aes::new().x("x").y("y"))
.geom_col()
};
let (_svg, warnings) = PlotGrid::new()
.add(discrete("a"))
.add(discrete("b"))
.ncol(1)
.axis_collect(AxisCollect::CollectX)
.render_svg_native_with_warnings(400, 400)
.expect("render");
assert!(
warnings
.iter()
.any(|w| w.contains("x domain not collected")),
"expected a warning about the discrete x scale: {warnings:?}"
);
}