#![allow(clippy::useless_conversion)]
use super::*;
use crate::core::{
FigureConfig, LineConfig as CoreLineConfig, MarginConfig, SpineConfig as CoreSpineConfig,
};
use tempfile::tempdir;
#[derive(Debug)]
struct FailingIngestionData;
impl crate::data::NumericData1D for FailingIngestionData {
fn len(&self) -> usize {
3
}
fn try_collect_f64_with_policy(
&self,
_null_policy: crate::data::NullPolicy,
) -> crate::core::Result<Vec<f64>> {
Err(PlottingError::DataExtractionFailed {
origin: "test::failing-ingestion".to_string(),
message: "forced ingestion failure".to_string(),
})
}
}
fn parse_svg_attr(line: &str, attr: &str) -> f32 {
let marker = format!(r#"{}=""#, attr);
let start = line
.find(&marker)
.unwrap_or_else(|| panic!("missing {} in line: {}", attr, line))
+ marker.len();
let end = line[start..]
.find('"')
.unwrap_or_else(|| panic!("unterminated {} in line: {}", attr, line))
+ start;
line[start..end]
.parse::<f32>()
.unwrap_or_else(|_| panic!("invalid {} value in line: {}", attr, line))
}
fn extract_svg_text_xy(svg: &str, text: &str) -> (f32, f32) {
let marker = format!(">{}</text>", text);
let line = svg
.lines()
.find(|line| line.contains(&marker))
.unwrap_or_else(|| panic!("missing text node for {}", text));
(parse_svg_attr(line, "x"), parse_svg_attr(line, "y"))
}
fn extract_svg_text_font_size(svg: &str, text: &str) -> f32 {
let marker = format!(">{}</text>", text);
let line = svg
.lines()
.find(|line| line.contains(&marker))
.unwrap_or_else(|| panic!("missing text node for {}", text));
parse_svg_attr(line, "font-size")
}
fn extract_svg_group_translate_xy(svg: &str, text: &str) -> (f32, f32) {
let marker = format!(">{}</text>", text);
let line = svg
.lines()
.find(|line| line.contains(&marker))
.unwrap_or_else(|| panic!("missing grouped text node for {}", text));
let transform_marker = r#"transform="translate("#;
let start = line
.find(transform_marker)
.unwrap_or_else(|| panic!("missing translate transform for {}", text))
+ transform_marker.len();
let end = line[start..]
.find(')')
.unwrap_or_else(|| panic!("unterminated translate transform for {}", text))
+ start;
let coords = &line[start..end];
let mut parts = coords.split(',');
let x = parts
.next()
.unwrap_or_else(|| panic!("missing translate x for {}", text))
.parse::<f32>()
.unwrap_or_else(|_| panic!("invalid translate x for {}", text));
let y = parts
.next()
.unwrap_or_else(|| panic!("missing translate y for {}", text))
.parse::<f32>()
.unwrap_or_else(|_| panic!("invalid translate y for {}", text));
(x, y)
}
fn extract_first_svg_polyline_points(svg: &str) -> Vec<(f32, f32)> {
let line = svg
.lines()
.find(|line| line.contains("<polyline "))
.expect("missing polyline");
let marker = r#"points=""#;
let start = line
.find(marker)
.expect("missing polyline points attribute")
+ marker.len();
let end = line[start..]
.find('"')
.expect("unterminated polyline points attribute")
+ start;
line[start..end]
.split_whitespace()
.map(|pair| {
let mut coords = pair.split(',');
let x = coords
.next()
.expect("missing point x")
.parse::<f32>()
.expect("invalid point x");
let y = coords
.next()
.expect("missing point y")
.parse::<f32>()
.expect("invalid point y");
(x, y)
})
.collect()
}
fn count_short_horizontal_svg_lines(svg: &str, max_length: f32) -> usize {
svg.lines()
.filter(|line| line.contains("<line "))
.filter(|line| {
let x1 = parse_svg_attr(line, "x1");
let x2 = parse_svg_attr(line, "x2");
let y1 = parse_svg_attr(line, "y1");
let y2 = parse_svg_attr(line, "y2");
(y1 - y2).abs() <= 0.1 && (x2 - x1).abs() > 0.5 && (x2 - x1).abs() <= max_length
})
.count()
}
fn mean_rgb(pixels: &[u8]) -> (f64, f64, f64) {
let total = pixels.chunks_exact(4).len() as f64;
let (r, g, b) = pixels
.chunks_exact(4)
.fold((0_u64, 0_u64, 0_u64), |(r, g, b), px| {
(r + px[0] as u64, g + px[1] as u64, b + px[2] as u64)
});
(r as f64 / total, g as f64 / total, b as f64 / total)
}
fn dark_pixel_fraction(pixels: &[u8]) -> f64 {
let total = pixels.chunks_exact(4).len() as f64;
let dark = pixels
.chunks_exact(4)
.filter(|px| px[0] < 32 && px[1] < 32 && px[2] < 32)
.count() as f64;
dark / total
}
fn non_background_fraction(pixels: &[u8]) -> f64 {
let total = pixels.chunks_exact(4).len() as f64;
let non_background = pixels
.chunks_exact(4)
.filter(|px| px[3] > 0 && (px[0] < 248 || px[1] < 248 || px[2] < 248))
.count() as f64;
non_background / total
}
fn decode_png_rgba(png_bytes: &[u8]) -> ::image::RgbaImage {
::image::load_from_memory(png_bytes)
.expect("PNG bytes should decode")
.to_rgba8()
}
fn plot_image_to_rgba(image: &Image) -> ::image::RgbaImage {
decode_png_rgba(
&image
.encode_png()
.expect("plot image should encode to straight-alpha PNG"),
)
}
fn mean_normalized_rgba_diff(lhs: &::image::RgbaImage, rhs: &::image::RgbaImage) -> f64 {
assert_eq!(lhs.dimensions(), rhs.dimensions());
lhs.as_raw()
.iter()
.zip(rhs.as_raw().iter())
.map(|(left, right)| (*left as f64 - *right as f64).abs() / 255.0)
.sum::<f64>()
/ lhs.as_raw().len() as f64
}
fn fraction_pixels_within_channel_delta(
lhs: &::image::RgbaImage,
rhs: &::image::RgbaImage,
max_delta: u8,
) -> f64 {
assert_eq!(lhs.dimensions(), rhs.dimensions());
let matching = lhs
.pixels()
.zip(rhs.pixels())
.filter(|(left, right)| {
left.0
.iter()
.zip(right.0.iter())
.all(|(lhs, rhs)| (*lhs as i16 - *rhs as i16).abs() <= max_delta as i16)
})
.count() as f64;
matching / (lhs.width() * lhs.height()) as f64
}
fn assert_rgba_parity_against_reference(
name: &str,
reference: &::image::RgbaImage,
candidate: &::image::RgbaImage,
) {
let mean_diff = mean_normalized_rgba_diff(reference, candidate);
let within_delta = fraction_pixels_within_channel_delta(reference, candidate, 24);
let reference_ink = non_background_fraction(reference.as_raw());
let candidate_ink = non_background_fraction(candidate.as_raw());
assert!(
mean_diff <= 0.015,
"{name} drifted too far from the reference render: mean_diff={mean_diff:.6}"
);
assert!(
within_delta >= 0.99,
"{name} has too many per-pixel outliers relative to the reference render: within_delta={within_delta:.4}"
);
assert!(
(reference_ink - candidate_ink).abs() <= 0.10,
"{name} changed visible ink coverage too much: reference_ink={reference_ink:.4} candidate_ink={candidate_ink:.4}"
);
}
fn assert_plot_image_parity_against_reference(name: &str, reference: &Image, candidate: &Image) {
let reference_rgba = plot_image_to_rgba(reference);
let candidate_rgba = plot_image_to_rgba(candidate);
assert_rgba_parity_against_reference(name, &reference_rgba, &candidate_rgba);
}
fn assert_plot_image_exact_rgba_match(name: &str, reference: &Image, candidate: &Image) {
assert_eq!(reference.width, candidate.width, "{name} width changed");
assert_eq!(reference.height, candidate.height, "{name} height changed");
assert_eq!(
reference.pixels, candidate.pixels,
"{name} RGBA pixels changed"
);
}
fn assert_png_parity_against_reference(name: &str, reference: &Image, candidate_png: &[u8]) {
let reference_rgba = plot_image_to_rgba(reference);
let candidate_rgba = decode_png_rgba(candidate_png);
assert_rgba_parity_against_reference(name, &reference_rgba, &candidate_rgba);
}
fn assert_png_background_preserved(name: &str, png_bytes: &[u8]) {
let image = decode_png_rgba(png_bytes);
let (mean_r, mean_g, mean_b) = mean_rgb(image.as_raw());
let dark_fraction = dark_pixel_fraction(image.as_raw());
assert!(
mean_r > 180.0 && mean_g > 180.0 && mean_b > 180.0,
"{name} PNG unexpectedly dark: mean=({mean_r:.2}, {mean_g:.2}, {mean_b:.2})"
);
assert!(
dark_fraction < 0.25,
"{name} PNG unexpectedly blacks out the canvas: dark_fraction={dark_fraction:.4}"
);
}
fn assert_png_visual_sane_against_blank(name: &str, png_bytes: &[u8], blank_png_bytes: &[u8]) {
let image = decode_png_rgba(png_bytes);
let blank = decode_png_rgba(blank_png_bytes);
let dark_fraction = dark_pixel_fraction(image.as_raw());
let ink_fraction = non_background_fraction(image.as_raw());
let diff = mean_normalized_rgba_diff(&image, &blank);
assert!(
dark_fraction < 0.8,
"{name} PNG is unexpectedly dominated by near-black pixels: dark_fraction={dark_fraction:.4}"
);
assert!(
ink_fraction > 0.001,
"{name} PNG does not appear to contain visible plot ink: ink_fraction={ink_fraction:.4}"
);
assert!(
diff > 0.002,
"{name} PNG is too close to a blank baseline render: diff={diff:.6}"
);
}
fn large_xy_data() -> (Vec<f64>, Vec<f64>) {
let x: Vec<f64> = (0..100_000).map(|index| index as f64 * 0.0001).collect();
let y: Vec<f64> = x
.iter()
.map(|value| value.sin() + 0.2 * (value * 3.0).cos())
.collect();
(x, y)
}
fn large_error_bar_xy_data() -> (Vec<f64>, Vec<f64>) {
let x: Vec<f64> = (0..25_000).map(|index| index as f64 * 0.0004).collect();
let y: Vec<f64> = x
.iter()
.map(|value| value.sin() + 0.2 * (value * 3.0).cos())
.collect();
(x, y)
}
fn large_scalar_samples() -> Vec<f64> {
(0..100_000)
.map(|index| {
let value = index as f64 * 0.0002;
value.sin() + 0.35 * (value * 1.7).cos()
})
.collect()
}
fn large_bar_data() -> (Vec<String>, Vec<f64>) {
let categories = (0..20_000).map(|index| format!("c{index}")).collect();
let values = (0..20_000)
.map(|index| {
let value = index as f64 * 0.00015;
1.0 + 0.45 * value.sin() + 0.1 * (value * 4.0).cos()
})
.collect();
(categories, values)
}
fn large_heatmap_matrix() -> Vec<Vec<f64>> {
let rows = 320usize;
let cols = 320usize;
(0..rows)
.map(|row| {
let y = -1.0 + 2.0 * row as f64 / (rows.saturating_sub(1)) as f64;
(0..cols)
.map(|col| {
let x = -1.0 + 2.0 * col as f64 / (cols.saturating_sub(1)) as f64;
let ridge = (-((x - 0.25).powi(2) + (y + 0.1).powi(2)) * 9.0).exp();
let waves = 0.35 * (x * 8.0).sin() * (y * 6.0).cos();
ridge + waves
})
.collect()
})
.collect()
}
fn large_contour_axes() -> (Vec<f64>, Vec<f64>, Vec<f64>) {
let x: Vec<f64> = (0..320)
.map(|index| -2.0 + 4.0 * index as f64 / 319.0)
.collect();
let y: Vec<f64> = (0..320)
.map(|index| -2.0 + 4.0 * index as f64 / 319.0)
.collect();
let mut z = Vec::with_capacity(x.len() * y.len());
for y_value in &y {
for x_value in &x {
let saddle = x_value.powi(2) - y_value.powi(2);
let ripple = 0.25 * (x_value * 3.0).sin() * (y_value * 2.0).cos();
z.push(saddle + ripple);
}
}
(x, y, z)
}
fn large_polar_data() -> (Vec<f64>, Vec<f64>) {
let theta: Vec<f64> = (0..100_000)
.map(|index| index as f64 * std::f64::consts::TAU / 10_000.0)
.collect();
let r: Vec<f64> = theta
.iter()
.map(|value| 1.0 + 0.25 * (value * 2.0).sin() + 0.1 * (value * 7.0).cos())
.collect();
(r, theta)
}
fn blank_large_plot_png() -> Vec<u8> {
Plot::new()
.size_px(320, 200)
.ticks(false)
.render_png_bytes()
.expect("blank large-plot baseline should render")
}
#[cfg(not(target_arch = "wasm32"))]
fn render_plot_to_renderer_png(plot: &Plot, width: u32, height: u32) -> Vec<u8> {
let mut renderer =
crate::render::SkiaRenderer::new(width, height, crate::render::Theme::default())
.expect("renderer should be created");
renderer.clear();
plot.render_to_renderer(&mut renderer, plot.display.dpi as f32)
.expect("plot should render to external renderer");
renderer
.encode_png_bytes()
.expect("renderer output should encode as PNG")
}
fn assert_large_plot_png_and_save(name: &str, plot: &Plot) {
let blank_png = blank_large_plot_png();
let rendered_png = plot
.render_png_bytes()
.unwrap_or_else(|err| panic!("{name} should render as PNG: {err}"));
assert_png_visual_sane_against_blank(name, &rendered_png, &blank_png);
let tempdir = tempdir().expect("tempdir should be created");
let output = tempdir.path().join(format!("{name}.png"));
plot.clone()
.save(&output)
.unwrap_or_else(|err| panic!("{name} should save as PNG: {err}"));
let saved_png = std::fs::read(&output).expect("saved PNG should be readable");
assert_png_visual_sane_against_blank(&format!("{name} saved"), &saved_png, &blank_png);
}
#[test]
fn test_plot_series_static_source_helpers_materialize_values() {
let mut series = PlotSeries {
series_type: SeriesType::Line {
x_data: PlotData::Static(vec![0.0, 1.0]),
y_data: PlotData::Static(vec![1.0, 2.0]),
},
streaming_source: None,
label: None,
props: SeriesStyleProps::default(),
marker_edge: None,
y_errors: None,
x_errors: None,
error_config: None,
inset_layout: None,
group_id: None,
resolved_radar_colors: None,
};
series.props.color.set(Color::RED.into());
series.props.line_width.set(0.01_f32.into());
series.props.line_style.set(LineStyle::Dashed.into());
series.props.marker_style.set(MarkerStyle::Square.into());
series.props.marker_size.set(0.01_f32.into());
series.props.alpha.set(1.5_f32.into());
assert_eq!(series.props.color.cloned(), Some(Color::RED));
assert!(series.props.color.source().is_none());
assert_eq!(series.props.line_width.cloned(), Some(0.1));
assert!(series.props.line_width.source().is_none());
assert_eq!(series.props.line_style.cloned(), Some(LineStyle::Dashed));
assert!(series.props.line_style.source().is_none());
assert_eq!(
series.props.marker_style.cloned(),
Some(MarkerStyle::Square)
);
assert!(series.props.marker_style.source().is_none());
assert_eq!(series.props.marker_size.cloned(), Some(0.1));
assert!(series.props.marker_size.source().is_none());
assert_eq!(series.props.alpha.cloned(), Some(1.0));
assert!(series.props.alpha.source().is_none());
}
#[test]
fn test_series_group_builder_static_source_setters_materialize_values() {
let plot = Plot::new().group(|group| {
group
.color_source(Color::RED)
.line_width_source(0.01_f32)
.line_style_source(LineStyle::Dashed)
.alpha_source(1.5_f32)
.line(&[0.0, 1.0], &[1.0, 2.0])
});
let series = &plot.series_mgr.series[0];
assert_eq!(series.props.color.cloned(), Some(Color::RED));
assert!(series.props.color.source().is_none());
assert_eq!(series.props.line_width.cloned(), Some(0.1));
assert!(series.props.line_width.source().is_none());
assert_eq!(series.props.line_style.cloned(), Some(LineStyle::Dashed));
assert!(series.props.line_style.source().is_none());
assert_eq!(series.props.alpha.cloned(), Some(1.0));
assert!(series.props.alpha.source().is_none());
}
fn extract_svg_root_attr(svg: &str, attr: &str) -> f32 {
let line = svg
.lines()
.find(|line| line.contains("<svg"))
.unwrap_or_else(|| panic!("missing svg root"));
parse_svg_attr(line, attr)
}
fn extract_first_svg_polyline_stroke_width(svg: &str) -> f32 {
let line = svg
.lines()
.find(|line| line.contains("<polyline"))
.unwrap_or_else(|| panic!("missing polyline element"));
parse_svg_attr(line, "stroke-width")
}
fn extract_first_svg_line_stroke_width(svg: &str) -> f32 {
let line = svg
.lines()
.find(|line| line.contains("<line"))
.unwrap_or_else(|| panic!("missing line element"));
parse_svg_attr(line, "stroke-width")
}
fn extract_first_stroked_svg_polygon_stroke_width(svg: &str) -> f32 {
let line = svg
.lines()
.find(|line| line.contains("<polygon") && line.contains("stroke-width"))
.unwrap_or_else(|| panic!("missing stroked polygon element"));
parse_svg_attr(line, "stroke-width")
}
fn image_pixel_is_dark(image: &Image, x: u32, y: u32) -> bool {
let idx = ((y * image.width + x) * 4) as usize;
image.pixels[idx..idx + 3]
.iter()
.all(|channel| *channel < 220)
}
fn image_pixel_is_red(image: &Image, x: u32, y: u32) -> bool {
let rgba = image_pixel_rgba(image, x, y);
rgba[0] > 180 && rgba[1] < 120 && rgba[2] < 120 && rgba[3] > 128
}
fn image_has_dark_pixel_near(image: &Image, x: u32, y: u32, radius: u32) -> bool {
let x_start = x.saturating_sub(radius);
let x_end = (x + radius).min(image.width.saturating_sub(1));
let y_start = y.saturating_sub(radius);
let y_end = (y + radius).min(image.height.saturating_sub(1));
for sample_y in y_start..=y_end {
for sample_x in x_start..=x_end {
if image_pixel_is_dark(image, sample_x, sample_y) {
return true;
}
}
}
false
}
fn image_has_red_pixel_near(image: &Image, x: u32, y: u32, radius: u32) -> bool {
let x_start = x.saturating_sub(radius);
let x_end = (x + radius).min(image.width.saturating_sub(1));
let y_start = y.saturating_sub(radius);
let y_end = (y + radius).min(image.height.saturating_sub(1));
for sample_y in y_start..=y_end {
for sample_x in x_start..=x_end {
if image_pixel_is_red(image, sample_x, sample_y) {
return true;
}
}
}
false
}
fn image_pixel_rgba(image: &Image, x: u32, y: u32) -> [u8; 4] {
let idx = ((y * image.width + x) * 4) as usize;
[
image.pixels[idx],
image.pixels[idx + 1],
image.pixels[idx + 2],
image.pixels[idx + 3],
]
}
fn longest_dark_pixel_run_at_y(image: &Image, y: u32) -> usize {
let mut longest = 0;
let mut current = 0;
for x in 0..image.width {
if image_pixel_is_dark(image, x, y) {
current += 1;
longest = longest.max(current);
} else {
current = 0;
}
}
longest
}
fn dark_pixel_run_right_from(image: &Image, x: u32, y: u32) -> usize {
let mut current = 0;
for sample_x in x..image.width {
if image_pixel_is_dark(image, sample_x, y) {
current += 1;
} else if current > 0 {
break;
}
}
current
}
fn middle_x_tick_pixel(plot: &Plot, plot_area: tiny_skia::Rect) -> u32 {
let (x_min, x_max, y_min, y_max) = plot
.effective_data_bounds()
.expect("data bounds should be available");
let (x_ticks, _) = plot.configured_major_ticks(x_min, x_max, y_min, y_max);
assert!(!x_ticks.is_empty(), "plot should have at least one x tick");
let tick = x_ticks[x_ticks.len() / 2];
crate::render::skia::map_data_to_pixels(tick, 0.0, x_min, x_max, y_min, y_max, plot_area)
.0
.round() as u32
}
fn dark_pixel_run_down_from(image: &Image, x: u32, y: u32) -> usize {
let mut current = 0;
for sample_y in y..image.height {
if image_pixel_is_dark(image, x, sample_y) {
current += 1;
} else if current > 0 {
break;
}
}
current
}
fn mean_normalized_channel_diff(lhs: &Image, rhs: &Image) -> f64 {
assert_eq!(lhs.width, rhs.width);
assert_eq!(lhs.height, rhs.height);
lhs.pixels
.iter()
.zip(&rhs.pixels)
.map(|(left, right)| (*left as f64 - *right as f64).abs() / 255.0)
.sum::<f64>()
/ lhs.pixels.len() as f64
}
fn compute_render_plot_area(plot: &Plot) -> tiny_skia::Rect {
let layout = compute_render_layout(plot);
Plot::plot_area_from_layout(&layout).expect("valid plot area")
}
fn compute_render_layout(plot: &Plot) -> ResolvedLayout {
let (x_min, x_max, y_min, y_max) = plot
.effective_data_bounds()
.expect("data bounds should be available");
let content = plot.create_plot_content(y_min, y_max);
let mut measurement_renderer = crate::render::SkiaRenderer::new(
plot.display.dimensions.0,
plot.display.dimensions.1,
plot.display.theme.clone(),
)
.expect("measurement renderer");
measurement_renderer.set_render_scale(plot.render_scale());
measurement_renderer.set_text_engine_mode(plot.display.text_engine);
let (layout, _, _) = plot
.compute_layout_with_configured_ticks(
&measurement_renderer,
plot.display.dimensions,
&content,
plot.display.config.figure.dpi,
x_min,
x_max,
y_min,
y_max,
)
.expect("configured layout with tick measurements");
layout
}
fn compute_render_tick_probe_points(plot: &Plot) -> ((u32, u32), (u32, u32)) {
let (x_min, x_max, y_min, y_max) = plot
.effective_data_bounds()
.expect("data bounds should be available");
let content = plot.create_plot_content(y_min, y_max);
let mut measurement_renderer = crate::render::SkiaRenderer::new(
plot.display.dimensions.0,
plot.display.dimensions.1,
plot.display.theme.clone(),
)
.expect("measurement renderer");
measurement_renderer.set_render_scale(plot.render_scale());
measurement_renderer.set_text_engine_mode(plot.display.text_engine);
let (layout, x_ticks, y_ticks) = plot
.compute_layout_with_configured_ticks(
&measurement_renderer,
plot.display.dimensions,
&content,
plot.display.config.figure.dpi,
x_min,
x_max,
y_min,
y_max,
)
.expect("configured layout with tick measurements");
let plot_area = Plot::plot_area_from_layout(&layout).expect("valid plot area");
let x_tick_pixels: Vec<f32> = x_ticks
.iter()
.map(|&tick| {
crate::render::skia::map_data_to_pixels(
tick, 0.0, x_min, x_max, y_min, y_max, plot_area,
)
.0
})
.collect();
let y_tick_pixels: Vec<f32> = y_ticks
.iter()
.map(|&tick| {
crate::render::skia::map_data_to_pixels(
0.0, tick, x_min, x_max, y_min, y_max, plot_area,
)
.1
})
.collect();
let x_probe = x_tick_pixels[x_tick_pixels.len() / 2].round() as u32;
let y_probe = y_tick_pixels[y_tick_pixels.len() / 2].round() as u32;
let top_probe = (x_probe, (plot_area.top() + 2.0).round() as u32);
let right_probe = ((plot_area.right() - 2.0).round() as u32, y_probe);
(top_probe, right_probe)
}
fn compute_layout_without_tick_measurements(plot: &Plot) -> ResolvedLayout {
let (x_min, x_max, y_min, y_max) = plot
.effective_data_bounds()
.expect("data bounds should be available");
let content = plot.create_plot_content(y_min, y_max);
plot.compute_layout_from_measurements(
plot.display.dimensions,
&content,
plot.display.config.figure.dpi,
None,
)
}
fn parse_svg_attr_pt(line: &str, attr: &str) -> f32 {
let marker = format!(r#"{}=""#, attr);
let start = line
.find(&marker)
.unwrap_or_else(|| panic!("missing {} in line: {}", attr, line))
+ marker.len();
let end = line[start..]
.find('"')
.unwrap_or_else(|| panic!("unterminated {} in line: {}", attr, line))
+ start;
let value = line[start..end].trim_end_matches("pt");
value
.parse::<f32>()
.unwrap_or_else(|_| panic!("invalid {} value in line: {}", attr, line))
}
fn extract_typst_group_boxes(svg: &str) -> Vec<(f32, f32, f32, f32)> {
svg.lines()
.filter(|line| line.contains(r#"data-ruviz-text-engine="typst""#))
.map(|line| {
let transform_marker = r#"transform="translate("#;
let start = line
.find(transform_marker)
.unwrap_or_else(|| panic!("missing translate transform in line: {}", line))
+ transform_marker.len();
let end = line[start..]
.find(')')
.unwrap_or_else(|| panic!("unterminated translate transform in line: {}", line))
+ start;
let coords = &line[start..end];
let mut parts = coords.split(',');
let tx = parts
.next()
.unwrap_or_else(|| panic!("missing translate x in line: {}", line))
.parse::<f32>()
.unwrap_or_else(|_| panic!("invalid translate x in line: {}", line));
let ty = parts
.next()
.unwrap_or_else(|| panic!("missing translate y in line: {}", line))
.parse::<f32>()
.unwrap_or_else(|_| panic!("invalid translate y in line: {}", line));
let width = parse_svg_attr_pt(line, "width");
let height = parse_svg_attr_pt(line, "height");
(tx, ty, width, height)
})
.collect()
}
#[test]
fn test_get_theme_method() {
use crate::render::Theme;
let plot = Plot::new();
let theme = plot.get_theme();
let custom_theme = Theme::dark();
let plot = Plot::new().theme(custom_theme);
let _retrieved_theme = plot.get_theme();
}
#[test]
fn test_pending_ingestion_error_preserves_single_error_shape() {
let bad = FailingIngestionData;
let y = vec![1.0, 2.0, 3.0];
let err = Plot::new().line(&bad, &y).render().unwrap_err();
match err {
PlottingError::DataExtractionFailed { origin, message } => {
assert_eq!(origin, "test::failing-ingestion");
assert_eq!(message, "forced ingestion failure");
}
other => panic!("expected DataExtractionFailed, got {other:?}"),
}
}
#[test]
fn test_pending_ingestion_error_precedes_temporal_resolution() {
use crate::data::Signal;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
let resolutions = Arc::new(AtomicUsize::new(0));
let resolutions_for_signal = Arc::clone(&resolutions);
let temporal = Signal::new(move |_| {
resolutions_for_signal.fetch_add(1, Ordering::Relaxed);
vec![0.0, 1.0, 2.0]
});
let bad = FailingIngestionData;
let valid = vec![0.0, 1.0, 2.0];
let plot: Plot = Plot::new()
.line_source(valid.clone(), temporal)
.end_series()
.line(&bad, &valid)
.into();
assert!(matches!(
plot.render(),
Err(PlottingError::DataExtractionFailed { .. })
));
assert_eq!(resolutions.load(Ordering::Relaxed), 0);
}
#[test]
fn test_quiver_preserves_ingestion_error_before_length_validation() {
let bad = FailingIngestionData;
let valid = vec![1.0, 2.0, 3.0];
let err = Plot::new()
.quiver(&bad, &valid, &valid, &valid)
.render()
.unwrap_err();
match err {
PlottingError::DataExtractionFailed { origin, message } => {
assert_eq!(origin, "test::failing-ingestion");
assert_eq!(message, "forced ingestion failure");
}
other => panic!("expected DataExtractionFailed, got {other:?}"),
}
}
#[test]
fn test_snapshot_validation_isolated_from_later_reactive_mutation() {
let x = crate::data::Observable::new(vec![0.0, 1.0]);
let plot = Plot::new().add_line_series(
PlotData::Reactive(x.clone()),
PlotData::Static(vec![1.0, 2.0]),
&crate::plots::basic::LineConfig::default(),
crate::core::plot::builder::SeriesStyle::default(),
);
let snapshot_series = plot.snapshot_series(0.0);
x.set(vec![0.0, f64::NAN]);
assert!(matches!(
plot.validate_runtime_inputs(),
Err(PlottingError::InvalidData { .. })
));
plot.validate_runtime_inputs_for_series(&snapshot_series)
.expect("snapshot validation should ignore later live mutations");
}
#[test]
fn test_resolved_frame_borrows_static_series_data() {
let plot = Plot::new()
.line(&[0.0, 1.0, 2.0], &[1.0, 2.0, 3.0])
.end_series();
let frame = plot
.resolve_frame(0.0)
.expect("static frame should resolve");
let ResolvedSeries::Line { x, y } = &frame.series[0] else {
panic!("line series should resolve as a line");
};
assert!(matches!(
x,
ResolvedData::Cow(std::borrow::Cow::Borrowed(_))
));
assert!(matches!(
y,
ResolvedData::Cow(std::borrow::Cow::Borrowed(_))
));
}
#[test]
fn test_render_resolves_temporal_series_once_for_bounds_and_drawing() {
use crate::data::Signal;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
let resolutions = Arc::new(AtomicUsize::new(0));
let resolutions_for_signal = Arc::clone(&resolutions);
let y = Signal::new(move |time| {
resolutions_for_signal.fetch_add(1, Ordering::Relaxed);
vec![time, time + 1.0, time + 2.0]
});
let plot: Plot = Plot::new().line_source(vec![0.0, 1.0, 2.0], y).into();
plot.render_at(2.0)
.expect("temporal series should render successfully");
assert_eq!(resolutions.load(Ordering::Relaxed), 1);
}
#[test]
fn test_resolved_frame_deduplicates_shared_temporal_data_source() {
use crate::data::Signal;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
let resolutions = Arc::new(AtomicUsize::new(0));
let resolutions_for_signal = Arc::clone(&resolutions);
let data = Signal::new(move |_| {
resolutions_for_signal.fetch_add(1, Ordering::Relaxed);
vec![0.0, 1.0, 2.0]
});
let plot: Plot = Plot::new().line_source(data.clone(), data).end_series();
let frame = plot.resolve_frame(0.0).expect("frame should resolve");
let ResolvedSeries::Line { x, y } = &frame.series[0] else {
panic!("line series should resolve as a line");
};
assert_eq!(resolutions.load(Ordering::Relaxed), 1);
let (ResolvedData::Shared(x), ResolvedData::Shared(y)) = (x, y) else {
panic!("temporal data should use shared owned frame storage");
};
assert!(Arc::ptr_eq(x, y));
}
#[test]
fn test_resolved_frame_acknowledges_generic_stream_exactly() {
use crate::data::{StreamingBuffer, StreamingRenderState};
let stream = StreamingBuffer::new(16);
stream.push_many([0.0, 1.0]);
let plot: Plot = Plot::new()
.line_source(stream.clone(), stream.clone())
.end_series();
let older_frame = plot.resolve_frame(0.0).expect("older frame should resolve");
assert_eq!(older_frame.streaming_acknowledgements.len(), 1);
stream.push(2.0);
let newer_frame = plot.resolve_frame(0.0).expect("newer frame should resolve");
assert_eq!(newer_frame.streaming_acknowledgements.len(), 1);
newer_frame.acknowledge_rendered(&plot);
stream.push(3.0);
older_frame.acknowledge_rendered(&plot);
assert_eq!(stream.appended_since_mark(), 1);
assert_eq!(
stream.render_state(),
StreamingRenderState::AppendOnly {
visible_appended: 1
}
);
}
#[test]
fn test_generic_lane_acknowledgement_respects_newer_paired_watermark() {
use crate::data::StreamingXY;
let stream = StreamingXY::new(16);
stream.push_many([(0.0, 0.0), (1.0, 1.0)]);
let generic_plot: Plot = Plot::new()
.line_source(stream.x().clone(), vec![0.0, 1.0])
.end_series();
let paired_plot = Plot::new().line_streaming(&stream).end_series();
let older_generic_frame = generic_plot
.resolve_frame(0.0)
.expect("generic lane frame should resolve");
stream.push(2.0, 4.0);
let newer_paired_frame = paired_plot
.resolve_frame(0.0)
.expect("paired frame should resolve");
newer_paired_frame.acknowledge_rendered(&paired_plot);
stream.push(3.0, 9.0);
older_generic_frame.acknowledge_rendered(&generic_plot);
assert_eq!(stream.x().appended_since_mark(), 1);
assert_eq!(stream.y().appended_since_mark(), 1);
}
#[test]
fn test_resolved_frame_deduplicates_paired_snapshot_acknowledgement() {
use crate::data::{StreamingRenderState, StreamingXY};
let stream = StreamingXY::new(16);
stream.push_many([(0.0, 0.0), (1.0, 1.0)]);
let plot = Plot::new()
.line_streaming(&stream)
.end_series()
.scatter_streaming(&stream)
.end_series();
let frame = plot
.resolve_frame(0.0)
.expect("paired frame should resolve");
assert_eq!(frame.paired_acknowledgements.len(), 1);
stream.push(2.0, 4.0);
frame.acknowledge_rendered(&plot);
assert_eq!(stream.appended_count(), 1);
assert_eq!(
stream.render_state(),
StreamingRenderState::AppendOnly {
visible_appended: 1
}
);
}
#[test]
fn test_resolved_svg_accepts_dedicated_series_variants() {
let plots = [
Plot::new()
.error_bars(&[0.0, 1.0], &[1.0, 2.0], &[0.1, 0.2])
.color(Color::RED)
.into_plot(),
Plot::new()
.error_bars_xy(&[0.0, 1.0], &[1.0, 2.0], &[0.1, 0.1], &[0.2, 0.2])
.color(Color::RED)
.into_plot(),
Plot::new()
.boxplot(&[1.0, 2.0, 3.0])
.color(Color::RED)
.into_plot(),
Plot::new()
.histogram(&[1.0, 1.5, 2.0, 2.5])
.color(Color::RED)
.into_plot(),
];
for plot in plots {
let svg = plot
.render_to_svg()
.expect("dedicated resolved series should preserve SVG export behavior");
assert!(
svg.contains("rgb(255,0,0)"),
"resolved SVG should contain the rendered series color"
);
}
}
#[test]
fn test_dedicated_error_bars_honor_asymmetric_overrides_in_svg_and_raster() {
let build = |override_errors: bool| {
let builder = Plot::new()
.size_px(300, 200)
.xlim(-2.0, 2.0)
.ylim(-2.0, 2.0)
.ticks(false)
.grid(false)
.error_bars_xy(&[0.0], &[0.0], &[0.25], &[0.5]);
if override_errors {
builder
.with_xerr_asymmetric(&[0.25], &[1.25])
.with_yerr_asymmetric(&[0.5], &[1.5])
.into_plot()
} else {
builder.into_plot()
}
};
let symmetric = build(false);
let asymmetric = build(true);
assert_ne!(
symmetric.render_to_svg().unwrap(),
asymmetric.render_to_svg().unwrap(),
"dedicated SVG error bars must use asymmetric series overrides"
);
assert_ne!(
symmetric.render().unwrap().pixels,
asymmetric.render().unwrap().pixels,
"dedicated raster error bars must use asymmetric series overrides"
);
}
#[test]
fn test_resolved_histogram_preserves_raw_sample_validation() {
let plot = Plot::new().histogram(&[1.0, f64::NAN, 2.0]).into_plot();
assert!(matches!(
plot.render(),
Err(PlottingError::InvalidData { .. })
));
}
#[test]
fn test_snapshot_bounds_cover_heatmap_and_pie_series() {
let heatmap = Plot::new()
.heatmap(&vec![vec![1.0, 2.0], vec![3.0, 4.0]])
.end_series();
let heatmap_bounds = heatmap
.calculate_data_bounds_for_series(&heatmap.snapshot_series(0.0))
.expect("heatmap bounds should resolve");
assert!(heatmap_bounds.0.is_finite());
assert!(heatmap_bounds.1.is_finite());
assert!(heatmap_bounds.2.is_finite());
assert!(heatmap_bounds.3.is_finite());
let pie = Plot::new().pie(&[2.0, 3.0, 5.0]).end_series();
let pie_bounds = pie
.calculate_data_bounds_for_series(&pie.snapshot_series(0.0))
.expect("pie bounds should resolve");
assert_eq!(pie_bounds, (0.0, 1.0, 0.0, 1.0));
}
#[test]
fn test_heatmap_render_preserves_downsampled_vertical_feature() {
let rows = 48usize;
let cols = 256usize;
let stripe_start = cols / 2 - 4;
let stripe_end = stripe_start + 8;
let mut values = vec![vec![0.0; cols]; rows];
for row in &mut values {
for cell in &mut row[stripe_start..stripe_end] {
*cell = 1.0;
}
}
let plot = Plot::new()
.size_px(120, 120)
.heatmap_with(
&values,
crate::plots::heatmap::HeatmapConfig::new().colorbar(false),
)
.end_series();
let image = plot.render().expect("heatmap render should succeed");
let plot_area = compute_render_plot_area(&plot);
let cell_width = plot_area.width() / cols as f32;
let stripe_center_x =
(plot_area.left() + ((stripe_start + stripe_end) as f32 * 0.5) * cell_width).round() as u32;
let background_x = (plot_area.left() + plot_area.width() * 0.2).round() as u32;
let y_start = (plot_area.top() + 4.0).round() as u32;
let y_end = (plot_area.bottom() - 4.0).round() as u32;
let stripe_peak_brightness = (stripe_center_x.saturating_sub(2)..=stripe_center_x + 2)
.flat_map(|x| (y_start..=y_end).map(move |y| (x, y)))
.map(|(x, y)| {
let pixel = image_pixel_rgba(&image, x.min(image.width - 1), y);
pixel[0] as u32 + pixel[1] as u32 + pixel[2] as u32
})
.max()
.unwrap_or(0);
let background_peak_brightness = (background_x.saturating_sub(2)..=background_x + 2)
.flat_map(|x| (y_start..=y_end).map(move |y| (x, y)))
.map(|(x, y)| {
let pixel = image_pixel_rgba(&image, x.min(image.width - 1), y);
pixel[0] as u32 + pixel[1] as u32 + pixel[2] as u32
})
.max()
.unwrap_or(0);
assert!(
stripe_peak_brightness > background_peak_brightness + 80,
"downsampled heatmap should keep the bright central stripe visible: stripe={} background={}",
stripe_peak_brightness,
background_peak_brightness
);
}
#[test]
fn test_heatmap_extent_maps_cells_into_physical_axis_limits() {
let rows = 24usize;
let cols = 80usize;
let stripe_start = cols / 2 - 3;
let stripe_end = stripe_start + 6;
let mut values = vec![vec![0.0; cols]; rows];
for row in &mut values {
for cell in &mut row[stripe_start..stripe_end] {
*cell = 1.0;
}
}
let plot = Plot::new()
.size_px(240, 160)
.heatmap_with(
&values,
crate::plots::heatmap::HeatmapConfig::new()
.colorbar(false)
.vmin(0.0)
.vmax(1.0)
.extent(0.0, 8.0, 0.0, 2.4),
)
.xlim(0.0, 8.0)
.ylim(0.0, 2.4)
.end_series();
let image = plot.render().expect("extent-aware heatmap should render");
let plot_area = compute_render_plot_area(&plot);
let stripe_center_x = (plot_area.left() + plot_area.width() * 0.5).round() as u32;
let background_x = (plot_area.left() + plot_area.width() * 0.1).round() as u32;
let center_y = (plot_area.top() + plot_area.height() * 0.5).round() as u32;
let stripe = image_pixel_rgba(&image, stripe_center_x, center_y);
let background = image_pixel_rgba(&image, background_x, center_y);
let stripe_brightness = stripe[0] as u32 + stripe[1] as u32 + stripe[2] as u32;
let background_brightness = background[0] as u32 + background[1] as u32 + background[2] as u32;
assert!(
stripe_brightness > background_brightness + 120,
"heatmap extent should map the central stripe into the visible 0..8 mm viewport: stripe={} background={}",
stripe_brightness,
background_brightness
);
}
#[test]
fn test_heatmap_render_default_has_no_cell_seams() {
let values = vec![vec![0.5, 0.5], vec![0.5, 0.5]];
let plot = Plot::new()
.size_px(240, 160)
.heatmap_with(
&values,
crate::plots::heatmap::HeatmapConfig::new()
.colorbar(false)
.vmin(0.0)
.vmax(1.0),
)
.end_series();
let image = plot.render().expect("uniform heatmap should render");
let plot_area = compute_render_plot_area(&plot);
let center_x = (plot_area.left() + plot_area.width() * 0.5).round() as u32;
let center_y = (plot_area.top() + plot_area.height() * 0.5).round() as u32;
let interior_x = (plot_area.left() + plot_area.width() * 0.25).round() as u32;
let interior_y = (plot_area.top() + plot_area.height() * 0.25).round() as u32;
let interior = image_pixel_rgba(&image, interior_x, interior_y);
assert_eq!(
image_pixel_rgba(&image, center_x, interior_y),
interior,
"shared vertical tile boundaries should not leave visible seams"
);
assert_eq!(
image_pixel_rgba(&image, interior_x, center_y),
interior,
"shared horizontal tile boundaries should not leave visible seams"
);
}
#[test]
fn test_heatmap_render_cell_borders_are_opt_in() {
let values = vec![vec![0.5, 0.5], vec![0.5, 0.5]];
let plot = Plot::new()
.size_px(240, 160)
.heatmap_with(
&values,
crate::plots::heatmap::HeatmapConfig::new()
.colorbar(false)
.vmin(0.0)
.vmax(1.0)
.cell_borders(true),
)
.end_series();
let image = plot.render().expect("heatmap with borders should render");
let plot_area = compute_render_plot_area(&plot);
let center_x = (plot_area.left() + plot_area.width() * 0.5).round() as u32;
let interior_x = (plot_area.left() + plot_area.width() * 0.25).round() as u32;
let interior_y = (plot_area.top() + plot_area.height() * 0.25).round() as u32;
let interior = image_pixel_rgba(&image, interior_x, interior_y);
assert_ne!(
image_pixel_rgba(&image, center_x, interior_y),
interior,
"enabled cell borders should make shared heatmap edges visually distinct"
);
}
#[test]
fn test_heatmap_render_skips_non_finite_cells() {
let plot = Plot::new()
.size_px(240, 160)
.heatmap_with(
&vec![vec![0.0, f64::NAN, 1.0]],
crate::plots::heatmap::HeatmapConfig::new().colorbar(false),
)
.end_series();
let image = plot
.render()
.expect("heatmap with non-finite values should render");
let plot_area = compute_render_plot_area(&plot);
let cell_width = plot_area.width() / 3.0;
let center_y = (plot_area.top() + plot_area.height() * 0.5).round() as u32;
let left_center_x = (plot_area.left() + cell_width * 0.5).round() as u32;
let nan_center_x = (plot_area.left() + cell_width * 1.5).round() as u32;
let right_center_x = (plot_area.left() + cell_width * 2.5).round() as u32;
let background = image_pixel_rgba(&image, 0, 0);
assert_eq!(
image_pixel_rgba(&image, nan_center_x, center_y),
background,
"non-finite heatmap cells should be skipped instead of colored"
);
assert_ne!(
image_pixel_rgba(&image, left_center_x, center_y),
background,
"finite heatmap cells should still render"
);
assert_ne!(
image_pixel_rgba(&image, right_center_x, center_y),
background,
"finite heatmap cells should still render"
);
}
#[test]
fn test_filled_contour_without_lines_has_no_cell_seams() {
let x = vec![0.0, 1.0, 2.0];
let y = vec![0.0, 1.0];
let z = vec![0.5; x.len() * y.len()];
let plot = Plot::new()
.size_px(240, 160)
.contour(&x, &y, &z)
.level_values(vec![0.0, 1.0])
.filled(true)
.show_lines(false)
.end_series();
let image = plot.render().expect("filled contour should render");
let rect = compute_render_plot_area(&plot);
let area = crate::plots::traits::PlotArea::new(
rect.left(),
rect.top(),
rect.width(),
rect.height(),
0.0,
2.0,
0.0,
1.0,
);
let (center_x, sample_y) = area.data_to_screen(1.0, 0.5);
let (interior_x, interior_y) = area.data_to_screen(0.5, 0.5);
let center_x = center_x.round() as u32;
let sample_y = sample_y.round() as u32;
let interior_x = interior_x.round() as u32;
let interior_y = interior_y.round() as u32;
assert_eq!(
image_pixel_rgba(&image, center_x, sample_y),
image_pixel_rgba(&image, interior_x, interior_y),
"filled contour regions without contour lines should not show cell seams"
);
}
#[test]
fn test_heatmap_render_skips_nonpositive_cells_on_log_scale() {
let plot = Plot::new()
.size_px(240, 160)
.heatmap_with(
&vec![vec![0.0, 1.0, 10.0]],
crate::plots::heatmap::HeatmapConfig::new()
.colorbar(false)
.value_scale(crate::axes::AxisScale::Log),
)
.end_series();
let image = plot
.render()
.expect("log heatmap with zero values should render");
let plot_area = compute_render_plot_area(&plot);
let cell_width = plot_area.width() / 3.0;
let center_y = (plot_area.top() + plot_area.height() * 0.5).round() as u32;
let zero_center_x = (plot_area.left() + cell_width * 0.5).round() as u32;
let one_center_x = (plot_area.left() + cell_width * 1.5).round() as u32;
let ten_center_x = (plot_area.left() + cell_width * 2.5).round() as u32;
let background = image_pixel_rgba(&image, 0, 0);
assert_eq!(
image_pixel_rgba(&image, zero_center_x, center_y),
background,
"nonpositive log heatmap cells should be skipped instead of colored"
);
assert_ne!(
image_pixel_rgba(&image, one_center_x, center_y),
background,
"positive log heatmap cells should still render"
);
assert_ne!(
image_pixel_rgba(&image, ten_center_x, center_y),
background,
"positive log heatmap cells should still render"
);
}
#[test]
fn test_heatmap_log_colorbar_layout_reserves_right_margin() {
let values = vec![vec![0.0, 1e-5, 1e-4, 1e-3], vec![1e-2, 1e-1, 1.0, 10.0]];
let without_colorbar = Plot::new()
.size_px(360, 220)
.heatmap_with(
&values,
crate::plots::heatmap::HeatmapConfig::new()
.value_scale(crate::axes::AxisScale::Log)
.colorbar(false),
)
.end_series();
let with_colorbar = Plot::new()
.size_px(360, 220)
.heatmap_with(
&values,
crate::plots::heatmap::HeatmapConfig::new()
.value_scale(crate::axes::AxisScale::Log)
.colorbar(true)
.colorbar_label("Absorbed Energy"),
)
.end_series();
let without_layout = compute_render_layout(&without_colorbar);
let with_layout = compute_render_layout(&with_colorbar);
assert!(
with_layout.margins.right > without_layout.margins.right + 40.0,
"colorbar layout should reserve a larger right margin: without={} with={}",
without_layout.margins.right,
with_layout.margins.right
);
assert!(
with_layout.plot_area.right < without_layout.plot_area.right,
"reserved colorbar margin should reduce available plot width"
);
}
#[test]
fn test_heatmap_colorbar_layout_scales_with_dpi() {
let values = vec![vec![0.0, 0.5], vec![1.0, 1.5]];
let config = || {
crate::plots::heatmap::HeatmapConfig::new()
.colorbar(true)
.colorbar_label("corrected")
};
let low_dpi = Plot::new()
.dpi(100)
.heatmap_with(&values, config())
.end_series();
let high_dpi = Plot::new()
.dpi(200)
.heatmap_with(&values, config())
.end_series();
let low_layout = compute_render_layout(&low_dpi);
let high_layout = compute_render_layout(&high_dpi);
assert!(
high_layout.margins.right > low_layout.margins.right * 1.8,
"colorbar right margin should scale with DPI: low={} high={}",
low_layout.margins.right,
high_layout.margins.right
);
}
#[test]
fn test_plot_preserves_reversed_manual_limits() {
let plot: Plot = Plot::new()
.line(&[0.0, 4.0], &[0.0, 4.0])
.xlim(4.0, 0.0)
.ylim(4.0, 0.0)
.into();
assert_eq!(plot.layout.x_limits, Some((4.0, 0.0)));
assert_eq!(plot.layout.y_limits, Some((4.0, 0.0)));
}
#[test]
fn test_auto_datashader_policy_excludes_large_line_series() {
let x: Vec<f64> = (0..100_000).map(|i| i as f64).collect();
let y: Vec<f64> = x.iter().map(|x| x.sin()).collect();
let plot = Plot::new().line(&x, &y).end_series();
let snapshot_series = plot.snapshot_series(0.0);
let total_points = Plot::calculate_total_points_for_series(&snapshot_series);
assert!(DataShader::should_activate(total_points));
assert!(!Plot::should_auto_use_datashader(
&snapshot_series,
total_points
));
}
#[test]
fn test_auto_datashader_policy_keeps_large_scatter_series_eligible() {
let x: Vec<f64> = (0..100_000).map(|i| i as f64).collect();
let y: Vec<f64> = x.iter().map(|x| x.sin()).collect();
let plot = Plot::new().scatter(&x, &y).end_series();
let snapshot_series = plot.snapshot_series(0.0);
let total_points = Plot::calculate_total_points_for_series(&snapshot_series);
assert!(DataShader::should_activate(total_points));
assert!(Plot::should_auto_use_datashader(
&snapshot_series,
total_points
));
}
#[test]
fn test_auto_datashader_policy_excludes_large_histogram_series() {
let samples: Vec<f64> = (0..100_000).map(|i| (i as f64 * 0.0002).sin()).collect();
let plot = Plot::new().histogram(&samples).end_series();
let snapshot_series = plot.snapshot_series(0.0);
let total_points = Plot::calculate_total_points_for_series(&snapshot_series);
assert!(DataShader::should_activate(total_points));
assert!(!Plot::should_auto_use_datashader(
&snapshot_series,
total_points
));
}
#[test]
fn test_prepared_frame_large_line_stays_off_auto_datashader() {
let x: Vec<f64> = (0..100_000).map(|i| i as f64).collect();
let y: Vec<f64> = x.iter().map(|x| x.sin()).collect();
let prepared = Plot::new()
.line(&x, &y)
.end_series()
.prepared_frame_plot((1280, 720), 1.0, 0.0);
let snapshot_series = prepared.snapshot_series(0.0);
let total_points = Plot::calculate_total_points_for_series(&snapshot_series);
assert!(DataShader::should_activate(total_points));
assert!(!Plot::should_auto_use_datashader(
&snapshot_series,
total_points
));
}
#[test]
fn test_prepared_frame_preserves_configured_dpi_for_matching_fixed_size() {
let plot = Plot::new().size(4.0, 3.0).dpi(250);
let configured_size = plot.config_canvas_size();
let prepared = plot.prepared_frame_plot(configured_size, 1.0, 0.0);
assert_eq!(prepared.display.dimensions, configured_size);
assert_eq!(prepared.config_canvas_size(), configured_size);
assert!((prepared.display.config.figure.dpi - 250.0).abs() < f32::EPSILON);
assert!(
(prepared.display.config.figure.width - 4.0).abs() < f32::EPSILON,
"fixed-size interactive frames should keep the authored figure geometry"
);
}
#[test]
fn test_prepared_frame_preserves_exact_non_fitted_surface_size() {
let plot = Plot::new().size(4.0, 3.0).dpi(250);
let prepared = plot.prepared_frame_plot((800, 500), 2.0, 0.0);
assert_eq!(prepared.display.dimensions, (800, 500));
assert_eq!(prepared.config_canvas_size(), (800, 500));
assert!((prepared.display.config.figure.dpi - 200.0).abs() < f32::EPSILON);
assert!((prepared.display.config.figure.width - 4.0).abs() < f32::EPSILON);
assert!((prepared.display.config.figure.height - 2.5).abs() < f32::EPSILON);
}
#[test]
fn test_prepared_frame_preserves_fitted_figure_and_ignores_device_scale_for_style() {
let plot = Plot::new().size(4.0, 3.0).dpi(250);
let fitted_size = plot.fitted_output_size_for_max_pixels((800, 500));
let prepared = plot.prepared_frame_plot(fitted_size, 2.0, 0.0);
assert_eq!(fitted_size, (666, 500));
assert_eq!(prepared.display.dimensions, fitted_size);
assert_eq!(prepared.config_canvas_size(), fitted_size);
assert!((prepared.display.config.figure.dpi - 500.0 / 3.0).abs() < 0.001);
assert!((prepared.display.config.figure.width - 4.0).abs() < f32::EPSILON);
assert!((prepared.display.config.figure.height - 3.0).abs() < f32::EPSILON);
let same_output_at_1x = plot.prepared_frame_plot(fitted_size, 1.0, 0.0);
assert_eq!(
same_output_at_1x.display.dimensions,
prepared.display.dimensions
);
assert!(
(same_output_at_1x.display.config.figure.dpi - prepared.display.config.figure.dpi).abs()
< f32::EPSILON
);
}
#[test]
fn test_fitted_prepared_frame_round_trips_rounded_canvas_size() {
let plot = Plot::new().size(16.0, 9.0).dpi(100);
let (fitted_size, dpi) = plot.fitted_output_for_max_pixels((1000, 1000));
let prepared = plot.prepared_frame_plot(fitted_size, 1.0, 0.0);
assert_eq!(fitted_size, (1000, 562));
assert!((dpi - 62.5).abs() < 0.001);
assert_eq!(prepared.display.dimensions, fitted_size);
assert_eq!(prepared.config_canvas_size(), fitted_size);
}
#[test]
fn test_fitted_prepared_frame_round_trips_difficult_canvas_ratios() {
for (width_in, height_in, max_size) in [
(7.3, 4.1, (997, 719)),
(std::f32::consts::SQRT_2, 1.0, (1000, 1000)),
(10.0, 3.0, (1234, 567)),
(4.7, 3.2, (853, 991)),
(1024.0001, 1.9999, (4096, 31)),
(1.9999, 1024.0001, (31, 4096)),
] {
let plot = Plot::new().size(width_in, height_in).dpi(100);
let fitted_size = plot.fitted_output_size_for_max_pixels(max_size);
let prepared = plot.prepared_frame_plot(fitted_size, 1.0, 0.0);
assert_eq!(
prepared.display.dimensions, fitted_size,
"display dimensions should match fitted size for {width_in}x{height_in}"
);
assert_eq!(
prepared.config_canvas_size(),
fitted_size,
"config canvas should round-trip fitted size for {width_in}x{height_in}"
);
assert!(
(prepared.display.config.figure.width - width_in).abs() < 0.0001,
"fitted render should preserve figure width for {width_in}x{height_in}"
);
assert!(
(prepared.display.config.figure.height - height_in).abs() < 0.0001,
"fitted render should preserve figure height for {width_in}x{height_in}"
);
}
}
#[test]
fn test_prepared_frame_style_metrics_scale_with_output_dimensions() {
let plot = Plot::new().size(4.0, 3.0).dpi(100);
let small = plot.prepared_frame_plot((400, 300), 1.0, 0.0);
let large = plot.prepared_frame_plot((800, 600), 1.0, 0.0);
assert_eq!(small.display.dimensions, (400, 300));
assert_eq!(large.display.dimensions, (800, 600));
assert!((small.display.config.figure.dpi - 100.0).abs() < f32::EPSILON);
assert!((large.display.config.figure.dpi - 200.0).abs() < f32::EPSILON);
let small_tick_font = small
.render_scale()
.points_to_pixels(small.display.config.typography.tick_size());
let large_tick_font = large
.render_scale()
.points_to_pixels(large.display.config.typography.tick_size());
assert!((large_tick_font / small_tick_font - 2.0).abs() < 0.001);
let small_metrics = small.axis_tick_metrics_px();
let large_metrics = large.axis_tick_metrics_px();
for (small_value, large_value) in [
(small_metrics.0, large_metrics.0),
(small_metrics.1, large_metrics.1),
(small_metrics.2, large_metrics.2),
(small_metrics.3, large_metrics.3),
(small_metrics.4, large_metrics.4),
] {
assert!((large_value / small_value - 2.0).abs() < 0.001);
}
}
#[test]
fn test_public_render_rejects_configured_subminimum_dpi() {
let err = Plot::with_config(PlotConfig {
figure: FigureConfig::new(6.4, 4.8, 50.0),
..PlotConfig::default()
})
.line(&[0.0, 1.0], &[0.0, 1.0])
.render()
.expect_err("public render path should reject configured subminimum DPI");
assert!(
format!("{err}").contains("Figure DPI must be at least"),
"unexpected low-DPI error: {err}"
);
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn test_all_public_margin_shapes_fail_cleanly_across_render_paths() {
fn expect_error<T>(result: Result<T>, message: &str) -> PlottingError {
match result {
Ok(_) => panic!("{message}"),
Err(err) => err,
}
}
let malformed = [
(
"proportional constructor",
MarginConfig::proportional_custom(f32::NAN, 0.1, 0.1, 0.1),
),
("auto constructor", MarginConfig::auto_with_bounds(1.0, 0.5)),
(
"fixed constructor",
MarginConfig::fixed(-0.1, 0.2, 0.2, 0.2),
),
(
"content-driven constructor",
MarginConfig::content_driven_custom(f32::INFINITY, true),
),
(
"oversized content-driven constructor",
MarginConfig::content_driven_custom(f32::MAX, true),
),
(
"direct variant construction",
MarginConfig::Proportional {
left: 0.6,
right: 0.5,
top: 0.1,
bottom: 0.1,
},
),
];
for (name, margins) in malformed {
let mut config = PlotConfig::default();
config.margins = margins;
let plot = Plot::with_config(config);
let raster_err = expect_error(
plot.clone().render(),
&format!("{name} should fail raster rendering"),
);
assert!(
matches!(raster_err, PlottingError::InvalidInput(_)),
"unexpected raster error for {name}: {raster_err}"
);
let svg_err = expect_error(
plot.clone().render_to_svg(),
&format!("{name} should fail SVG rendering"),
);
assert!(
matches!(svg_err, PlottingError::InvalidInput(_)),
"unexpected SVG error for {name}: {svg_err}"
);
let interactive_err = expect_error(
plot.prepare_interactive().render_to_image(ImageTarget {
size_px: (640, 480),
scale_factor: 1.0,
time_seconds: 0.0,
}),
&format!("{name} should fail interactive rendering"),
);
assert!(
matches!(interactive_err, PlottingError::InvalidInput(_)),
"unexpected interactive error for {name}: {interactive_err}"
);
}
}
#[test]
fn test_auto_margin_maximum_may_exceed_half_the_figure() {
let config = PlotConfig {
figure: FigureConfig::new(1.0, 1.0, 100.0),
margins: MarginConfig::auto_with_bounds(0.1, 1.0),
..PlotConfig::default()
};
Plot::with_config(config)
.render()
.expect("an auto-margin upper bound is not itself allocated on every side");
}
#[test]
fn test_prepared_frame_subminimum_dpi_bypass_is_scoped_to_low_output() {
let plot = Plot::new().size(4.0, 3.0).dpi(100);
let normal = plot.prepared_frame_plot((667, 500), 1.0, 0.0);
assert!((normal.display.config.figure.dpi - 166.75).abs() < 0.001);
assert!(!normal.render.allow_subminimum_dpi);
let low = plot.prepared_frame_plot((200, 150), 1.0, 0.0);
assert!((low.display.config.figure.dpi - 50.0).abs() < f32::EPSILON);
assert!(low.render.allow_subminimum_dpi);
}
#[test]
fn test_prepared_frame_low_output_dpi_can_render() {
let plot = Plot::new()
.size(6.4, 4.8)
.line(&[0.0, 1.0], &[0.0, 1.0])
.end_series();
let prepared = plot.prepared_frame_plot((320, 240), 1.0, 0.0);
assert_eq!(prepared.display.dimensions, (320, 240));
assert!((prepared.display.config.figure.dpi - 50.0).abs() < f32::EPSILON);
let image = prepared
.render()
.expect("interactive output below 72 DPI should still render");
assert_eq!((image.width, image.height), (320, 240));
}
#[test]
fn test_render_datashader_path_still_validates_mismatched_scatter_series() {
let x: Vec<f64> = (0..100_001).map(|i| i as f64).collect();
let y: Vec<f64> = (0..100_000).map(|i| i as f64).collect();
let err = Plot::new()
.scatter(&x, &y)
.render()
.expect_err("datashader path should reject mismatched inputs");
assert!(matches!(err, PlottingError::DataLengthMismatch { .. }));
}
#[test]
fn test_render_with_datashader_uses_captured_snapshot_for_reactive_series() {
let x = crate::data::Observable::new((0..100_001).map(|i| i as f64).collect::<Vec<_>>());
let y = crate::data::Observable::new((0..100_001).map(|i| i as f64).collect::<Vec<_>>());
let plot = Plot::new().add_line_series(
PlotData::Reactive(x.clone()),
PlotData::Reactive(y.clone()),
&crate::plots::basic::LineConfig::default(),
crate::core::plot::builder::SeriesStyle::default(),
);
let snapshot_series = plot.snapshot_series(0.0);
x.set(vec![0.0, f64::NAN]);
y.set(vec![1.0]);
assert!(plot.validate_runtime_inputs().is_err());
let image = plot
.render_with_datashader(&snapshot_series)
.expect("datashader helper should render the captured snapshot");
assert!(image.width > 0);
assert!(image.height > 0);
}
#[test]
fn test_render_path_still_validates_empty_series() {
let empty: Vec<f64> = Vec::new();
let err = Plot::new()
.line(&empty, &empty)
.end_series()
.line(&[0.0, 1.0], &[1.0, 2.0])
.render()
.expect_err("render path should reject empty inputs");
assert!(matches!(err, PlottingError::EmptyDataSet));
}
#[test]
fn test_pending_ingestion_error_reports_additional_failures() {
let bad = FailingIngestionData;
let err = Plot::new().line(&bad, &bad).render().unwrap_err();
match err {
PlottingError::DataExtractionFailed { origin, message } => {
assert_eq!(origin, "ruviz::plot-ingestion");
assert!(message.contains("forced ingestion failure"));
assert!(message.contains("1 additional ingestion error"));
}
other => panic!("expected DataExtractionFailed, got {other:?}"),
}
}
#[test]
fn test_pending_ingestion_error_wraps_the_first_error_exactly_once() {
let ragged = vec![vec![1.0, 2.0], vec![3.0]];
let err = Plot::new().heatmap(&ragged).render().unwrap_err();
assert_eq!(
err.to_string(),
"Failed to extract numeric data from ruviz::plot-ingestion: \
NumericData2D: row 1 has 1 values, expected 2 \
(and 1 additional ingestion error)"
);
}
#[test]
fn test_group_scopes_shared_style_and_does_not_leak() {
let x = vec![0.0, 1.0, 2.0];
let y1 = vec![0.0, 1.0, 2.0];
let y2 = vec![0.0, 2.0, 4.0];
let y3 = vec![0.0, 3.0, 6.0];
let plot = Plot::new()
.group(|g| {
g.color(Color::RED)
.line_width(3.0)
.line_style(LineStyle::Dashed)
.alpha(0.35)
.line(&x, &y1)
.line(&x, &y2)
})
.line(&x, &y3)
.end_series();
assert_eq!(plot.series_mgr.series.len(), 3);
assert_eq!(
plot.series_mgr.series[0].props.color.cloned(),
Some(Color::RED)
);
assert_eq!(
plot.series_mgr.series[1].props.color.cloned(),
Some(Color::RED)
);
assert_eq!(
plot.series_mgr.series[0].props.line_width.cloned(),
Some(3.0)
);
assert_eq!(
plot.series_mgr.series[1].props.line_width.cloned(),
Some(3.0)
);
assert!(matches!(
plot.series_mgr.series[0].props.line_style.cloned(),
Some(LineStyle::Dashed)
));
assert!(matches!(
plot.series_mgr.series[1].props.line_style.cloned(),
Some(LineStyle::Dashed)
));
assert_eq!(plot.series_mgr.series[0].props.alpha.cloned(), Some(0.35));
assert_eq!(plot.series_mgr.series[1].props.alpha.cloned(), Some(0.35));
assert_ne!(
plot.series_mgr.series[2].props.color.cloned(),
Some(Color::RED)
);
assert_ne!(
plot.series_mgr.series[2].props.line_width.cloned(),
Some(3.0)
);
assert!(matches!(
plot.series_mgr.series[2].props.line_style.cloned(),
Some(LineStyle::Solid)
));
assert_eq!(plot.series_mgr.series[2].props.alpha.cloned(), Some(1.0));
}
#[test]
fn test_group_label_collapses_legend_to_single_item() {
let x = vec![0.0, 1.0, 2.0];
let y1 = vec![0.0, 1.0, 2.0];
let y2 = vec![0.0, 2.0, 4.0];
let y3 = vec![0.0, 3.0, 6.0];
let plot = Plot::new()
.group(|g| {
g.group_label("Grouped")
.line_style(LineStyle::Dashed)
.line(&x, &y1)
.line(&x, &y2)
})
.line(&x, &y3)
.label("Solo")
.end_series();
let legend_items = plot.collect_legend_items();
assert_eq!(legend_items.len(), 2);
assert!(legend_items.iter().any(|item| item.label == "Grouped"));
assert!(legend_items.iter().any(|item| item.label == "Solo"));
let grouped = legend_items
.iter()
.find(|item| item.label == "Grouped")
.expect("group legend item should exist");
assert!(matches!(grouped.item_type, LegendItemType::Line { .. }));
}
#[test]
fn test_labeled_fill_between_reaches_the_legend() {
let x = vec![0.0, 1.0, 2.0];
let lower = vec![0.0, 1.0, 2.0];
let upper = vec![1.0, 2.0, 3.0];
let plot = Plot::new()
.line(&x, &upper)
.label("mean")
.end_series()
.fill_between_labeled(&x, &lower, &upper, Color::BLUE, "95% CI");
let legend_items = plot.collect_legend_items();
assert_eq!(legend_items.len(), 2);
let band = legend_items
.iter()
.find(|item| item.label == "95% CI")
.expect("labelled fill should get a legend entry");
assert!(matches!(band.item_type, LegendItemType::Area { .. }));
assert!(band.color.a < 255);
}
#[test]
fn test_unlabeled_fill_between_stays_out_of_the_legend() {
let x = vec![0.0, 1.0, 2.0];
let lower = vec![0.0, 1.0, 2.0];
let upper = vec![1.0, 2.0, 3.0];
let plot = Plot::new()
.line(&x, &upper)
.label("mean")
.end_series()
.fill_between(&x, &lower, &upper);
let legend_items = plot.collect_legend_items();
assert_eq!(legend_items.len(), 1);
assert_eq!(legend_items[0].label, "mean");
}
#[test]
fn test_user_headless_arrows_render_as_overlay() {
use crate::core::ArrowHead;
let headless = Annotation::arrow_styled(
0.0,
0.0,
0.0,
1.0,
ArrowStyle::new()
.head_style(ArrowHead::None)
.tail_style(ArrowHead::None),
);
assert!(Plot::is_overlay_annotation(&headless));
assert!(!Plot::is_underlay_annotation(&headless));
let pointer = Annotation::arrow_styled(0.0, 0.0, 1.0, 1.0, ArrowStyle::new());
assert!(Plot::is_overlay_annotation(&pointer));
}
fn only_legend_item_type(plot: &Plot) -> crate::core::LegendItemType {
let mut items = plot.collect_legend_items();
assert_eq!(items.len(), 1, "expected exactly one legend item");
items.remove(0).item_type
}
#[test]
fn test_bar_legend_key_carries_the_edge_the_bars_are_drawn_with() {
let categories = ["A", "B", "C"];
let values = [2.0, 4.0, 3.0];
let plot = Plot::new()
.bar(&categories, &values)
.label("bars")
.color(Color::BLUE)
.end_series();
let LegendItemType::Bar { edge } = only_legend_item_type(&plot) else {
panic!("a bar series must produce a bar legend key");
};
let (edge_color, edge_width) = edge.expect("bars carry a default edge, so the key must too");
assert_eq!(edge_color, Color::BLUE.darken(0.3));
assert!((edge_width - 0.8).abs() < f32::EPSILON);
}
#[test]
fn test_bar_legend_key_is_flat_when_the_bars_are_flat() {
let categories = ["A", "B"];
let values = [1.0, 2.0];
let plot = Plot::new()
.bar(&categories, &values)
.label("bars")
.edge_width(0.0)
.end_series();
assert!(
matches!(
only_legend_item_type(&plot),
LegendItemType::Bar { edge: None }
),
"edge_width(0.0) draws flat bars, so the key must be flat as well"
);
}
#[test]
fn test_histogram_legend_key_carries_the_bin_edge() {
let data = vec![0.1, 0.4, 0.6, 0.9, 1.2, 1.5, 1.9, 2.4];
let plot = Plot::new().histogram(&data).label("hist").end_series();
let LegendItemType::Histogram { edge } = only_legend_item_type(&plot) else {
panic!("a histogram series must produce a histogram legend key");
};
let (_, edge_width) = edge.expect("histogram bins are always stroked, so the key must be too");
assert!((edge_width - 0.8).abs() < f32::EPSILON);
}
#[test]
fn test_svg_bars_carry_the_same_edge_as_raster_bars() {
let categories = ["A", "B", "C"];
let values = [2.0, 4.0, 3.0];
let svg = Plot::new()
.bar(&categories, &values)
.color(Color::BLUE)
.edge_color(Color::RED)
.end_series()
.render_to_svg()
.expect("bar SVG render should succeed");
assert!(
svg.contains(r#"fill="rgb(0,0,255)" stroke="rgb(255,0,0)""#),
"expected blue bars stroked in red in the SVG: {svg}"
);
}
#[test]
fn test_svg_bars_are_flat_when_the_edge_is_switched_off() {
let categories = ["A", "B"];
let values = [1.0, 2.0];
let svg = Plot::new()
.bar(&categories, &values)
.color(Color::BLUE)
.edge_width(0.0)
.end_series()
.render_to_svg()
.expect("bar SVG render should succeed");
assert!(
!svg.contains(r#"fill="rgb(0,0,255)" stroke="#),
"edge_width(0.0) must leave the SVG bars unstroked: {svg}"
);
}
#[test]
fn test_svg_scatter_markers_carry_a_requested_rim() {
let x = [0.0, 1.0, 2.0];
let y = [1.0, 3.0, 2.0];
let svg = Plot::new()
.scatter(&x, &y)
.color(Color::BLUE)
.edge_color(Color::RED)
.end_series()
.render_to_svg()
.expect("scatter SVG render should succeed");
assert!(
svg.contains(r#"stroke="rgb(255,0,0)""#),
"an explicit marker edge colour must reach the SVG: {svg}"
);
}
#[test]
fn test_group_without_label_is_omitted_from_legend() {
let x = vec![0.0, 1.0, 2.0];
let y1 = vec![0.0, 1.0, 2.0];
let y2 = vec![0.0, 2.0, 4.0];
let plot = Plot::new().group(|g| g.line(&x, &y1).line(&x, &y2));
let legend_items = plot.collect_legend_items();
assert!(legend_items.is_empty());
}
#[test]
fn test_group_without_color_uses_single_palette_color() {
let x = vec![0.0, 1.0, 2.0];
let y1 = vec![0.0, 1.0, 2.0];
let y2 = vec![0.0, 2.0, 4.0];
let y3 = vec![0.0, 3.0, 6.0];
let plot = Plot::new()
.group(|g| g.line(&x, &y1).line(&x, &y2))
.line(&x, &y3)
.end_series();
assert_eq!(plot.series_mgr.series.len(), 3);
let frame = plot.resolve_frame(0.0).expect("frame should resolve");
assert_eq!(frame.style.series[0].color, frame.style.series[1].color);
assert_ne!(frame.style.series[0].color, frame.style.series[2].color);
}
#[test]
fn test_group_mixed_series_uses_first_member_legend_glyph() {
let x = vec![0.0, 1.0, 2.0];
let y1 = vec![0.0, 1.0, 2.0];
let y2 = vec![0.0, 2.0, 4.0];
let plot = Plot::new().group(|g| {
g.group_label("Mixed")
.scatter(&x, &y1)
.line(&x, &y2)
.line_style(LineStyle::Dashed)
});
let legend_items = plot.collect_legend_items();
assert_eq!(legend_items.len(), 1);
assert_eq!(legend_items[0].label, "Mixed");
assert!(matches!(
legend_items[0].item_type,
LegendItemType::Scatter { .. }
));
}
#[test]
fn test_svg_legend_default_font_size_uses_typography_and_dpi() {
let x = vec![0.0, 1.0];
let y = vec![1.0, 2.0];
let plot: Plot = Plot::new()
.line(&x, &y)
.label("Legend Label")
.legend_best()
.dpi(144)
.into();
let svg = plot.render_to_svg().unwrap();
let font_size = extract_svg_text_font_size(&svg, "Legend Label");
assert!(
(font_size - 18.0).abs() <= 0.2,
"default legend font should be typography legend size (9pt) scaled to 144 DPI: {font_size}"
);
}
fn outside_legend_plot(position: LegendPosition, label: &str) -> Plot {
Plot::new()
.size_px(640, 480)
.legend_position(position)
.line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 0.5])
.label(label)
.end_series()
}
#[test]
fn test_plot_preserves_all_outside_legend_positions() {
for position in [
LegendPosition::OutsideRight,
LegendPosition::OutsideLeft,
LegendPosition::OutsideUpper,
LegendPosition::OutsideLower,
] {
let plot = outside_legend_plot(position, "Series");
assert_eq!(plot.layout.legend.position, position);
assert_eq!(
plot.layout
.legend
.to_legend(plot.display.config.typography.legend_size())
.position,
position
);
}
}
#[test]
fn test_all_outside_legend_rects_are_reserved_beyond_data_area() {
let baseline_plot = Plot::new()
.size_px(640, 480)
.line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 0.5])
.end_series();
let baseline = compute_render_layout(&baseline_plot);
for position in [
LegendPosition::OutsideRight,
LegendPosition::OutsideLeft,
LegendPosition::OutsideUpper,
LegendPosition::OutsideLower,
] {
let layout = compute_render_layout(&outside_legend_plot(position, "Series"));
let legend = layout.legend_rect.expect("outside legend rectangle");
assert!(legend.left >= 0.0 && legend.top >= 0.0);
assert!(legend.right <= 640.0 && legend.bottom <= 480.0);
match position {
LegendPosition::OutsideRight => {
assert!(legend.left > layout.plot_area.right);
assert!(layout.plot_area.right < baseline.plot_area.right);
}
LegendPosition::OutsideLeft => {
assert!(legend.right < layout.plot_area.left);
assert!(layout.plot_area.left > baseline.plot_area.left);
}
LegendPosition::OutsideUpper => {
assert!(legend.bottom < layout.plot_area.top);
assert!(layout.plot_area.top > baseline.plot_area.top);
}
LegendPosition::OutsideLower => {
assert!(legend.top > layout.plot_area.bottom);
assert!(layout.plot_area.bottom < baseline.plot_area.bottom);
}
_ => unreachable!(),
}
}
}
#[test]
fn test_outside_side_band_tracks_long_label_measurement() {
let short = compute_render_layout(&outside_legend_plot(LegendPosition::OutsideRight, "Short"));
let long_label = "A much longer legend label measured by the text engine";
let long = compute_render_layout(&outside_legend_plot(
LegendPosition::OutsideRight,
long_label,
));
let short_rect = short.legend_rect.unwrap();
let long_rect = long.legend_rect.unwrap();
assert!(long_rect.width() > short_rect.width() + 100.0);
assert!(long.margins.right > short.margins.right + 100.0);
assert!(long_rect.right <= 640.0);
}
#[test]
fn test_oversized_outside_legend_band_is_capped_and_still_renders() {
let huge_label = "This is a valid but extremely long legend label that would otherwise \
consume the entire canvas width when reserved as an outside band";
for position in [
LegendPosition::OutsideRight,
LegendPosition::OutsideLeft,
LegendPosition::OutsideUpper,
LegendPosition::OutsideLower,
] {
let plot = outside_legend_plot(position, huge_label);
let layout = compute_render_layout(&plot);
assert!(
layout.plot_area.right > layout.plot_area.left,
"plot area collapsed horizontally for {position:?}"
);
assert!(
layout.plot_area.bottom > layout.plot_area.top,
"plot area collapsed vertically for {position:?}"
);
plot.render()
.unwrap_or_else(|e| panic!("raster render failed for {position:?}: {e}"));
outside_legend_plot(position, huge_label)
.render_to_svg()
.unwrap_or_else(|e| panic!("SVG render failed for {position:?}: {e}"));
}
}
#[test]
fn test_outside_lower_multicolumn_uses_row_count_for_band_height() {
let make_plot = |columns| {
Plot::new()
.size_px(640, 480)
.legend_position(LegendPosition::OutsideLower)
.legend_columns(columns)
.line(&[0.0, 1.0], &[0.0, 1.0])
.label("One")
.line(&[0.0, 1.0], &[1.0, 2.0])
.label("Two")
.line(&[0.0, 1.0], &[2.0, 3.0])
.label("Three")
.line(&[0.0, 1.0], &[3.0, 4.0])
.label("Four")
.end_series()
};
let single_column = compute_render_layout(&make_plot(1));
let two_columns = compute_render_layout(&make_plot(2));
assert!(
two_columns.legend_rect.unwrap().height()
< single_column.legend_rect.unwrap().height() * 0.7
);
assert!(two_columns.legend_rect.unwrap().top > two_columns.plot_area.bottom);
make_plot(2).render().expect("multi-column raster legend");
make_plot(2)
.render_to_svg()
.expect("multi-column SVG legend");
}
#[test]
fn test_explicit_margins_remain_minimums_for_outside_legend() {
let mut plot = outside_legend_plot(LegendPosition::OutsideLeft, "Explicit margins");
plot.display.config.margins = MarginConfig::fixed(1.1, 0.4, 0.7, 0.9);
let layout = compute_render_layout(&plot);
let legend = layout.legend_rect.unwrap();
assert!(layout.margins.left > 110.0);
assert!(legend.right < layout.plot_area.left);
assert!(legend.left >= 0.0);
plot.render().expect("outside legend with fixed margins");
}
#[test]
fn test_outside_right_legend_is_additive_with_colorbar_band() {
let values = vec![vec![0.0, 0.5], vec![1.0, 1.5]];
let base = Plot::new()
.size_px(640, 480)
.heatmap_with(
&values,
crate::plots::heatmap::HeatmapConfig::new()
.colorbar(true)
.colorbar_label("Field"),
)
.line(&[0.0, 1.0], &[0.0, 1.0])
.label("Overlay")
.end_series();
let colorbar_layout = compute_render_layout(&base);
let with_legend = base.legend_position(LegendPosition::OutsideRight);
let layout = compute_render_layout(&with_legend);
let legend = layout.legend_rect.unwrap();
assert!(layout.plot_area.right < colorbar_layout.plot_area.right);
assert!(legend.left - layout.plot_area.right >= colorbar_layout.margins.right);
assert!(legend.right <= 640.0);
with_legend
.render()
.expect("colorbar and outside legend raster");
with_legend
.render_to_svg()
.expect("colorbar and outside legend SVG");
}
#[test]
fn test_outside_legend_layout_scales_with_dpi_and_matches_exports() {
let low = outside_legend_plot(LegendPosition::OutsideRight, "DPI legend").dpi(100);
let high = outside_legend_plot(LegendPosition::OutsideRight, "DPI legend").dpi(200);
let low_layout = compute_render_layout(&low);
let high_layout = compute_render_layout(&high);
assert!(
high_layout.legend_rect.unwrap().width() > low_layout.legend_rect.unwrap().width() * 1.8
);
low.render().expect("outside legend PNG");
low.render_to_svg().expect("outside legend SVG");
}
#[test]
fn test_png_and_svg_resolve_identical_outside_legend_rect() {
let plot =
outside_legend_plot(LegendPosition::OutsideLower, "Shared rectangle").legend_columns(2);
let raster_layout = compute_render_layout(&plot);
let (x_min, x_max, y_min, y_max) = plot.calculate_data_bounds().unwrap();
let content = plot.create_plot_content(y_min, y_max);
let mut renderer = crate::render::SkiaRenderer::new(
plot.display.dimensions.0,
plot.display.dimensions.1,
plot.display.theme.clone(),
)
.unwrap();
renderer.set_render_scale(plot.render_scale());
renderer.set_text_engine_mode(plot.display.text_engine);
let x_layout = crate::axes::TickLayout::compute(
x_min,
x_max,
0.0,
1.0,
&plot.layout.x_scale,
plot.layout.tick_config.major_ticks_x,
);
let y_layout = crate::axes::TickLayout::compute_y_axis(
y_min,
y_max,
0.0,
1.0,
&plot.layout.y_scale,
plot.layout.tick_config.major_ticks_y,
);
let measurements = plot
.measure_layout_text_with_ticks(
&renderer,
&content,
plot.display.config.figure.dpi,
&x_layout.labels,
&y_layout.labels,
)
.unwrap();
let svg_layout = plot.compute_layout_from_measurements(
plot.display.dimensions,
&content,
plot.display.config.figure.dpi,
measurements.as_ref(),
);
assert_eq!(raster_layout.legend_rect, svg_layout.legend_rect);
assert_eq!(raster_layout.plot_area, svg_layout.plot_area);
plot.render().unwrap();
plot.render_to_svg().unwrap();
}
#[test]
fn test_legacy_position_api_keeps_inside_layout_behavior() {
let legacy = Plot::new()
.size_px(640, 480)
.legend(LegendPosition::UpperRight)
.line(&[0.0, 1.0], &[0.0, 1.0])
.label("Legacy")
.end_series();
let modern = Plot::new()
.size_px(640, 480)
.legend_position(LegendPosition::UpperRight)
.line(&[0.0, 1.0], &[0.0, 1.0])
.label("Legacy")
.end_series();
assert_eq!(legacy.layout.legend.position, LegendPosition::UpperRight);
assert_eq!(
compute_render_layout(&legacy),
compute_render_layout(&modern)
);
assert!(compute_render_layout(&legacy).legend_rect.is_none());
}
#[test]
fn test_log_minor_ticks_are_generated_between_decades_by_default() {
let major_ticks = vec![1.0, 10.0, 100.0, 1000.0];
let minor_ticks = Plot::minor_tick_values_for_scale(
&major_ticks,
1.0,
1000.0,
&crate::axes::AxisScale::Log,
0,
);
assert_eq!(minor_ticks.len(), 24);
assert!(minor_ticks.contains(&2.0));
assert!(minor_ticks.contains(&9.0));
assert!(minor_ticks.contains(&20.0));
assert!(minor_ticks.contains(&900.0));
assert!(!minor_ticks.contains(&10.0));
assert!(!minor_ticks.contains(&100.0));
}
#[test]
fn test_sub_epsilon_log_minor_ticks_are_not_deduplicated_as_linear_values() {
let min = f64::EPSILON / 1024.0;
let max = f64::EPSILON / 8.0;
let major_ticks = crate::axes::generate_log_ticks(min, max, 6);
let minor_ticks =
Plot::minor_tick_values_for_scale(&major_ticks, min, max, &crate::axes::AxisScale::Log, 4);
assert!(!minor_ticks.is_empty());
assert!(minor_ticks.windows(2).all(|pair| pair[0] < pair[1]));
assert!(minor_ticks.iter().all(|tick| *tick >= min && *tick <= max));
}
#[test]
fn test_log_axis_raster_draws_minor_tick_marks() {
let plot: Plot = Plot::new()
.size_px(480, 360)
.xlim(0.0, 1.0)
.ylim(1.0, 1000.0)
.yscale(crate::axes::AxisScale::Log)
.major_ticks_y(4)
.show_bottom_ticks(false)
.show_top_ticks(false)
.show_right_ticks(false)
.grid(false)
.into();
let plot_area = compute_render_plot_area(&plot);
let image = plot.render().unwrap();
let minor_y = plot_area.bottom() - (2.0_f64.log10() / 3.0) as f32 * plot_area.height();
let longest_run = longest_dark_pixel_run_at_y(&image, minor_y.round() as u32);
assert!(
longest_run >= 4,
"log minor tick at y=2 should create a short horizontal dark run; got {longest_run}"
);
}
#[test]
fn test_png_annotation_hline_uses_log_y_scale() {
let plot: Plot = Plot::new()
.size_px(480, 360)
.grid(false)
.ticks(false)
.yscale(crate::axes::AxisScale::Log)
.line(&[0.0, 1.0], &[1.0, 1000.0])
.line_width(0.1)
.hline_styled(10.0, Color::RED, 3.0, LineStyle::Solid)
.into();
let image = plot.render().unwrap();
let plot_area = compute_render_plot_area(&plot);
let (x_min, x_max, y_min, y_max) = plot.effective_data_bounds().unwrap();
let (expected_x, expected_y) = crate::render::skia::map_data_to_pixels_scaled(
0.5,
10.0,
x_min,
x_max,
y_min,
y_max,
plot_area,
&crate::axes::AxisScale::Linear,
&crate::axes::AxisScale::Log,
);
let (_, linear_y) =
crate::render::skia::map_data_to_pixels(0.5, 10.0, x_min, x_max, y_min, y_max, plot_area);
assert!(
image_has_red_pixel_near(
&image,
expected_x.round() as u32,
expected_y.round() as u32,
3
),
"log-scaled annotation should render near y={expected_y}"
);
assert!(
!image_has_red_pixel_near(
&image,
expected_x.round() as u32,
linear_y.round() as u32,
3
),
"annotation should not render at the linear y position {linear_y}"
);
}
#[test]
fn test_svg_log_axis_draws_minor_tick_marks() {
let svg = Plot::new()
.size_px(480, 360)
.xlim(0.0, 1.0)
.ylim(1.0, 1000.0)
.yscale(crate::axes::AxisScale::Log)
.major_ticks_y(4)
.show_bottom_ticks(false)
.show_top_ticks(false)
.show_right_ticks(false)
.grid(false)
.render_to_svg()
.unwrap();
let short_horizontal_lines = count_short_horizontal_svg_lines(&svg, 12.0);
assert!(
short_horizontal_lines >= 25,
"log y-axis should include major and minor left tick marks; got {short_horizontal_lines}"
);
}
#[test]
fn test_svg_line_uses_log_y_scale_for_geometry() {
let x = vec![0.0, 1.0, 2.0, 3.0];
let y = vec![1.0, 10.0, 100.0, 1000.0];
let plot: Plot = Plot::new()
.line(&x, &y)
.xlim(0.0, 3.0)
.ylim(1.0, 1000.0)
.yscale(crate::axes::AxisScale::Log)
.into();
let svg = plot.render_to_svg().unwrap();
let points = extract_first_svg_polyline_points(&svg);
assert_eq!(points.len(), 4);
let step_1 = (points[0].1 - points[1].1).abs();
let step_2 = (points[1].1 - points[2].1).abs();
let step_3 = (points[2].1 - points[3].1).abs();
assert!(
(step_1 - step_2).abs() < 2.0 && (step_2 - step_3).abs() < 2.0,
"log-spaced y values should have nearly equal pixel spacing: {points:?}"
);
}
#[test]
fn test_log_axis_rejects_non_positive_render_range() {
let result = Plot::new()
.line(&[1.0, 2.0], &[1.0, 10.0])
.yscale(crate::axes::AxisScale::Log)
.ylim(0.0, 10.0)
.render();
let err = result.expect_err("log scale should reject a zero y limit");
assert!(matches!(err, PlottingError::InvalidInput(_)));
assert!(err.to_string().contains("Invalid y-axis range"));
}
#[test]
fn test_log_axis_autoscale_skips_samples_it_cannot_place() {
Plot::new()
.line(&[0.0, 1.0], &[0.0, 1.0])
.yscale(crate::axes::AxisScale::Log)
.render()
.expect("a log axis autoscales to the samples it can place");
}
#[test]
fn test_log_axis_reports_data_it_cannot_place_at_all() {
let err = Plot::new()
.line(&[1.0, 2.0], &[-3.0, -1.0])
.yscale(crate::axes::AxisScale::Log)
.render()
.expect_err("a log axis with no placeable sample must say so");
assert!(matches!(err, PlottingError::InvalidInput(_)));
let message = err.to_string();
assert!(message.contains("logarithmic y axis"), "{message}");
assert!(message.contains("yscale"), "{message}");
}
#[test]
fn test_render_to_renderer_basic() {
use crate::render::{SkiaRenderer, Theme};
let x_data = vec![1.0, 2.0, 3.0];
let y_data = vec![2.0, 4.0, 3.0];
let plot = Plot::new()
.line(&x_data, &y_data)
.title("Test Plot")
.xlabel("X")
.ylabel("Y")
.end_series();
let mut renderer = SkiaRenderer::new(400, 300, Theme::default()).unwrap();
let result = plot.render_to_renderer(&mut renderer, 96.0);
assert!(result.is_ok());
}
#[test]
fn test_render_to_renderer_preserves_fractional_dpi_output_pixels() {
use crate::render::{SkiaRenderer, Theme};
let plot = Plot::new();
let mut renderer = SkiaRenderer::new(101, 101, Theme::dark()).unwrap();
plot.render_to_renderer(&mut renderer, 100.5).unwrap();
let image = renderer.into_image();
assert_eq!((image.width, image.height), (101, 101));
let bottom_right = &image.pixels[image.pixels.len() - 4..];
assert_eq!(bottom_right, &[255, 255, 255, 255]);
}
#[test]
fn test_explicit_top_level_output_retains_dimension_minimum() {
let err = Plot::new()
.set_output_pixels(99, 101)
.render()
.expect_err("top-level output below 100 pixels should still fail");
assert!(matches!(err, PlottingError::InvalidDimensions { .. }));
}
#[test]
fn test_render_to_renderer_empty_series() {
use crate::render::{SkiaRenderer, Theme};
let plot = Plot::new().title("Empty Plot");
let mut renderer = SkiaRenderer::new(400, 300, Theme::default()).unwrap();
let result = plot.render_to_renderer(&mut renderer, 96.0);
assert!(result.is_ok());
}
#[test]
fn test_render_to_renderer_multiple_series() {
use crate::render::{SkiaRenderer, Theme};
let x1 = vec![1.0, 2.0, 3.0];
let y1 = vec![2.0, 4.0, 3.0];
let x2 = vec![1.5, 2.5, 3.5];
let y2 = vec![1.0, 3.0, 2.0];
let plot = Plot::new()
.line(&x1, &y1)
.label("Series 1")
.line(&x2, &y2)
.label("Series 2")
.title("Multi-series Plot")
.end_series();
let mut renderer = SkiaRenderer::new(400, 300, Theme::default()).unwrap();
let result = plot.render_to_renderer(&mut renderer, 96.0);
assert!(result.is_ok());
}
#[test]
fn test_render_to_renderer_dpi_scaling() {
use crate::render::{SkiaRenderer, Theme};
let x_data = vec![1.0, 2.0, 3.0];
let y_data = vec![2.0, 4.0, 3.0];
let plot = Plot::new()
.line(&x_data, &y_data)
.title("DPI Test")
.end_series();
let mut renderer = SkiaRenderer::new(400, 300, Theme::default()).unwrap();
let result_96 = plot.clone().render_to_renderer(&mut renderer, 96.0);
assert!(result_96.is_ok());
let result_144 = plot.clone().render_to_renderer(&mut renderer, 144.0);
assert!(result_144.is_ok());
let result_300 = plot.render_to_renderer(&mut renderer, 300.0);
assert!(result_300.is_ok());
}
#[test]
fn test_render_to_svg_empty_plot_succeeds() {
let svg = Plot::new()
.title("Empty Plot")
.xlabel("X")
.ylabel("Y")
.render_to_svg()
.expect("empty SVG render should succeed");
assert!(svg.starts_with("<?xml"));
assert!(svg.contains("Empty Plot"));
}
#[test]
fn test_tight_layout_pad_changes_computed_layout_margins() {
let base_plot = Plot::new()
.size_px(800, 600)
.line(&[0.0, 1.0, 2.0], &[1.0, 4.0, 9.0])
.title("Tight Layout")
.xlabel("X Axis")
.ylabel("Y Axis")
.end_series();
let small_pad = base_plot.clone().tight_layout_pad(1.0);
let large_pad = base_plot.tight_layout_pad(12.0);
let small_layout = compute_layout_without_tick_measurements(&small_pad);
let large_layout = compute_layout_without_tick_measurements(&large_pad);
assert!(large_layout.margins.top > small_layout.margins.top);
assert!(large_layout.margins.bottom > small_layout.margins.bottom);
assert!(large_layout.margins.left > small_layout.margins.left);
assert!(large_layout.plot_area.width() < small_layout.plot_area.width());
assert!(large_layout.plot_area.height() < small_layout.plot_area.height());
}
#[test]
fn test_compute_layout_honors_fixed_margins() {
let mut plot = Plot::new()
.size_px(800, 600)
.line(&[0.0, 1.0], &[1.0, 3.0])
.title("Fixed Margins")
.xlabel("X")
.ylabel("Y")
.end_series();
plot.display.config.margins = MarginConfig::fixed(1.1, 0.4, 0.7, 0.9);
let layout = compute_layout_without_tick_measurements(&plot);
assert!((layout.margins.left - 110.0).abs() < 0.1);
assert!((layout.margins.right - 40.0).abs() < 0.1);
assert!((layout.margins.top - 70.0).abs() < 0.1);
assert!((layout.margins.bottom - 90.0).abs() < 0.1);
assert!((layout.plot_area.left - 110.0).abs() < 0.1);
assert!((layout.plot_area.right - 760.0).abs() < 0.1);
assert!((layout.plot_area.top - 70.0).abs() < 0.1);
assert!((layout.plot_area.bottom - 510.0).abs() < 0.1);
}
#[test]
fn test_compute_layout_honors_proportional_margins() {
let mut plot = Plot::new()
.size_px(800, 600)
.line(&[0.0, 1.0], &[1.0, 3.0])
.end_series();
plot.display.config.margins = MarginConfig::proportional_custom(0.2, 0.15, 0.1, 0.25);
let layout = compute_layout_without_tick_measurements(&plot);
assert!((layout.margins.left - 160.0).abs() < 0.1);
assert!((layout.margins.right - 120.0).abs() < 0.1);
assert!((layout.margins.top - 60.0).abs() < 0.1);
assert!((layout.margins.bottom - 150.0).abs() < 0.1);
assert!((layout.plot_area.width() - 520.0).abs() < 0.1);
assert!((layout.plot_area.height() - 390.0).abs() < 0.1);
}
#[test]
fn test_render_layout_uses_configured_major_ticks() {
let plot = Plot::new()
.size_px(640, 480)
.major_ticks_x(4)
.major_ticks_y(3)
.line(&[0.0, 1.0, 2.0, 3.0], &[1.0, 4.0, 9.0, 16.0])
.end_series();
let (x_min, x_max, y_min, y_max) = plot
.effective_data_bounds()
.expect("data bounds should be available");
let content = plot.create_plot_content(y_min, y_max);
let mut measurement_renderer = crate::render::SkiaRenderer::new(
plot.display.dimensions.0,
plot.display.dimensions.1,
plot.display.theme.clone(),
)
.expect("measurement renderer");
measurement_renderer.set_render_scale(plot.render_scale());
measurement_renderer.set_text_engine_mode(plot.display.text_engine);
let (_layout, x_ticks, y_ticks) = plot
.compute_layout_with_configured_ticks(
&measurement_renderer,
plot.display.dimensions,
&content,
plot.display.config.figure.dpi,
x_min,
x_max,
y_min,
y_max,
)
.expect("configured layout with tick measurements");
let (expected_x_ticks, expected_y_ticks) =
plot.configured_major_ticks(x_min, x_max, y_min, y_max);
assert_eq!(x_ticks, expected_x_ticks);
assert_eq!(y_ticks, expected_y_ticks);
}
#[test]
fn test_render_honors_top_and_right_tick_sides() {
let base_plot = Plot::new()
.size_px(400, 300)
.grid(false)
.line(&[0.0, 10.0, 20.0], &[0.0, 50.0, 100.0])
.end_series();
let all_sides = base_plot.clone().ticks_all_sides();
let bottom_left = base_plot.ticks_bottom_left();
let (top_probe, right_probe) = compute_render_tick_probe_points(&all_sides);
let image_all_sides = all_sides.render().expect("all-sides render should succeed");
let image_bottom_left = bottom_left
.render()
.expect("bottom-left render should succeed");
assert!(image_has_dark_pixel_near(
&image_all_sides,
top_probe.0,
top_probe.1,
1
));
assert!(!image_has_dark_pixel_near(
&image_bottom_left,
top_probe.0,
top_probe.1,
1
));
assert!(image_has_dark_pixel_near(
&image_all_sides,
right_probe.0,
right_probe.1,
1
));
assert!(!image_has_dark_pixel_near(
&image_bottom_left,
right_probe.0,
right_probe.1,
1
));
}
fn compute_categorical_render_top_tick_probe(plot: &Plot) -> (u32, u32) {
let (x_min, x_max, y_min, y_max) = plot
.effective_data_bounds()
.expect("data bounds should be available");
let content = plot.create_plot_content(y_min, y_max);
let mut measurement_renderer = crate::render::SkiaRenderer::new(
plot.display.dimensions.0,
plot.display.dimensions.1,
plot.display.theme.clone(),
)
.expect("measurement renderer");
measurement_renderer.set_render_scale(plot.render_scale());
measurement_renderer.set_text_engine_mode(plot.display.text_engine);
let (layout, _x_ticks, _y_ticks) = plot
.compute_layout_with_configured_ticks(
&measurement_renderer,
plot.display.dimensions,
&content,
plot.display.config.figure.dpi,
x_min,
x_max,
y_min,
y_max,
)
.expect("configured layout with tick measurements");
let plot_area = Plot::plot_area_from_layout(&layout).expect("valid plot area");
let category_axis = super::series_internal::CategoryAxis::harvest(&plot.series_mgr.series)
.expect("categorical plot should contain bar categories");
let x_tick_pixels =
Plot::categorical_x_tick_pixels(plot_area, x_min, x_max, &category_axis.positions)
.expect("categorical ticks should be available");
let x_probe = x_tick_pixels[0].round() as u32;
(x_probe, (plot_area.top() + 3.0).round() as u32)
}
#[test]
fn test_render_honors_top_ticks_for_categorical_bar() {
let categories = ["A", "B", "C"];
let values = [2.0, 4.0, 3.0];
let base_plot = Plot::new()
.size_px(400, 300)
.grid(false)
.bar(&categories, &values)
.end_series();
let all_sides = base_plot.clone().ticks_all_sides();
let bottom_left = base_plot.ticks_bottom_left();
let top_probe = compute_categorical_render_top_tick_probe(&all_sides);
let image_all_sides = all_sides.render().expect("all-sides render should succeed");
let image_bottom_left = bottom_left
.render()
.expect("bottom-left render should succeed");
assert!(image_has_dark_pixel_near(
&image_all_sides,
top_probe.0,
top_probe.1,
1
));
assert!(!image_has_dark_pixel_near(
&image_bottom_left,
top_probe.0,
top_probe.1,
1
));
}
#[test]
fn test_render_to_renderer_honors_top_ticks_for_categorical_bar() {
let categories = ["A", "B", "C"];
let values = [2.0, 4.0, 3.0];
let base_plot = Plot::new()
.size_px(400, 300)
.grid(false)
.bar(&categories, &values)
.end_series();
let all_sides = base_plot.clone().ticks_all_sides();
let bottom_left = base_plot.ticks_bottom_left();
let top_probe = compute_categorical_render_top_tick_probe(&all_sides);
let mut renderer_all =
crate::render::SkiaRenderer::new(400, 300, all_sides.display.theme.clone())
.expect("renderer");
all_sides
.render_to_renderer(&mut renderer_all, 100.0)
.expect("all-sides render_to_renderer should succeed");
let image_all_sides = renderer_all.into_image();
let mut renderer_bottom_left =
crate::render::SkiaRenderer::new(400, 300, bottom_left.display.theme.clone())
.expect("renderer");
bottom_left
.render_to_renderer(&mut renderer_bottom_left, 100.0)
.expect("bottom-left render_to_renderer should succeed");
let image_bottom_left = renderer_bottom_left.into_image();
assert!(image_has_dark_pixel_near(
&image_all_sides,
top_probe.0,
top_probe.1,
1
));
assert!(!image_has_dark_pixel_near(
&image_bottom_left,
top_probe.0,
top_probe.1,
1
));
}
#[test]
fn test_render_to_svg_uses_layout_positions_for_title_and_labels() {
use crate::render::{FontConfig, FontFamily, TextRenderer};
let x_data = vec![0.0, 1.0, 2.0, 3.0];
let y_data = vec![1.0, 3.0, 2.0, 4.0];
let plot = Plot::new()
.line(&x_data, &y_data)
.title("SVG_LAYOUT_TITLE")
.xlabel("SVG_LAYOUT_X")
.ylabel("SVG_LAYOUT_Y")
.end_series();
let svg = plot.render_to_svg().expect("SVG render should succeed");
let (x_min, x_max, y_min, y_max) = plot
.effective_data_bounds()
.expect("data bounds should be available");
let content = plot.create_plot_content(y_min, y_max);
let mut measurement_renderer = crate::render::SkiaRenderer::new(
plot.display.dimensions.0,
plot.display.dimensions.1,
plot.display.theme.clone(),
)
.expect("measurement renderer");
let render_scale = plot.render_scale();
measurement_renderer.set_render_scale(render_scale);
measurement_renderer.set_text_engine_mode(plot.display.text_engine);
let x_measurement_layout = crate::axes::TickLayout::compute(
x_min,
x_max,
0.0,
1.0,
&plot.layout.x_scale,
plot.layout.tick_config.major_ticks_x,
);
let y_measurement_layout = crate::axes::TickLayout::compute_y_axis(
y_min,
y_max,
0.0,
1.0,
&plot.layout.y_scale,
plot.layout.tick_config.major_ticks_y,
);
let measured_dimensions = plot
.measure_layout_text_with_ticks(
&measurement_renderer,
&content,
plot.display.config.figure.dpi,
&x_measurement_layout.labels,
&y_measurement_layout.labels,
)
.expect("layout text measurements");
let layout = plot.compute_layout_from_measurements(
plot.display.dimensions,
&content,
plot.display.config.figure.dpi,
measured_dimensions.as_ref(),
);
let title_pos = layout.title_pos.as_ref().expect("title position");
let xlabel_pos = layout.xlabel_pos.as_ref().expect("xlabel position");
let ylabel_pos = layout.ylabel_pos.as_ref().expect("ylabel position");
let text_renderer = TextRenderer::new();
let title_metrics = text_renderer
.measure_text_placement(
"SVG_LAYOUT_TITLE",
&FontConfig::new(FontFamily::SansSerif, title_pos.size),
)
.expect("title metrics");
let xlabel_metrics = text_renderer
.measure_text_placement(
"SVG_LAYOUT_X",
&FontConfig::new(FontFamily::SansSerif, xlabel_pos.size),
)
.expect("xlabel metrics");
let (title_x, title_y) = extract_svg_text_xy(&svg, "SVG_LAYOUT_TITLE");
let (xlabel_x, xlabel_y) = extract_svg_text_xy(&svg, "SVG_LAYOUT_X");
let (ylabel_x, ylabel_y) = extract_svg_group_translate_xy(&svg, "SVG_LAYOUT_Y");
assert!(
(title_x - title_pos.x).abs() <= 0.6
&& (title_y - (title_pos.y + title_metrics.baseline_from_top)).abs() <= 0.6,
"title should follow layout position: svg=({}, {}), layout=({}, {})",
title_x,
title_y,
title_pos.x,
title_pos.y + title_metrics.baseline_from_top
);
assert!(
(xlabel_x - xlabel_pos.x).abs() <= 0.6
&& (xlabel_y - (xlabel_pos.y + xlabel_metrics.baseline_from_top)).abs() <= 0.6,
"xlabel should follow layout position: svg=({}, {}), layout=({}, {})",
xlabel_x,
xlabel_y,
xlabel_pos.x,
xlabel_pos.y + xlabel_metrics.baseline_from_top
);
assert!(
(ylabel_x - ylabel_pos.x).abs() <= 0.6 && (ylabel_y - ylabel_pos.y).abs() <= 0.6,
"ylabel should follow layout position: svg=({}, {}), layout=({}, {})",
ylabel_x,
ylabel_y,
ylabel_pos.x,
ylabel_pos.y
);
}
#[test]
fn test_render_to_svg_preserves_line_marker_shape() {
let marker_color = Color::from_rgb(17, 119, 51);
let plot = Plot::new()
.line(&[0.0, 1.0], &[0.0, 1.0])
.color(marker_color)
.marker(MarkerStyle::Square)
.marker_size(10.0)
.ticks(false)
.grid(false)
.end_series();
let svg = plot.render_to_svg().expect("SVG render should succeed");
let marker_fill = r#"fill="rgb(17,119,51)""#;
assert!(
svg.lines()
.any(|line| line.contains("<rect") && line.contains(marker_fill)),
"square line markers should render as filled rects in SVG"
);
assert!(
!svg.lines()
.any(|line| line.contains("<circle") && line.contains(marker_fill)),
"square line markers should not fall back to circles in SVG"
);
}
#[test]
fn test_render_to_svg_ticks_false_omits_tick_artifacts_but_keeps_axis_labels() {
let x_data = vec![0.0, 1.0, 2.0, 3.0];
let y_data = vec![1.0, 3.0, 2.0, 4.0];
let svg_with_ticks = Plot::new()
.line(&x_data, &y_data)
.title("NO_TICK_TITLE")
.xlabel("NO_TICK_X")
.ylabel("NO_TICK_Y")
.render_to_svg()
.expect("SVG render should succeed");
let svg_without_ticks = Plot::new()
.line(&x_data, &y_data)
.ticks(false)
.title("NO_TICK_TITLE")
.xlabel("NO_TICK_X")
.ylabel("NO_TICK_Y")
.render_to_svg()
.expect("SVG render should succeed");
assert!(
svg_without_ticks.matches("<line ").count() < svg_with_ticks.matches("<line ").count(),
"ticks(false) should reduce axis/tick line segments in SVG output"
);
assert_eq!(
svg_without_ticks.matches("</text>").count(),
3,
"ticks(false) should keep only title/xlabel/ylabel text nodes"
);
assert!(svg_without_ticks.contains(">NO_TICK_TITLE</text>"));
assert!(svg_without_ticks.contains(">NO_TICK_X</text>"));
assert!(svg_without_ticks.contains(">NO_TICK_Y</text>"));
}
#[test]
fn test_render_to_svg_ticks_false_uses_configured_frame_stroke() {
let config = PlotConfig {
figure: FigureConfig::new(6.4, 4.8, 200.0),
lines: CoreLineConfig {
axis_width: 1.2,
..CoreLineConfig::default()
},
..PlotConfig::default()
};
let svg = Plot::new()
.plot_config(config)
.line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 4.0])
.ticks(false)
.render_to_svg()
.expect("SVG render should succeed");
assert!(
svg.contains(r#"stroke-width="3.33""#),
"ticks(false) should use PlotConfig.lines.axis_width for the frame"
);
}
#[test]
fn test_render_to_svg_ticks_false_honors_despine_config() {
let config = PlotConfig {
spines: CoreSpineConfig::despine(),
..PlotConfig::default()
};
let svg = Plot::new()
.plot_config(config)
.grid(false)
.ticks(false)
.line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 4.0])
.render_to_svg()
.expect("SVG render should succeed");
assert_eq!(
svg.matches("<line ").count(),
2,
"despine should draw only left and bottom frame lines when ticks are hidden"
);
}
#[test]
fn test_render_ticks_false_uses_configured_frame_width() {
let test_config = |axis_width| PlotConfig {
figure: FigureConfig::new(360.0 / 200.0, 260.0 / 200.0, 200.0),
lines: CoreLineConfig {
axis_width,
..CoreLineConfig::default()
},
..PlotConfig::default()
};
let thin_config = test_config(0.3);
let thick_config = test_config(6.0);
let make_plot = |config| -> Plot {
Plot::new()
.plot_config(config)
.grid(false)
.ticks(false)
.line(&[0.0, 1.0], &[10.0, 11.0])
.into()
};
let thin_plot = make_plot(thin_config);
let thick_plot = make_plot(thick_config);
let thin_image = thin_plot.render().expect("thin frame render");
let thick_image = thick_plot.render().expect("thick frame render");
let thin_area = compute_render_plot_area(&thin_plot);
let thick_area = compute_render_plot_area(&thick_plot);
let thin_y = (thin_area.top() + thin_area.height() * 0.5).round() as u32;
let thick_y = (thick_area.top() + thick_area.height() * 0.5).round() as u32;
let thin_x = (thin_area.left().round() as u32).saturating_sub(1);
let thick_x = (thick_area.left().round() as u32).saturating_sub(1);
let thin_run = dark_pixel_run_right_from(&thin_image, thin_x, thin_y);
let thick_run = dark_pixel_run_right_from(&thick_image, thick_x, thick_y);
assert!(
thick_run > thin_run + 4,
"configured thick frame should render visibly wider than thin frame: thin={thin_run}, thick={thick_run}"
);
}
#[test]
fn test_axis_tick_metrics_follow_line_config() {
let test_config = |axis_width, tick_width, tick_length| PlotConfig {
figure: FigureConfig::new(4.0, 3.0, 144.0),
lines: CoreLineConfig {
axis_width,
tick_width,
tick_length,
..CoreLineConfig::default()
},
..PlotConfig::default()
};
let thin = Plot::new()
.plot_config(test_config(0.3, 0.2, 2.0))
.line(&[0.0, 1.0], &[0.0, 1.0])
.end_series();
let thick = Plot::new()
.plot_config(test_config(2.4, 1.6, 7.0))
.line(&[0.0, 1.0], &[0.0, 1.0])
.end_series();
let (thin_axis, thin_tick_len, _, thin_tick_width, _) = thin.axis_tick_metrics_px();
let (thick_axis, thick_tick_len, _, thick_tick_width, _) = thick.axis_tick_metrics_px();
assert!(
thick_axis > thin_axis * 4.0,
"axis width should follow PlotConfig.lines.axis_width: thin={thin_axis}, thick={thick_axis}"
);
assert!(
thick_tick_len > thin_tick_len * 3.0,
"tick length should follow PlotConfig.lines.tick_length: thin={thin_tick_len}, thick={thick_tick_len}"
);
assert!(
thick_tick_width > thin_tick_width * 4.0,
"tick width should follow PlotConfig.lines.tick_width: thin={thin_tick_width}, thick={thick_tick_width}"
);
}
#[test]
fn test_rendered_axis_and_tick_geometry_follow_line_config() {
let test_config = |axis_width, tick_width, tick_length| PlotConfig {
figure: FigureConfig::new(360.0 / 200.0, 260.0 / 200.0, 200.0),
lines: CoreLineConfig {
axis_width,
tick_width,
tick_length,
..CoreLineConfig::default()
},
..PlotConfig::default()
};
let make_plot = |config| -> Plot {
Plot::new()
.plot_config(config)
.grid(false)
.ticks_bottom_left()
.tick_direction_outside()
.major_ticks_x(3)
.major_ticks_y(3)
.line(&[0.0, 10.0], &[2.0, 8.0])
.into()
};
let thin_plot = make_plot(test_config(0.25, 0.20, 2.0));
let thick_plot = make_plot(test_config(2.4, 1.6, 7.0));
let thin_image = thin_plot.render().expect("thin axis render");
let thick_image = thick_plot.render().expect("thick axis render");
let thin_area = compute_render_plot_area(&thin_plot);
let thick_area = compute_render_plot_area(&thick_plot);
let thin_axis_y = (thin_area.top() + thin_area.height() * 0.5).round() as u32;
let thick_axis_y = (thick_area.top() + thick_area.height() * 0.5).round() as u32;
let thin_axis_x = (thin_area.left().round() as u32).saturating_sub(1);
let thick_axis_x = (thick_area.left().round() as u32).saturating_sub(1);
let thin_axis_run = dark_pixel_run_right_from(&thin_image, thin_axis_x, thin_axis_y);
let thick_axis_run = dark_pixel_run_right_from(&thick_image, thick_axis_x, thick_axis_y);
let thin_tick_x = middle_x_tick_pixel(&thin_plot, thin_area);
let thick_tick_x = middle_x_tick_pixel(&thick_plot, thick_area);
let thin_tick_y = thin_area.bottom().round() as u32;
let thick_tick_y = thick_area.bottom().round() as u32;
let thin_tick_run = dark_pixel_run_down_from(&thin_image, thin_tick_x, thin_tick_y);
let thick_tick_run = dark_pixel_run_down_from(&thick_image, thick_tick_x, thick_tick_y);
assert!(
thick_axis_run >= thin_axis_run + 3,
"rendered border width should follow PlotConfig.lines.axis_width: thin={thin_axis_run}, thick={thick_axis_run}"
);
assert!(
thick_tick_run > thin_tick_run + 8,
"rendered tick length should follow PlotConfig.lines.tick_length: thin={thin_tick_run}, thick={thick_tick_run}"
);
}
#[cfg(feature = "typst-math")]
#[test]
fn test_render_to_svg_typst_uses_layout_anchor_contract() {
let x_data = vec![0.0, 1.0, 2.0, 3.0];
let y_data = vec![1.0, 3.0, 2.0, 4.0];
let plot = Plot::new()
.line(&x_data, &y_data)
.title("SVG_LAYOUT_TITLE")
.xlabel("SVG_LAYOUT_X")
.ylabel("SVG_LAYOUT_Y")
.typst(true)
.end_series();
let svg = plot.render_to_svg().expect("SVG render should succeed");
let (x_min, x_max, y_min, y_max) = plot
.effective_data_bounds()
.expect("data bounds should be available");
let content = plot.create_plot_content(y_min, y_max);
let mut measurement_renderer = crate::render::SkiaRenderer::new(
plot.display.dimensions.0,
plot.display.dimensions.1,
plot.display.theme.clone(),
)
.expect("measurement renderer");
let render_scale = plot.render_scale();
measurement_renderer.set_render_scale(render_scale);
measurement_renderer.set_text_engine_mode(plot.display.text_engine);
let x_measurement_layout = crate::axes::TickLayout::compute(
x_min,
x_max,
0.0,
1.0,
&plot.layout.x_scale,
plot.layout.tick_config.major_ticks_x,
);
let y_measurement_layout = crate::axes::TickLayout::compute_y_axis(
y_min,
y_max,
0.0,
1.0,
&plot.layout.y_scale,
plot.layout.tick_config.major_ticks_y,
);
let measured_dimensions = plot
.measure_layout_text_with_ticks(
&measurement_renderer,
&content,
plot.display.config.figure.dpi,
&x_measurement_layout.labels,
&y_measurement_layout.labels,
)
.expect("layout text measurements");
let layout = plot.compute_layout_from_measurements(
plot.display.dimensions,
&content,
plot.display.config.figure.dpi,
measured_dimensions.as_ref(),
);
let title_pos = layout.title_pos.as_ref().expect("title position");
let xlabel_pos = layout.xlabel_pos.as_ref().expect("xlabel position");
let ylabel_pos = layout.ylabel_pos.as_ref().expect("ylabel position");
let typst_groups = extract_typst_group_boxes(&svg);
assert!(
typst_groups.len() >= 3,
"expected at least three typst text groups, found {}",
typst_groups.len()
);
let n = typst_groups.len();
let title = typst_groups[n - 3];
let xlabel = typst_groups[n - 2];
let ylabel = typst_groups[n - 1];
let title_center_x = title.0 + title.2 / 2.0;
let xlabel_center_x = xlabel.0 + xlabel.2 / 2.0;
let ylabel_center_x = ylabel.0 + ylabel.2 / 2.0;
let ylabel_center_y = ylabel.1 + ylabel.3 / 2.0;
assert!(
(title_center_x - title_pos.x).abs() <= 0.8 && (title.1 - title_pos.y).abs() <= 0.8,
"typst title should follow top-center anchor: group=({}, {}, {}x{}), layout=({}, {})",
title.0,
title.1,
title.2,
title.3,
title_pos.x,
title_pos.y
);
assert!(
(xlabel_center_x - xlabel_pos.x).abs() <= 0.8 && (xlabel.1 - xlabel_pos.y).abs() <= 0.8,
"typst xlabel should follow top-center anchor: group=({}, {}, {}x{}), layout=({}, {})",
xlabel.0,
xlabel.1,
xlabel.2,
xlabel.3,
xlabel_pos.x,
xlabel_pos.y
);
assert!(
(ylabel_center_x - ylabel_pos.x).abs() <= 0.8
&& (ylabel_center_y - ylabel_pos.y).abs() <= 0.8,
"typst ylabel should follow center anchor: group=({}, {}, {}x{}), layout=({}, {})",
ylabel.0,
ylabel.1,
ylabel.2,
ylabel.3,
ylabel_pos.x,
ylabel_pos.y
);
}
#[test]
fn test_render_to_svg_preserves_line_width_ratio_across_dpi() {
let x_data = vec![0.0, 1.0, 2.0, 3.0];
let y_data = vec![1.0, 3.0, 2.0, 4.0];
let plot_100 = Plot::new()
.size(6.4, 4.8)
.dpi(100)
.line(&x_data, &y_data)
.line_width(2.0)
.end_series();
let plot_200 = Plot::new()
.size(6.4, 4.8)
.dpi(200)
.line(&x_data, &y_data)
.line_width(2.0)
.end_series();
let svg_100 = plot_100.render_to_svg().expect("100 DPI SVG render");
let svg_200 = plot_200.render_to_svg().expect("200 DPI SVG render");
let width_100 = extract_svg_root_attr(&svg_100, "width");
let width_200 = extract_svg_root_attr(&svg_200, "width");
let stroke_100 = extract_first_svg_polyline_stroke_width(&svg_100);
let stroke_200 = extract_first_svg_polyline_stroke_width(&svg_200);
let ratio_100 = stroke_100 / width_100;
let ratio_200 = stroke_200 / width_200;
assert!(
(ratio_100 - ratio_200).abs() < 0.0005,
"stroke-to-canvas ratio should remain stable across DPI: {} vs {}",
ratio_100,
ratio_200
);
}
#[test]
#[cfg(feature = "gpu")]
fn test_gpu_method_sets_backend_and_reports_skia_fallback() {
let plot = Plot::new().auto_optimize().gpu(true);
assert_eq!(plot.get_backend_name(), "gpu");
assert!(plot.render.enable_gpu);
assert!(!plot.render.auto_optimized);
let resolution = plot.backend_resolution(BackendOperation::Png);
assert_eq!(resolution.actual_backend(), BackendType::Skia);
assert_eq!(
resolution.fallback_reason(),
Some(BackendFallbackReason::UnsupportedOperation)
);
}
#[test]
#[cfg(feature = "gpu")]
fn test_gpu_method_disabled() {
let plot = Plot::new().gpu(false);
assert!(!plot.render.enable_gpu);
}
#[test]
#[cfg(feature = "gpu")]
fn test_gpu_threshold_constants() {
const DATASHADER_THRESHOLD: usize = 100_000;
const GPU_THRESHOLD: usize = 5_000;
let datashader_threshold = std::hint::black_box(DATASHADER_THRESHOLD);
let gpu_threshold = std::hint::black_box(GPU_THRESHOLD);
assert!(gpu_threshold < datashader_threshold);
assert!(gpu_threshold > 0);
}
#[test]
#[cfg(feature = "gpu")]
fn test_gpu_with_small_dataset() {
let x_data: Vec<f64> = (0..100).map(|i| i as f64).collect();
let y_data: Vec<f64> = x_data.iter().map(|x| x * x).collect();
let plot = Plot::new()
.gpu(true)
.line(&x_data, &y_data)
.title("Small Dataset GPU Test")
.end_series();
let result = plot.render();
assert!(result.is_ok());
}
#[test]
#[cfg(feature = "gpu")]
fn test_gpu_with_medium_dataset() {
let x_data: Vec<f64> = (0..6000).map(|i| i as f64 * 0.01).collect();
let y_data: Vec<f64> = x_data.iter().map(|x| x.sin()).collect();
let plot = Plot::new()
.gpu(true)
.line(&x_data, &y_data)
.title("Medium Dataset GPU Test")
.end_series();
let result = plot.render();
assert!(result.is_ok());
}
#[test]
#[cfg(feature = "gpu")]
fn test_gpu_scatter_plot() {
let x_data: Vec<f64> = (0..5500).map(|i| i as f64 * 0.01).collect();
let y_data: Vec<f64> = x_data.iter().map(|x| x.cos()).collect();
let plot = Plot::new()
.gpu(true)
.scatter(&x_data, &y_data)
.title("Scatter GPU Test")
.end_series();
let result = plot.render();
assert!(result.is_ok());
}
#[test]
#[cfg(feature = "gpu")]
fn test_gpu_fallback_on_unsupported_series() {
let categories = vec!["A", "B", "C", "D"];
let values = vec![10.0, 20.0, 15.0, 25.0];
let plot = Plot::new()
.gpu(true)
.bar(&categories, &values)
.title("Bar Chart GPU Fallback")
.end_series();
let result = plot.render();
assert!(result.is_ok());
}
#[test]
#[cfg(feature = "gpu")]
fn test_plot_series_builder_gpu_method() {
let x_data: Vec<f64> = (0..100).map(|i| i as f64).collect();
let y_data: Vec<f64> = x_data.iter().map(|x| x * 2.0).collect();
let plot = Plot::new().line(&x_data, &y_data).gpu(true);
assert_eq!(plot.get_backend_name(), "gpu");
}
#[test]
fn test_backend_selection_without_gpu_feature() {
let plot = Plot::new().backend(BackendType::Parallel);
assert_eq!(plot.get_backend_name(), "parallel");
let plot2 = Plot::new().backend(BackendType::DataShader);
assert_eq!(plot2.get_backend_name(), "datashader");
}
#[test]
fn test_auto_backend_selection() {
let x_small: Vec<f64> = (0..100).map(|i| i as f64).collect();
let y_small: Vec<f64> = x_small.iter().map(|x| x * x).collect();
let plot = Plot::new().line(&x_small, &y_small).end_series();
let plot = plot.auto_optimize();
let backend_name = plot.get_backend_name();
assert_eq!(backend_name, "skia");
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_benchmark_save_png_bytes_uses_skia_backend() {
let x_data = [0.0, 1.0, 2.0];
let y_data = [0.0, 1.0, 4.0];
let plot = Plot::new().line(&x_data, &y_data).end_series();
let (png_bytes, backend) = plot.benchmark_save_png_bytes().unwrap();
assert_eq!(backend, "skia");
assert!(png_bytes.starts_with(b"\x89PNG\r\n\x1a\n"));
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_benchmark_save_png_bytes_keeps_large_scatter_on_skia_reference_path() {
let x_data: Vec<f64> = (0..100_000).map(|i| i as f64 * 0.00001).collect();
let y_data: Vec<f64> = x_data.iter().map(|x| x.sin()).collect();
let plot = Plot::new().scatter(&x_data, &y_data).end_series();
let (_, backend) = plot.benchmark_save_png_bytes().unwrap();
assert_eq!(backend, "skia");
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_public_png_auto_optimize_keeps_large_scatter_on_skia_visual_path() {
let x_data: Vec<f64> = (0..100_000).map(|i| i as f64 * 0.00001).collect();
let y_data: Vec<f64> = x_data.iter().map(|x| x.sin()).collect();
let plot = Plot::new()
.scatter(&x_data, &y_data)
.auto_optimize()
.into_plot();
assert_eq!(plot.get_backend_name(), "skia");
assert_eq!(plot.resolved_backend_name(), "skia");
let resolution = plot.backend_resolution(BackendOperation::Png);
assert_eq!(resolution.requested_backend(), Some(BackendType::Skia));
assert_eq!(resolution.actual_backend(), BackendType::Skia);
assert_eq!(resolution.fallback_reason(), None);
let (png_bytes, backend, diagnostics) = plot
.benchmark_save_png_bytes_with_diagnostics()
.expect("auto-optimized scatter PNG render should preserve Skia visuals");
assert_eq!(backend, "skia");
assert_eq!(diagnostics.actual_backend(), BackendType::Skia);
assert_eq!(diagnostics.render_mode, "reference");
assert!(!diagnostics.used_auto_datashader);
assert!(png_bytes.starts_with(b"\x89PNG\r\n\x1a\n"));
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_public_png_explicit_datashader_uses_density_path_for_large_scatter() {
let x_data: Vec<f64> = (0..100_000).map(|i| i as f64 * 0.00001).collect();
let y_data: Vec<f64> = x_data.iter().map(|x| x.sin()).collect();
let plot = Plot::new()
.backend(BackendType::DataShader)
.scatter(&x_data, &y_data)
.into_plot();
assert_eq!(plot.get_backend_name(), "datashader");
assert_eq!(plot.resolved_backend_name(), "datashader");
let resolution = plot.backend_resolution(BackendOperation::Png);
assert_eq!(
resolution.requested_backend(),
Some(BackendType::DataShader)
);
assert_eq!(resolution.actual_backend(), BackendType::DataShader);
assert_eq!(resolution.fallback_reason(), None);
let (png_bytes, backend, diagnostics) = plot
.benchmark_save_png_bytes_with_diagnostics()
.expect("explicit DataShader scatter PNG render should use density path");
assert_eq!(backend, "datashader");
assert_eq!(diagnostics.actual_backend(), BackendType::DataShader);
assert_eq!(diagnostics.render_mode, "optimized");
assert!(diagnostics.used_auto_datashader);
assert!(png_bytes.starts_with(b"\x89PNG\r\n\x1a\n"));
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_public_png_auto_optimize_refuses_unroutable_large_line_backend() {
let x_data: Vec<f64> = (0..100_000).map(|i| i as f64 * 0.00001).collect();
let y_data: Vec<f64> = x_data.iter().map(|x| x.sin()).collect();
let plot = Plot::new()
.line(&x_data, &y_data)
.auto_optimize()
.into_plot();
assert_eq!(plot.get_backend_name(), "skia");
assert_eq!(plot.resolved_backend_name(), "skia");
assert!(
!plot
.backend_resolution(BackendOperation::Png)
.used_fallback()
);
let (_, backend, diagnostics) = plot
.benchmark_save_png_bytes_with_diagnostics()
.expect("auto-selected Skia line PNG render should stay on Skia");
assert_eq!(backend, "skia");
assert_eq!(diagnostics.actual_backend(), BackendType::Skia);
assert_eq!(diagnostics.render_mode, "reference");
assert!(!diagnostics.used_auto_datashader);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_benchmark_save_png_bytes_keeps_large_histogram_on_skia() {
let samples: Vec<f64> = (0..100_000).map(|i| (i as f64 * 0.0002).sin()).collect();
let plot = Plot::new().histogram(&samples).end_series();
let (_, backend) = plot.benchmark_save_png_bytes().unwrap();
assert_eq!(backend, "skia");
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_reference_save_png_reports_line_raster_optimizations() {
let (x, y) = large_xy_data();
let plot = Plot::new()
.size_px(640, 480)
.ticks(false)
.grid(false)
.line(&x, &y)
.into_plot();
let (_, backend, diagnostics) = plot
.benchmark_save_png_bytes_with_diagnostics()
.expect("reference PNG render should produce diagnostics");
assert_eq!(backend, "skia");
assert_eq!(diagnostics.render_mode, "reference");
assert!(diagnostics.used_exact_line_canonicalization);
assert!(diagnostics.used_raster_line_reduction);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_reference_save_png_keeps_line_markers_off_raster_reduction() {
let (x, y) = large_xy_data();
let plot = Plot::new()
.size_px(640, 480)
.ticks(false)
.grid(false)
.line(&x, &y)
.marker(MarkerStyle::Circle)
.marker_size(6.0)
.into_plot();
let (_, backend, diagnostics) = plot
.benchmark_save_png_bytes_with_diagnostics()
.expect("reference line-marker PNG render should produce diagnostics");
assert_eq!(backend, "skia");
assert_eq!(diagnostics.render_mode, "reference");
assert!(!diagnostics.used_raster_line_reduction);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_reference_save_png_reports_marker_sprite_compositor_for_large_scatter() {
let (x, y) = large_xy_data();
let plot = Plot::new()
.size_px(640, 480)
.ticks(false)
.grid(false)
.scatter(&x, &y)
.marker(MarkerStyle::Circle)
.marker_size(6.0)
.into_plot();
let (_, backend, diagnostics) = plot
.benchmark_save_png_bytes_with_diagnostics()
.expect("reference scatter PNG render should produce diagnostics");
assert_eq!(backend, "skia");
assert_eq!(diagnostics.render_mode, "reference");
assert!(diagnostics.used_marker_sprite_compositor);
assert!(diagnostics.used_marker_sprite_cache);
assert!(!diagnostics.used_marker_sprite_fallback);
assert!(diagnostics.used_marker_scanline_blit);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_reference_save_png_marker_edge_keeps_the_sprite_compositor() {
let (x, y) = large_xy_data();
let plot = Plot::new()
.size_px(640, 480)
.ticks(false)
.grid(false)
.scatter(&x, &y)
.marker(MarkerStyle::Circle)
.marker_size(6.0)
.edge_color(Color::BLACK)
.into_plot();
let (png, _, diagnostics) = plot
.benchmark_save_png_bytes_with_diagnostics()
.expect("reference edged-scatter PNG render should produce diagnostics");
assert!(
diagnostics.used_marker_sprite_compositor,
"an edged marker batch must still use the sprite compositor"
);
let image = decode_png_rgba(&png);
let black_pixels = image
.pixels()
.filter(|p| p.0[3] == 255 && p.0[0] < 40 && p.0[1] < 40 && p.0[2] < 40)
.count();
assert!(
black_pixels > 200,
"the requested black rim must be painted through the sprite path, saw {black_pixels} px"
);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_edged_marker_batch_matches_across_the_sprite_threshold() {
let render = |count: usize| {
let x: Vec<f64> = (0..count).map(|i| ((i % 31) % 8) as f64).collect();
let y: Vec<f64> = (0..count).map(|i| ((i % 31) / 8) as f64).collect();
Plot::new()
.size_px(320, 240)
.ticks(false)
.grid(false)
.scatter(&x, &y)
.marker(MarkerStyle::Square)
.marker_size(10.0)
.edge_color(Color::from_rgb(255, 0, 0))
.edge_width(2.0)
.into_plot()
.benchmark_save_png_bytes_with_diagnostics()
.expect("threshold scatter PNG render should succeed")
};
let (vector_png, _, vector_diagnostics) = render(31);
let (sprite_png, _, sprite_diagnostics) = render(32);
assert!(!vector_diagnostics.used_marker_sprite_compositor);
assert!(sprite_diagnostics.used_marker_sprite_compositor);
let red = |png: &[u8]| {
decode_png_rgba(png)
.pixels()
.filter(|p| p.0 == [255, 0, 0, 255])
.count()
};
let vector_red = red(&vector_png);
let sprite_red = red(&sprite_png);
assert!(vector_red > 0, "the vector path must paint the rim");
let delta = vector_red.abs_diff(sprite_red);
assert!(
delta * 20 <= vector_red,
"vector and sprite rims must agree within 5%: {vector_red} vs {sprite_red}"
);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_reference_save_png_marker_sprite_cache_keeps_image_bytes_unchanged() {
let (x, y) = large_xy_data();
let plot = || {
Plot::new()
.size_px(640, 480)
.ticks(false)
.grid(false)
.scatter(&x, &y)
.marker(MarkerStyle::Circle)
.marker_size(6.0)
.into_plot()
};
let (cold_png, cold_backend, cold_diagnostics) = plot()
.benchmark_save_png_bytes_with_diagnostics()
.expect("cold reference scatter PNG render should succeed");
let (warm_png, warm_backend, warm_diagnostics) = plot()
.benchmark_save_png_bytes_with_diagnostics()
.expect("warm reference scatter PNG render should succeed");
assert_eq!(cold_backend, "skia");
assert_eq!(warm_backend, "skia");
assert_eq!(cold_diagnostics.render_mode, "reference");
assert_eq!(warm_diagnostics.render_mode, "reference");
assert!(cold_diagnostics.used_marker_sprite_compositor);
assert!(warm_diagnostics.used_marker_sprite_compositor);
assert_eq!(cold_png, warm_png);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_reference_save_png_reports_marker_sprite_compositor_for_line_markers() {
let (x, y) = large_xy_data();
let plot = Plot::new()
.size_px(640, 480)
.ticks(false)
.grid(false)
.line(&x, &y)
.marker(MarkerStyle::Diamond)
.marker_size(7.0)
.into_plot();
let (_, backend, diagnostics) = plot
.benchmark_save_png_bytes_with_diagnostics()
.expect("reference line-marker PNG render should produce diagnostics");
assert_eq!(backend, "skia");
assert_eq!(diagnostics.render_mode, "reference");
assert!(diagnostics.used_marker_sprite_compositor);
assert!(diagnostics.used_marker_sprite_cache);
assert!(!diagnostics.used_marker_sprite_fallback);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_reference_save_png_reports_marker_sprite_compositor_for_scatter_with_error_bars() {
let (x, y) = large_error_bar_xy_data();
let y_errors: Vec<f64> = x
.iter()
.map(|value| 0.03 + 0.01 * (value * 0.7).sin().abs())
.collect();
let x_errors: Vec<f64> = x
.iter()
.map(|value| 0.02 + 0.008 * (value * 0.9).cos().abs())
.collect();
let plot = Plot::new()
.size_px(640, 480)
.ticks(false)
.grid(false)
.scatter(&x, &y)
.marker(MarkerStyle::Diamond)
.marker_size(9.0)
.with_yerr(&y_errors)
.with_xerr(&x_errors)
.into_plot();
let (_, backend, diagnostics) = plot
.benchmark_save_png_bytes_with_diagnostics()
.expect("reference scatter-with-errors PNG render should produce diagnostics");
assert_eq!(backend, "skia");
assert_eq!(diagnostics.render_mode, "reference");
assert!(diagnostics.used_marker_sprite_compositor);
assert!(diagnostics.used_marker_sprite_cache);
assert!(!diagnostics.used_marker_sprite_fallback);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_reference_save_png_reports_direct_rect_fill_for_large_heatmap() {
let heatmap_values = large_heatmap_matrix();
let plot = Plot::new()
.size_px(640, 480)
.heatmap_with(
&heatmap_values,
crate::plots::heatmap::HeatmapConfig::new().colorbar(false),
)
.into_plot();
let (_, backend, diagnostics) = plot
.benchmark_save_png_bytes_with_diagnostics()
.expect("reference heatmap PNG render should produce diagnostics");
assert_eq!(backend, "skia");
assert_eq!(diagnostics.render_mode, "reference");
assert!(diagnostics.used_direct_rect_fill || diagnostics.used_pixel_aligned_rect_fill);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_render_large_scatter_png_preserves_background() {
let x_data: Vec<f64> = (0..100_000).map(|i| i as f64 * 0.00001).collect();
let y_data: Vec<f64> = x_data.iter().map(|x| x.sin()).collect();
let png = Plot::new()
.size_px(640, 480)
.title("large scatter")
.xlabel("x")
.ylabel("y")
.scatter(&x_data, &y_data)
.render_png_bytes()
.expect("large scatter should render as PNG");
assert_png_background_preserved("large scatter", &png);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_render_large_scatter_source_png_preserves_background() {
let x_data: Vec<f64> = (0..100_000).map(|i| i as f64 * 0.00001).collect();
let y_data: Vec<f64> = x_data.iter().map(|x| x.sin()).collect();
let png = Plot::new()
.size_px(640, 480)
.scatter_source(x_data.clone(), y_data)
.into_plot()
.render_png_bytes()
.expect("source-backed large scatter should render as PNG");
assert_png_background_preserved("source-backed large scatter", &png);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_render_to_renderer_large_scatter_stays_visually_sane() {
let (x, y) = large_xy_data();
let blank = Plot::new().size_px(320, 200).ticks(false).into_plot();
let plot = Plot::new()
.size_px(320, 200)
.ticks(false)
.scatter(&x, &y)
.into_plot();
let blank_png = render_plot_to_renderer_png(&blank, 320, 200);
let rendered_png = render_plot_to_renderer_png(&plot, 320, 200);
assert_png_visual_sane_against_blank(
"render_to_renderer large scatter",
&rendered_png,
&blank_png,
);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_render_large_line_and_histogram_png_preserve_background() {
let x_data: Vec<f64> = (0..100_000).map(|i| i as f64 * 0.00001).collect();
let y_data: Vec<f64> = x_data.iter().map(|x| x.sin()).collect();
let line_png = Plot::new()
.size_px(640, 480)
.line(&x_data, &y_data)
.render_png_bytes()
.expect("large line should render as PNG");
assert_png_background_preserved("large line", &line_png);
let histogram_input: Vec<f64> = (0..100_000).map(|i| (i as f64 * 0.0001).sin()).collect();
let histogram_png = Plot::new()
.size_px(640, 480)
.histogram(&histogram_input)
.render_png_bytes()
.expect("large histogram should render as PNG");
assert_png_background_preserved("large histogram", &histogram_png);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_optimized_line_render_stays_in_parity_with_reference_render() {
let (x, y) = large_xy_data();
let plot = Plot::new()
.size_px(320, 200)
.ticks(false)
.grid(false)
.line(&x, &y)
.into_plot();
let reference = plot.render().expect("reference line render should succeed");
let (optimized, diagnostics) = plot
.render_optimized_for_test_with_diagnostics()
.expect("optimized line render should succeed");
assert_plot_image_parity_against_reference("optimized line render", &reference, &optimized);
assert!(diagnostics.used_exact_line_canonicalization || diagnostics.used_raster_line_reduction);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_png_export_matches_reference_render_output_for_large_scatter() {
let (x, y) = large_xy_data();
let plot = Plot::new()
.size_px(320, 200)
.ticks(false)
.grid(false)
.scatter(&x, &y)
.marker(MarkerStyle::Circle)
.marker_size(6.0)
.into_plot();
let reference = plot
.render()
.expect("reference scatter render should succeed");
let (candidate_png, backend) = plot
.benchmark_save_png_bytes()
.expect("PNG export path should render");
assert_eq!(backend, "skia");
assert_png_parity_against_reference("PNG export path", &reference, &candidate_png);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_render_to_renderer_matches_reference_render_output_for_large_scatter() {
let (x, y) = large_xy_data();
let plot = Plot::new()
.size_px(320, 200)
.ticks(false)
.grid(false)
.scatter(&x, &y)
.marker(MarkerStyle::Circle)
.marker_size(6.0)
.into_plot();
let reference = plot
.render()
.expect("reference scatter render should succeed");
let renderer_png = render_plot_to_renderer_png(&plot, 320, 200);
assert_png_parity_against_reference("render_to_renderer", &reference, &renderer_png);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_large_xy_family_png_and_save_paths_stay_visually_sane() {
let (x, y) = large_xy_data();
let (error_x, error_y) = large_error_bar_xy_data();
let y_errors: Vec<f64> = error_x
.iter()
.map(|value| 0.03 + 0.01 * (value * 0.7).sin().abs())
.collect();
let x_errors: Vec<f64> = error_x
.iter()
.map(|value| 0.02 + 0.008 * (value * 0.9).cos().abs())
.collect();
let (r, theta) = large_polar_data();
let line = Plot::new()
.size_px(320, 200)
.ticks(false)
.line(&x, &y)
.into_plot();
assert_large_plot_png_and_save("large-line-family-line", &line);
let scatter = Plot::new()
.size_px(320, 200)
.ticks(false)
.scatter(&x, &y)
.into_plot();
assert_large_plot_png_and_save("large-line-family-scatter", &scatter);
let error_bars = Plot::new()
.size_px(320, 200)
.ticks(false)
.error_bars(&error_x, &error_y, &y_errors)
.into_plot();
assert_large_plot_png_and_save("large-line-family-error-bars", &error_bars);
let error_bars_xy = Plot::new()
.size_px(320, 200)
.ticks(false)
.error_bars_xy(&error_x, &error_y, &x_errors, &y_errors)
.into_plot();
assert_large_plot_png_and_save("large-line-family-error-bars-xy", &error_bars_xy);
let polar = Plot::new()
.size_px(320, 200)
.ticks(false)
.polar_line(&r, &theta)
.into_plot();
assert_large_plot_png_and_save("large-line-family-polar-line", &polar);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_large_distribution_and_categorical_png_and_save_paths_stay_visually_sane() {
let samples = large_scalar_samples();
let (categories, values) = large_bar_data();
let histogram = Plot::new()
.size_px(320, 200)
.ticks(false)
.histogram(&samples)
.into_plot();
assert_large_plot_png_and_save("large-distribution-histogram", &histogram);
let boxplot = Plot::new()
.size_px(320, 200)
.ticks(false)
.boxplot(&samples)
.into_plot();
assert_large_plot_png_and_save("large-distribution-boxplot", &boxplot);
let violin = Plot::new()
.size_px(320, 200)
.ticks(false)
.violin(&samples)
.into_plot();
assert_large_plot_png_and_save("large-distribution-violin", &violin);
let kde = Plot::new()
.size_px(320, 200)
.ticks(false)
.kde(&samples)
.into_plot();
assert_large_plot_png_and_save("large-distribution-kde", &kde);
let ecdf = Plot::new()
.size_px(320, 200)
.ticks(false)
.ecdf(&samples)
.into_plot();
assert_large_plot_png_and_save("large-distribution-ecdf", &ecdf);
let bar = Plot::new()
.size_px(320, 200)
.ticks(false)
.bar(&categories, &values)
.into_plot();
assert_large_plot_png_and_save("large-distribution-bar", &bar);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_large_grid_family_png_and_save_paths_stay_visually_sane() {
let heatmap_values = large_heatmap_matrix();
let (contour_x, contour_y, contour_z) = large_contour_axes();
let heatmap = Plot::new()
.size_px(320, 200)
.ticks(false)
.heatmap_with(
&heatmap_values,
crate::plots::heatmap::HeatmapConfig::new().colorbar(false),
)
.into_plot();
assert_large_plot_png_and_save("large-grid-heatmap", &heatmap);
let contour = Plot::new()
.size_px(320, 200)
.ticks(false)
.contour(&contour_x, &contour_y, &contour_z)
.into_plot();
assert_large_plot_png_and_save("large-grid-contour", &contour);
}
#[test]
fn test_line_streaming_basic() {
use crate::data::StreamingXY;
let stream = StreamingXY::new(100);
stream.push_many(vec![(0.0, 0.0), (1.0, 1.0), (2.0, 4.0), (3.0, 9.0)]);
let plot = Plot::new()
.line_streaming(&stream)
.title("Streaming Line Plot")
.end_series();
assert_eq!(plot.series_mgr.series.len(), 1);
if let SeriesType::Line { x_data, y_data } = &plot.series_mgr.series[0].series_type {
let x_resolved = x_data.resolve(0.0);
let y_resolved = y_data.resolve(0.0);
assert_eq!(x_resolved.len(), 4);
assert_eq!(y_resolved.len(), 4);
assert_eq!(x_resolved[0], 0.0);
assert_eq!(y_resolved[3], 9.0);
} else {
panic!("Expected Line series type");
}
}
#[test]
fn test_scatter_streaming_basic() {
use crate::data::StreamingXY;
let stream = StreamingXY::new(100);
stream.push_many(vec![(1.0, 10.0), (2.0, 20.0), (3.0, 30.0)]);
let plot = Plot::new()
.scatter_streaming(&stream)
.title("Streaming Scatter")
.end_series();
assert_eq!(plot.series_mgr.series.len(), 1);
if let SeriesType::Scatter { x_data, y_data } = &plot.series_mgr.series[0].series_type {
assert_eq!(x_data.len(), 3);
assert_eq!(y_data.len(), 3);
} else {
panic!("Expected Scatter series type");
}
}
#[test]
fn test_streaming_marks_rendered() {
use crate::data::StreamingXY;
let stream = StreamingXY::new(100);
stream.push_many(vec![(0.0, 0.0), (1.0, 1.0)]);
assert_eq!(stream.appended_count(), 2);
let plot = Plot::new().line_streaming(&stream).end_series();
assert_eq!(stream.appended_count(), 2);
plot.render().expect("streaming plot should render");
assert_eq!(stream.appended_count(), 0);
}
#[test]
fn test_generic_streaming_buffers_are_acknowledged_after_render_and_svg() {
use crate::data::StreamingBuffer;
let x = StreamingBuffer::new(16);
let y = StreamingBuffer::new(16);
x.push_many(vec![0.0, 1.0]);
y.push_many(vec![0.0, 1.0]);
let plot: Plot = Plot::new().line_source(x.clone(), y.clone()).into();
plot.render().expect("generic streaming plot should render");
assert_eq!(x.appended_since_mark(), 0);
assert_eq!(y.appended_since_mark(), 0);
x.push(2.0);
y.push(4.0);
plot.render_to_svg()
.expect("generic streaming plot should render to SVG");
assert_eq!(x.appended_since_mark(), 0);
assert_eq!(y.appended_since_mark(), 0);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn test_generic_streaming_buffers_are_acknowledged_after_png_and_save() {
use crate::data::StreamingBuffer;
let x = StreamingBuffer::new(16);
let y = StreamingBuffer::new(16);
x.push_many(vec![0.0, 1.0]);
y.push_many(vec![0.0, 1.0]);
let plot: Plot = Plot::new().line_source(x.clone(), y.clone()).into();
plot.render_png_bytes()
.expect("generic streaming PNG bytes should render");
assert_eq!(x.appended_since_mark(), 0);
assert_eq!(y.appended_since_mark(), 0);
x.push(2.0);
y.push(4.0);
let path = std::env::temp_dir().join(format!(
"ruviz-generic-streaming-save-{}-{}.png",
std::process::id(),
x.version()
));
plot.save(&path)
.expect("generic streaming plot should save successfully");
assert_eq!(x.appended_since_mark(), 0);
assert_eq!(y.appended_since_mark(), 0);
std::fs::remove_file(path).expect("temporary saved plot should be removable");
}
#[test]
fn test_resolved_frame_acknowledges_exact_captured_sequence() {
use crate::data::{StreamingRenderState, StreamingXY};
let stream = StreamingXY::new(100);
stream.push_many(vec![(0.0, 0.0), (1.0, 1.0)]);
let plot = Plot::new().line_streaming(&stream).end_series();
let frame = plot.resolve_frame(0.0).expect("frame should resolve");
stream.push(2.0, 4.0);
frame.acknowledge_rendered(&plot);
assert_eq!(stream.appended_count(), 1);
assert_eq!(stream.read_appended_x(), vec![2.0]);
assert_eq!(stream.read_appended_y(), vec![4.0]);
assert_eq!(
stream.render_state(),
StreamingRenderState::AppendOnly {
visible_appended: 1
}
);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn test_failed_streaming_save_does_not_acknowledge_snapshot() {
use crate::data::StreamingXY;
use std::time::{SystemTime, UNIX_EPOCH};
let stream = StreamingXY::new(100);
stream.push_many(vec![(0.0, 0.0), (1.0, 1.0)]);
let plot = Plot::new().line_streaming(&stream).end_series();
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should follow the Unix epoch")
.as_nanos();
let invalid_parent =
std::env::temp_dir().join(format!("ruviz-file-parent-{}-{unique}", std::process::id()));
std::fs::write(&invalid_parent, b"not a directory")
.expect("temporary blocker file should be writable");
let path = invalid_parent.join("plot.png");
assert!(plot.save(path).is_err());
assert_eq!(stream.appended_count(), 2);
std::fs::remove_file(invalid_parent).expect("temporary blocker file should be removable");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn test_failed_streaming_svg_export_does_not_acknowledge_snapshot() {
use crate::data::StreamingXY;
use std::time::{SystemTime, UNIX_EPOCH};
let stream = StreamingXY::new(100);
stream.push_many(vec![(0.0, 0.0), (1.0, 1.0)]);
let plot = Plot::new().line_streaming(&stream).end_series();
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should follow the Unix epoch")
.as_nanos();
let invalid_parent =
std::env::temp_dir().join(format!("ruviz-svg-parent-{}-{unique}", std::process::id()));
std::fs::write(&invalid_parent, b"not a directory")
.expect("temporary blocker file should be writable");
let path = invalid_parent.join("plot.svg");
assert!(plot.export_svg(path).is_err());
assert_eq!(stream.appended_count(), 2);
std::fs::remove_file(invalid_parent).expect("temporary blocker file should be removable");
}
#[test]
fn test_line_streaming_reads_updates_after_build() {
use crate::data::StreamingXY;
let stream = StreamingXY::new(100);
stream.push_many(vec![(0.0, 0.0), (1.0, 1.0)]);
let plot = Plot::new().line_streaming(&stream).end_series();
stream.push(2.0, 4.0);
if let SeriesType::Line { x_data, y_data } = &plot.series_mgr.series[0].series_type {
assert_eq!(x_data.resolve(0.0), vec![0.0, 1.0, 2.0]);
assert_eq!(y_data.resolve(0.0), vec![0.0, 1.0, 4.0]);
} else {
panic!("Expected Line series type");
}
}
#[test]
fn test_streaming_render_output() {
use crate::data::StreamingXY;
let stream = StreamingXY::new(100);
stream.push_many(vec![(0.0, 0.0), (1.0, 1.0), (2.0, 4.0)]);
let plot = Plot::new()
.line_streaming(&stream)
.title("Streaming Test")
.end_series();
let result = plot.render();
assert!(result.is_ok());
}
#[test]
fn test_streaming_with_ring_buffer_wrap() {
use crate::data::StreamingXY;
let stream = StreamingXY::new(3);
stream.push_many(vec![
(0.0, 0.0),
(1.0, 1.0),
(2.0, 2.0),
(3.0, 3.0),
(4.0, 4.0),
]);
assert_eq!(stream.len(), 3);
let plot = Plot::new().line_streaming(&stream).end_series();
if let SeriesType::Line { x_data, y_data: _ } = &plot.series_mgr.series[0].series_type {
let x_resolved = x_data.resolve(0.0);
assert_eq!(x_resolved.len(), 3);
assert_eq!(x_resolved[0], 2.0);
assert_eq!(x_resolved[1], 3.0);
assert_eq!(x_resolved[2], 4.0);
} else {
panic!("Expected Line series type");
}
}
#[test]
fn test_streaming_empty_buffer() {
use crate::data::StreamingXY;
let stream = StreamingXY::new(100);
let plot = Plot::new()
.line_streaming(&stream)
.title("Empty Stream")
.end_series();
assert_eq!(plot.series_mgr.series.len(), 1);
if let SeriesType::Line { x_data, y_data } = &plot.series_mgr.series[0].series_type {
assert!(x_data.is_empty());
assert!(y_data.is_empty());
}
}
#[test]
fn test_streaming_multiple_series() {
use crate::data::StreamingXY;
let stream1 = StreamingXY::new(100);
let stream2 = StreamingXY::new(100);
stream1.push_many(vec![(0.0, 0.0), (1.0, 1.0), (2.0, 2.0)]);
stream2.push_many(vec![(0.0, 0.0), (1.0, 2.0), (2.0, 4.0)]);
let plot = Plot::new()
.line_streaming(&stream1)
.label("Linear")
.line_streaming(&stream2)
.label("Quadratic")
.title("Multiple Streaming Series")
.end_series();
assert_eq!(plot.series_mgr.series.len(), 2);
if let SeriesType::Line { x_data, .. } = &plot.series_mgr.series[0].series_type {
assert_eq!(x_data.len(), 3);
}
if let SeriesType::Line { x_data, .. } = &plot.series_mgr.series[1].series_type {
assert_eq!(x_data.len(), 3);
}
let result = plot.render();
assert!(result.is_ok());
}
#[test]
fn test_streaming_mixed_with_static() {
use crate::data::StreamingXY;
let stream = StreamingXY::new(100);
stream.push_many(vec![(0.0, 0.0), (1.0, 1.0), (2.0, 4.0)]);
let static_x = vec![0.0, 1.0, 2.0];
let static_y = vec![0.0, 2.0, 4.0];
let plot = Plot::new()
.line_streaming(&stream)
.label("Streaming")
.line(&static_x, &static_y)
.label("Static")
.title("Mixed Data Sources")
.end_series();
assert_eq!(plot.series_mgr.series.len(), 2);
let result = plot.render();
assert!(result.is_ok());
}
#[test]
fn test_streaming_with_styling() {
use crate::data::StreamingXY;
let stream = StreamingXY::new(100);
stream.push_many(vec![(0.0, 0.0), (1.0, 1.0), (2.0, 4.0)]);
let plot = Plot::new()
.line_streaming(&stream)
.color(Color::from_rgb(255, 0, 0))
.line_width(3.0)
.label("Styled Streaming")
.title("Styled Streaming Plot")
.xlabel("X Axis")
.ylabel("Y Axis")
.end_series();
assert_eq!(
plot.series_mgr.series[0].props.color.cloned(),
Some(Color::from_rgb(255, 0, 0))
);
assert_eq!(
plot.series_mgr.series[0].props.line_width.cloned(),
Some(3.0)
);
let result = plot.render();
assert!(result.is_ok());
}
#[test]
fn test_streaming_scatter_with_styling() {
use crate::data::StreamingXY;
let stream = StreamingXY::new(100);
stream.push_many(vec![(0.0, 0.0), (1.0, 1.0), (2.0, 4.0)]);
let plot = Plot::new()
.scatter_streaming(&stream)
.color(Color::from_rgb(0, 255, 0))
.marker_size(10.0)
.end_series();
assert_eq!(
plot.series_mgr.series[0].props.color.cloned(),
Some(Color::from_rgb(0, 255, 0))
);
assert_eq!(
plot.series_mgr.series[0].props.marker_size.cloned(),
Some(10.0)
);
let result = plot.render();
assert!(result.is_ok());
}
#[test]
fn test_streaming_version_changes_on_data_update() {
use crate::data::StreamingXY;
let stream = StreamingXY::new(100);
let v0 = stream.version();
stream.push(1.0, 1.0);
let v1 = stream.version();
assert!(v1 > v0, "Version should increase after push");
let _plot = Plot::new().line_streaming(&stream).end_series();
stream.push(2.0, 2.0);
let v2 = stream.version();
assert!(v2 > v1, "Version should increase after second push");
}
#[test]
fn test_plot_is_reactive_for_reactive_title() {
use crate::data::Observable;
let title = Observable::new("Reactive Title".to_string());
let plot = Plot::new()
.title(title)
.line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 4.0])
.end_series();
assert!(plot.is_reactive());
}
#[test]
fn test_with_yerr_symmetric() {
let x = vec![1.0, 2.0, 3.0];
let y = vec![2.0, 4.0, 3.0];
let yerr = vec![0.3, 0.4, 0.25];
let plot = Plot::new()
.line(&x, &y)
.with_yerr(&yerr)
.label("Test")
.end_series();
assert_eq!(plot.series_mgr.series.len(), 1);
assert!(plot.series_mgr.series[0].y_errors.is_some());
assert!(plot.series_mgr.series[0].x_errors.is_none());
if let Some(ErrorValues::Symmetric(errs)) = &plot.series_mgr.series[0].y_errors {
assert_eq!(errs.len(), 3);
assert!((errs[0] - 0.3).abs() < 1e-10);
} else {
panic!("Expected symmetric error values");
}
}
#[test]
fn test_with_xerr_symmetric() {
let x = vec![1.0, 2.0, 3.0];
let y = vec![2.0, 4.0, 3.0];
let xerr = vec![0.15, 0.2, 0.1];
let plot = Plot::new()
.scatter(&x, &y)
.with_xerr(&xerr)
.label("Test")
.end_series();
assert_eq!(plot.series_mgr.series.len(), 1);
assert!(plot.series_mgr.series[0].x_errors.is_some());
assert!(plot.series_mgr.series[0].y_errors.is_none());
if let Some(ErrorValues::Symmetric(errs)) = &plot.series_mgr.series[0].x_errors {
assert_eq!(errs.len(), 3);
assert!((errs[1] - 0.2).abs() < 1e-10);
} else {
panic!("Expected symmetric error values");
}
}
#[test]
fn test_with_yerr_asymmetric() {
let x = vec![1.0, 2.0, 3.0];
let y = vec![2.0, 4.0, 3.0];
let lower = vec![0.2, 0.3, 0.2];
let upper = vec![0.5, 0.6, 0.4];
let plot = Plot::new()
.line(&x, &y)
.with_yerr_asymmetric(&lower, &upper)
.label("Test")
.end_series();
assert_eq!(plot.series_mgr.series.len(), 1);
assert!(plot.series_mgr.series[0].y_errors.is_some());
if let Some(ErrorValues::Asymmetric(lo, hi)) = &plot.series_mgr.series[0].y_errors {
assert_eq!(lo.len(), 3);
assert_eq!(hi.len(), 3);
assert!((lo[0] - 0.2).abs() < 1e-10);
assert!((hi[0] - 0.5).abs() < 1e-10);
} else {
panic!("Expected asymmetric error values");
}
}
#[test]
fn test_with_xerr_asymmetric() {
let x = vec![1.0, 2.0, 3.0];
let y = vec![2.0, 4.0, 3.0];
let left = vec![0.1, 0.15, 0.1];
let right = vec![0.2, 0.25, 0.2];
let plot = Plot::new()
.scatter(&x, &y)
.with_xerr_asymmetric(&left, &right)
.label("Test")
.end_series();
assert_eq!(plot.series_mgr.series.len(), 1);
assert!(plot.series_mgr.series[0].x_errors.is_some());
if let Some(ErrorValues::Asymmetric(lo, hi)) = &plot.series_mgr.series[0].x_errors {
assert_eq!(lo.len(), 3);
assert_eq!(hi.len(), 3);
assert!((lo[1] - 0.15).abs() < 1e-10);
assert!((hi[1] - 0.25).abs() < 1e-10);
} else {
panic!("Expected asymmetric error values");
}
}
#[test]
fn test_error_config() {
let x = vec![1.0, 2.0, 3.0];
let y = vec![2.0, 4.0, 3.0];
let yerr = vec![0.3, 0.4, 0.25];
let config = ErrorBarConfig::default().cap_size(0.15).line_width(2.0);
let plot = Plot::new()
.line(&x, &y)
.with_yerr(&yerr)
.error_config(config)
.label("Test")
.end_series();
assert!(plot.series_mgr.series[0].error_config.is_some());
let cfg = plot.series_mgr.series[0].error_config.as_ref().unwrap();
assert!((cfg.cap_size - 0.15).abs() < 1e-10);
assert!((cfg.line_width - 2.0).abs() < 1e-10);
}
#[test]
fn test_combined_xy_errors() {
let x = vec![1.0, 2.0, 3.0];
let y = vec![2.0, 4.0, 3.0];
let xerr = vec![0.15, 0.2, 0.1];
let yerr = vec![0.3, 0.4, 0.25];
let plot = Plot::new()
.scatter(&x, &y)
.with_yerr(&yerr)
.with_xerr(&xerr)
.label("Test")
.end_series();
assert_eq!(plot.series_mgr.series.len(), 1);
assert!(plot.series_mgr.series[0].y_errors.is_some());
assert!(plot.series_mgr.series[0].x_errors.is_some());
}
#[test]
fn test_error_bars_continuation_method() {
let x = vec![1.0, 2.0, 3.0];
let y1 = vec![2.0, 4.0, 3.0];
let y1_err = vec![0.3, 0.4, 0.25];
let y2 = vec![1.5, 3.5, 2.5];
let y2_err = vec![0.2, 0.3, 0.2];
let plot = Plot::new()
.error_bars(&x, &y1, &y1_err)
.label("Series A")
.error_bars(&x, &y2, &y2_err) .label("Series B")
.end_series();
assert_eq!(plot.series_mgr.series.len(), 2);
assert!(matches!(
&plot.series_mgr.series[0].series_type,
SeriesType::ErrorBars { .. }
));
assert!(matches!(
&plot.series_mgr.series[1].series_type,
SeriesType::ErrorBars { .. }
));
}
#[test]
fn test_line_with_error_bars_renders() {
use crate::render::{SkiaRenderer, Theme};
let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let y = vec![2.0, 4.0, 3.0, 5.0, 4.5];
let yerr = vec![0.3, 0.4, 0.25, 0.5, 0.35];
let plot = Plot::new()
.line(&x, &y)
.with_yerr(&yerr)
.title("Line with Error Bars")
.end_series();
let mut renderer = SkiaRenderer::new(400, 300, Theme::default()).unwrap();
let result = plot.render_to_renderer(&mut renderer, 96.0);
assert!(result.is_ok());
}
#[test]
fn test_scatter_with_xy_error_bars_renders() {
use crate::render::{SkiaRenderer, Theme};
let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let y = vec![2.0, 4.0, 3.0, 5.0, 4.5];
let xerr = vec![0.15, 0.2, 0.1, 0.15, 0.2];
let yerr = vec![0.3, 0.4, 0.25, 0.5, 0.35];
let plot = Plot::new()
.scatter(&x, &y)
.with_yerr(&yerr)
.with_xerr(&xerr)
.title("Scatter with XY Error Bars")
.end_series();
let mut renderer = SkiaRenderer::new(400, 300, Theme::default()).unwrap();
let result = plot.render_to_renderer(&mut renderer, 96.0);
assert!(result.is_ok());
}
#[test]
fn test_multiple_series_with_different_errors() {
use crate::render::{SkiaRenderer, Theme};
let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let y1 = vec![2.0, 4.0, 3.0, 5.0, 4.5];
let y1_err = vec![0.3, 0.4, 0.25, 0.5, 0.35];
let y2 = vec![1.5, 3.5, 2.5, 4.5, 4.0];
let y2_err = vec![0.2, 0.3, 0.2, 0.4, 0.3];
let plot = Plot::new()
.line(&x, &y1)
.with_yerr(&y1_err)
.label("Series A")
.scatter(&x, &y2)
.with_yerr(&y2_err)
.label("Series B")
.title("Multiple Series with Error Bars")
.end_series();
let mut renderer = SkiaRenderer::new(400, 300, Theme::default()).unwrap();
let result = plot.render_to_renderer(&mut renderer, 96.0);
assert!(result.is_ok());
}
#[test]
fn test_max_resolution_height_constrained() {
let plot = Plot::new().max_resolution(1920, 1080);
assert_eq!(plot.display.dimensions, (1440, 1080));
assert!((plot.display.config.figure.dpi - 225.0).abs() < 1.0);
}
#[test]
fn test_max_resolution_width_constrained() {
let plot = Plot::new().max_resolution(800, 800);
assert_eq!(plot.display.dimensions, (800, 600));
assert!((plot.display.config.figure.dpi - 125.0).abs() < 1.0);
}
#[test]
fn test_max_resolution_exact_fit() {
let plot = Plot::new().max_resolution(1920, 1440);
assert_eq!(plot.display.dimensions, (1920, 1440));
assert!((plot.display.config.figure.dpi - 300.0).abs() < 1.0);
}
#[test]
fn test_max_resolution_custom_figure() {
let plot = Plot::new().size(16.0, 9.0).max_resolution(1920, 1080);
assert_eq!(plot.display.dimensions, (1920, 1080));
assert!((plot.display.config.figure.dpi - 120.0).abs() < 1.0);
}
#[test]
fn test_max_resolution_equivalent_to_dpi() {
let plot_max_res = Plot::new().max_resolution(1920, 1440);
let plot_dpi = Plot::new().dpi(300);
assert_eq!(plot_max_res.display.dimensions, plot_dpi.display.dimensions);
assert!(
(plot_max_res.display.config.figure.dpi - plot_dpi.display.config.figure.dpi).abs() < 1.0
);
}
#[test]
fn test_set_output_pixels_uses_actual_dpi_for_geometry() {
let plot = Plot::with_config(PlotConfig {
figure: FigureConfig::new(6.4, 4.8, 0.5),
..PlotConfig::default()
})
.set_output_pixels(800, 600);
assert!((plot.display.config.figure.width - 1600.0).abs() < f32::EPSILON);
assert!((plot.display.config.figure.height - 1200.0).abs() < f32::EPSILON);
assert_eq!(plot.display.dimensions, (800, 600));
}
#[test]
fn test_set_output_pixels_with_zero_dpi_keeps_direct_dpi_error() {
let err = Plot::with_config(PlotConfig {
figure: FigureConfig::new(6.4, 4.8, 0.0),
..PlotConfig::default()
})
.set_output_pixels(800, 600)
.line(&[0.0, 1.0], &[1.0, 2.0])
.render()
.expect_err("zero DPI should still fail with the direct DPI validation error");
assert!(matches!(
err,
PlottingError::InvalidInput(message)
if message.contains("Figure DPI must be positive") && message.contains("0")
));
}
#[test]
fn test_render_rejects_non_positive_figure_width_before_sanitizing() {
let mut plot = Plot::new().line(&[0.0, 1.0], &[1.0, 2.0]).end_series();
plot.display.config.figure = FigureConfig::new(0.0, 4.8, 100.0);
let err = plot
.render()
.expect_err("non-positive figure width should fail validation");
assert!(matches!(
err,
PlottingError::InvalidDimensions {
width: 0,
height: 480
}
));
}
#[test]
fn test_plot_builder_can_chain_histogram_without_end_series() {
let plot: Plot = Plot::new()
.line(&[0.0, 10.0], &[0.0, 1.0])
.histogram(&[1.0, 2.0, 3.0, 4.0])
.into();
assert_eq!(plot.series_mgr.series.len(), 2);
assert!(matches!(
plot.series_mgr.series[0].series_type,
SeriesType::Line { .. }
));
assert!(matches!(
plot.series_mgr.series[1].series_type,
SeriesType::Histogram { .. }
));
}
#[test]
fn test_static_histogram_prepares_histogram_data() {
let plot: Plot = Plot::new().histogram(&[1.0, 2.0, 3.0, 4.0]).into();
match &plot.series_mgr.series[0].series_type {
SeriesType::Histogram { prepared, .. } => {
let prepared = prepared.as_ref().expect("expected prepared histogram data");
assert!(!prepared.counts.is_empty());
assert_eq!(prepared.bin_edges.len(), prepared.counts.len() + 1);
}
other => panic!("expected histogram series, got {other:?}"),
}
}
#[test]
fn test_histogram_source_keeps_prepared_histogram_lazy() {
let plot: Plot = Plot::new()
.histogram_source(vec![1.0, 2.0, 3.0, 4.0])
.into();
match &plot.series_mgr.series[0].series_type {
SeriesType::Histogram { prepared, .. } => assert!(prepared.is_none()),
other => panic!("expected histogram series, got {other:?}"),
}
}
#[test]
fn test_histogram_prepared_and_source_backed_paths_match() {
let data: Vec<f64> = (0..200)
.map(|i| ((i as f64) * 0.17).sin() * 2.0 + (i % 11) as f64 * 0.1)
.collect();
let config = crate::plots::histogram::HistogramConfig::new()
.bins(18)
.density(true)
.bar_width(0.85);
let static_plot: Plot = Plot::new()
.size_px(320, 240)
.histogram_with(&data, config.clone())
.into();
let source_plot: Plot = Plot::new()
.size_px(320, 240)
.histogram_source_with(data.clone(), config)
.into();
let static_hist = static_plot.series_mgr.series[0]
.series_type
.histogram_data_at(0.0)
.expect("static histogram data");
let source_hist = source_plot.series_mgr.series[0]
.series_type
.histogram_data_at(0.0)
.expect("source histogram data");
assert_eq!(static_hist.bin_edges.len(), source_hist.bin_edges.len());
assert_eq!(static_hist.counts.len(), source_hist.counts.len());
assert_eq!(static_hist.n_samples, source_hist.n_samples);
assert_eq!(static_hist.is_density, source_hist.is_density);
assert!((static_hist.bar_width - source_hist.bar_width).abs() < f32::EPSILON);
for (left, right) in static_hist.bin_edges.iter().zip(&source_hist.bin_edges) {
assert!((left - right).abs() < 1e-12);
}
for (left, right) in static_hist.counts.iter().zip(&source_hist.counts) {
assert!((left - right).abs() < 1e-12);
}
let static_image = static_plot.render().expect("static histogram render");
let source_image = source_plot.render().expect("source histogram render");
let diff = mean_normalized_channel_diff(&static_image, &source_image);
assert!(
diff <= f64::EPSILON,
"prepared and source-backed histogram renders should match exactly, diff={diff:.6}"
);
}
#[test]
fn test_plot_builder_can_add_styled_vline_without_end_series() {
let plot: Plot = Plot::new()
.line(&[0.0, 10.0], &[0.0, 1.0])
.vline_styled(5.0, Color::RED, 2.0, LineStyle::Dashed)
.into();
assert_eq!(plot.series_mgr.series.len(), 1);
assert_eq!(plot.annotations.len(), 1);
assert!(matches!(
plot.annotations[0],
Annotation::VLine { x, .. } if (x - 5.0).abs() < f64::EPSILON
));
}
#[test]
fn test_plot_series_builder_can_chain_boxplot_without_end_series() {
let plot: Plot = Plot::new()
.histogram(&[1.0, 2.0, 3.0, 4.0])
.boxplot(&[2.0, 3.0, 5.0, 8.0])
.into();
assert_eq!(plot.series_mgr.series.len(), 2);
assert!(matches!(
plot.series_mgr.series[0].series_type,
SeriesType::Histogram { .. }
));
assert!(matches!(
plot.series_mgr.series[1].series_type,
SeriesType::BoxPlot { .. }
));
}
#[test]
fn test_mixed_coordinate_plots_keep_cartesian_axes() {
let theta = vec![0.0, std::f64::consts::PI * 0.5, std::f64::consts::PI];
let r = vec![1.0, 2.0, 1.5];
let plot: Plot = Plot::new()
.line(&[0.0, 10.0], &[0.0, 1.0])
.polar_line(&r, &theta)
.into();
assert!(plot.needs_cartesian_axes());
assert!(plot.series_mgr.series[1].inset_layout.is_some());
}
#[test]
fn test_empty_plot_uses_cartesian_axes_and_default_bounds() {
let plot = Plot::new().title("Empty Plot");
assert!(plot.needs_cartesian_axes());
assert_eq!(
plot.effective_main_panel_bounds_for_series(&[])
.expect("empty plot bounds should resolve"),
(0.0, 1.0, 0.0, 1.0)
);
}
#[test]
fn test_empty_plot_honors_manual_axis_limits() {
let plot = Plot::new().xlim(2.0, 4.0).ylim(-3.0, 5.0);
assert_eq!(
plot.effective_main_panel_bounds_for_series(&[])
.expect("manual limits should override empty bounds"),
(2.0, 4.0, -3.0, 5.0)
);
}
#[test]
fn test_non_cartesian_builder_inset_layout_is_stored() {
let theta = vec![0.0, std::f64::consts::PI * 0.5, std::f64::consts::PI];
let r = vec![1.0, 2.0, 1.5];
let plot: Plot = Plot::new()
.line(&[0.0, 10.0], &[0.0, 1.0])
.polar_line(&r, &theta)
.inset_anchor(InsetAnchor::BottomLeft)
.inset_size_frac(0.4, 0.25)
.inset_margin_pt(18.0)
.into();
let layout = plot.series_mgr.series[1]
.inset_layout
.expect("polar series should store inset metadata");
assert_eq!(layout.anchor, InsetAnchor::BottomLeft);
assert!((layout.width_frac - 0.4).abs() < f32::EPSILON);
assert!((layout.height_frac - 0.25).abs() < f32::EPSILON);
assert!((layout.margin_pt - 18.0).abs() < f32::EPSILON);
}
#[test]
fn test_mixed_cartesian_polar_raster_render_succeeds() {
let theta = vec![0.0, std::f64::consts::PI * 0.5, std::f64::consts::PI];
let r = vec![1.0, 2.0, 1.5];
let image = Plot::new()
.line(&[0.0, 10.0], &[0.0, 1.0])
.polar_line(&r, &theta)
.render()
.expect("mixed polar raster render should succeed");
assert!(!image.pixels.is_empty());
}
#[test]
fn test_mixed_cartesian_polar_renders_svg_with_inset_geometry() {
let theta = vec![0.0, std::f64::consts::PI * 0.5, std::f64::consts::PI];
let r = vec![1.0, 2.0, 1.5];
let svg = Plot::new()
.line(&[0.0, 10.0], &[0.0, 1.0])
.polar_line(&r, &theta)
.render_to_svg()
.expect("mixed polar SVG render should succeed");
assert!(
svg.matches("<polyline").count() >= 2,
"expected both Cartesian and polar polylines in SVG: {svg}"
);
assert!(
svg.contains("0°"),
"expected polar theta labels in SVG: {svg}"
);
}
#[test]
fn test_polar_svg_scales_line_width_and_markers_with_dpi() {
let theta = vec![0.0, std::f64::consts::PI * 0.5, std::f64::consts::PI];
let r = vec![1.0, 2.0, 1.5];
let plot: Plot = Plot::new()
.dpi(200)
.polar_line(&r, &theta)
.marker_size(13.0)
.into();
let (expected_stroke_width, expected_marker_radius) =
match &plot.series_mgr.series[0].series_type {
SeriesType::Polar { data } => (
plot.render_scale().points_to_pixels(data.config.line_width),
plot.render_scale()
.points_to_pixels(data.config.marker_size)
/ 2.0,
),
other => panic!("expected polar series, got {other:?}"),
};
let svg = plot
.render_to_svg()
.expect("polar SVG render should succeed");
assert!(
svg.contains(&format!(r#"stroke-width="{expected_stroke_width:.2}""#)),
"expected polar line width to scale with DPI: {svg}"
);
assert!(
svg.contains(&format!(r#"r="{expected_marker_radius:.2}" fill=""#)),
"expected polar marker radius to scale with DPI: {svg}"
);
}
#[test]
fn test_quiver_svg_scales_stroke_width_with_dpi() {
let x = vec![0.0, 1.0];
let y = vec![0.0, 1.0];
let u = vec![1.0, 0.5];
let v = vec![0.25, 0.75];
let plot: Plot = Plot::new()
.dpi(200)
.quiver(&x, &y, &u, &v)
.arrow_width(1.2)
.into();
let expected_stroke_width = match &plot.series_mgr.series[0].series_type {
SeriesType::Quiver { data } => plot.render_scale().points_to_pixels(data.config.width),
other => panic!("expected quiver series, got {other:?}"),
};
let svg = plot
.render_to_svg()
.expect("quiver SVG render should succeed");
assert!(
svg.contains(&format!(r#"stroke-width="{expected_stroke_width:.2}""#)),
"expected quiver line width to scale with DPI: {svg}"
);
}
#[test]
fn test_quiver_png_uses_log_x_scale_for_geometry() {
let bounds_x = vec![1.0, 1000.0];
let bounds_y = vec![0.0, 2.0];
let x = vec![10.0];
let y = vec![1.0];
let u = vec![90.0];
let v = vec![0.0];
let plot: Plot = Plot::new()
.size_px(480, 360)
.grid(false)
.ticks(false)
.xscale(crate::axes::AxisScale::Log)
.scatter(&bounds_x, &bounds_y)
.marker_size(0.1)
.quiver(&x, &y, &u, &v)
.color(Color::RED)
.arrow_width(4.0)
.arrow_head_length(0.0)
.arrow_head_width(0.0)
.into();
let image = plot.render().unwrap();
let plot_area = compute_render_plot_area(&plot);
let (x_min, x_max, y_min, y_max) = plot.effective_data_bounds().unwrap();
let data_midpoint = (10.0_f64 * 100.0).sqrt();
let (expected_x, expected_y) = crate::render::skia::map_data_to_pixels_scaled(
data_midpoint,
1.0,
x_min,
x_max,
y_min,
y_max,
plot_area,
&crate::axes::AxisScale::Log,
&crate::axes::AxisScale::Linear,
);
let (linear_x, linear_y) = crate::render::skia::map_data_to_pixels(
data_midpoint,
1.0,
x_min,
x_max,
y_min,
y_max,
plot_area,
);
assert!(
image_has_red_pixel_near(
&image,
expected_x.round() as u32,
expected_y.round() as u32,
5
),
"log-scaled quiver should render near x={expected_x}"
);
assert!(
!image_has_red_pixel_near(&image, linear_x.round() as u32, linear_y.round() as u32, 5),
"quiver should not render at the old linear x position {linear_x}"
);
}
#[test]
fn test_quiver_bounds_include_arrow_head_vertices() {
let x = vec![0.0];
let y = vec![0.0];
let u = vec![1.0];
let v = vec![0.0];
let plot: Plot = Plot::new()
.quiver(&x, &y, &u, &v)
.arrow_head_length(0.2)
.arrow_head_width(1.0)
.into();
let (_, _, y_min, y_max) = plot.calculate_data_bounds().unwrap();
assert!(y_min <= -0.5);
assert!(y_max >= 0.5);
}
#[test]
fn test_pie_svg_scales_edge_width_with_dpi() {
let mut plot_100: Plot = Plot::new().dpi(100).pie(&[2.0, 3.0, 4.0]).into();
let mut plot_200: Plot = Plot::new().dpi(200).pie(&[2.0, 3.0, 4.0]).into();
for plot in [&mut plot_100, &mut plot_200] {
let SeriesType::Pie { data } = &mut plot.series_mgr.series[0].series_type else {
panic!("expected pie series");
};
let data = Arc::make_mut(data);
data.config.edge_color = Some(Color::BLACK);
data.config.edge_width = 2.5;
}
let svg_100 = plot_100.render_to_svg().expect("100 DPI pie SVG render");
let svg_200 = plot_200.render_to_svg().expect("200 DPI pie SVG render");
let width_100 = extract_svg_root_attr(&svg_100, "width");
let width_200 = extract_svg_root_attr(&svg_200, "width");
let stroke_100 = extract_first_stroked_svg_polygon_stroke_width(&svg_100);
let stroke_200 = extract_first_stroked_svg_polygon_stroke_width(&svg_200);
let ratio_100 = stroke_100 / width_100;
let ratio_200 = stroke_200 / width_200;
assert!(
(ratio_100 - ratio_200).abs() < 0.0005,
"pie edge stroke-to-canvas ratio should remain stable across DPI: {} vs {}",
ratio_100,
ratio_200
);
}
#[test]
fn test_auto_placed_insets_preserve_gap_with_mixed_sizes() {
let theta = vec![0.0, std::f64::consts::PI * 0.5, std::f64::consts::PI];
let r = vec![1.0, 2.0, 1.5];
let plot: Plot = Plot::new()
.line(&[0.0, 10.0], &[0.0, 1.0])
.pie(&[2.0, 3.0, 4.0])
.inset_size_frac(0.18, 0.18)
.polar_line(&r, &theta)
.inset_size_frac(0.35, 0.35)
.into();
let plot_area =
tiny_skia::Rect::from_ltrb(0.0, 0.0, 1000.0, 800.0).expect("valid test plot area");
let rects = plot
.inset_rects_for_series(&plot.series_mgr.series, plot_area, plot.render_scale())
.expect("auto inset rects should be computed");
let right_inset = rects[1].expect("pie inset rect");
let left_inset = rects[2].expect("polar inset rect");
let actual_gap = right_inset.x() - (left_inset.x() + left_inset.width());
let expected_gap = plot
.render_scale()
.points_to_pixels(InsetLayout::DEFAULT_MARGIN_PT)
.max(4.0);
assert!(
(actual_gap - expected_gap).abs() < 0.01,
"auto insets should keep a constant inter-column gap: {} vs {}",
actual_gap,
expected_gap
);
}
#[test]
fn test_radar_plot_area_is_the_largest_centered_square() {
let plot_area =
tiny_skia::Rect::from_ltrb(100.0, 200.0, 300.0, 600.0).expect("valid test rect");
let area = Plot::radar_plot_area(plot_area, -1.25, 1.25, -1.25, 1.25);
assert!(
(area.width - 200.0).abs() < 0.01,
"unexpected radar inset width: {}",
area.width
);
assert!(
(area.height - 200.0).abs() < 0.01,
"unexpected radar inset height: {}",
area.height
);
assert!(
(area.x - 100.0).abs() < 0.01,
"unexpected radar inset x: {}",
area.x
);
assert!(
(area.y - 300.0).abs() < 0.01,
"unexpected radar inset y: {}",
area.y
);
assert!((area.x + area.width * 0.5 - 200.0).abs() < 0.01);
assert!((area.y + area.height * 0.5 - 400.0).abs() < 0.01);
let landscape = tiny_skia::Rect::from_ltrb(0.0, 0.0, 640.0, 400.0).expect("valid test rect");
let area = Plot::radar_plot_area(landscape, -1.25, 1.25, -1.25, 1.25);
assert!((area.width - 400.0).abs() < 0.01);
assert!((area.x + area.width * 0.5 - 320.0).abs() < 0.01);
assert!((area.y + area.height * 0.5 - 200.0).abs() < 0.01);
}
#[test]
fn test_mixed_cartesian_pie_renders_svg_with_inset_polygons() {
let svg = Plot::new()
.line(&[0.0, 1.0, 2.0], &[1.0, 3.0, 2.0])
.pie(&[2.0, 3.0, 4.0])
.labels(&["A", "B", "C"])
.render_to_svg()
.expect("mixed pie SVG render should succeed");
assert!(
svg.matches("<polygon").count() >= 3,
"expected pie wedge polygons in SVG: {svg}"
);
assert!(
svg.contains("22.2%"),
"expected pie percentage labels in SVG: {svg}"
);
assert!(
svg.matches("<clipPath").count() >= 2,
"expected a nested inset clip path in addition to the main plot clip: {svg}"
);
}
#[test]
fn test_mixed_cartesian_radar_renders_svg_with_inset_geometry() {
let svg = Plot::new()
.line(&[0.0, 1.0, 2.0], &[1.0, 3.0, 2.0])
.radar(&["Speed", "Power", "Skill"])
.add_series("Alpha", &[1.0, 2.0, 3.0])
.render_to_svg()
.expect("mixed radar SVG render should succeed");
assert!(
svg.matches("<polygon").count() >= 1,
"expected radar polygon geometry in SVG: {svg}"
);
assert!(
svg.contains(">Speed<"),
"expected radar axis labels in SVG: {svg}"
);
}
#[test]
fn test_radar_internal_palette_is_shared_by_frame_shell_svg_and_legend() {
let theme = Theme {
color_palette: vec![Color::RED, Color::BLUE, Color::GREEN],
..Theme::default()
};
let plot: Plot = Plot::new()
.theme(theme.clone())
.line(&[0.0, 1.0], &[0.0, 1.0])
.radar(&["A", "B", "C"])
.add_series("first", &[1.0, 2.0, 3.0])
.add_series("second", &[3.0, 2.0, 1.0])
.into();
let frame = plot.resolve_frame(0.0).expect("frame should resolve");
let colors = frame.style.series[1]
.radar_colors
.as_ref()
.expect("radar colors should resolve");
assert_eq!(colors.as_ref(), &[Color::BLUE, Color::GREEN]);
assert_eq!(colors[0], theme.get_color(1));
let shell = plot.resolved_style_shell(&frame.style);
let legend = shell.collect_legend_items();
assert_eq!(legend.len(), 2);
assert!(matches!(
legend[0].item_type,
LegendItemType::Area {
edge_color: Some(Color::BLUE)
}
));
assert!(matches!(
legend[1].item_type,
LegendItemType::Area {
edge_color: Some(Color::GREEN)
}
));
let svg = plot.render_to_svg().expect("radar SVG should render");
assert!(svg.contains("rgb(0,0,255)"));
assert!(svg.contains("rgb(0,128,0)"));
}
#[test]
fn test_radar_top_level_reactive_color_styles_unconfigured_internal_series_once() {
use crate::data::Signal;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
let calls = Arc::new(AtomicUsize::new(0));
let calls_for_signal = Arc::clone(&calls);
let color = Signal::new(move |_| {
calls_for_signal.fetch_add(1, Ordering::Relaxed);
Color::RED
});
let plot: Plot = Plot::new()
.radar(&["A", "B", "C"])
.add_series("configured", &[1.0, 2.0, 3.0])
.series_color(Color::GREEN)
.add_series("reactive", &[3.0, 2.0, 1.0])
.color_source(color)
.into();
let frame = plot.resolve_frame(0.0).expect("frame should resolve");
let colors = frame.style.series[0]
.radar_colors
.as_deref()
.expect("radar colors should resolve");
assert_eq!(calls.load(Ordering::Relaxed), 1);
assert_eq!(colors, &[Color::GREEN, Color::RED]);
let shell = plot.resolved_style_shell(&frame.style);
let legend = shell.collect_legend_items();
assert!(matches!(
legend[0].item_type,
LegendItemType::Area {
edge_color: Some(Color::GREEN)
}
));
assert!(matches!(
legend[1].item_type,
LegendItemType::Area {
edge_color: Some(Color::RED)
}
));
}
#[test]
fn test_radar_grid_uses_canonical_resolved_style_in_svg() {
let grid = GridStyle::default()
.color(Color::RED)
.alpha(1.0)
.line_width(3.0)
.line_style(LineStyle::Dashed);
let plot: Plot = Plot::new()
.with_grid_style(grid.clone())
.radar(&["A", "B", "C"])
.add_series("series", &[1.0, 2.0, 3.0])
.into();
let frame = plot.resolve_frame(0.0).expect("frame should resolve");
assert_eq!(frame.style.grid_style, grid);
let svg = plot.render_to_svg().expect("radar SVG should render");
let grid_line = svg
.lines()
.find(|line| line.contains("<line ") && line.contains("stroke=\"rgb(255,0,0)\""))
.expect("resolved radar grid line should be present");
assert!(grid_line.contains("stroke-dasharray="));
let width = parse_svg_attr(grid_line, "stroke-width");
let expected = plot.render_scale().points_to_pixels(3.0);
assert!(
(width - expected).abs() < 0.01,
"width={width}, expected={expected}"
);
}
#[test]
fn test_resolved_shell_shares_specialized_payloads() {
let plot: Plot = Plot::new()
.radar(&["A", "B", "C"])
.add_series("large", &[1.0, 2.0, 3.0])
.into();
let frame = plot.resolve_frame(0.0).expect("frame should resolve");
let shell = plot.resolved_style_shell(&frame.style);
let (SeriesType::Radar { data: original }, SeriesType::Radar { data: resolved }) = (
&plot.series_mgr.series[0].series_type,
&shell.series_mgr.series[0].series_type,
) else {
panic!("expected radar payloads");
};
assert!(Arc::ptr_eq(original, resolved));
}
#[test]
fn test_from_plot_series_builder_for_plot() {
let x_data = vec![1.0, 2.0, 3.0];
let y_data = vec![2.0, 4.0, 3.0];
let builder = Plot::new()
.line(&x_data, &y_data)
.color(crate::render::Color::RED)
.label("Test Series");
let plot: Plot = builder.into();
assert_eq!(plot.series_mgr.series.len(), 1);
assert_eq!(
plot.series_mgr.series[0].label,
Some("Test Series".to_string())
);
}
#[test]
fn test_into_plot_trait_for_plot() {
use builder::IntoPlot;
let plot = Plot::new().title("Test");
let converted = plot.into_plot();
match &converted.display.title {
Some(data::PlotText::Static(s)) => assert_eq!(s, "Test"),
_ => panic!("Expected Static PlotText with 'Test'"),
}
}
#[test]
fn test_into_plot_trait_for_plot_series_builder() {
use builder::IntoPlot;
let x_data = vec![1.0, 2.0, 3.0];
let y_data = vec![2.0, 4.0, 3.0];
let builder = Plot::new().line(&x_data, &y_data).label("Via IntoPlot");
let plot = builder.into_plot();
assert_eq!(plot.series_mgr.series.len(), 1);
assert_eq!(
plot.series_mgr.series[0].label,
Some("Via IntoPlot".to_string())
);
}
#[test]
fn test_as_plot_for_plot_series_builder() {
use builder::IntoPlot;
let x_data = vec![1.0, 2.0, 3.0];
let y_data = vec![2.0, 4.0, 3.0];
let builder = Plot::new().title("Inspectable").line(&x_data, &y_data);
let plot_ref = builder.as_plot();
match &plot_ref.display.title {
Some(data::PlotText::Static(s)) => assert_eq!(s, "Inspectable"),
_ => panic!("Expected Static PlotText with 'Inspectable'"),
}
let plot = builder.into_plot();
assert_eq!(plot.series_mgr.series.len(), 1);
}
#[test]
fn test_order_independent_method_chaining() {
let x_data = vec![1.0, 2.0, 3.0];
let y_data = vec![2.0, 4.0, 3.0];
let plot1: Plot = Plot::new()
.title("My Plot")
.xlabel("X")
.line(&x_data, &y_data)
.into();
let plot2: Plot = Plot::new()
.line(&x_data, &y_data)
.title("My Plot")
.xlabel("X")
.into();
match (&plot1.display.title, &plot2.display.title) {
(Some(data::PlotText::Static(s1)), Some(data::PlotText::Static(s2))) => {
assert_eq!(s1, s2);
}
_ => panic!("Expected matching Static PlotText titles"),
}
match (&plot1.display.xlabel, &plot2.display.xlabel) {
(Some(data::PlotText::Static(s1)), Some(data::PlotText::Static(s2))) => {
assert_eq!(s1, s2);
}
_ => panic!("Expected matching Static PlotText xlabels"),
}
assert_eq!(plot1.series_mgr.series.len(), plot2.series_mgr.series.len());
}
#[test]
fn test_generic_function_with_into_plot() {
use builder::IntoPlot;
fn count_series(p: impl IntoPlot) -> usize {
p.into_plot().series_mgr.series.len()
}
let x_data = vec![1.0, 2.0, 3.0];
let y_data = vec![2.0, 4.0, 3.0];
assert_eq!(count_series(Plot::new()), 0);
let builder = Plot::new().line(&x_data, &y_data);
assert_eq!(count_series(builder), 1);
}
#[test]
fn test_implicit_conversion_in_function_param() {
fn accepts_into_plot(p: impl Into<Plot>) -> Plot {
p.into()
}
let x_data = vec![1.0, 2.0, 3.0];
let y_data = vec![2.0, 4.0, 3.0];
let builder = Plot::new().line(&x_data, &y_data).label("Implicit");
let plot = accepts_into_plot(builder);
assert_eq!(plot.series_mgr.series.len(), 1);
assert_eq!(
plot.series_mgr.series[0].label,
Some("Implicit".to_string())
);
}
#[test]
fn test_area_and_stem_bounds_include_baseline_annotations() {
let x = vec![10.0, 11.0, 12.0];
let y = vec![5.0, 6.0, 7.0];
let area: Plot = Plot::new().area(&x, &y, 0.0).into();
let (_, _, area_y_min, area_y_max) = area.calculate_data_bounds().unwrap();
assert!(area_y_min <= 0.0);
assert!(area_y_max >= 7.0);
let stem: Plot = Plot::new().stem(&x, &y, 0.0).into();
let (_, _, stem_y_min, stem_y_max) = stem.calculate_data_bounds().unwrap();
assert!(stem_y_min <= 0.0);
assert!(stem_y_max >= 7.0);
}
#[test]
fn test_horizontal_boxen_bounds_put_data_range_on_x_axis() {
let data = vec![10.0, 12.0, 18.0, 25.0, 30.0];
let plot: Plot = Plot::new().boxen(&data).horizontal().into();
let (x_min, x_max, y_min, y_max) = plot.calculate_data_bounds().unwrap();
assert!(x_min <= 10.0);
assert!(x_max >= 30.0);
let (slot_lo, slot_hi) = crate::plots::boxplot::category_slot_span(0.0);
assert!(y_min <= slot_lo);
assert!(y_max >= slot_hi);
}
#[test]
fn test_quiver_rejects_non_finite_input_values() {
let x = vec![0.0, f64::NAN];
let y = vec![0.0, 1.0];
let u = vec![1.0, 1.0];
let v = vec![0.0, 0.0];
let err = Plot::new().quiver(&x, &y, &u, &v).render().unwrap_err();
assert!(matches!(err, PlottingError::InvalidData { .. }));
}
#[test]
fn test_plot_font_family_api_updates_typography_config() {
let plot = Plot::new().font_family("New Computer Modern Sans");
assert_eq!(
plot.get_config().typography.family,
crate::render::FontFamily::Name("New Computer Modern Sans".to_string())
);
}
#[test]
fn test_render_to_svg_propagates_named_font_family() {
let x = vec![0.0, 1.0];
let y = vec![0.0, 1.0];
let svg = Plot::new()
.line(&x, &y)
.title("Label")
.font_family(crate::render::FontFamily::Name("serif".to_string()))
.render_to_svg()
.expect("SVG render should succeed");
assert!(svg.contains(r#"font-family=""serif"""#));
}
fn plot_with_weighted_title(weight: crate::render::FontWeight) -> Plot {
let config = PlotConfig::builder()
.typography(|typography| typography.title_weight(weight))
.build();
Plot::new()
.plot_config(config)
.line(&[0.0, 1.0], &[0.0, 1.0])
.color(Color::RED)
.title("Weighted raster title")
.text_styled(
0.5,
0.5,
"Normal annotation",
crate::core::TextStyle::default()
.font_size(18.0)
.color(Color::BLUE),
)
.end_series()
.set_output_pixels(360, 280)
}
fn blue_dominant_pixel_indices(image: &Image) -> Vec<usize> {
image
.pixels
.chunks_exact(4)
.enumerate()
.filter_map(|(index, pixel)| {
(pixel[3] > 0
&& pixel[2] > 80
&& pixel[2] > pixel[0].saturating_add(40)
&& pixel[2] > pixel[1].saturating_add(40))
.then_some(index)
})
.collect()
}
fn differing_pixel_count(left: &Image, right: &Image) -> usize {
left.pixels
.chunks_exact(4)
.zip(right.pixels.chunks_exact(4))
.filter(|(left, right)| left != right)
.count()
}
#[test]
fn best_legend_avoids_the_data_on_both_backends() {
let x: Vec<f64> = (0..200).map(|index| 0.6 + index as f64 * 0.002).collect();
let y: Vec<f64> = x.iter().map(|value| 0.7 + value * 0.25).collect();
let build = |position: LegendPosition| -> Plot {
let plot = Plot::new()
.size_px(640, 480)
.xlim(0.0, 1.0)
.ylim(0.0, 1.0)
.line(&x, &y)
.label("top right")
.end_series();
match position {
LegendPosition::Best => plot.legend_best(),
explicit => plot.legend_position(explicit),
}
};
let best = build(LegendPosition::Best).render().expect("best raster");
let upper_left = build(LegendPosition::UpperLeft)
.render()
.expect("upper-left raster");
let upper_right = build(LegendPosition::UpperRight)
.render()
.expect("upper-right raster");
assert_eq!(
differing_pixel_count(&best, &upper_left),
0,
"`Best` should have picked the empty upper-left corner"
);
assert!(
differing_pixel_count(&best, &upper_right) > 0,
"`Best` degraded to `UpperRight`, which is where the data is"
);
let best_svg = build(LegendPosition::Best)
.render_to_svg()
.expect("best svg");
let upper_left_svg = build(LegendPosition::UpperLeft)
.render_to_svg()
.expect("upper-left svg");
let upper_right_svg = build(LegendPosition::UpperRight)
.render_to_svg()
.expect("upper-right svg");
assert_eq!(
best_svg, upper_left_svg,
"the SVG backend must resolve `Best` the same way the raster backend does"
);
assert_ne!(best_svg, upper_right_svg);
}
#[test]
fn plain_svg_multiline_title_uses_weighted_measurement_and_reserves_each_line() {
let build = |title: &str| {
let config = PlotConfig::builder()
.typography(|typography| {
typography
.family(crate::render::FontFamily::Monospace)
.title_weight(crate::render::FontWeight::Bold)
})
.build();
Plot::new()
.plot_config(config)
.line(&[0.0, 1.0], &[0.0, 1.0])
.title(title)
.end_series()
.set_output_pixels(360, 280)
};
let inspect = |plot: &Plot| {
let (_, _, y_min, y_max) = plot.calculate_data_bounds().unwrap();
let content = plot.create_plot_content(y_min, y_max);
let mut renderer = crate::render::SkiaRenderer::with_font_family(
plot.display.dimensions.0,
plot.display.dimensions.1,
plot.display.theme.clone(),
plot.display.config.typography.family.clone(),
)
.unwrap();
renderer.set_render_scale(plot.render_scale());
let measured = plot
.measure_layout_text(&renderer, &content, plot.display.config.figure.dpi)
.unwrap()
.unwrap();
let layout = plot.compute_layout_from_measurements(
plot.display.dimensions,
&content,
plot.display.config.figure.dpi,
Some(&measured),
);
(measured.title.unwrap(), layout.plot_area.top)
};
let single = build("wide title");
let multiline = build("wide title\nshort");
let (single_measurement, single_top) = inspect(&single);
let (multiline_measurement, multiline_top) = inspect(&multiline);
assert!(multiline_measurement.1 > single_measurement.1 * 1.8);
assert!(multiline_top > single_top + single_measurement.1 * 0.8);
let svg = multiline.render_to_svg().unwrap();
let title = svg
.lines()
.find(|line| line.contains("wide title"))
.expect("multiline plain-SVG title");
assert!(title.contains(r#"font-family="monospace""#));
assert!(title.contains(r#"font-weight="700""#));
assert!(title.contains(r#"text-anchor="middle""#));
assert_eq!(title.matches("<tspan ").count(), 2);
}
#[test]
fn serial_raster_title_honors_weight_without_changing_annotation_weight() {
let normal = plot_with_weighted_title(crate::render::FontWeight::Normal)
.render()
.expect("normal-weight serial raster");
let bold = plot_with_weighted_title(crate::render::FontWeight::Bold)
.render()
.expect("bold serial raster");
assert!(differing_pixel_count(&normal, &bold) > 20);
assert_eq!(
blue_dominant_pixel_indices(&normal),
blue_dominant_pixel_indices(&bold),
"title weight must not leak into annotation text"
);
}
#[test]
fn test_svg_text_annotation_uses_resolved_typography_and_full_text_style() {
let x = vec![0.0, 1.0];
let y = vec![0.0, 1.0];
let config = PlotConfig::builder()
.figure(2.0, 2.0)
.dpi(144.0)
.typography(|typography| {
typography
.family(crate::render::FontFamily::Name(
"Annotation Font".to_string(),
))
.title_weight(crate::render::FontWeight::Bold)
})
.build();
let style = crate::core::TextStyle {
font_size: 10.0,
color: Color::from_rgba(20, 30, 40, 200),
align: crate::core::TextAlign::Right,
valign: crate::core::TextVAlign::Bottom,
rotation: 25.0,
background: Some(Color::from_rgba(240, 230, 220, 128)),
padding: 3.0,
border_color: Some(Color::BLUE),
border_width: 2.0,
};
let svg = Plot::new()
.plot_config(config)
.line(&x, &y)
.title("Weighted title")
.text_styled(0.5, 0.5, "Styled annotation", style)
.render_to_svg()
.expect("SVG render should succeed");
assert!(svg.contains(r#"data-ruviz-text-style="annotation""#));
assert!(svg.contains("rotate(-25.00)"));
assert!(svg.contains(r#"font-family=""Annotation Font"""#));
let annotation_line = svg
.lines()
.find(|line| line.contains(">Styled annotation</text>"))
.expect("annotation text should be present");
let title_line = svg
.lines()
.find(|line| line.contains(">Weighted title</text>"))
.expect("title text should be present");
assert!(annotation_line.contains(r#"font-weight="400""#));
assert!(title_line.contains(r#"font-weight="700""#));
assert!(svg.contains(r#"text-anchor="end""#));
assert!(svg.contains(r#"fill="rgba(240,230,220,0.502)""#));
assert!(svg.contains(r#"stroke-width="4.00""#));
}
#[test]
fn test_theme_sets_plot_typography_font_family() {
let themed = Plot::new().theme(crate::render::Theme::publication());
assert_eq!(
themed.get_config().typography.family,
crate::render::FontFamily::Name("Times New Roman".to_string())
);
let constructed = Plot::with_theme(crate::render::Theme::minimal());
assert_eq!(
constructed.get_config().typography.family,
crate::render::FontFamily::Name("Helvetica".to_string())
);
}
#[test]
fn test_theme_and_font_family_follow_last_call_precedence() {
let theme = crate::render::Theme::publication();
let theme_family = crate::render::FontFamily::from(theme.font_family.as_str());
let theme_last = Plot::new()
.font_family("Explicit Font")
.theme(theme.clone());
assert_eq!(theme_last.get_config().typography.family, theme_family);
let font_last = Plot::new().theme(theme).font_family("Explicit Font");
assert_eq!(
font_last.get_config().typography.family,
crate::render::FontFamily::Name("Explicit Font".to_string())
);
}
#[test]
fn test_plot_builder_font_family_forwards_to_plot() {
let x = vec![0.0, 1.0, 2.0];
let y = vec![0.0, 1.0, 4.0];
let plot: Plot = Plot::new()
.line(&x, &y)
.font_family("New Computer Modern Sans")
.into();
assert_eq!(
plot.get_config().typography.family,
crate::render::FontFamily::Name("New Computer Modern Sans".to_string())
);
}
#[test]
fn test_theme_and_plot_config_follow_last_builder_call_precedence() {
let theme = crate::render::Theme::publication();
let config = PlotConfig::builder()
.font_size(18.0)
.font_family("Config Font")
.lines(|lines| lines.data_width(4.0).axis_width(2.0).grid_width(1.25))
.build();
let theme_last = Plot::new().plot_config(config.clone()).theme(theme.clone());
assert_eq!(
theme_last.get_config().typography,
theme.to_typography_config()
);
assert_eq!(theme_last.get_config().lines, theme.to_line_config());
assert_eq!(theme_last.layout.grid_style.color, theme.grid_color);
assert_eq!(
theme_last.layout.grid_style.line_width,
theme.to_line_config().grid_width
);
let config_last = Plot::new().theme(theme.clone()).plot_config(config.clone());
assert_eq!(config_last.get_config().typography, config.typography);
assert_eq!(config_last.get_config().lines, config.lines);
assert_eq!(config_last.layout.grid_style.color, theme.grid_color);
assert_eq!(
config_last.layout.grid_style.line_width,
config.lines.grid_width
);
let explicit_grid_last = Plot::new()
.theme(theme)
.with_grid_style(GridStyle::default().color(Color::RED).line_width(3.0));
assert_eq!(explicit_grid_last.layout.grid_style.color, Color::RED);
assert_eq!(explicit_grid_last.layout.grid_style.line_width, 3.0);
}
#[test]
fn test_default_theme_application_preserves_default_metrics() {
let baseline = Plot::new();
let themed = Plot::new().theme(Theme::default());
assert_eq!(
baseline.get_config().typography,
themed.get_config().typography
);
assert_eq!(baseline.get_config().lines, themed.get_config().lines);
}
#[test]
fn test_new_plot_preserves_established_default_grid_color() {
assert_eq!(Plot::new().layout.grid_style, GridStyle::default());
}
#[test]
fn test_grid_layers_keep_minor_lines_subordinate_to_major() {
let style = GridStyle::default();
let x_major = [10.0f32, 20.0];
let y_major = [30.0f32];
let x_minor = [12.0f32, 14.0];
let y_minor = [32.0f32, 34.0];
let to_px = |points: f32| points * 4.0;
let major_only = Plot::grid_layers(
&style,
&GridMode::MajorOnly,
&x_major,
&y_major,
&x_minor,
&y_minor,
to_px,
);
assert_eq!(major_only.len(), 1);
assert_eq!(major_only[0].x_pixels, x_major.to_vec());
assert_eq!(major_only[0].y_pixels, y_major.to_vec());
assert_eq!(major_only[0].color, style.effective_color());
let minor_only = Plot::grid_layers(
&style,
&GridMode::MinorOnly,
&x_major,
&y_major,
&x_minor,
&y_minor,
to_px,
);
assert_eq!(minor_only.len(), 1);
assert_eq!(minor_only[0].x_pixels, x_minor.to_vec());
assert_eq!(minor_only[0].color, style.effective_minor_color());
let both = Plot::grid_layers(
&style,
&GridMode::Both,
&x_major,
&y_major,
&x_minor,
&y_minor,
to_px,
);
assert_eq!(both.len(), 2, "Both must emit a minor and a major pass");
let (minor, major) = (&both[0], &both[1]);
assert_eq!(minor.x_pixels, x_minor.to_vec());
assert_eq!(minor.y_pixels, y_minor.to_vec());
assert_eq!(major.x_pixels, x_major.to_vec());
assert_eq!(major.y_pixels, y_major.to_vec());
assert!(
minor.color.a < major.color.a,
"minor grid must be more transparent: {} vs {}",
minor.color.a,
major.color.a
);
assert!(
minor.width_px < major.width_px,
"minor grid must be thinner: {} vs {}",
minor.width_px,
major.width_px
);
}
#[test]
fn test_grid_layers_floor_every_pass_at_one_device_pixel() {
let style = GridStyle::default();
let layers = Plot::grid_layers(
&style,
&GridMode::Both,
&[1.0],
&[2.0],
&[3.0],
&[4.0],
|points| points * 0.1,
);
for layer in &layers {
assert!(
layer.width_px >= crate::core::style_utils::defaults::MIN_GRID_LINE_WIDTH_PX,
"grid stroke must not fall below one device pixel: {}",
layer.width_px
);
}
}
#[test]
fn test_invalid_theme_base_font_does_not_poison_resolved_typography() {
for invalid in [0.0, f32::NAN, f32::INFINITY] {
let theme = Theme {
font_size: invalid,
..Theme::default()
};
let plot = Plot::new().theme(theme);
let typography = &plot.get_config().typography;
assert!(typography.base_size.is_finite() && typography.base_size > 0.0);
assert!(typography.title_size().is_finite());
assert!(typography.label_size().is_finite());
assert!(typography.tick_size().is_finite());
assert!(typography.legend_size().is_finite());
plot.render()
.expect("sanitized theme typography should render");
}
}
#[test]
fn test_later_theme_and_plot_config_clear_legacy_legend_font_override() {
let themed_plot = Plot::new()
.legend_font_size(22.0)
.theme(Theme::publication());
let themed = themed_plot
.resolve_frame(0.0)
.expect("frame should resolve");
assert_eq!(
themed.style.legend.font_size,
themed.style.config.typography.legend_size()
);
let config = PlotConfig::builder().font_size(16.0).build();
let configured_plot = Plot::new().legend_font_size(22.0).plot_config(config);
let configured = configured_plot
.resolve_frame(0.0)
.expect("frame should resolve");
assert_eq!(
configured.style.legend.font_size,
configured.style.config.typography.legend_size()
);
let explicit_last_plot = Plot::new()
.theme(Theme::publication())
.legend_font_size(22.0);
let explicit_last = explicit_last_plot
.resolve_frame(0.0)
.expect("frame should resolve");
assert_eq!(explicit_last.style.legend.font_size, 22.0);
let font_size_plot = Plot::new().legend_font_size(22.0).font_size(20.0);
let font_size_last = font_size_plot
.resolve_frame(0.0)
.expect("frame should resolve");
assert_eq!(font_size_last.style.legend.font_size, 18.0);
let scaled_plot = Plot::new().legend_font_size(22.0).scale_typography(2.0);
let scaled_last = scaled_plot
.resolve_frame(0.0)
.expect("frame should resolve");
assert_eq!(scaled_last.style.legend.font_size, 18.0);
}
#[test]
fn test_absent_series_metrics_preserve_established_fallbacks() {
let mut plot = Plot::new();
plot.add_line(&[0.0, 1.0], &[0.0, 1.0])
.expect("line should be added");
plot.series_mgr.series[0]
.props
.marker_style
.set(MarkerStyle::Circle.into());
let frame = plot.resolve_frame(0.0).expect("frame should resolve");
assert_eq!(frame.style.series[0].line_width, None);
assert_eq!(frame.style.series[0].marker_size, None);
let shell = plot.resolved_style_shell(&frame.style);
assert_eq!(shell.series_mgr.series[0].props.line_width.cloned(), None);
assert_eq!(shell.series_mgr.series[0].props.marker_size.cloned(), None);
let line: Plot = Plot::new()
.line(&[0.0, 1.0], &[0.0, 1.0])
.marker(MarkerStyle::Circle)
.into();
let line_frame = line.resolve_frame(0.0).expect("line frame should resolve");
assert_eq!(line_frame.style.series[0].line_width, None);
assert_eq!(line_frame.style.series[0].marker_size, None);
let scatter: Plot = Plot::new().scatter(&[0.0, 1.0], &[0.0, 1.0]).into();
let scatter_frame = scatter
.resolve_frame(0.0)
.expect("scatter frame should resolve");
assert_eq!(scatter_frame.style.series[0].line_width, None);
assert_eq!(scatter_frame.style.series[0].marker_size, None);
}
#[test]
fn test_resolved_alpha_reaches_svg_and_legend_once() {
let plot: Plot = Plot::new()
.line(&[0.0, 1.0], &[0.0, 1.0])
.color(Color::RED)
.alpha(0.5)
.label("alpha")
.into();
let svg = plot.render_to_svg().expect("SVG should render");
assert!(svg.contains("rgba(255,0,0,0.498)"));
let frame = plot.resolve_frame(0.0).expect("frame should resolve");
let shell = plot.resolved_style_shell(&frame.style);
let items = shell.collect_legend_items();
assert_eq!(items.len(), 1);
assert_eq!(items[0].color.a, 127);
}
#[test]
fn test_specialized_kde_svg_uses_resolved_width_and_alpha() {
let samples = [0.0, 0.5, 1.0, 1.5, 2.0];
let plot: Plot = Plot::new()
.kde(&samples)
.color(Color::RED)
.line_width(4.0)
.alpha(0.5)
.into();
let frame = plot.resolve_frame(0.0).expect("frame should resolve");
assert_eq!(frame.style.series[0].line_width, Some(4.0));
assert_eq!(frame.style.series[0].alpha, 0.5);
let svg = plot.render_to_svg().expect("KDE SVG should render");
let curve = svg
.lines()
.find(|line| line.contains("<polyline ") && line.contains("rgba(255,0,0,0.498)"))
.expect("resolved KDE curve should be present in SVG");
let width = parse_svg_attr(curve, "stroke-width");
let expected = plot.render_scale().points_to_pixels(4.0);
assert!(
(width - expected).abs() < 0.01,
"width={width}, expected={expected}"
);
}
#[test]
fn test_shared_f32_style_source_is_sampled_once_across_properties() {
use crate::data::Signal;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
let calls = Arc::new(AtomicUsize::new(0));
let calls_for_signal = Arc::clone(&calls);
let value = Signal::new(move |_| {
calls_for_signal.fetch_add(1, Ordering::Relaxed);
0.5_f32
});
let plot: Plot = Plot::new()
.line(&[0.0, 1.0], &[0.0, 1.0])
.line_width_source(value.clone())
.marker_size_source(value.clone())
.alpha_source(value)
.into();
let frame = plot.resolve_frame(0.0).expect("frame should resolve");
assert_eq!(calls.load(Ordering::Relaxed), 1);
assert_eq!(frame.style.series[0].line_width, Some(0.5));
assert_eq!(frame.style.series[0].marker_size, Some(0.5));
assert_eq!(frame.style.series[0].alpha, 0.5);
}
#[test]
fn test_auto_palette_color_is_resolved_after_late_theme_change() {
let x = vec![0.0, 1.0];
let y = vec![1.0, 2.0];
let plot: Plot = Plot::new().line(&x, &y).into();
assert_eq!(plot.series_mgr.series[0].props.color.cloned(), None);
let mut theme = Theme::dark();
theme.color_palette = vec![Color::RED, Color::BLUE];
let plot = plot.theme(theme);
let frame = plot.resolve_frame(0.0).expect("frame should resolve");
assert_eq!(frame.style.series[0].color, Color::RED);
}
#[test]
fn test_resolved_series_metrics_stay_in_points_across_backend_shells() {
let plot: Plot = Plot::new()
.line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 0.0])
.line_width(3.5)
.marker(MarkerStyle::Diamond)
.marker_size(9.0)
.into();
let frame = plot.resolve_frame(0.0).expect("frame should resolve");
let resolved = &frame.style.series[0];
let style_shell = plot.resolved_style_shell(&frame.style);
let prepared_shell = plot.prepared_frame_shell_with_style((800, 600), 1.0, &frame.style);
for backend_shell in [&style_shell, &prepared_shell] {
let series = &backend_shell.series_mgr.series[0];
assert_eq!(series.props.line_width.cloned(), resolved.line_width);
assert_eq!(series.props.marker_size.cloned(), resolved.marker_size);
assert_eq!(series.props.marker_style.cloned(), resolved.marker_style);
}
let svg = plot.render_to_svg().expect("SVG should render");
let series_polyline = svg
.lines()
.find(|line| line.contains("<polyline "))
.expect("line series should render as a polyline");
let svg_width_px = parse_svg_attr(series_polyline, "stroke-width");
let expected_width_px =
style_shell.dpi_scaled_line_width(resolved.line_width.expect("explicit line width"));
assert!(
(svg_width_px - expected_width_px).abs() < 0.01,
"SVG width {svg_width_px} should match raster width {expected_width_px}"
);
}
#[test]
fn test_dark_theme_resolves_legend_semantic_colors() {
let theme = Theme::dark();
let plot: Plot = Plot::new()
.theme(theme.clone())
.line(&[0.0, 1.0], &[0.0, 1.0])
.label("series")
.into();
let frame = plot.resolve_frame(0.0).expect("frame should resolve");
assert_eq!(frame.style.legend.text_color, theme.foreground);
assert_eq!(frame.style.legend.style.face_color, theme.background);
assert_eq!(frame.style.legend.style.edge_color, Some(theme.grid_color));
}
#[test]
fn test_shared_reactive_series_style_is_sampled_once_per_frame() {
use crate::data::Signal;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
let calls = Arc::new(AtomicUsize::new(0));
let calls_for_signal = Arc::clone(&calls);
let color = Signal::new(move |_| {
calls_for_signal.fetch_add(1, Ordering::Relaxed);
Color::BLUE
});
let x = vec![0.0, 1.0];
let y1 = vec![1.0, 2.0];
let y2 = vec![2.0, 3.0];
let plot = Plot::new().group(|group| group.color_source(color).line(&x, &y1).line(&x, &y2));
let frame = plot.resolve_frame(0.0).expect("frame should resolve");
assert_eq!(calls.load(Ordering::Relaxed), 1);
assert_eq!(frame.style.series[0].color, Color::BLUE);
assert_eq!(frame.style.series[1].color, Color::BLUE);
let style_shell = plot.resolved_style_shell(&frame.style);
let _ = style_shell.collect_legend_items();
assert_eq!(calls.load(Ordering::Relaxed), 1);
}
#[test]
fn test_backend_getter_does_not_sample_reactive_style() {
use crate::data::Signal;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
let calls = Arc::new(AtomicUsize::new(0));
let calls_for_signal = Arc::clone(&calls);
let color = Signal::new(move |_| {
calls_for_signal.fetch_add(1, Ordering::Relaxed);
Color::RED
});
let plot: Plot = Plot::new()
.line(&[0.0, 1.0], &[0.0, 1.0])
.color_source(color)
.into();
assert_eq!(plot.resolved_backend_name(), "skia");
assert_eq!(calls.load(Ordering::Relaxed), 0);
}
#[cfg(feature = "typst-math")]
mod typst;
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_histogram_adjacent_bins_keep_a_visible_boundary() {
let samples: Vec<f64> = (0..8).map(|value| value as f64).collect();
let config = crate::plots::histogram::HistogramConfig::new().bins(4);
let png = Plot::new()
.size_px(400, 300)
.ticks(false)
.grid(false)
.histogram_with(&samples, config)
.end_series()
.render_png_bytes()
.expect("histogram should render as PNG");
let image = decode_png_rgba(&png);
let row = image.height() * 3 / 4;
let scanline: Vec<[u8; 4]> = (0..image.width())
.map(|column| image.get_pixel(column, row).0)
.collect();
let mut counts: std::collections::HashMap<[u8; 4], usize> = std::collections::HashMap::new();
for pixel in &scanline {
if pixel[0] < 240 || pixel[1] < 240 || pixel[2] < 240 {
*counts.entry(*pixel).or_default() += 1;
}
}
let (fill, fill_count) = counts
.into_iter()
.max_by_key(|(_, count)| *count)
.expect("the scanline should cross the histogram bars");
assert!(
fill_count > 100,
"expected a wide run of bar fill, got {fill_count} px"
);
let luminance = |pixel: &[u8; 4]| pixel[0] as i32 + pixel[1] as i32 + pixel[2] as i32;
let fill_luminance = luminance(&fill);
let mut boundaries = 0usize;
let mut inside_dark = false;
for pixel in &scanline {
let dark = pixel[3] > 0 && luminance(pixel) < fill_luminance - 45;
if dark && !inside_dark {
boundaries += 1;
}
inside_dark = dark;
}
assert!(
boundaries >= 5,
"expected 4 bins to show 5 edges (2 outer + 3 shared), found {boundaries}"
);
}
#[test]
fn test_log_axis_gap_breaks_the_line_in_the_svg_backend() {
let x = [1.0, 2.0, 3.0, 4.0, 5.0];
let y = [1.0, 10.0, 0.0, 100.0, 1000.0];
let svg = Plot::new()
.line(&x, &y)
.into_plot()
.yscale(crate::axes::AxisScale::Log)
.ylim(1.0, 1000.0)
.xlim(1.0, 5.0)
.render_to_svg()
.expect("log-axis line with a gap should render");
assert!(
!svg.contains("NaN"),
"no NaN coordinate may reach the SVG output"
);
let polylines = svg.matches("<polyline").count();
assert_eq!(
polylines, 2,
"the unrepresentable sample must split the series into two sub-paths, got {polylines}"
);
}
#[test]
fn test_log_axis_without_gaps_stays_one_polyline() {
let x = [1.0, 2.0, 3.0, 4.0, 5.0];
let y = [1.0, 10.0, 50.0, 100.0, 1000.0];
let svg = Plot::new()
.line(&x, &y)
.into_plot()
.yscale(crate::axes::AxisScale::Log)
.ylim(1.0, 1000.0)
.xlim(1.0, 5.0)
.render_to_svg()
.expect("log-axis line should render");
assert_eq!(svg.matches("<polyline").count(), 1);
}
mod xtick_rotation_knob {
use super::*;
use crate::render::XTickRotation;
fn colliding_categories() -> (Vec<String>, Vec<f64>) {
let names: Vec<String> = [
"North Atlantic Basin",
"South Atlantic Basin",
"Eastern Pacific Basin",
"Western Pacific Basin",
"Northern Indian Basin",
"Southern Indian Basin",
]
.iter()
.map(|s| (*s).to_string())
.collect();
let values = vec![3.0, 5.0, 2.0, 8.0, 4.0, 6.0];
(names, values)
}
fn bar_svg(rotation: XTickRotation) -> String {
let (names, values) = colliding_categories();
Plot::new()
.size_px(640, 480)
.bar(&names, &values)
.into_plot()
.xtick_rotation(rotation)
.render_to_svg()
.expect("a categorical bar chart should render to SVG")
}
#[test]
fn vertical_turns_the_row_a_quarter_turn() {
let svg = bar_svg(XTickRotation::Vertical);
assert_eq!(
svg.matches("rotate(-90").count(),
6,
"every category label must be drawn rotated: {svg}"
);
}
#[test]
fn horizontal_never_rotates_however_badly_the_labels_collide() {
let svg = bar_svg(XTickRotation::Horizontal);
assert_eq!(
svg.matches("rotate(-90").count(),
0,
"an explicit `Horizontal` must thin the row, never turn it"
);
}
#[test]
fn auto_rotates_a_row_that_would_collide() {
let svg = bar_svg(XTickRotation::Auto);
assert!(
svg.contains("rotate(-90"),
"six long names under a 640 px axis collide, so `Auto` must rotate them"
);
}
#[test]
fn auto_leaves_a_row_that_fits_alone() {
let svg = Plot::new()
.size_px(640, 480)
.bar(&["A", "B", "C"], &[1.0, 2.0, 3.0])
.into_plot()
.xtick_rotation(XTickRotation::Auto)
.render_to_svg()
.expect("a short categorical bar chart should render to SVG");
assert_eq!(
svg.matches("rotate(-90").count(),
0,
"three one-character labels fit, so nothing may be rotated"
);
}
#[test]
fn the_builder_forwards_to_the_plot() {
let (names, values) = colliding_categories();
let via_builder = Plot::new()
.size_px(640, 480)
.bar(&names, &values)
.xtick_rotation(XTickRotation::Vertical)
.render_to_svg()
.expect("builder-side knob should render");
let via_plot = bar_svg(XTickRotation::Vertical);
assert_eq!(via_builder, via_plot);
}
}
mod quiver_colour_key {
use super::*;
fn field() -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
let x = vec![0.0, 1.0, 2.0, 3.0];
let y = vec![0.0, 1.0, 0.0, 1.0];
let u = vec![1.0, 0.5, -1.0, 0.25];
let v = vec![0.0, 1.0, 0.5, -1.0];
(x, y, u, v)
}
#[test]
fn magnitude_colours_reserve_the_right_margin_a_colorbar_needs() {
let (x, y, u, v) = field();
let without = Plot::new()
.size_px(360, 220)
.quiver(&x, &y, &u, &v)
.color_by_magnitude(true)
.colorbar(false)
.into_plot();
let with = Plot::new()
.size_px(360, 220)
.quiver(&x, &y, &u, &v)
.color_by_magnitude(true)
.colorbar_label("wind speed (m/s)")
.into_plot();
let without_layout = compute_render_layout(&without);
let with_layout = compute_render_layout(&with);
assert!(
with_layout.margins.right > without_layout.margins.right + 40.0,
"a quiver colour key must reserve right margin like every other one: \
without={} with={}",
without_layout.margins.right,
with_layout.margins.right
);
}
#[test]
fn the_key_is_on_by_default_but_only_when_colour_carries_meaning() {
let (x, y, u, v) = field();
let plain = Plot::new()
.size_px(360, 220)
.quiver(&x, &y, &u, &v)
.into_plot();
let coloured = Plot::new()
.size_px(360, 220)
.quiver(&x, &y, &u, &v)
.color_by_magnitude(true)
.into_plot();
assert!(
compute_render_layout(&coloured).margins.right
> compute_render_layout(&plain).margins.right,
"one uniform colour decodes nothing, so a plain quiver must not grow a key"
);
}
#[test]
fn the_svg_export_draws_the_same_key() {
let (x, y, u, v) = field();
let svg = Plot::new()
.size_px(360, 220)
.quiver(&x, &y, &u, &v)
.color_by_magnitude(true)
.colorbar_label("wind speed (m/s)")
.render_to_svg()
.expect("a magnitude-coloured quiver should render to SVG");
assert!(
svg.contains("wind speed (m/s)"),
"the colorbar caption must reach the SVG export"
);
}
}