use super::*;
use crate::core::FigureConfig;
#[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 {
source: "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_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)
}
#[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,
color: None,
color_source: None,
line_width: None,
line_width_source: None,
line_style: None,
line_style_source: None,
marker_style: None,
marker_style_source: None,
marker_size: None,
marker_size_source: None,
alpha: None,
alpha_source: None,
y_errors: None,
x_errors: None,
error_config: None,
inset_layout: None,
group_id: None,
};
series.set_color_source_value(Color::RED.into());
series.set_line_width_source_value(0.01_f32.into());
series.set_line_style_source_value(LineStyle::Dashed.into());
series.set_marker_style_source_value(MarkerStyle::Square.into());
series.set_marker_size_source_value(0.01_f32.into());
series.set_alpha_source_value(1.5_f32.into());
assert_eq!(series.color, Some(Color::RED));
assert!(series.color_source.is_none());
assert_eq!(series.line_width, Some(0.1));
assert!(series.line_width_source.is_none());
assert_eq!(series.line_style, Some(LineStyle::Dashed));
assert!(series.line_style_source.is_none());
assert_eq!(series.marker_style, Some(MarkerStyle::Square));
assert!(series.marker_style_source.is_none());
assert_eq!(series.marker_size, Some(0.1));
assert!(series.marker_size_source.is_none());
assert_eq!(series.alpha, Some(1.0));
assert!(series.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.color, Some(Color::RED));
assert!(series.color_source.is_none());
assert_eq!(series.line_width, Some(0.1));
assert!(series.line_width_source.is_none());
assert_eq!(series.line_style, Some(LineStyle::Dashed));
assert!(series.line_style_source.is_none());
assert_eq!(series.alpha, Some(1.0));
assert!(series.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_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_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 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) -> PlotLayout {
let (x_min, x_max, y_min, y_max) = plot
.calculate_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
.calculate_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) -> PlotLayout {
let (x_min, x_max, y_min, y_max) = plot
.calculate_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 { source, message } => {
assert_eq!(source, "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_snapshot_bounds_cover_heatmap_and_pie_series() {
let heatmap = Plot::new()
.heatmap(&vec![vec![1.0, 2.0], vec![3.0, 4.0]], None)
.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(
&values,
Some(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(
&values,
Some(
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(
&values,
Some(
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(
&values,
Some(
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(
&vec![vec![0.0, f64::NAN, 1.0]],
Some(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(
&vec![vec![0.0, 1.0, 10.0]],
Some(
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(
&values,
Some(
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(
&values,
Some(
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_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_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_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);
}
#[cfg(feature = "parallel")]
#[test]
fn test_render_parallel_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("parallel 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 { source, message } => {
assert_eq!(source, "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_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].color, Some(Color::RED));
assert_eq!(plot.series_mgr.series[1].color, Some(Color::RED));
assert_eq!(plot.series_mgr.series[0].line_width, Some(3.0));
assert_eq!(plot.series_mgr.series[1].line_width, Some(3.0));
assert!(matches!(
plot.series_mgr.series[0].line_style,
Some(LineStyle::Dashed)
));
assert!(matches!(
plot.series_mgr.series[1].line_style,
Some(LineStyle::Dashed)
));
assert_eq!(plot.series_mgr.series[0].alpha, Some(0.35));
assert_eq!(plot.series_mgr.series[1].alpha, Some(0.35));
assert_ne!(plot.series_mgr.series[2].color, Some(Color::RED));
assert_ne!(plot.series_mgr.series[2].line_width, Some(3.0));
assert!(matches!(
plot.series_mgr.series[2].line_style,
Some(LineStyle::Solid)
));
assert_eq!(plot.series_mgr.series[2].alpha, 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_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);
assert_eq!(
plot.series_mgr.series[0].color,
plot.series_mgr.series[1].color
);
assert_ne!(
plot.series_mgr.series[0].color,
plot.series_mgr.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_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_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
.calculate_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)
.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_pixel_is_dark(
&image_all_sides,
top_probe.0,
top_probe.1
));
assert!(!image_pixel_is_dark(
&image_bottom_left,
top_probe.0,
top_probe.1
));
assert!(image_pixel_is_dark(
&image_all_sides,
right_probe.0,
right_probe.1
));
assert!(!image_pixel_is_dark(
&image_bottom_left,
right_probe.0,
right_probe.1
));
}
fn compute_categorical_render_top_tick_probe(plot: &Plot) -> (u32, u32) {
let (x_min, x_max, y_min, y_max) = plot
.calculate_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 categories = plot
.series_mgr
.series
.iter()
.find_map(|series| {
if let SeriesType::Bar { categories, .. } = &series.series_type {
Some(categories.len())
} else {
None
}
})
.expect("categorical plot should contain bar categories");
let x_tick_pixels =
Plot::categorical_x_tick_pixels(plot_area, x_min, x_max, Some(categories), &[])
.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)
.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)
.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
.calculate_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.expect("title position");
let xlabel_pos = layout.xlabel_pos.expect("xlabel position");
let ylabel_pos = layout.ylabel_pos.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::new(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_keeps_dpi_scaled_frame_stroke() {
let svg = Plot::new()
.dpi(200)
.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.00""#),
"ticks(false) should keep the DPI-scaled 1.5 logical px frame width"
);
}
#[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
.calculate_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.expect("title position");
let xlabel_pos = layout.xlabel_pos.expect("xlabel position");
let ylabel_pos = layout.ylabel_pos.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() {
let plot = Plot::new().gpu(true);
assert_eq!(plot.get_backend_name(), "gpu");
assert!(plot.render.enable_gpu);
}
#[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_uses_datashader_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().scatter(&x_data, &y_data).end_series();
let (_, backend) = plot.benchmark_save_png_bytes().unwrap();
assert_eq!(backend, "datashader");
}
#[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_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::new(255, 0, 0))
.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].color, Some(Color::new(255, 0, 0)));
assert_eq!(plot.series_mgr.series[0].line_width, 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::new(0, 255, 0))
.marker_size(10.0)
.end_series();
assert_eq!(plot.series_mgr.series[0].color, Some(Color::new(0, 255, 0)));
assert_eq!(plot.series_mgr.series[0].marker_size, 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], None)
.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], None).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], None)
.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(&data, Some(config.clone()))
.into();
let source_plot: Plot = Plot::new()
.size_px(320, 240)
.histogram_source(data.clone(), Some(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], None)
.boxplot(&[2.0, 3.0, 5.0, 8.0], None)
.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_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");
};
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_centers_portrait_insets_before_title_clearance() {
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.x - 100.0).abs() < 0.01,
"unexpected radar inset x: {}",
area.x
);
assert!(
(area.y - 340.0).abs() < 0.01,
"unexpected radar inset y: {}",
area.y
);
assert!(
(area.width - 160.0).abs() < 0.01,
"unexpected radar inset width: {}",
area.width
);
assert!(
(area.height - 160.0).abs() < 0.01,
"unexpected radar inset height: {}",
area.height
);
}
#[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_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())
);
}
#[cfg(feature = "typst-math")]
mod typst;