#![allow(clippy::useless_conversion)]
use super::*;
use crate::core::{AlphaMode, IntoPlot};
use crate::data::{Observable, StreamingBuffer, StreamingRenderState, StreamingXY, signal};
use crate::prelude::Plot;
use crate::render::{Color, MarkerStyle};
use std::sync::{Arc, atomic::Ordering};
fn render_target() -> SurfaceTarget {
SurfaceTarget {
size_px: (320, 240),
scale_factor: 1.0,
time_seconds: 0.0,
}
}
fn install_render_test_hook(
session: &InteractivePlotSession,
point: RenderTestPoint,
) -> (Arc<std::sync::Barrier>, Arc<std::sync::Barrier>) {
let entered = Arc::new(std::sync::Barrier::new(2));
let release = Arc::new(std::sync::Barrier::new(2));
*session
.inner
.render_test_hook
.lock()
.expect("InteractivePlotSession render test hook lock poisoned") = Some(RenderTestHook {
point,
entered: Arc::clone(&entered),
release: Arc::clone(&release),
});
(entered, release)
}
fn assert_index_matches_brute_force(
session: &InteractivePlotSession,
data_probes: &[ViewportPoint],
) {
assert!(
session.indexed_point_series_count() > 0,
"test fixture must exercise the indexed path"
);
let plot_area = session.viewport_snapshot().unwrap().plot_area;
let mut queries = vec![
plot_area.min,
plot_area.max,
ViewportPoint::new(plot_area.min.x, plot_area.max.y),
ViewportPoint::new(plot_area.max.x, plot_area.min.y),
ViewportPoint::new(
(plot_area.min.x + plot_area.max.x) * 0.5,
(plot_area.min.y + plot_area.max.y) * 0.5,
),
];
queries.extend(
data_probes
.iter()
.filter_map(|&point| session.data_to_screen(point).unwrap()),
);
let mut random_state = 0x9e37_79b9_7f4a_7c15_u64;
for _ in 0..64 {
random_state = random_state
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
let x_fraction = (random_state >> 11) as f64 / (1_u64 << 53) as f64;
random_state = random_state
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
let y_fraction = (random_state >> 11) as f64 / (1_u64 << 53) as f64;
queries.push(ViewportPoint::new(
plot_area.min.x + x_fraction * plot_area.width(),
plot_area.min.y + y_fraction * plot_area.height(),
));
}
for tolerance_px in [0.0, 0.25, 3.0, 8.0, 17.0, 64.0, 1.0e9] {
for &query in &queries {
assert_eq!(
session.hit_test_with_tolerance_px(query, tolerance_px),
session.hit_test_brute_force_with_tolerance_px(query, tolerance_px),
"indexed and brute-force hit tests differed at {query:?} with radius {tolerance_px}"
);
}
}
for tolerance_px in [f64::NAN, f64::INFINITY, -1.0] {
let query = queries[queries.len() / 2];
assert_eq!(
session.hit_test_with_tolerance_px(query, tolerance_px),
session.hit_test_brute_force_with_tolerance_px(query, tolerance_px)
);
}
}
fn dense_series(count: usize, x_value: impl Fn(f64) -> f64, phase: f64) -> (Vec<f64>, Vec<f64>) {
let mut x = Vec::with_capacity(count);
let mut y = Vec::with_capacity(count);
for index in 0..count {
let fraction = index as f64 / (count.saturating_sub(1).max(1)) as f64;
x.push(x_value(fraction));
y.push(((fraction * std::f64::consts::TAU) + phase).sin());
}
(x, y)
}
fn derived_y_ticks(session: &InteractivePlotSession) -> Vec<f64> {
let geometry = session
.geometry_snapshot()
.expect("geometry should be available after render");
let plot = session.prepared_plot().plot();
crate::axes::generate_ticks_for_scale(
geometry.y_bounds.0,
geometry.y_bounds.1,
plot.layout.tick_config.major_ticks_y,
&plot.layout.y_scale,
)
}
#[test]
fn test_render_to_image_preserves_requested_surface_size() {
let plot: Plot = Plot::new()
.size(4.0, 3.0)
.line(&[0.0, 1.0], &[0.0, 1.0])
.into();
let session = plot.prepare_interactive();
assert_eq!(session.fitted_frame_size_px((800, 500)), (666, 500));
let frame = session
.render_to_image(ImageTarget {
size_px: (800, 500),
scale_factor: 2.0,
time_seconds: 0.0,
})
.expect("interactive image should preserve the requested surface size");
assert_eq!((frame.image.width, frame.image.height), (800, 500));
assert_eq!(
(frame.layers.base.width, frame.layers.base.height),
(800, 500)
);
if let Some(overlay) = frame.layers.overlay.as_ref() {
assert_eq!((overlay.width, overlay.height), (800, 500));
}
let snapshot = session
.viewport_snapshot()
.expect("render should establish viewport geometry");
assert!(snapshot.plot_area.max.x <= 800.0);
assert!(snapshot.plot_area.max.y <= 500.0);
}
#[test]
fn test_interactive_capture_honors_outside_legend_layout() {
let plot: Plot = Plot::new()
.legend_position(crate::core::LegendPosition::OutsideRight)
.line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 0.5])
.label("Interactive legend")
.into();
let session = plot.clone().prepare_interactive();
let frame = session
.render_to_image(ImageTarget {
size_px: (640, 480),
scale_factor: 1.0,
time_seconds: 0.0,
})
.expect("interactive outside legend capture");
let snapshot = session.viewport_snapshot().unwrap();
let plain: Plot = Plot::new().line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 0.5]).into();
let plain_session = plain.prepare_interactive();
plain_session
.render_to_image(ImageTarget {
size_px: (640, 480),
scale_factor: 1.0,
time_seconds: 0.0,
})
.unwrap();
let plain_snapshot = plain_session.viewport_snapshot().unwrap();
assert_eq!((frame.image.width, frame.image.height), (640, 480));
assert!(snapshot.plot_area.max.x < plain_snapshot.plot_area.max.x);
}
#[test]
fn test_render_to_image_uses_fitted_size_when_requested() {
let plot: Plot = Plot::new()
.size(4.0, 3.0)
.line(&[0.0, 1.0], &[0.0, 1.0])
.into();
let session = plot.prepare_interactive();
let fitted_size = session.fitted_frame_size_px((800, 500));
assert_eq!(fitted_size, (666, 500));
let frame = session
.render_to_image(ImageTarget {
size_px: fitted_size,
scale_factor: 2.0,
time_seconds: 0.0,
})
.expect("interactive image should render to caller-selected fitted size");
assert_eq!((frame.image.width, frame.image.height), fitted_size);
assert_eq!(
(frame.layers.base.width, frame.layers.base.height),
fitted_size
);
if let Some(overlay) = frame.layers.overlay.as_ref() {
assert_eq!((overlay.width, overlay.height), fitted_size);
}
let snapshot = session
.viewport_snapshot()
.expect("render should establish viewport geometry");
assert!(snapshot.plot_area.max.x <= f64::from(fitted_size.0));
assert!(snapshot.plot_area.max.y <= f64::from(fitted_size.1));
}
#[test]
fn test_small_interactive_frame_renders() {
let plot: Plot = Plot::new()
.ticks(false)
.grid(false)
.line(&[0.0, 1.0], &[0.0, 1.0])
.into();
let session = plot.prepare_interactive();
let frame = session
.render_to_surface(SurfaceTarget {
size_px: (80, 80),
scale_factor: 1.0,
time_seconds: 0.0,
})
.expect("interactive frames may be smaller than the public figure minimum");
assert_eq!((frame.image.width, frame.image.height), (80, 80));
assert_eq!(
(frame.layers.base.width, frame.layers.base.height),
(80, 80)
);
}
#[test]
fn test_viewport_snapshot_remains_available_before_first_render() {
let plot: Plot = Plot::new()
.line(&[0.0, 1.0], &[0.0, 1.0])
.xlim(1.0, 0.0)
.into();
let session = plot.prepare_interactive();
session.resize((320, 240), 1.0);
let snapshot = session
.viewport_snapshot()
.expect("viewport snapshot should preserve pre-render compatibility");
assert_eq!(
(snapshot.visible_bounds.min.x, snapshot.visible_bounds.max.x),
(1.0, 0.0)
);
assert!(snapshot.plot_area.width() > 0.0);
assert!(snapshot.plot_area.height() > 0.0);
}
#[test]
fn test_view_bounds_snapshot_is_available_immediately_after_construction() {
let plot: Plot = Plot::new()
.line(&[1.0, 1000.0], &[-5.0, 5.0])
.xscale(crate::axes::AxisScale::Log)
.xlim(1000.0, 1.0)
.ylim(-5.0, 5.0)
.into();
let session = plot.prepare_interactive();
let snapshot = session.view_bounds_snapshot();
assert_eq!(snapshot.visible_bounds, snapshot.base_bounds);
assert_eq!(
(snapshot.base_bounds.min.x, snapshot.base_bounds.max.x),
(1000.0, 1.0)
);
assert_eq!(snapshot.x_scale, crate::axes::AxisScale::Log);
assert_eq!(snapshot.y_scale, crate::axes::AxisScale::Linear);
}
#[test]
fn test_view_bounds_snapshot_reflects_restore_before_next_render() {
let plot: Plot = Plot::new()
.line(&[0.0, 10.0], &[0.0, 20.0])
.xlim(0.0, 10.0)
.ylim(0.0, 20.0)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial frame should render");
let displayed_before = session.viewport_snapshot().unwrap().visible_bounds;
let restored = ViewportRect {
min: ViewportPoint::new(2.0, 4.0),
max: ViewportPoint::new(8.0, 16.0),
};
assert!(session.restore_visible_bounds(restored));
let pending = session.view_bounds_snapshot();
assert!(bounds_close(
DataBounds::from_viewport_rect(pending.visible_bounds),
DataBounds::from_viewport_rect(restored),
));
assert_eq!(pending.base_bounds, displayed_before);
assert_eq!(
session.viewport_snapshot().unwrap().visible_bounds,
displayed_before,
"the geometry-backed snapshot should still describe the displayed frame"
);
}
#[test]
fn test_displayed_coordinate_conversion_supports_scales_reversal_and_clamping() {
let plot: Plot = Plot::new()
.line(&[1.0, 1000.0], &[-100.0, 100.0])
.xscale(crate::axes::AxisScale::Log)
.yscale(crate::axes::AxisScale::symlog(1.0))
.xlim(1000.0, 1.0)
.ylim(100.0, -100.0)
.into();
let session = plot.prepare_interactive();
assert!(
session
.screen_to_data(ViewportPoint::new(0.0, 0.0))
.is_err()
);
session
.render_to_surface(render_target())
.expect("scaled conversion frame should render");
let plot_area = session
.viewport_snapshot()
.expect("displayed viewport should be available")
.plot_area;
let center = ViewportPoint::new(
(plot_area.min.x + plot_area.max.x) * 0.5,
(plot_area.min.y + plot_area.max.y) * 0.5,
);
let center_data = session
.screen_to_data(center)
.expect("checked screen conversion should succeed")
.expect("plot center should be inside the displayed geometry");
assert!((center_data.x.log10() - 1.5).abs() < 1e-6);
assert!(center_data.y.abs() < 1e-6);
let round_trip = session
.data_to_screen(center_data)
.expect("checked data conversion should succeed")
.expect("converted center data should be inside the displayed bounds");
assert!((round_trip.x - center.x).abs() < 1e-4);
assert!((round_trip.y - center.y).abs() < 1e-4);
assert_eq!(
session
.screen_to_data(ViewportPoint::new(plot_area.min.x - 1.0, center.y))
.expect("outside checked screen conversion should succeed"),
None
);
assert_eq!(
session
.data_to_screen(ViewportPoint::new(0.5, 0.0))
.expect("outside checked data conversion should succeed"),
None
);
assert_eq!(
session
.data_to_screen(ViewportPoint::new(0.0, 0.0))
.expect("zero log input should be rejected without conversion"),
None
);
assert_eq!(
session
.data_to_screen(ViewportPoint::new(-1.0, 0.0))
.expect("negative log input should be rejected without conversion"),
None
);
assert_eq!(
session
.screen_to_data(ViewportPoint::new(f64::NAN, center.y))
.expect("non-finite checked conversion should return no point"),
None
);
let clamped_data = session
.screen_to_data_clamped(ViewportPoint::new(
plot_area.min.x - 100.0,
plot_area.min.y - 100.0,
))
.expect("clamped screen conversion should succeed");
assert!((clamped_data.x - 1000.0).abs() < 1e-6);
assert!((clamped_data.y + 100.0).abs() < 1e-6);
let clamped_screen = session
.data_to_screen_clamped(ViewportPoint::new(2000.0, -200.0))
.expect("clamped data conversion should succeed");
assert!((clamped_screen.x - plot_area.min.x).abs() < 1e-4);
assert!((clamped_screen.y - plot_area.min.y).abs() < 1e-4);
assert!(
session
.data_to_screen_clamped(ViewportPoint::new(f64::INFINITY, 0.0))
.is_err()
);
}
#[test]
fn test_high_dpi_conversion_uses_backing_pixels_and_logical_hit_tolerance() {
let plot: Plot = Plot::new()
.scatter(&[10.0], &[0.0])
.xscale(crate::axes::AxisScale::Log)
.xlim(1.0, 100.0)
.ylim(-1.0, 1.0)
.ticks(false)
.grid(false)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(SurfaceTarget {
size_px: (640, 480),
scale_factor: 2.0,
time_seconds: 0.0,
})
.expect("high-DPI frame should render");
assert_eq!(session.indexed_point_series_count(), 0);
let data_position = ViewportPoint::new(10.0, 0.0);
let screen_position = session
.data_to_screen(data_position)
.expect("high-DPI data conversion should succeed")
.expect("point should be inside the displayed bounds");
let round_trip = session
.screen_to_data(screen_position)
.expect("high-DPI screen conversion should succeed")
.expect("backing-pixel point should be inside the plot area");
assert!((round_trip.x - data_position.x).abs() < 1e-6);
assert!((round_trip.y - data_position.y).abs() < 1e-6);
let six_logical_pixels_away = ViewportPoint::new(screen_position.x + 12.0, screen_position.y);
let fallback_hit = session.hit_test(six_logical_pixels_away);
assert_eq!(
fallback_hit,
session.hit_test_brute_force_with_tolerance_px(six_logical_pixels_away, 16.0)
);
assert!(matches!(
fallback_hit,
HitResult::SeriesPoint { point_index: 0, .. }
));
let nine_logical_pixels_away = ViewportPoint::new(screen_position.x + 18.0, screen_position.y);
assert_eq!(session.hit_test(nine_logical_pixels_away), HitResult::None);
}
#[test]
fn test_indexed_hit_test_matches_brute_force_across_scales_reversal_and_radii() {
let (linear_x, linear_y) = dense_series(640, |fraction| fraction * 20.0 - 10.0, 0.0);
let (_, linear_y_second) = dense_series(640, |fraction| fraction, 0.7);
let linear_plot: Plot = Plot::new()
.scatter(&linear_x, &linear_y)
.line(&linear_x, &linear_y_second)
.xlim(-10.0, 10.0)
.ylim(-1.2, 1.2)
.ticks(false)
.grid(false)
.into();
let linear_session = linear_plot.prepare_interactive();
linear_session
.render_to_surface(render_target())
.expect("linear indexed frame should render");
assert_index_matches_brute_force(
&linear_session,
&[
ViewportPoint::new(-10.0, 0.0),
ViewportPoint::new(0.0, 0.0),
ViewportPoint::new(10.0, 0.0),
],
);
let (scaled_x, mut scaled_y) = dense_series(640, |fraction| 10_f64.powf(3.0 * fraction), 0.0);
let (_, mut scaled_y_second) = dense_series(640, |fraction| fraction, 0.9);
for value in &mut scaled_y {
*value *= 100.0;
}
for value in &mut scaled_y_second {
*value *= 100.0;
}
let scaled_plot: Plot = Plot::new()
.line(&scaled_x, &scaled_y)
.scatter(&scaled_x, &scaled_y_second)
.xscale(crate::axes::AxisScale::Log)
.yscale(crate::axes::AxisScale::symlog(1.0))
.xlim(1000.0, 1.0)
.ylim(100.0, -100.0)
.ticks(false)
.grid(false)
.into();
let scaled_session = scaled_plot.prepare_interactive();
scaled_session
.render_to_surface(SurfaceTarget {
size_px: (640, 480),
scale_factor: 2.0,
time_seconds: 0.0,
})
.expect("scaled reversed indexed frame should render");
assert_index_matches_brute_force(
&scaled_session,
&[
ViewportPoint::new(1.0, -100.0),
ViewportPoint::new(10.0, 0.0),
ViewportPoint::new(1000.0, 100.0),
],
);
let errors = vec![0.05; linear_x.len()];
let error_plot: Plot = Plot::new()
.error_bars(&linear_x, &linear_y, &errors)
.error_bars_xy(&linear_x, &linear_y_second, &errors, &errors)
.into_plot()
.xlim(-10.0, 10.0)
.ylim(-1.2, 1.2)
.ticks(false)
.grid(false)
.into();
let error_session = error_plot.prepare_interactive();
error_session
.render_to_surface(render_target())
.expect("indexed error-bar frame should render");
assert_eq!(error_session.indexed_point_series_count(), 2);
assert_index_matches_brute_force(
&error_session,
&[ViewportPoint::new(0.0, 0.0), ViewportPoint::new(5.0, 0.5)],
);
}
#[test]
fn test_symlog_infinite_linthresh_hits_rendered_center_point() {
let x = (0..320)
.map(|index| index as f64 / 159.5 - 1.0)
.collect::<Vec<_>>();
let y = vec![0.0; x.len()];
let plot: Plot = Plot::new()
.scatter(&x, &y)
.xscale(crate::axes::AxisScale::symlog(f64::INFINITY))
.xlim(-1.0, 1.0)
.ylim(-1.0, 1.0)
.ticks(false)
.grid(false)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("infinite-linthresh SymLog frame should render");
let expected_data_position = ViewportPoint::new(-1.0, 0.0);
let expected_screen_position = session
.data_to_screen(expected_data_position)
.expect("displayed conversion should succeed")
.expect("finite SymLog point should map to the displayed plot area");
let plot_area = session.viewport_snapshot().unwrap().plot_area;
assert!((expected_screen_position.x - (plot_area.min.x + plot_area.max.x) * 0.5).abs() < 1e-5);
assert_eq!(session.indexed_point_series_count(), 1);
for hit in [
session.hit_test(expected_screen_position),
session.hit_test_brute_force_with_tolerance_px(
expected_screen_position,
HIT_TEST_TOLERANCE_LOGICAL_PX,
),
] {
match hit {
HitResult::SeriesPoint {
series_index,
point_index,
screen_position,
data_position,
distance_px,
} => {
assert_eq!(series_index, 0);
assert_eq!(point_index, 0);
assert_eq!(screen_position, expected_screen_position);
assert_eq!(data_position, expected_data_position);
assert_eq!(distance_px, 0.0);
}
other => panic!("expected the rendered center point, got {other:?}"),
}
}
}
#[test]
fn test_screen_space_grid_safely_rejects_invalid_points_and_huge_queries() {
let plot: Plot = Plot::new()
.scatter(&[0.0, 1.0], &[0.0, 1.0])
.xlim(0.0, 1.0)
.ylim(0.0, 1.0)
.ticks(false)
.grid(false)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("grid edge-case frame should render");
let geometry = session.displayed_geometry().unwrap();
let mut x = (0..320)
.map(|index| index as f64 / 319.0)
.collect::<Vec<_>>();
let mut y = x.clone();
x[3] = f64::NAN;
y[4] = f64::INFINITY;
let cell_size_px = geometry.logical_pixels_to_pixels(HIT_TEST_TOLERANCE_LOGICAL_PX);
let grid = ScreenSpacePointGrid::build(&x, &y, &geometry, cell_size_px)
.expect("remaining finite points should still be indexed");
let center = session
.data_to_screen(ViewportPoint::new(0.5, 0.5))
.unwrap()
.unwrap();
assert!(matches!(
grid.nearest(center, 1.0e300),
GridQueryResult::Fallback
));
assert!(ScreenSpacePointGrid::build(&[], &[], &geometry, cell_size_px).is_none());
}
#[test]
fn test_indexed_hit_test_preserves_point_and_heatmap_tie_ordering() {
let mut first_x = vec![0.0; 320];
let mut first_y = vec![0.0; 320];
let mut second_x = vec![0.0; 320];
let mut second_y = vec![0.0; 320];
for index in 1..320 {
let value = 0.6 + index as f64 / 1000.0;
first_x[index] = value;
first_y[index] = value;
second_x[index] = value;
second_y[index] = -value;
}
let point_tie_plot: Plot = Plot::new()
.scatter(&first_x, &first_y)
.scatter(&second_x, &second_y)
.xlim(-1.0, 1.0)
.ylim(-1.0, 1.0)
.ticks(false)
.grid(false)
.into();
let point_tie_session = point_tie_plot.prepare_interactive();
point_tie_session
.render_to_surface(render_target())
.expect("point tie frame should render");
assert_eq!(point_tie_session.indexed_point_series_count(), 2);
let center = point_tie_session
.data_to_screen(ViewportPoint::new(0.0, 0.0))
.unwrap()
.unwrap();
let indexed = point_tie_session.hit_test(center);
assert_eq!(
indexed,
point_tie_session.hit_test_brute_force_with_tolerance_px(center, 8.0)
);
assert!(matches!(
indexed,
HitResult::SeriesPoint {
series_index: 0,
point_index: 0,
..
}
));
let heatmap_values = vec![vec![1.0]];
let mixed_plot: Plot = Plot::new()
.scatter(&first_x, &first_y)
.heatmap_with(
&heatmap_values,
crate::plots::heatmap::HeatmapConfig::new()
.extent(-1.0, 1.0, -1.0, 1.0)
.colorbar(false),
)
.scatter(&second_x, &second_y)
.xlim(-1.0, 1.0)
.ylim(-1.0, 1.0)
.ticks(false)
.grid(false)
.into();
let mixed_session = mixed_plot.prepare_interactive();
mixed_session
.render_to_surface(render_target())
.expect("mixed tie frame should render");
assert_eq!(mixed_session.indexed_point_series_count(), 2);
let center = mixed_session
.data_to_screen(ViewportPoint::new(0.0, 0.0))
.unwrap()
.unwrap();
let indexed = mixed_session.hit_test(center);
assert_eq!(
indexed,
mixed_session.hit_test_brute_force_with_tolerance_px(center, 8.0)
);
assert!(matches!(
indexed,
HitResult::HeatmapCell {
series_index: 1,
row: 0,
col: 0,
..
}
));
}
#[test]
fn test_indexed_hit_test_tracks_displayed_reactive_viewport_and_time_frames() {
let x = (0..640)
.map(|index| index as f64 / 639.0)
.collect::<Vec<_>>();
let y = Observable::new(x.clone());
let plot: Plot = Plot::new()
.scatter_source(x.clone(), y.clone())
.xlim(0.0, 1.0)
.ylim(0.0, 1.0)
.ticks(false)
.grid(false)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(SurfaceTarget {
size_px: (640, 480),
scale_factor: 2.0,
time_seconds: 0.0,
})
.expect("initial reactive indexed frame should render");
assert_index_matches_brute_force(
&session,
&[
ViewportPoint::new(0.25, 0.25),
ViewportPoint::new(0.75, 0.75),
],
);
y.set(x.iter().map(|value| 1.0 - value).collect());
assert_index_matches_brute_force(
&session,
&[
ViewportPoint::new(0.25, 0.25),
ViewportPoint::new(0.75, 0.75),
],
);
session.apply_input(PlotInputEvent::Pan {
delta_px: ViewportPoint::new(40.0, -20.0),
});
assert_index_matches_brute_force(
&session,
&[
ViewportPoint::new(0.25, 0.25),
ViewportPoint::new(0.75, 0.75),
],
);
session
.render_to_surface(SurfaceTarget {
size_px: (640, 480),
scale_factor: 2.0,
time_seconds: 0.0,
})
.expect("updated reactive viewport frame should render");
assert_index_matches_brute_force(
&session,
&[
ViewportPoint::new(0.25, 0.75),
ViewportPoint::new(0.75, 0.25),
],
);
let temporal_y = signal::of(|time| {
(0..640)
.map(|index| (index as f64 / 639.0 + time).fract())
.collect::<Vec<_>>()
});
let temporal_plot: Plot = Plot::new()
.scatter_source(x, temporal_y)
.xlim(0.0, 1.0)
.ylim(0.0, 1.0)
.ticks(false)
.grid(false)
.into();
let temporal_session = temporal_plot.prepare_interactive();
temporal_session
.render_to_surface(SurfaceTarget {
time_seconds: 0.0,
..render_target()
})
.expect("initial temporal indexed frame should render");
assert_index_matches_brute_force(&temporal_session, &[ViewportPoint::new(0.25, 0.25)]);
temporal_session
.render_to_surface(SurfaceTarget {
time_seconds: 0.25,
..render_target()
})
.expect("updated temporal indexed frame should render");
assert_index_matches_brute_force(&temporal_session, &[ViewportPoint::new(0.25, 0.5)]);
}
#[test]
fn test_interactive_log_conversion_preserves_sub_epsilon_positive_limits() {
let min = f64::EPSILON / 1024.0;
let max = f64::EPSILON / 16.0;
let plot: Plot = Plot::new()
.line(&[min, max], &[0.0, 1.0])
.xscale(crate::axes::AxisScale::Log)
.xlim(min, max)
.ylim(0.0, 1.0)
.ticks(false)
.grid(false)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("sub-epsilon log frame should render");
let snapshot = session.viewport_snapshot().unwrap();
assert_eq!(
(snapshot.visible_bounds.min.x, snapshot.visible_bounds.max.x),
(min, max)
);
let center = ViewportPoint::new(
(snapshot.plot_area.min.x + snapshot.plot_area.max.x) * 0.5,
(snapshot.plot_area.min.y + snapshot.plot_area.max.y) * 0.5,
);
let data = session.screen_to_data(center).unwrap().unwrap();
assert!(((data.x - (min * max).sqrt()) / data.x).abs() < 1e-12);
session.apply_input(PlotInputEvent::Zoom {
factor: 2.0,
center_px: center,
});
session
.render_to_surface(render_target())
.expect("zoomed sub-epsilon log frame should render");
let zoomed = session.screen_to_data(center).unwrap().unwrap();
assert!(((zoomed.x - data.x) / data.x).abs() < 1e-6);
}
#[test]
fn test_interactive_log_auto_bounds_preserve_sub_epsilon_positive_range() {
let min = f64::EPSILON / 1024.0;
let max = f64::EPSILON / 16.0;
let plot: Plot = Plot::new()
.line(&[min, max], &[0.0, 1.0])
.xscale(crate::axes::AxisScale::Log)
.ylim(0.0, 1.0)
.ticks(false)
.grid(false)
.into();
let (expected_min, expected_max, _, _) = plot.effective_data_bounds().unwrap();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("auto-bounded sub-epsilon log frame should render");
let snapshot = session.viewport_snapshot().unwrap();
assert_eq!(
(snapshot.visible_bounds.min.x, snapshot.visible_bounds.max.x),
(expected_min, expected_max)
);
assert!(expected_min > 0.0, "expected_min = {expected_min}");
assert!(expected_min < min && expected_max > max);
}
#[test]
fn test_log_zoom_and_symlog_pan_follow_displayed_transform() {
let plot: Plot = Plot::new()
.line(&[1.0, 10.0, 1000.0], &[-100.0, 0.0, 100.0])
.xscale(crate::axes::AxisScale::Log)
.yscale(crate::axes::AxisScale::symlog(1.0))
.xlim(1.0, 1000.0)
.ylim(-100.0, 100.0)
.ticks(false)
.grid(false)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial nonlinear frame should render");
let plot_area = session.viewport_snapshot().unwrap().plot_area;
let anchor = ViewportPoint::new(
plot_area.min.x + plot_area.width() * 0.3,
plot_area.min.y + plot_area.height() * 0.65,
);
let anchor_data = session.screen_to_data(anchor).unwrap().unwrap();
session.apply_input(PlotInputEvent::Zoom {
factor: 2.0,
center_px: anchor,
});
session
.render_to_surface(render_target())
.expect("zoomed nonlinear frame should render");
let after_zoom = session.screen_to_data(anchor).unwrap().unwrap();
assert!(
((after_zoom.x - anchor_data.x) / anchor_data.x).abs() < 1e-6,
"x anchor moved from {} to {}",
anchor_data.x,
after_zoom.x
);
assert!(
(after_zoom.y - anchor_data.y).abs() < 1e-6,
"y anchor moved from {} to {}",
anchor_data.y,
after_zoom.y
);
let tracked_data = ViewportPoint::new(10.0, 0.0);
let before_pan = session.data_to_screen(tracked_data).unwrap().unwrap();
let delta = ViewportPoint::new(18.0, 12.0);
session.apply_input(PlotInputEvent::Pan { delta_px: delta });
session
.render_to_surface(render_target())
.expect("panned nonlinear frame should render");
let after_pan = session.data_to_screen(tracked_data).unwrap().unwrap();
assert!((after_pan.x - before_pan.x - delta.x).abs() < 1e-4);
assert!((after_pan.y - before_pan.y - delta.y).abs() < 1e-4);
}
#[test]
fn test_zoom_limit_preserves_off_center_anchor_and_reuses_base_at_limit() {
let plot: Plot = Plot::new()
.line(&[0.0, 100.0], &[0.0, 100.0])
.xlim(0.0, 100.0)
.ylim(0.0, 100.0)
.ticks(false)
.grid(false)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial zoom-limit frame should render");
let plot_area = session.viewport_snapshot().unwrap().plot_area;
let anchor = ViewportPoint::new(
plot_area.min.x + plot_area.width() * 0.2,
plot_area.min.y + plot_area.height() * 0.7,
);
let anchor_before = session.screen_to_data(anchor).unwrap().unwrap();
session.apply_input(PlotInputEvent::Zoom {
factor: 1e12,
center_px: anchor,
});
session
.render_to_surface(render_target())
.expect("clamped zoom-limit frame should render");
let anchor_after = session.screen_to_data(anchor).unwrap().unwrap();
assert!((anchor_after.x - anchor_before.x).abs() < 1e-8);
assert!((anchor_after.y - anchor_before.y).abs() < 1e-8);
session.apply_input(PlotInputEvent::Zoom {
factor: 1e12,
center_px: anchor,
});
let repeated = session
.render_to_surface(render_target())
.expect("repeated zoom-limit frame should reuse the base");
assert!(!repeated.layer_state.base_dirty);
}
#[test]
fn test_failed_reactive_log_render_rolls_back_viewport_state() {
let y = Observable::new(vec![1.0, 10.0]);
let plot: Plot = Plot::new()
.line_source(vec![1.0, 2.0], y.clone())
.yscale(crate::axes::AxisScale::Log)
.xlim(1.0, 2.0)
.ticks(false)
.grid(false)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial positive log frame should render");
let before = session.viewport_snapshot().unwrap();
y.set(vec![-10.0, -1.0]);
assert!(session.render_to_surface(render_target()).is_err());
let after = session.viewport_snapshot().unwrap();
assert_eq!(after.base_bounds, before.base_bounds);
assert_eq!(after.visible_bounds, before.visible_bounds);
assert_eq!(after.plot_area, before.plot_area);
}
#[test]
fn test_restore_visible_bounds_rejects_invalid_log_domain() {
let plot: Plot = Plot::new()
.line(&[1.0, 100.0], &[0.0, 1.0])
.xscale(crate::axes::AxisScale::Log)
.xlim(1.0, 100.0)
.ylim(0.0, 1.0)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial log frame should render");
let before = session.viewport_snapshot().unwrap();
assert!(!session.restore_visible_bounds(ViewportRect {
min: ViewportPoint::new(-10.0, 0.0),
max: ViewportPoint::new(-1.0, 1.0),
}));
assert_eq!(session.viewport_snapshot().unwrap(), before);
assert!(!session.dirty_domains().needs_base_render());
}
#[test]
fn test_restore_visible_bounds_rejects_extreme_finite_linear_bounds() {
let plot: Plot = Plot::new()
.line(&[0.0, 1.0], &[0.0, 1.0])
.xlim(0.0, 1.0)
.ylim(0.0, 1.0)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial linear frame should render");
let before = session.view_bounds_snapshot();
for (x_min, x_max) in [(1e308, 1.1e308), (1.1e308, 1e308)] {
assert!(!session.restore_visible_bounds(ViewportRect {
min: ViewportPoint::new(x_min, 0.0),
max: ViewportPoint::new(x_max, 1.0),
}));
let after = session.view_bounds_snapshot();
assert_eq!(after, before);
assert!(after.visible_bounds.min.x.is_finite());
assert!(after.visible_bounds.max.x.is_finite());
assert!(after.visible_bounds.min.y.is_finite());
assert!(after.visible_bounds.max.y.is_finite());
assert!(!session.dirty_domains().needs_base_render());
}
}
#[test]
fn test_equal_tiny_positive_log_bounds_expand_multiplicatively() {
let value = f64::EPSILON / 1024.0;
let static_plot: Plot = Plot::new()
.scatter(&[value], &[1.0])
.xscale(crate::axes::AxisScale::Log)
.ticks(false)
.grid(false)
.into();
let (x_min, x_max, _, _) = static_plot
.effective_data_bounds()
.expect("static log bounds should resolve");
assert!(x_min > 0.0 && x_min < x_max);
static_plot
.render()
.expect("static equal-value log plot should render");
let interactive_plot: Plot = Plot::new()
.scatter(&[value], &[1.0])
.xscale(crate::axes::AxisScale::Log)
.ticks(false)
.grid(false)
.into();
let session = interactive_plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("interactive equal-value log plot should render");
let bounds = session.viewport_snapshot().unwrap().base_bounds;
assert!(bounds.min.x > 0.0 && bounds.min.x < bounds.max.x);
}
#[test]
fn test_selected_hit_overlay_is_clipped_after_pan() {
let plot: Plot = Plot::new()
.scatter(&[5.0], &[5.0])
.xlim(0.0, 10.0)
.ylim(0.0, 10.0)
.ticks(false)
.grid(false)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial selection frame should render");
let before = session.viewport_snapshot().unwrap();
let point = session
.data_to_screen(ViewportPoint::new(5.0, 5.0))
.unwrap()
.unwrap();
session.apply_input(PlotInputEvent::SelectAt { position_px: point });
session.apply_input(PlotInputEvent::Pan {
delta_px: ViewportPoint::new(before.plot_area.width() * 0.5 + 3.0, 0.0),
});
let frame = session
.render_to_surface(render_target())
.expect("panned selection frame should render");
let after = session.viewport_snapshot().unwrap();
assert_eq!(after.selected_count, 1);
let overlay = frame
.layers
.overlay
.expect("selection overlay should exist");
let plot_area = tiny_skia::Rect::from_ltrb(
after.plot_area.min.x as f32,
after.plot_area.min.y as f32,
after.plot_area.max.x as f32,
after.plot_area.max.y as f32,
)
.unwrap();
assert_eq!(
count_matching_pixels_outside_rect(&overlay, plot_area, |pixel| pixel[3] > 0),
0
);
}
#[test]
fn test_large_offset_narrow_range_pan_is_not_discarded() {
let x_min = 1e15;
let x_max = x_min + 100.0;
let plot: Plot = Plot::new()
.line(&[x_min, x_max], &[0.0, 1.0])
.xlim(x_min, x_max)
.ylim(0.0, 1.0)
.ticks(false)
.grid(false)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial large-offset frame should render");
let tracked = ViewportPoint::new(x_min + 0.5, 0.5);
let before = session.data_to_screen(tracked).unwrap().unwrap();
let delta = ViewportPoint::new(20.0, 0.0);
session.apply_input(PlotInputEvent::Pan { delta_px: delta });
session
.render_to_surface(render_target())
.expect("large-offset panned frame should render");
let after = session.data_to_screen(tracked).unwrap().unwrap();
assert!((after.x - before.x - delta.x).abs() < 0.1);
}
#[test]
fn test_scaled_error_bar_hit_and_selection_refresh_use_displayed_geometry() {
let plot: Plot = Plot::new()
.error_bars(&[1.0, 10.0, 100.0], &[-10.0, 0.0, 10.0], &[1.0, 1.0, 1.0])
.into_plot()
.xscale(crate::axes::AxisScale::Log)
.yscale(crate::axes::AxisScale::symlog(1.0))
.xlim(1.0, 100.0)
.ylim(-10.0, 10.0)
.ticks(false)
.grid(false)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("scaled error-bar frame should render");
let data_position = ViewportPoint::new(10.0, 0.0);
let screen_position = session.data_to_screen(data_position).unwrap().unwrap();
assert!(matches!(
session.hit_test(screen_position),
HitResult::SeriesPoint { point_index: 1, .. }
));
session.apply_input(PlotInputEvent::SelectAt {
position_px: screen_position,
});
let plot_area = session.viewport_snapshot().unwrap().plot_area;
session.apply_input(PlotInputEvent::Zoom {
factor: 2.0,
center_px: ViewportPoint::new(
(plot_area.min.x + plot_area.max.x) * 0.5,
(plot_area.min.y + plot_area.max.y) * 0.5,
),
});
session
.render_to_surface(render_target())
.expect("zoomed error-bar frame should render");
let expected = session.data_to_screen(data_position).unwrap().unwrap();
let state = session
.inner
.state
.lock()
.expect("InteractivePlotSession state lock poisoned");
match state.selected.as_slice() {
[
HitResult::SeriesPoint {
point_index,
screen_position,
..
},
] => {
assert_eq!(*point_index, 1);
assert!((screen_position.x - expected.x).abs() < 1e-4);
assert!((screen_position.y - expected.y).abs() < 1e-4);
}
other => panic!("expected refreshed error-bar selection, got {other:?}"),
}
}
#[test]
fn test_heatmap_axis_hit_uses_scaled_screen_to_data_and_cell_geometry() {
let values = vec![vec![1.0, 2.0]];
let plot: Plot = Plot::new()
.heatmap_with(
&values,
crate::plots::heatmap::HeatmapConfig::new()
.extent(1.0, 100.0, 1.0, 100.0)
.colorbar(false),
)
.xlim(1.0, 100.0)
.ylim(1.0, 100.0)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("heatmap frame should render");
let screen_position = session
.data_to_screen(ViewportPoint::new(10.0, 10.0))
.unwrap()
.unwrap();
match session.hit_test(screen_position) {
HitResult::HeatmapCell {
row,
col,
value,
screen_rect,
..
} => {
assert_eq!((row, col), (0, 0));
assert_eq!(value, 1.0);
assert!(screen_rect.contains(screen_position));
}
other => panic!("expected scaled heatmap cell hit, got {other:?}"),
}
}
#[test]
fn test_heatmap_cell_hit_agrees_with_a_logarithmic_axis() {
use crate::axes::AxisScale;
let values = vec![vec![1.0, 2.0]];
let plot: Plot = Plot::new()
.heatmap_with(
&values,
crate::plots::heatmap::HeatmapConfig::new()
.extent(1.0, 100.0, 1.0, 100.0)
.colorbar(false),
)
.xscale(AxisScale::Log)
.yscale(AxisScale::Log)
.xlim(1.0, 100.0)
.ylim(1.0, 100.0)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("a log-axis heatmap must render, with its cells following the axis");
let screen_position = session
.data_to_screen(ViewportPoint::new(3.0, 3.0))
.unwrap()
.unwrap();
match session.hit_test(screen_position) {
HitResult::HeatmapCell {
row,
col,
value,
screen_rect,
..
} => {
assert_eq!((row, col), (0, 0));
assert_eq!(value, 1.0);
assert!(screen_rect.contains(screen_position));
let expected_min = session
.data_to_screen(ViewportPoint::new(1.0, 100.0))
.unwrap()
.unwrap();
let expected_max = session
.data_to_screen(ViewportPoint::new(50.5, 1.0))
.unwrap()
.unwrap();
assert!((screen_rect.min.x - expected_min.x).abs() < 1e-3);
assert!((screen_rect.min.y - expected_min.y).abs() < 1e-3);
assert!((screen_rect.max.x - expected_max.x).abs() < 1e-3);
assert!((screen_rect.max.y - expected_max.y).abs() < 1e-3);
}
other => panic!("expected a scaled heatmap cell hit, got {other:?}"),
}
}
#[test]
fn test_heatmap_origin_hit_rows_values_and_rectangles_survive_reversed_y_axis() {
use crate::plots::{HeatmapConfig, HeatmapOrigin};
let values = vec![vec![10.0, 11.0], vec![20.0, 21.0]];
for origin in [HeatmapOrigin::Upper, HeatmapOrigin::Lower] {
for reversed in [false, true] {
let (y_min, y_max) = if reversed { (24.0, 20.0) } else { (20.0, 24.0) };
let plot: Plot = Plot::new()
.heatmap_with(
&values,
HeatmapConfig::new()
.extent(10.0, 14.0, 20.0, 24.0)
.origin(origin)
.colorbar(false),
)
.into_plot()
.xlim(10.0, 14.0)
.ylim(y_min, y_max)
.ticks(false)
.grid(false)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("heatmap origin frame should render");
for (row, row_values) in values.iter().enumerate() {
let data_y = match (origin, row) {
(HeatmapOrigin::Upper, 0) | (HeatmapOrigin::Lower, 1) => 23.0,
(HeatmapOrigin::Upper, 1) | (HeatmapOrigin::Lower, 0) => 21.0,
_ => unreachable!(),
};
let screen_position = session
.data_to_screen(ViewportPoint::new(13.0, data_y))
.unwrap()
.unwrap();
match session.hit_test(screen_position) {
HitResult::HeatmapCell {
row: hit_row,
col,
value,
screen_rect,
..
} => {
assert_eq!((hit_row, col), (row, 1));
assert_eq!(value, row_values[1]);
assert!(screen_rect.contains(screen_position));
let y_bounds = if data_y > 22.0 {
(22.0, 24.0)
} else {
(20.0, 22.0)
};
let first = session
.data_to_screen(ViewportPoint::new(12.0, y_bounds.0))
.unwrap()
.unwrap();
let second = session
.data_to_screen(ViewportPoint::new(14.0, y_bounds.1))
.unwrap()
.unwrap();
let expected = ViewportRect::from_points(first, second);
assert!((screen_rect.min.x - expected.min.x).abs() < 1e-4);
assert!((screen_rect.min.y - expected.min.y).abs() < 1e-4);
assert!((screen_rect.max.x - expected.max.x).abs() < 1e-4);
assert!((screen_rect.max.y - expected.max.y).abs() < 1e-4);
}
other => panic!("expected heatmap cell hit, got {other:?}"),
}
}
}
}
}
#[test]
fn test_incremental_streaming_uses_scaled_geometry_and_displayed_hit_data() {
let stream = StreamingXY::new(32);
stream.push_many(vec![(1.0, -10.0), (10.0, 0.0)]);
let plot: Plot = Plot::new()
.line_streaming(&stream)
.color(Color::from_rgb(220, 20, 20))
.into_plot()
.xscale(crate::axes::AxisScale::Log)
.yscale(crate::axes::AxisScale::symlog(1.0))
.xlim(1.0, 1000.0)
.ylim(-100.0, 100.0)
.ticks(false)
.grid(false)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial scaled streaming frame should render");
stream.push(100.0, 10.0);
let incremental = session
.render_to_surface(render_target())
.expect("incremental scaled streaming frame should render");
assert!(incremental.layer_state.used_incremental_data);
let screen_position = session
.data_to_screen(ViewportPoint::new(100.0, 10.0))
.unwrap()
.unwrap();
assert!(
count_matching_pixels_near(
incremental.layers.base.as_ref(),
screen_position,
6,
|pixel| pixel[3] > 0 && pixel[0] > 150 && pixel[1] < 100 && pixel[2] < 100
) > 0
);
assert!(matches!(
session.hit_test(screen_position),
HitResult::SeriesPoint { point_index: 2, .. }
));
}
#[test]
fn test_incremental_streaming_replaces_index_with_displayed_frame_data() {
let stream = StreamingXY::new(1024);
stream.push_many((0..320).map(|index| {
let value = index as f64 / 400.0;
(value, value)
}));
let plot: Plot = Plot::new()
.scatter_streaming(&stream)
.into_plot()
.xlim(0.0, 1.0)
.ylim(0.0, 1.0)
.ticks(false)
.grid(false)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial indexed streaming frame should render");
assert!(!session.point_hit_index_initialized());
assert_eq!(session.indexed_point_series_count(), 1);
assert!(session.point_hit_index_initialized());
assert_index_matches_brute_force(&session, &[ViewportPoint::new(0.5, 0.5)]);
stream.push(0.9, 0.1);
let frame = session
.render_to_surface(render_target())
.expect("incremental indexed streaming frame should render");
assert!(frame.layer_state.used_incremental_data);
assert!(!session.point_hit_index_initialized());
assert_eq!(session.indexed_point_series_count(), 1);
assert!(session.point_hit_index_initialized());
assert_index_matches_brute_force(
&session,
&[ViewportPoint::new(0.5, 0.5), ViewportPoint::new(0.9, 0.1)],
);
let new_point = session
.data_to_screen(ViewportPoint::new(0.9, 0.1))
.unwrap()
.unwrap();
assert!(matches!(
session.hit_test(new_point),
HitResult::SeriesPoint {
point_index: 320,
..
}
));
}
fn color_centroid<F>(image: &Image, predicate: F) -> Option<ViewportPoint>
where
F: Fn(&[u8]) -> bool,
{
let mut x_sum = 0.0;
let mut y_sum = 0.0;
let mut count = 0.0;
for (index, pixel) in image.pixels.chunks_exact(4).enumerate() {
if !predicate(pixel) {
continue;
}
let x = (index as u32 % image.width) as f64;
let y = (index as u32 / image.width) as f64;
x_sum += x;
y_sum += y;
count += 1.0;
}
(count > 0.0).then(|| ViewportPoint::new(x_sum / count, y_sum / count))
}
fn count_matching_pixels_near<F>(
image: &Image,
center: ViewportPoint,
radius: u32,
predicate: F,
) -> usize
where
F: Fn(&[u8]) -> bool,
{
let min_x = center.x.round().max(0.0) as i32 - radius as i32;
let max_x = center.x.round().min(image.width as f64) as i32 + radius as i32;
let min_y = center.y.round().max(0.0) as i32 - radius as i32;
let max_y = center.y.round().min(image.height as f64) as i32 + radius as i32;
let mut count = 0usize;
for y in min_y.max(0)..max_y.min(image.height as i32) {
for x in min_x.max(0)..max_x.min(image.width as i32) {
let index = ((y as u32 * image.width + x as u32) * 4) as usize;
if predicate(&image.pixels[index..index + 4]) {
count += 1;
}
}
}
count
}
fn count_matching_pixels_outside_rect<F>(
image: &Image,
rect: tiny_skia::Rect,
predicate: F,
) -> usize
where
F: Fn(&[u8]) -> bool,
{
let left = rect.left().floor() as i32;
let right = rect.right().ceil() as i32;
let top = rect.top().floor() as i32;
let bottom = rect.bottom().ceil() as i32;
let mut count = 0usize;
for y in 0..image.height as i32 {
for x in 0..image.width as i32 {
if x >= left && x < right && y >= top && y < bottom {
continue;
}
let index = ((y as u32 * image.width + x as u32) * 4) as usize;
if predicate(&image.pixels[index..index + 4]) {
count += 1;
}
}
}
count
}
fn matching_pixel_bounds<F>(image: &Image, predicate: F) -> Option<(u32, u32, u32, u32)>
where
F: Fn(&[u8]) -> bool,
{
let mut min_x = u32::MAX;
let mut min_y = u32::MAX;
let mut max_x = 0u32;
let mut max_y = 0u32;
let mut found = false;
for y in 0..image.height {
for x in 0..image.width {
let index = ((y * image.width + x) * 4) as usize;
if !predicate(&image.pixels[index..index + 4]) {
continue;
}
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x);
max_y = max_y.max(y);
found = true;
}
}
found.then_some((min_x, min_y, max_x, max_y))
}
fn dark_pixel_fraction(image: &Image) -> f64 {
let total = image.pixels.chunks_exact(4).len() as f64;
let dark = image
.pixels
.chunks_exact(4)
.filter(|pixel| pixel[0] < 32 && pixel[1] < 32 && pixel[2] < 32)
.count() as f64;
dark / total
}
fn non_background_fraction(image: &Image) -> f64 {
let total = image.pixels.chunks_exact(4).len() as f64;
let non_background = image
.pixels
.chunks_exact(4)
.filter(|pixel| pixel[3] > 0 && (pixel[0] < 248 || pixel[1] < 248 || pixel[2] < 248))
.count() as f64;
non_background / total
}
fn assert_surface_base_visually_sane(name: &str, image: &Image) {
let dark_fraction = dark_pixel_fraction(image);
let ink_fraction = non_background_fraction(image);
assert!(
dark_fraction < 0.8,
"{name} surface frame should not black out: dark_fraction={dark_fraction:.4}"
);
assert!(
ink_fraction > 0.001,
"{name} surface frame should contain visible plot ink: ink_fraction={ink_fraction:.4}"
);
}
fn surface_image_to_rgba(image: &Image) -> ::image::RgbaImage {
::image::load_from_memory(
&image
.encode_png()
.expect("interactive frame should encode to straight-alpha PNG"),
)
.expect("interactive frame PNG should decode")
.to_rgba8()
}
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_surface_frame_parity(name: &str, reference: &Image, candidate: &Image) {
let reference_rgba = surface_image_to_rgba(reference);
let candidate_rgba = surface_image_to_rgba(candidate);
let mean_diff = mean_normalized_rgba_diff(&reference_rgba, &candidate_rgba);
let within_delta = fraction_pixels_within_channel_delta(&reference_rgba, &candidate_rgba, 24);
let reference_ink = non_background_fraction(reference);
let candidate_ink = non_background_fraction(candidate);
assert!(
mean_diff <= 0.015,
"{name} drifted too far from the image reference frame: mean_diff={mean_diff:.6}"
);
assert!(
within_delta >= 0.99,
"{name} has too many per-pixel outliers relative to the image reference frame: 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}"
);
}
#[test]
fn test_dirty_domains_mark_and_clear() {
let mut dirty = DirtyDomains::default();
dirty.mark(DirtyDomain::Layout);
dirty.mark(DirtyDomain::Overlay);
dirty.mark(DirtyDomain::Temporal);
assert!(dirty.layout);
assert!(dirty.overlay);
assert!(dirty.temporal);
assert!(dirty.needs_base_render());
assert!(dirty.needs_overlay_render());
dirty.clear_base();
assert!(!dirty.layout);
assert!(!dirty.data);
assert!(!dirty.temporal);
assert!(!dirty.interaction);
assert!(dirty.overlay);
dirty.clear_overlay();
assert!(!dirty.overlay);
}
#[test]
fn test_resize_updates_size_and_scale_factor_together() {
let plot: Plot = Plot::new().line(&[0.0, 1.0], &[0.0, 1.0]).into();
let session = plot.prepare_interactive();
session.resize((640, 360), 2.0);
let state = session
.inner
.state
.lock()
.expect("InteractivePlotSession state lock poisoned")
.clone();
assert_eq!(state.size_px, (640, 360));
assert_eq!(state.scale_factor, 2.0);
assert!(session.dirty_domains().layout);
}
#[test]
fn test_resize_event_updates_size_and_scale_factor_together() {
let plot: Plot = Plot::new().line(&[0.0, 1.0], &[0.0, 1.0]).into();
let session = plot.prepare_interactive();
session.apply_input(PlotInputEvent::Resize {
size_px: (640, 360),
scale_factor: 2.0,
});
let state = session
.inner
.state
.lock()
.expect("InteractivePlotSession state lock poisoned")
.clone();
assert_eq!(state.size_px, (640, 360));
assert_eq!(state.scale_factor, 2.0);
assert!(session.dirty_domains().layout);
}
#[test]
fn test_inflight_dirty_marks_survive_render_clear() {
let plot: Plot = Plot::new().line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 4.0]).into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial frame should render");
session.mark_dirty(DirtyDomain::Data);
let (_, _, render_epoch) = session.render_snapshot();
let generation = session
.displayed_frame_generation()
.expect("initial frame should have a generation");
session.mark_dirty(DirtyDomain::Overlay);
assert!(
session
.commit_frame_if_current(render_epoch, generation)
.is_err()
);
let dirty = session.dirty_domains();
assert!(dirty.data);
assert!(dirty.overlay);
}
#[test]
fn test_state_mutation_and_dirty_publication_are_atomic_to_render_snapshot() {
let plot: Plot = Plot::new()
.line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 4.0])
.xlim(0.0, 2.0)
.ylim(0.0, 4.0)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial frame should render");
let visible = session.viewport_snapshot().unwrap().visible_bounds;
let next_visible = ViewportRect::from_points(
ViewportPoint::new(
visible.min.x + visible.width() * 0.1,
visible.min.y + visible.height() * 0.1,
),
ViewportPoint::new(
visible.max.x - visible.width() * 0.1,
visible.max.y - visible.height() * 0.1,
),
);
let (entered, release) = install_render_test_hook(&session, RenderTestPoint::BeforeDirtyMark);
let mutation_session = session.clone();
let mutation =
std::thread::spawn(move || mutation_session.restore_visible_bounds(next_visible));
entered.wait();
let render_started = Arc::new(std::sync::Barrier::new(2));
let render_started_in_thread = Arc::clone(&render_started);
let render_session = session.clone();
let (render_tx, render_rx) = std::sync::mpsc::sync_channel(1);
let render = std::thread::spawn(move || {
render_started_in_thread.wait();
let result = render_session.render_to_surface_stamped(render_target());
render_tx
.send(result)
.expect("render result receiver dropped");
});
render_started.wait();
assert!(
matches!(
render_rx.try_recv(),
Err(std::sync::mpsc::TryRecvError::Empty)
),
"render snapshot must not cross a partially published state mutation"
);
release.wait();
assert!(mutation.join().expect("mutation thread should not panic"));
let frame = render_rx
.recv()
.expect("render thread dropped its result")
.expect("render should observe the fully published mutation");
assert!(session.is_render_stamp_current(frame.render_stamp()));
render.join().expect("render thread should not panic");
}
#[test]
fn test_overlapping_render_requests_cannot_commit_stale_base_cache() {
let first_resolution = Arc::new(std::sync::atomic::AtomicBool::new(true));
let entered = Arc::new(std::sync::Barrier::new(2));
let release = Arc::new(std::sync::Barrier::new(2));
let first_resolution_for_signal = Arc::clone(&first_resolution);
let entered_for_signal = Arc::clone(&entered);
let release_for_signal = Arc::clone(&release);
let color = signal::of(move |_| {
if first_resolution_for_signal.swap(false, Ordering::AcqRel) {
entered_for_signal.wait();
release_for_signal.wait();
}
Color::RED
});
let plot: Plot = Plot::new()
.line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 4.0])
.color_source(color)
.into();
let session = plot.prepare_interactive();
let first_session = session.clone();
let first_render = std::thread::spawn(move || first_session.render_to_surface(render_target()));
entered.wait();
let second_session = session.clone();
let second_target = SurfaceTarget {
size_px: (400, 300),
..render_target()
};
let second_render = std::thread::spawn(move || second_session.render_to_surface(second_target));
std::thread::sleep(std::time::Duration::from_millis(50));
release.wait();
let first_result = first_render
.join()
.expect("first render thread should not panic");
if let Err(error) = first_result {
assert!(
error.is_render_superseded(),
"only typed latest-request cancellation is expected: {error}"
);
}
second_render
.join()
.expect("second render thread should not panic")
.expect("second render should succeed");
session
.render_to_surface(second_target)
.expect("latest render target should retain a coherent cache");
}
#[test]
fn test_invalidate_during_render_supersedes_cache_publication_without_panicking() {
let first_resolution = Arc::new(std::sync::atomic::AtomicBool::new(true));
let entered = Arc::new(std::sync::Barrier::new(2));
let release = Arc::new(std::sync::Barrier::new(2));
let first_resolution_for_signal = Arc::clone(&first_resolution);
let entered_for_signal = Arc::clone(&entered);
let release_for_signal = Arc::clone(&release);
let color = signal::of(move |_| {
if first_resolution_for_signal.swap(false, Ordering::AcqRel) {
entered_for_signal.wait();
release_for_signal.wait();
}
Color::RED
});
let plot: Plot = Plot::new()
.line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 4.0])
.color_source(color)
.into();
let session = plot.prepare_interactive();
let render_session = session.clone();
let render = std::thread::spawn(move || render_session.render_to_surface(render_target()));
entered.wait();
session.invalidate();
release.wait();
let error = render
.join()
.expect("render thread should not panic")
.expect_err("invalidated render should be superseded");
assert!(error.is_render_superseded());
assert_eq!(session.displayed_frame_generation(), None);
session
.render_to_surface(render_target())
.expect("a render after invalidation should recover normally");
assert!(session.displayed_frame_generation().is_some());
}
#[test]
fn test_invalidation_after_base_publication_supersedes_final_return() {
let plot: Plot = Plot::new().line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 4.0]).into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial frame should render");
session.apply_input(PlotInputEvent::Pan {
delta_px: ViewportPoint::new(20.0, 0.0),
});
let (entered, release) =
install_render_test_hook(&session, RenderTestPoint::AfterBasePublication);
let render_session = session.clone();
let render = std::thread::spawn(move || render_session.render_to_surface(render_target()));
entered.wait();
session.invalidate();
release.wait();
let error = render
.join()
.expect("render thread should not panic")
.expect_err("invalidation after publication must supersede the final return");
assert!(error.is_render_superseded());
assert_eq!(session.displayed_frame_generation(), None);
}
#[test]
fn test_cache_hit_and_overlay_only_invalidation_supersede_final_return() {
for overlay_only in [false, true] {
let plot: Plot = Plot::new().scatter(&[0.5], &[0.5]).into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial frame should render");
if overlay_only {
let point = session
.data_to_screen(ViewportPoint::new(0.5, 0.5))
.expect("mapping should succeed")
.expect("point should be visible");
session.apply_input(PlotInputEvent::Hover { position_px: point });
assert!(session.dirty_domains().overlay);
assert!(!session.dirty_domains().needs_base_render());
}
let (entered, release) =
install_render_test_hook(&session, RenderTestPoint::BeforeFinalCommit);
let render_session = session.clone();
let render = std::thread::spawn(move || render_session.render_to_surface(render_target()));
entered.wait();
session.invalidate();
release.wait();
let error = render
.join()
.expect("render thread should not panic")
.expect_err("invalidation before final commit must supersede the frame");
assert!(error.is_render_superseded());
assert_eq!(session.displayed_frame_generation(), None);
}
}
#[test]
fn test_overlay_refresh_does_not_overwrite_concurrent_pointer_mutation() {
let plot: Plot = Plot::new()
.scatter(&[0.25, 0.75], &[0.25, 0.75])
.xlim(0.0, 1.0)
.ylim(0.0, 1.0)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial frame should render");
let point = session
.data_to_screen(ViewportPoint::new(0.25, 0.25))
.expect("mapping should succeed")
.expect("point should be visible");
session.apply_input(PlotInputEvent::Hover { position_px: point });
assert!(
session
.inner
.state
.lock()
.expect("InteractivePlotSession state lock poisoned")
.hovered
.is_some()
);
session.apply_input(PlotInputEvent::Pan {
delta_px: ViewportPoint::new(20.0, 0.0),
});
let (entered, release) =
install_render_test_hook(&session, RenderTestPoint::BeforeOverlayRefreshCommit);
let render_session = session.clone();
let render = std::thread::spawn(move || render_session.render_to_surface(render_target()));
entered.wait();
session.apply_input(PlotInputEvent::ClearHover);
release.wait();
let error = render
.join()
.expect("render thread should not panic")
.expect_err("concurrent pointer mutation must supersede stale overlay refresh");
assert!(error.is_render_superseded());
let state = session
.inner
.state
.lock()
.expect("InteractivePlotSession state lock poisoned");
assert!(state.hovered.is_none());
assert!(state.tooltip.is_none());
}
#[test]
fn test_base_generation_exhaustion_returns_error_without_poisoning_state() {
let plot: Plot = Plot::new().line(&[0.0, 1.0], &[0.0, 1.0]).into();
let session = plot.prepare_interactive();
session
.inner
.state
.lock()
.expect("InteractivePlotSession state lock poisoned")
.base_generation = u64::MAX;
let error = session
.render_to_surface(render_target())
.expect_err("generation exhaustion should be reported");
assert!(matches!(
error,
PlottingError::RenderError(message) if message.contains("generation exhausted")
));
assert_eq!(session.displayed_frame_generation(), None);
assert_eq!(
session
.inner
.state
.lock()
.expect("state lock should remain usable")
.base_generation,
u64::MAX
);
}
#[test]
fn test_base_frame_generation_tracks_published_interactive_cache() {
let plot: Plot = Plot::new()
.line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 4.0])
.xlim(0.0, 2.0)
.ylim(0.0, 4.0)
.into();
let session = plot.prepare_interactive();
assert_eq!(session.displayed_frame_generation(), None);
let first = session
.render_to_surface_with_generation(render_target())
.expect("initial frame should render");
assert_eq!(
session.displayed_frame_generation(),
Some(first.base_generation)
);
session.apply_input(PlotInputEvent::Hover {
position_px: ViewportPoint::new(200.0, 150.0),
});
let overlay_only = session
.render_to_surface_with_generation(render_target())
.expect("overlay-only frame should render");
assert_eq!(overlay_only.base_generation, first.base_generation);
session.apply_input(PlotInputEvent::Pan {
delta_px: ViewportPoint::new(20.0, 0.0),
});
let second = session
.render_to_surface_with_generation(render_target())
.expect("panned frame should render");
assert!(second.base_generation > first.base_generation);
assert_eq!(
session.displayed_frame_generation(),
Some(second.base_generation)
);
session.invalidate();
assert_eq!(session.displayed_frame_generation(), None);
let third = session
.render_to_surface_with_generation(render_target())
.expect("invalidated frame should render");
assert!(third.base_generation > second.base_generation);
}
#[test]
fn test_render_stamp_tracks_full_frame_currentness_and_session_identity() {
let plot: Plot = Plot::new()
.scatter(&[0.25, 0.75], &[0.25, 0.75])
.xlim(0.0, 1.0)
.ylim(0.0, 1.0)
.into();
let session = plot.prepare_interactive();
let first = session
.render_to_surface_stamped(render_target())
.expect("initial frame should render");
let first_stamp = first.render_stamp();
assert!(session.is_render_stamp_current(first_stamp));
let legacy = InteractiveFrameWithGeneration {
frame: first.frame.clone(),
base_generation: first.base_generation,
};
assert_eq!(legacy.base_generation, first.base_generation);
let other_session = session.prepared_plot().clone().into_interactive();
other_session
.render_to_surface_stamped(render_target())
.expect("other session should render");
assert!(!other_session.is_render_stamp_current(first_stamp));
session.apply_input(PlotInputEvent::ShowTooltip {
content: "new overlay".to_string(),
position_px: ViewportPoint::new(120.0, 80.0),
});
assert!(
!session.is_render_stamp_current(first_stamp),
"an overlay-only change must make the whole frame stamp stale"
);
let overlay_frame = session
.render_to_surface_stamped(render_target())
.expect("overlay-only frame should render");
assert_eq!(overlay_frame.base_generation, first.base_generation);
assert_ne!(overlay_frame.render_stamp(), first_stamp);
assert!(session.is_render_stamp_current(overlay_frame.render_stamp()));
session.invalidate();
assert!(!session.is_render_stamp_current(overlay_frame.render_stamp()));
}
#[test]
fn test_change_subscription_unifies_mutations_and_runs_callbacks_outside_locks() {
let y = Observable::new(vec![0.0, 1.0]);
let plot: Plot = Plot::new().line_source(vec![0.0, 1.0], y.clone()).into();
let session = plot.prepare_interactive();
let revisions = Arc::new(std::sync::Mutex::new(Vec::new()));
let revisions_for_callback = Arc::clone(&revisions);
let session_for_callback = session.clone();
let subscription = session.subscribe_changes(move |revision| {
let _ = session_for_callback.dirty_domains();
let _ = session_for_callback.view_bounds_snapshot();
revisions_for_callback
.lock()
.expect("revision list lock poisoned")
.push(revision);
});
let initial_revision = session.change_revision();
session.set_frame_pacing(FramePacing::Manual);
session.resize((640, 360), 1.5);
session.apply_input(PlotInputEvent::ShowTooltip {
content: "manual".to_string(),
position_px: ViewportPoint::new(10.0, 20.0),
});
let annotation = session.add_annotation(Annotation::vline(0.5)).unwrap();
session
.update_annotation(annotation, Annotation::vline(0.75))
.unwrap();
assert!(session.remove_annotation(annotation).unwrap());
y.set(vec![1.0, 0.0]);
session.invalidate();
let observed = revisions
.lock()
.expect("revision list lock poisoned")
.clone();
assert!(
observed.len() >= 8,
"every public mutation family should notify; got {observed:?}"
);
assert!(observed[0] > initial_revision);
assert!(
observed.windows(2).all(|pair| pair[0] < pair[1]),
"change revisions must be strictly monotonic: {observed:?}"
);
assert_eq!(observed.last().copied(), Some(session.change_revision()));
drop(subscription);
let count_after_drop = observed.len();
session.set_prefer_gpu(true);
assert_eq!(
revisions.lock().expect("revision list lock poisoned").len(),
count_after_drop,
"dropping the subscription must unregister its callback"
);
}
#[test]
fn test_noop_configuration_and_brush_updates_preserve_revision() {
let plot: Plot = Plot::new().line(&[0.0, 1.0], &[0.0, 1.0]).into();
let session = plot.prepare_interactive();
let initial = session.change_revision();
session.set_frame_pacing(FramePacing::Display);
session.set_quality_policy(QualityPolicy::Balanced);
session.set_prefer_gpu(false);
assert_eq!(session.change_revision(), initial);
let position = ViewportPoint::new(80.0, 60.0);
session.apply_input(PlotInputEvent::BrushStart {
position_px: position,
});
let after_start = session.change_revision();
session.apply_input(PlotInputEvent::BrushStart {
position_px: position,
});
assert_eq!(session.change_revision(), after_start);
}
#[test]
fn test_reentrant_change_dispatch_preserves_subscriber_order_and_stops_at_exhaustion() {
let hub = Arc::new(ChangeHub::default());
let first_seen = Arc::new(std::sync::Mutex::new(Vec::new()));
let second_seen = Arc::new(std::sync::Mutex::new(Vec::new()));
let reentered = Arc::new(std::sync::atomic::AtomicBool::new(false));
let hub_for_first = Arc::clone(&hub);
let first_seen_for_callback = Arc::clone(&first_seen);
let reentered_for_callback = Arc::clone(&reentered);
let first = hub.subscribe(move |revision| {
first_seen_for_callback.lock().unwrap().push(revision);
if !reentered_for_callback.swap(true, Ordering::AcqRel) {
hub_for_first.notify();
}
});
let second_seen_for_callback = Arc::clone(&second_seen);
let second = hub.subscribe(move |revision| {
second_seen_for_callback.lock().unwrap().push(revision);
});
let first_revision = hub.notify();
let second_revision = hub.revision();
assert!(second_revision > first_revision);
assert_eq!(
first_seen.lock().unwrap().as_slice(),
&[first_revision, second_revision]
);
assert_eq!(
second_seen.lock().unwrap().as_slice(),
&[first_revision, second_revision]
);
let callback_count_before_exhaustion = second_seen.lock().unwrap().len();
{
let mut state = lock_recover(&hub.state);
state.revision = InteractiveChangeRevision {
era: u64::MAX,
sequence: u64::MAX,
};
}
let terminal = hub.revision();
assert_eq!(hub.notify(), terminal);
assert_eq!(hub.notify(), terminal);
assert!(hub.is_exhausted());
assert_eq!(
second_seen.lock().unwrap().len(),
callback_count_before_exhaustion,
"terminal exhaustion must not redeliver a duplicate revision"
);
drop((first, second));
}
#[test]
fn test_change_dispatch_recovers_after_callback_panic() {
let hub = Arc::new(ChangeHub::default());
let panicking = hub.subscribe(|_| panic!("subscriber panic"));
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| hub.notify()));
assert!(result.is_err());
assert!(!lock_recover(&hub.state).dispatching);
drop(panicking);
let observed = Arc::new(std::sync::Mutex::new(Vec::new()));
let observed_for_callback = Arc::clone(&observed);
let subscription = hub.subscribe(move |revision| {
observed_for_callback.lock().unwrap().push(revision);
});
let revision = hub.notify();
assert_eq!(observed.lock().unwrap().as_slice(), &[revision]);
drop(subscription);
}
#[test]
fn test_callback_panic_drains_concurrently_queued_revisions_before_unwind() {
let hub = Arc::new(ChangeHub::default());
let panic_started = Arc::new(std::sync::Barrier::new(2));
let release_panic = Arc::new(std::sync::Barrier::new(2));
let panic_once = Arc::new(std::sync::atomic::AtomicBool::new(false));
let observer_revisions = Arc::new(std::sync::Mutex::new(Vec::new()));
let panic_started_for_callback = Arc::clone(&panic_started);
let release_panic_for_callback = Arc::clone(&release_panic);
let panic_once_for_callback = Arc::clone(&panic_once);
let panicking = hub.subscribe(move |_| {
if !panic_once_for_callback.swap(true, Ordering::AcqRel) {
panic_started_for_callback.wait();
release_panic_for_callback.wait();
panic!("first revision callback panic");
}
});
let observer_revisions_for_callback = Arc::clone(&observer_revisions);
let observer = hub.subscribe(move |revision| {
observer_revisions_for_callback
.lock()
.unwrap()
.push(revision);
});
let hub_for_enqueue = Arc::clone(&hub);
let panic_started_for_enqueue = Arc::clone(&panic_started);
let release_panic_for_enqueue = Arc::clone(&release_panic);
let queued_revision = Arc::new(std::sync::Mutex::new(None));
let queued_revision_for_enqueue = Arc::clone(&queued_revision);
let enqueue = std::thread::spawn(move || {
panic_started_for_enqueue.wait();
let revision = hub_for_enqueue.notify();
*queued_revision_for_enqueue.lock().unwrap() = Some(revision);
release_panic_for_enqueue.wait();
});
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| hub.notify()));
assert!(
result.is_err(),
"the initiating caller must observe the panic"
);
enqueue.join().unwrap();
let observed = observer_revisions.lock().unwrap().clone();
let queued = queued_revision
.lock()
.unwrap()
.expect("enqueue thread recorded its revision");
assert_eq!(observed.len(), 2);
assert!(observed[0] < observed[1]);
assert_eq!(observed[1], queued);
let state = lock_recover(&hub.state);
assert!(state.pending.is_empty());
assert!(!state.dispatching);
drop(state);
drop((panicking, observer));
}
#[test]
fn test_change_counters_fail_closed_without_aba_at_terminal_boundary() {
let epoch = AtomicU64::new(u64::MAX);
let exhausted = AtomicBool::new(false);
assert!(!advance_atomic_epoch(&epoch, &exhausted));
assert!(!advance_atomic_epoch(&epoch, &exhausted));
assert_eq!(epoch.load(Ordering::Acquire), u64::MAX);
assert!(exhausted.load(Ordering::Acquire));
let identity = AtomicU64::new(u64::MAX);
assert_eq!(reserve_atomic_id(&identity), None);
assert_eq!(identity.load(Ordering::Acquire), u64::MAX);
let hub = Arc::new(ChangeHub::default());
{
let mut state = lock_recover(&hub.state);
state.revision = InteractiveChangeRevision {
era: 7,
sequence: u64::MAX,
};
state.next_subscription_id = u64::MAX;
}
let first = hub.subscribe(|_| {});
lock_recover(&hub.state).next_subscription_id = u64::MAX;
let second = hub.subscribe(|_| {});
assert_eq!(lock_recover(&hub.state).callbacks.len(), 2);
assert_eq!(
hub.notify(),
InteractiveChangeRevision {
era: 8,
sequence: 0
}
);
{
let mut state = lock_recover(&hub.state);
state.revision = InteractiveChangeRevision {
era: u64::MAX,
sequence: u64::MAX,
};
}
let terminal = hub.revision();
assert_eq!(hub.notify(), terminal);
assert!(hub.is_exhausted());
drop((first, second));
}
#[test]
fn test_session_epoch_exhaustion_rejects_old_stamp_and_future_renders() {
let plot: Plot = Plot::new().line(&[0.0, 1.0], &[0.0, 1.0]).into();
let session = plot.prepare_interactive();
let target = render_target();
let frame = session
.render_to_surface_stamped(target)
.expect("initial frame should render");
let stamp = frame.render_stamp();
session
.inner
.mutation_epoch
.store(u64::MAX, Ordering::Release);
session.apply_input(PlotInputEvent::ShowTooltip {
content: "exhaust".to_string(),
position_px: ViewportPoint::new(10.0, 20.0),
});
assert!(session.inner.epoch_exhausted.load(Ordering::Acquire));
assert_eq!(
session.inner.mutation_epoch.load(Ordering::Acquire),
u64::MAX
);
assert!(!session.is_render_stamp_current(stamp));
let error = session
.render_to_surface_stamped(target)
.expect_err("an exhausted session must fail closed");
assert!(matches!(
error,
PlottingError::RenderError(message) if message.contains("revision space exhausted")
));
}
#[test]
fn test_session_change_revision_exhaustion_fails_closed() {
let plot: Plot = Plot::new().line(&[0.0, 1.0], &[0.0, 1.0]).into();
let session = plot.prepare_interactive();
let target = render_target();
let frame = session
.render_to_surface_stamped(target)
.expect("initial frame should render");
let stamp = frame.render_stamp();
{
let mut state = lock_recover(&session.inner.change_hub.state);
state.revision = InteractiveChangeRevision {
era: u64::MAX,
sequence: u64::MAX,
};
}
session.apply_input(PlotInputEvent::ShowTooltip {
content: "exhaust revision".to_string(),
position_px: ViewportPoint::new(10.0, 20.0),
});
assert!(session.inner.change_hub.is_exhausted());
assert!(!session.is_render_stamp_current(stamp));
let error = session
.render_to_surface_stamped(target)
.expect_err("a revision-exhausted session must fail closed");
assert!(matches!(
error,
PlottingError::RenderError(message) if message.contains("revision space exhausted")
));
}
#[test]
fn test_new_session_locks_recover_from_poison() {
let plot: Plot = Plot::new().line(&[0.0, 1.0], &[0.0, 1.0]).into();
let session = plot.prepare_interactive();
let inner = Arc::clone(&session.inner);
assert!(
std::thread::spawn(move || {
let _guard = inner.state.lock().unwrap();
panic!("poison state");
})
.join()
.is_err()
);
let inner = Arc::clone(&session.inner);
assert!(
std::thread::spawn(move || {
let _guard = inner.frame_pacing.lock().unwrap();
panic!("poison pacing");
})
.join()
.is_err()
);
let inner = Arc::clone(&session.inner);
assert!(
std::thread::spawn(move || {
let _guard = inner.dirty.lock().unwrap();
panic!("poison dirty");
})
.join()
.is_err()
);
let inner = Arc::clone(&session.inner);
assert!(
std::thread::spawn(move || {
let _guard = inner.change_hub.state.lock().unwrap();
panic!("poison change hub");
})
.join()
.is_err()
);
assert_eq!(session.displayed_frame_generation(), None);
session.set_frame_pacing(FramePacing::Manual);
assert_eq!(session.frame_pacing(), FramePacing::Manual);
let subscription = session.subscribe_changes(|_| {});
session.set_quality_policy(QualityPolicy::Interactive);
drop(subscription);
}
#[test]
fn test_poisoned_partial_state_invalidates_stamp_and_rerenders_safely() {
let plot: Plot = Plot::new().line(&[0.0, 1.0], &[0.0, 1.0]).into();
let session = plot.prepare_interactive();
let target = render_target();
let first = session
.render_to_surface_stamped(target)
.expect("initial frame should render");
let first_stamp = first.render_stamp();
assert!(session.is_render_stamp_current(first_stamp));
let callback_observed_unlocked_state = Arc::new(std::sync::atomic::AtomicBool::new(false));
let callback_observed_unlocked_state_for_callback =
Arc::clone(&callback_observed_unlocked_state);
let callback_session = session.clone();
let subscription = session.subscribe_changes(move |_| {
let state_unlocked = callback_session.inner.state.try_lock().is_ok();
let dirty_unlocked = callback_session.inner.dirty.try_lock().is_ok();
callback_observed_unlocked_state_for_callback
.store(state_unlocked && dirty_unlocked, Ordering::Release);
});
let inner = Arc::clone(&session.inner);
assert!(
std::thread::spawn(move || {
let mut state = inner.state.lock().unwrap();
state.time_seconds = 42.0;
panic!("panic after partial state mutation but before record_mutation");
})
.join()
.is_err()
);
assert!(!session.is_render_stamp_current(first_stamp));
assert!(callback_observed_unlocked_state.load(Ordering::Acquire));
assert_eq!(session.displayed_frame_generation(), None);
assert_eq!(session.dirty_domains(), DirtyDomains::with_all());
let second = session
.render_to_surface_stamped(target)
.expect("state poison recovery should permit a safe full rerender");
let second_stamp = second.render_stamp();
assert!(session.is_render_stamp_current(second_stamp));
let inner = Arc::clone(&session.inner);
assert!(
std::thread::spawn(move || {
let mut dirty = inner.dirty.lock().unwrap();
*dirty = DirtyDomains::default();
panic!("panic after corrupting dirty domains");
})
.join()
.is_err()
);
assert!(!session.is_render_stamp_current(second_stamp));
assert_eq!(session.dirty_domains(), DirtyDomains::with_all());
session
.render_to_surface_stamped(target)
.expect("dirty poison recovery should permit a safe full rerender");
drop(subscription);
}
#[test]
fn test_change_polling_and_subscription_entry_points_repair_poisoned_state() {
let make_poisoned_session = || {
let plot: Plot = Plot::new().line(&[0.0, 1.0], &[0.0, 1.0]).into();
let session = plot.prepare_interactive();
session
.render_to_surface_stamped(render_target())
.expect("initial frame should render");
let inner = Arc::clone(&session.inner);
assert!(
std::thread::spawn(move || {
let mut state = inner.state.lock().unwrap();
state.time_seconds = 99.0;
panic!("partial mutation before notification");
})
.join()
.is_err()
);
session
};
let polling_session = make_poisoned_session();
let revision_before = polling_session.change_revision_raw();
let repaired_revision = polling_session.change_revision();
assert!(repaired_revision > revision_before);
assert!(!polling_session.inner.state.is_poisoned());
assert_eq!(polling_session.displayed_frame_generation(), None);
let subscribing_session = make_poisoned_session();
let notifications = Arc::new(std::sync::atomic::AtomicU64::new(0));
let notifications_for_callback = Arc::clone(¬ifications);
let subscription = subscribing_session.subscribe_changes(move |_| {
notifications_for_callback.fetch_add(1, Ordering::AcqRel);
});
assert_eq!(notifications.load(Ordering::Acquire), 1);
assert!(!subscribing_session.inner.state.is_poisoned());
assert_eq!(subscribing_session.displayed_frame_generation(), None);
drop(subscription);
}
#[test]
fn test_render_setup_callbacks_can_render_without_holding_render_gate() {
let plot: Plot = Plot::new().line(&[0.0, 1.0], &[0.0, 1.0]).into();
let session = plot.prepare_interactive();
let target = render_target();
let callback_rendered = Arc::new(std::sync::atomic::AtomicBool::new(false));
let callback_rendered_for_callback = Arc::clone(&callback_rendered);
let callback_session = session.clone();
let subscription = session.subscribe_changes(move |_| {
if callback_rendered_for_callback.swap(true, Ordering::AcqRel) {
return;
}
assert!(
callback_session.inner.render_gate.try_lock().is_ok(),
"change callbacks must not run while the render gate is held"
);
callback_session
.render_to_surface_stamped(target)
.expect("a change callback must be able to trigger a render");
});
session
.render_to_surface_stamped(target)
.expect("outer render should complete after the callback render");
assert!(callback_rendered.load(Ordering::Acquire));
drop(subscription);
}
#[test]
fn test_reentrant_render_request_returns_error() {
let session_slot = Arc::new(std::sync::Mutex::new(None));
let nested_error = Arc::new(std::sync::Mutex::new(None));
let session_slot_for_signal = Arc::clone(&session_slot);
let nested_error_for_signal = Arc::clone(&nested_error);
let color = signal::of(move |_| {
let session: Option<InteractivePlotSession> = session_slot_for_signal
.lock()
.expect("session slot lock poisoned")
.clone();
if let Some(session) = session {
let error = session
.render_to_surface(render_target())
.expect_err("reentrant render should return an error")
.to_string();
*nested_error_for_signal
.lock()
.expect("nested error lock poisoned") = Some(error);
}
Color::RED
});
let plot: Plot = Plot::new()
.line(&[0.0, 1.0], &[0.0, 1.0])
.color_source(color)
.into();
let session = plot.prepare_interactive();
*session_slot.lock().expect("session slot lock poisoned") = Some(session.clone());
session
.render_to_surface(render_target())
.expect("outer render should complete");
assert!(
nested_error
.lock()
.expect("nested error lock poisoned")
.as_deref()
.is_some_and(|error| error.contains("reentrant interactive render"))
);
}
#[test]
fn test_session_invalidate_forces_base_rerender() {
let plot: Plot = Plot::new()
.line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 4.0])
.title("Invalidate")
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial surface frame should render");
assert!(!session.dirty_domains().needs_base_render());
session.invalidate();
assert!(session.dirty_domains().needs_base_render());
let rerendered = session
.render_to_surface(render_target())
.expect("invalidated surface frame should rerender");
assert!(rerendered.layer_state.base_dirty);
}
#[test]
fn test_empty_plot_surface_uses_default_cartesian_bounds() {
let plot: Plot = Plot::new().title("Empty Plot").into();
let session = plot.prepare_interactive();
let frame = session
.render_to_surface(render_target())
.expect("empty surface frame should render");
let geometry = session
.geometry_snapshot()
.expect("geometry should be available after empty render");
assert!(!frame.layers.base.pixels.is_empty());
assert_eq!(geometry.x_bounds, (0.0, 1.0));
assert_eq!(geometry.y_bounds, (0.0, 1.0));
}
#[test]
fn test_compose_images_alpha_blends_overlay() {
let base = Image::new(1, 1, vec![0, 0, 255, 255]);
let overlay = Image::new(1, 1, vec![255, 0, 0, 128]);
let composed = compose_images(&base, &overlay);
assert!(composed.pixels[0] > 0);
assert!(composed.pixels[2] > 0);
assert_eq!(composed.pixels[3], 255);
}
#[test]
fn test_compose_images_preserves_transparency_with_straight_alpha() {
let transparent_base = Image::new(1, 1, vec![0, 0, 255, 0]);
let overlay = Image::new(1, 1, vec![255, 0, 0, 128]);
let composed = compose_images(&transparent_base, &overlay);
assert_eq!(composed.pixels, vec![255, 0, 0, 128]);
assert_eq!(composed.alpha_mode(), AlphaMode::Straight);
}
#[test]
fn test_tooltip_tiny_skia_round_trip_returns_straight_alpha() {
let size_px = (160, 96);
let mut pixels = vec![0; size_px.0 as usize * size_px.1 as usize * 4];
draw_tooltip_overlay(
&mut pixels,
size_px,
&TooltipState {
content: "x".to_string(),
position_px: ViewportPoint::new(80.0, 48.0),
},
);
assert!(
pixels
.chunks_exact(4)
.any(|pixel| pixel == [255, 255, 220, 220]),
"the translucent tooltip background must be demultiplied after text rendering"
);
}
#[test]
fn test_overlay_only_updates_reuse_cached_base_layer() {
let plot: Plot = Plot::new()
.line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 4.0])
.title("Layer Reuse")
.into();
let session = plot.prepare_interactive();
let first = session
.render_to_surface(SurfaceTarget {
size_px: (320, 240),
scale_factor: 1.0,
time_seconds: 0.0,
})
.expect("surface frame should render");
assert!(first.layer_state.base_dirty);
let geometry = session
.geometry_snapshot()
.expect("geometry should be available after first frame");
let (hover_x, hover_y) = map_data_to_pixels(
1.0,
1.0,
geometry.x_bounds.0,
geometry.x_bounds.1,
geometry.y_bounds.0,
geometry.y_bounds.1,
geometry.plot_area,
);
session.apply_input(PlotInputEvent::Hover {
position_px: ViewportPoint::new(hover_x as f64, hover_y as f64),
});
let second = session
.render_to_surface(SurfaceTarget {
size_px: (320, 240),
scale_factor: 1.0,
time_seconds: 0.0,
})
.expect("surface frame should render after hover");
assert!(!second.layer_state.base_dirty);
assert!(second.layer_state.overlay_dirty);
assert!(Arc::ptr_eq(&first.layers.base, &second.layers.base));
}
#[test]
fn test_tooltip_overlay_renders_text_pixels() {
let plot: Plot = Plot::new()
.line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 4.0])
.title("Tooltip")
.into();
let session = plot.prepare_interactive();
session.apply_input(PlotInputEvent::ShowTooltip {
content: "x=1.234, y=5.678".to_string(),
position_px: ViewportPoint::new(180.0, 120.0),
});
let frame = session
.render_to_surface(SurfaceTarget {
size_px: (320, 240),
scale_factor: 1.0,
time_seconds: 0.0,
})
.expect("surface frame should render with tooltip");
let overlay = frame
.layers
.overlay
.expect("surface frame should include overlay pixels");
let dark_text_pixels = overlay
.pixels
.chunks_exact(4)
.filter(|pixel| pixel[3] > 0 && (pixel[0] < 220 || pixel[1] < 220 || pixel[2] < 180))
.count();
assert!(
dark_text_pixels > 0,
"tooltip overlay should contain dark text pixels in addition to the background box"
);
}
#[test]
fn test_brush_overlay_renders_visible_outline() {
let plot: Plot = Plot::new()
.line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 4.0])
.title("Brush")
.into();
let session = plot.prepare_interactive();
session.apply_input(PlotInputEvent::BrushStart {
position_px: ViewportPoint::new(96.0, 72.0),
});
session.apply_input(PlotInputEvent::BrushMove {
position_px: ViewportPoint::new(160.0, 136.0),
});
let frame = session
.render_to_surface(SurfaceTarget {
size_px: (320, 240),
scale_factor: 1.0,
time_seconds: 0.0,
})
.expect("surface frame should render with brush overlay");
let overlay = frame
.layers
.overlay
.expect("surface frame should include brush overlay");
let width = frame.layers.base.width as usize;
let border_index = (72usize * width + 96usize) * 4;
let interior_index = (104usize * width + 128usize) * 4;
assert!(
overlay.pixels[border_index + 3] > overlay.pixels[interior_index + 3],
"brush outline should be more visible than the fill interior"
);
}
#[test]
fn test_cancel_interaction_clears_only_transient_brush_and_notifies_once() {
let plot: Plot = Plot::new()
.scatter(&[0.5], &[0.5])
.xlim(0.0, 1.0)
.ylim(0.0, 1.0)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial frame should render");
let point = session
.data_to_screen(ViewportPoint::new(0.5, 0.5))
.unwrap()
.unwrap();
session.apply_input(PlotInputEvent::SelectAt { position_px: point });
assert_eq!(session.viewport_snapshot().unwrap().selected_count, 1);
session.apply_input(PlotInputEvent::BrushStart {
position_px: ViewportPoint::new(80.0, 60.0),
});
session.apply_input(PlotInputEvent::BrushMove {
position_px: ViewportPoint::new(160.0, 120.0),
});
let before_cancel = session.change_revision();
let notification_count = Arc::new(std::sync::atomic::AtomicU64::new(0));
let notification_count_for_callback = Arc::clone(¬ification_count);
let subscription = session.subscribe_changes(move |_| {
notification_count_for_callback.fetch_add(1, Ordering::AcqRel);
});
assert!(session.cancel_interaction());
assert!(session.change_revision() > before_cancel);
assert_eq!(notification_count.load(Ordering::Acquire), 1);
{
let state = session
.inner
.state
.lock()
.expect("InteractivePlotSession state lock poisoned");
assert!(state.brush_anchor.is_none());
assert!(state.brushed_region.is_none());
assert_eq!(state.selected.len(), 1);
}
let after_cancel = session.change_revision();
assert!(!session.cancel_interaction());
assert_eq!(session.change_revision(), after_cancel);
assert_eq!(notification_count.load(Ordering::Acquire), 1);
drop(subscription);
}
#[test]
fn test_draw_rect_outline_clamps_to_buffer_bounds() {
let mut pixels = vec![0u8; 4 * 4 * 4];
draw_rect_outline(
&mut pixels,
(4, 4),
ViewportRect::from_points(ViewportPoint::new(-1.0, -1.0), ViewportPoint::new(3.0, 3.0)),
Color::from_rgba(255, 128, 64, 255),
2,
);
assert!(
pixels.chunks_exact(4).any(|pixel| pixel[3] > 0),
"outline should still draw visible pixels when clamped to the frame"
);
}
#[test]
fn test_supported_surface_series_use_fast_path_on_full_rerender() {
let plot: Plot = Plot::new()
.line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 4.0])
.title("Fast Path")
.into();
let session = plot.prepare_interactive();
let frame = session
.render_to_surface(SurfaceTarget {
size_px: (320, 240),
scale_factor: 1.0,
time_seconds: 0.0,
})
.expect("supported surface frame should render");
assert_eq!(frame.surface_capability, SurfaceCapability::FastPath);
assert!(!frame.layer_state.used_incremental_data);
}
#[test]
fn test_surface_frames_stay_in_parity_with_image_frames_for_supported_series() {
let line_plot: Plot = Plot::new()
.size_px(320, 240)
.ticks(false)
.grid(false)
.line(&[0.0, 1.0, 2.0, 3.0], &[0.0, 1.0, 4.0, 9.0])
.into();
let line_session = line_plot.prepare_interactive();
let line_image = line_session
.render_to_image(ImageTarget {
size_px: (320, 240),
scale_factor: 1.0,
time_seconds: 0.0,
})
.expect("line image frame should render");
let line_surface = line_session
.render_to_surface(render_target())
.expect("line surface frame should render");
assert_eq!(line_surface.surface_capability, SurfaceCapability::FastPath);
assert_surface_frame_parity(
"line surface frame",
line_image.layers.base.as_ref(),
line_surface.layers.base.as_ref(),
);
let scatter_plot: Plot = Plot::new()
.size_px(320, 240)
.ticks(false)
.grid(false)
.scatter(&[0.0, 0.5, 1.5, 2.0], &[1.0, 0.2, 1.8, 0.9])
.marker(MarkerStyle::Circle)
.marker_size(8.0)
.into();
let scatter_session = scatter_plot.prepare_interactive();
let scatter_image = scatter_session
.render_to_image(ImageTarget {
size_px: (320, 240),
scale_factor: 1.0,
time_seconds: 0.0,
})
.expect("scatter image frame should render");
let scatter_surface = scatter_session
.render_to_surface(render_target())
.expect("scatter surface frame should render");
assert_eq!(
scatter_surface.surface_capability,
SurfaceCapability::FastPath
);
assert_surface_frame_parity(
"scatter surface frame",
scatter_image.layers.base.as_ref(),
scatter_surface.layers.base.as_ref(),
);
let heatmap_values = vec![
vec![0.1, 0.3, 0.6, 0.9],
vec![0.2, 0.5, 0.7, 0.4],
vec![0.8, 0.6, 0.2, 0.1],
vec![0.9, 0.7, 0.4, 0.2],
];
let heatmap_plot: Plot = Plot::new()
.size_px(320, 240)
.ticks(false)
.grid(false)
.heatmap(&heatmap_values)
.into();
let heatmap_session = heatmap_plot.prepare_interactive();
let heatmap_image = heatmap_session
.render_to_image(ImageTarget {
size_px: (320, 240),
scale_factor: 1.0,
time_seconds: 0.0,
})
.expect("heatmap image frame should render");
let heatmap_surface = heatmap_session
.render_to_surface(render_target())
.expect("heatmap surface frame should render");
assert_eq!(
heatmap_surface.surface_capability,
SurfaceCapability::FastPath
);
assert_surface_frame_parity(
"heatmap surface frame",
heatmap_image.layers.base.as_ref(),
heatmap_surface.layers.base.as_ref(),
);
}
#[test]
fn test_unsupported_surface_series_fall_back_to_image_capability() {
let plot: Plot = Plot::new().histogram(&[0.0, 1.0, 1.5, 2.0, 2.5]).into();
let session = plot.prepare_interactive();
let frame = session
.render_to_surface(SurfaceTarget {
size_px: (320, 240),
scale_factor: 1.0,
time_seconds: 0.0,
})
.expect("fallback surface frame should render");
assert_eq!(frame.surface_capability, SurfaceCapability::FallbackImage);
}
#[test]
fn test_large_dataset_surface_frames_render_without_blackout() {
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();
let histogram_samples: Vec<f64> = (0..100_000)
.map(|index| {
let value = index as f64 * 0.0002;
value.sin() + 0.35 * (value * 1.7).cos()
})
.collect();
let heatmap: Vec<Vec<f64>> = (0..320)
.map(|row| {
let y = -1.0 + 2.0 * row as f64 / 319.0;
(0..320)
.map(|col| {
let x = -1.0 + 2.0 * col as f64 / 319.0;
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();
let categories: Vec<String> = (0..20_000).map(|index| format!("c{index}")).collect();
let bar_values: Vec<f64> = (0..20_000)
.map(|index| {
let value = index as f64 * 0.001;
1.0 + 0.45 * value.sin() + 0.1 * (value * 4.0).cos()
})
.collect();
let line: Plot = Plot::new()
.size_px(320, 200)
.ticks(false)
.line(&x, &y)
.into();
let line_session = line.prepare_interactive();
let line_frame = line_session
.render_to_surface(render_target())
.expect("large line surface frame should render");
assert_surface_base_visually_sane("large line", line_frame.layers.base.as_ref());
let histogram: Plot = Plot::new()
.size_px(320, 200)
.ticks(false)
.histogram(&histogram_samples)
.into();
let histogram_session = histogram.prepare_interactive();
let histogram_frame = histogram_session
.render_to_surface(render_target())
.expect("large histogram surface frame should render");
assert_surface_base_visually_sane("large histogram", histogram_frame.layers.base.as_ref());
let heatmap_plot: Plot = Plot::new()
.size_px(320, 200)
.ticks(false)
.heatmap_with(
&heatmap,
crate::plots::heatmap::HeatmapConfig::new().colorbar(false),
)
.into();
let heatmap_session = heatmap_plot.prepare_interactive();
let heatmap_frame = heatmap_session
.render_to_surface(render_target())
.expect("large heatmap surface frame should render");
assert_surface_base_visually_sane("large heatmap", heatmap_frame.layers.base.as_ref());
let bar: Plot = Plot::new()
.size_px(320, 200)
.ticks(false)
.bar(&categories, &bar_values)
.into();
let bar_session = bar.prepare_interactive();
let bar_frame = bar_session
.render_to_surface(render_target())
.expect("large bar surface frame should render");
assert_surface_base_visually_sane("large bar", bar_frame.layers.base.as_ref());
}
#[test]
fn test_streaming_surface_render_uses_incremental_fast_path() {
let stream = StreamingXY::new(256);
stream.push_many(vec![(0.0, 0.0), (1.0, 0.5), (2.0, 1.0)]);
let plot: Plot = Plot::new()
.line_streaming(&stream)
.xlim(0.0, 10.0)
.ylim(-2.0, 2.0)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(SurfaceTarget {
size_px: (320, 240),
scale_factor: 1.0,
time_seconds: 0.0,
})
.expect("initial surface frame should render");
stream.push(3.0, 0.75);
let incremental = session
.render_to_surface(SurfaceTarget {
size_px: (320, 240),
scale_factor: 1.0,
time_seconds: 0.0,
})
.expect("incremental surface frame should render");
assert!(incremental.layer_state.used_incremental_data);
assert_eq!(incremental.surface_capability, SurfaceCapability::FastPath);
assert_eq!(stream.appended_count(), 0);
}
#[test]
fn test_streaming_surface_render_falls_back_after_wraparound() {
let stream = StreamingXY::new(3);
stream.push_many(vec![(0.0, 0.0), (1.0, 0.5), (2.0, 1.0)]);
let plot: Plot = Plot::new()
.line_streaming(&stream)
.xlim(0.0, 3.0)
.ylim(-1.0, 2.0)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial wrapped-stream frame should render");
stream.push(3.0, 1.25);
let rerendered = session
.render_to_surface(render_target())
.expect("wrapped-stream surface frame should render");
assert!(!rerendered.layer_state.used_incremental_data);
assert_eq!(stream.appended_count(), 0);
}
#[test]
fn test_streaming_surface_render_falls_back_and_acknowledges_replacement() {
let stream = StreamingXY::new(8);
stream.push_many([(0.0, 0.0), (1.0, 0.5), (2.0, 1.0)]);
let plot: Plot = Plot::new()
.line_streaming(&stream)
.xlim(0.0, 6.0)
.ylim(-1.0, 2.0)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial replacement stream frame should render");
stream.replace([(3.0, 1.25), (4.0, 0.75), (5.0, 0.25)]);
assert_eq!(
stream.render_state(),
StreamingRenderState::FullRedrawRequired
);
let rerendered = session
.render_to_surface(render_target())
.expect("replacement stream frame should render");
assert!(!rerendered.layer_state.used_incremental_data);
assert_eq!(stream.render_state(), StreamingRenderState::Unchanged);
stream.replace(std::iter::empty());
let cleared = session
.render_to_surface(render_target())
.expect("empty replacement stream frame should render");
assert!(cleared.layer_state.base_dirty);
assert!(!cleared.layer_state.used_incremental_data);
assert_ne!(cleared.layers.base.pixels, rerendered.layers.base.pixels);
assert_eq!(stream.render_state(), StreamingRenderState::Unchanged);
}
#[test]
fn test_two_streaming_sessions_keep_independent_acknowledgement_watermarks() {
let stream = StreamingXY::new(16);
stream.push_many([(0.0, 0.0), (1.0, 0.5), (2.0, 1.0)]);
let plot: Plot = Plot::new()
.line_streaming(&stream)
.xlim(0.0, 8.0)
.ylim(-1.0, 2.0)
.into();
let session_1 = plot.prepare_interactive();
let session_2 = plot.prepare_interactive();
session_1
.render_to_surface(render_target())
.expect("session 1 initial frame should render");
session_2
.render_to_surface(render_target())
.expect("session 2 initial frame should render");
stream.replace([(3.0, 1.25), (4.0, 0.75), (5.0, 0.25)]);
let session_1_replacement = session_1
.render_to_surface(render_target())
.expect("session 1 replacement frame should render");
assert!(!session_1_replacement.layer_state.used_incremental_data);
stream.push(6.0, 0.5);
assert_eq!(
stream.render_state(),
StreamingRenderState::AppendOnly {
visible_appended: 1
}
);
let session_2_frame = session_2
.render_to_surface(render_target())
.expect("session 2 stale frame should render");
assert!(session_2_frame.layer_state.base_dirty);
assert!(!session_2_frame.layer_state.used_incremental_data);
}
#[test]
fn test_prepared_render_does_not_advance_interactive_stream_watermark() {
let stream = StreamingXY::new(16);
stream.push_many([(0.0, 0.0), (1.0, 0.5), (2.0, 1.0)]);
let plot: Plot = Plot::new()
.line_streaming(&stream)
.xlim(0.0, 8.0)
.ylim(-1.0, 2.0)
.into();
let prepared = plot.prepare();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial interactive frame should render");
stream.replace([(3.0, 1.25), (4.0, 0.75), (5.0, 0.25)]);
prepared
.render_frame((320, 240), 1.0, 0.0)
.expect("prepared replacement frame should render");
stream.push(6.0, 0.5);
assert_eq!(
stream.render_state(),
StreamingRenderState::AppendOnly {
visible_appended: 1
}
);
let interactive_frame = session
.render_to_surface(render_target())
.expect("stale interactive frame should render");
assert!(interactive_frame.layer_state.base_dirty);
assert!(!interactive_frame.layer_state.used_incremental_data);
}
#[test]
fn test_interactive_full_render_acknowledges_generic_streaming_buffers() {
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())
.xlim(0.0, 2.0)
.ylim(0.0, 2.0)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("interactive generic streaming frame should render");
assert_eq!(x.appended_since_mark(), 0);
assert_eq!(y.appended_since_mark(), 0);
}
#[test]
fn test_interactive_generic_streaming_buffers_render_after_clear() {
let x = StreamingBuffer::new(16);
let y = StreamingBuffer::new(16);
x.push_many(vec![0.0, 1.0, 2.0]);
y.push_many(vec![0.0, 0.5, 1.0]);
let plot: Plot = Plot::new().line_source(x.clone(), y.clone()).into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial generic streaming frame should render");
x.clear();
y.clear();
session
.render_to_surface(render_target())
.expect("cleared generic streaming frame should render");
}
#[test]
fn test_viewport_snapshot_on_initially_empty_stream() {
let stream = StreamingXY::new(8);
let plot: Plot = Plot::new().line_streaming(&stream).into();
let session = plot.prepare_interactive();
session.resize((320, 240), 1.0);
let snapshot = session
.viewport_snapshot()
.expect("viewport snapshot should succeed before any data arrives");
assert!(snapshot.base_bounds.min.x.is_finite());
assert!(snapshot.base_bounds.max.x > snapshot.base_bounds.min.x);
assert!(snapshot.base_bounds.max.y > snapshot.base_bounds.min.y);
stream.push_many([(0.0, 0.0), (1.0, 1.0)]);
stream.replace(std::iter::empty());
let after_empty_replace = session
.viewport_snapshot()
.expect("viewport snapshot should succeed after replace(empty)");
assert!(after_empty_replace.base_bounds.min.x.is_finite());
session
.render_to_surface(render_target())
.expect("empty stream should still render a frame");
}
#[test]
fn test_hover_and_selection_refresh_after_view_change() {
let plot: Plot = Plot::new()
.line(&[0.0, 5.0, 10.0], &[0.0, 5.0, 10.0])
.title("Overlay Refresh")
.xlabel("X Label")
.ylabel("Y Label")
.xlim(0.0, 10.0)
.ylim(0.0, 10.0)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial surface frame should render");
let before_geometry = session
.geometry_snapshot()
.expect("geometry should be available before hover");
let (hover_x, hover_y) = map_data_to_pixels(
5.0,
5.0,
before_geometry.x_bounds.0,
before_geometry.x_bounds.1,
before_geometry.y_bounds.0,
before_geometry.y_bounds.1,
before_geometry.plot_area,
);
let hover_px = ViewportPoint::new(hover_x as f64, hover_y as f64);
session.apply_input(PlotInputEvent::Hover {
position_px: hover_px,
});
session.apply_input(PlotInputEvent::SelectAt {
position_px: hover_px,
});
session.apply_input(PlotInputEvent::Pan {
delta_px: ViewportPoint::new(36.0, 18.0),
});
session
.render_to_surface(render_target())
.expect("surface frame should rerender after pan");
let after_geometry = session
.geometry_snapshot()
.expect("geometry should be available after pan");
let (expected_x, expected_y) = map_data_to_pixels(
5.0,
5.0,
after_geometry.x_bounds.0,
after_geometry.x_bounds.1,
after_geometry.y_bounds.0,
after_geometry.y_bounds.1,
after_geometry.plot_area,
);
let state = session
.inner
.state
.lock()
.expect("InteractivePlotSession state lock poisoned")
.clone();
let hovered = state.hovered.expect("hovered hit should be refreshed");
let selected = state
.selected
.first()
.cloned()
.expect("selected hit should be refreshed");
let tooltip = state.tooltip.expect("hover tooltip should be refreshed");
match hovered {
HitResult::SeriesPoint {
screen_position,
data_position,
..
} => {
assert!((screen_position.x - expected_x as f64).abs() < 1e-6);
assert!((screen_position.y - expected_y as f64).abs() < 1e-6);
assert_eq!(data_position, ViewportPoint::new(5.0, 5.0));
assert_eq!(tooltip.position_px, screen_position);
}
other => panic!("expected series-point hover hit, got {other:?}"),
}
match selected {
HitResult::SeriesPoint {
screen_position,
data_position,
..
} => {
assert!((screen_position.x - expected_x as f64).abs() < 1e-6);
assert!((screen_position.y - expected_y as f64).abs() < 1e-6);
assert_eq!(data_position, ViewportPoint::new(5.0, 5.0));
}
other => panic!("expected series-point selection hit, got {other:?}"),
}
}
#[test]
fn test_hover_refreshes_after_time_change() {
let temporal_y = signal::of(|time| vec![time, 1.0 + time, 2.0 + time]);
let plot: Plot = Plot::new()
.line_source(vec![0.0, 1.0, 2.0], temporal_y)
.xlim(0.0, 2.0)
.ylim(0.0, 4.0)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial temporal surface frame should render");
let before_geometry = session
.geometry_snapshot()
.expect("geometry should be available before temporal update");
let (hover_x, hover_y) = map_data_to_pixels(
1.0,
1.0,
before_geometry.x_bounds.0,
before_geometry.x_bounds.1,
before_geometry.y_bounds.0,
before_geometry.y_bounds.1,
before_geometry.plot_area,
);
session.apply_input(PlotInputEvent::Hover {
position_px: ViewportPoint::new(hover_x as f64, hover_y as f64),
});
session
.render_to_surface(SurfaceTarget {
time_seconds: 1.0,
..render_target()
})
.expect("temporal surface frame should render at updated time");
let after_geometry = session
.geometry_snapshot()
.expect("geometry should be available after temporal update");
let (expected_x, expected_y) = map_data_to_pixels(
1.0,
2.0,
after_geometry.x_bounds.0,
after_geometry.x_bounds.1,
after_geometry.y_bounds.0,
after_geometry.y_bounds.1,
after_geometry.plot_area,
);
let state = session
.inner
.state
.lock()
.expect("InteractivePlotSession state lock poisoned")
.clone();
let hovered = state
.hovered
.expect("hovered hit should survive temporal update");
let tooltip = state
.tooltip
.expect("hover tooltip should survive temporal update");
match hovered {
HitResult::SeriesPoint {
screen_position,
data_position,
..
} => {
assert_eq!(data_position, ViewportPoint::new(1.0, 2.0));
assert!((screen_position.x - expected_x as f64).abs() < 1e-6);
assert!((screen_position.y - expected_y as f64).abs() < 1e-6);
}
other => panic!("expected series-point hover hit, got {other:?}"),
}
assert_eq!(tooltip.content, "x=1.000, y=2.000");
assert!((tooltip.position_px.x - expected_x as f64).abs() < 1e-6);
assert!((tooltip.position_px.y - expected_y as f64).abs() < 1e-6);
}
#[test]
fn test_interactive_render_resolves_each_temporal_source_once() {
let resolutions = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let resolutions_for_signal = Arc::clone(&resolutions);
let data = signal::of(move |_| {
resolutions_for_signal.fetch_add(1, Ordering::Relaxed);
vec![0.0, 1.0, 2.0]
});
let text_resolutions = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let text_resolutions_for_signal = Arc::clone(&text_resolutions);
let title = signal::of(move |_| {
text_resolutions_for_signal.fetch_add(1, Ordering::Relaxed);
"frame title".to_string()
});
let plot: Plot = Plot::new()
.line_source(data.clone(), data)
.title(title)
.xlim(0.0, 2.0)
.ylim(0.0, 2.0)
.into();
let session = plot.prepare_interactive();
resolutions.store(0, Ordering::Relaxed);
text_resolutions.store(0, Ordering::Relaxed);
session
.render_to_surface(render_target())
.expect("interactive temporal frame should render");
assert_eq!(resolutions.load(Ordering::Relaxed), 1);
assert_eq!(text_resolutions.load(Ordering::Relaxed), 1);
let plot_area = session.viewport_snapshot().unwrap().plot_area;
let center = ViewportPoint::new(
(plot_area.min.x + plot_area.max.x) * 0.5,
(plot_area.min.y + plot_area.max.y) * 0.5,
);
let data = session.screen_to_data(center).unwrap().unwrap();
assert!(session.data_to_screen(data).unwrap().is_some());
let _ = session.hit_test(center);
assert_eq!(resolutions.load(Ordering::Relaxed), 1);
assert_eq!(text_resolutions.load(Ordering::Relaxed), 1);
}
#[test]
fn test_hit_test_uses_displayed_reactive_frame() {
let y = Observable::new(vec![0.0, 1.0, 2.0]);
let plot: Plot = Plot::new()
.line_source(vec![0.0, 1.0, 2.0], y.clone())
.xlim(0.0, 2.0)
.ylim(0.0, 2.0)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial reactive frame should render");
let geometry = session
.geometry_snapshot()
.expect("geometry should be available after render");
let (screen_x, screen_y) = map_data_to_pixels(
1.0,
1.0,
geometry.x_bounds.0,
geometry.x_bounds.1,
geometry.y_bounds.0,
geometry.y_bounds.1,
geometry.plot_area,
);
y.set(vec![100.0, 100.0, 100.0]);
let hit = session.hit_test(ViewportPoint::new(screen_x as f64, screen_y as f64));
match hit {
HitResult::SeriesPoint {
point_index,
data_position,
..
} => {
assert_eq!(point_index, 1);
assert_eq!(data_position, ViewportPoint::new(1.0, 1.0));
}
other => panic!("expected displayed series point, got {other:?}"),
}
}
#[test]
fn test_hit_test_uses_displayed_geometry_until_next_render() {
let plot: Plot = Plot::new()
.line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 2.0])
.xlim(0.0, 2.0)
.ylim(0.0, 2.0)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial frame should render");
let geometry = session
.geometry_snapshot()
.expect("geometry should be available after render");
let (screen_x, screen_y) = map_data_to_pixels(
1.0,
1.0,
geometry.x_bounds.0,
geometry.x_bounds.1,
geometry.y_bounds.0,
geometry.y_bounds.1,
geometry.plot_area,
);
session.apply_input(PlotInputEvent::Pan {
delta_px: ViewportPoint::new(80.0, 0.0),
});
let hit = session.hit_test(ViewportPoint::new(screen_x as f64, screen_y as f64));
assert!(matches!(hit, HitResult::SeriesPoint { point_index: 1, .. }));
}
#[test]
fn test_heatmap_hover_skips_masked_log_cells() {
let values = vec![vec![0.0, 1.0, 10.0]];
let plot: Plot = Plot::new()
.heatmap_with(
&values,
crate::plots::heatmap::HeatmapConfig::new()
.value_scale(crate::axes::AxisScale::Log)
.colorbar(false),
)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("log heatmap surface frame should render");
let geometry = session
.geometry_snapshot()
.expect("geometry should be available after log heatmap render");
let (masked_x, masked_y) = map_data_to_pixels(
0.5,
0.5,
geometry.x_bounds.0,
geometry.x_bounds.1,
geometry.y_bounds.0,
geometry.y_bounds.1,
geometry.plot_area,
);
session.apply_input(PlotInputEvent::Hover {
position_px: ViewportPoint::new(masked_x as f64, masked_y as f64),
});
let state = session
.inner
.state
.lock()
.expect("InteractivePlotSession state lock poisoned")
.clone();
assert!(state.hovered.is_none());
assert!(state.tooltip.is_none());
let (valid_x, valid_y) = map_data_to_pixels(
1.5,
0.5,
geometry.x_bounds.0,
geometry.x_bounds.1,
geometry.y_bounds.0,
geometry.y_bounds.1,
geometry.plot_area,
);
session.apply_input(PlotInputEvent::Hover {
position_px: ViewportPoint::new(valid_x as f64, valid_y as f64),
});
let state = session
.inner
.state
.lock()
.expect("InteractivePlotSession state lock poisoned")
.clone();
match state.hovered {
Some(HitResult::HeatmapCell {
row, col, value, ..
}) => {
assert_eq!((row, col), (0, 1));
assert_eq!(value, 1.0);
}
other => panic!("expected unmasked heatmap hover hit, got {other:?}"),
}
}
#[test]
fn test_refresh_hit_result_drops_masked_log_heatmap_cells() {
let values = vec![vec![0.0, 1.0, 10.0]];
let plot: Plot = Plot::new()
.heatmap_with(
&values,
crate::plots::heatmap::HeatmapConfig::new()
.value_scale(crate::axes::AxisScale::Log)
.colorbar(false),
)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("log heatmap surface frame should render");
let geometry = session
.geometry_snapshot()
.expect("geometry should be available after log heatmap render");
let source_plot = session.prepared_plot().plot();
let displayed_data = session
.inner
.state
.lock()
.expect("InteractivePlotSession state lock poisoned")
.base_cache
.as_ref()
.expect("displayed base cache should be available")
.displayed_data
.clone();
let masked = HitResult::HeatmapCell {
series_index: 0,
row: 0,
col: 0,
value: 0.0,
screen_rect: ViewportRect::from_points(
ViewportPoint::new(0.0, 0.0),
ViewportPoint::new(1.0, 1.0),
),
};
assert!(refresh_hit_result(&masked, source_plot, &displayed_data, &geometry).is_none());
let valid = HitResult::HeatmapCell {
series_index: 0,
row: 0,
col: 1,
value: 1.0,
screen_rect: ViewportRect::from_points(
ViewportPoint::new(0.0, 0.0),
ViewportPoint::new(1.0, 1.0),
),
};
match refresh_hit_result(&valid, source_plot, &displayed_data, &geometry) {
Some(HitResult::HeatmapCell {
row, col, value, ..
}) => {
assert_eq!((row, col), (0, 1));
assert_eq!(value, 1.0);
}
other => panic!("expected refresh to preserve unmasked heatmap cell, got {other:?}"),
}
}
#[test]
fn test_zoom_keeps_cursor_anchor_stable() {
let plot: Plot = Plot::new()
.line(&[0.0, 10.0], &[0.0, 10.0])
.title("Zoom Anchor")
.xlabel("Time")
.ylabel("Value")
.xlim(0.0, 10.0)
.ylim(0.0, 10.0)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial surface frame should render");
let before_geometry = session
.geometry_snapshot()
.expect("geometry should be available before zoom");
let anchor_px = ViewportPoint::new(
0.0,
before_geometry.plot_area.top() as f64
+ f64::from(before_geometry.plot_area.height()) * 0.5,
);
let before_state = session
.inner
.state
.lock()
.expect("InteractivePlotSession state lock poisoned")
.clone();
let before_visible = visible_bounds(&before_state);
let anchor_before = screen_to_data(before_visible, before_geometry.plot_area, anchor_px);
session.apply_input(PlotInputEvent::Zoom {
factor: 2.0,
center_px: anchor_px,
});
let after_state = session
.inner
.state
.lock()
.expect("InteractivePlotSession state lock poisoned")
.clone();
let after_visible = visible_bounds(&after_state);
let after_geometry = session
.geometry_snapshot()
.expect("geometry should be available after zoom");
let anchor_after = screen_to_data(after_visible, after_geometry.plot_area, anchor_px);
assert!((anchor_before.x - anchor_after.x).abs() < 1e-9);
assert!((anchor_before.y - anchor_after.y).abs() < 1e-9);
}
#[test]
fn test_zoom_rect_maps_screen_region_to_visible_bounds() {
let plot: Plot = Plot::new()
.line(&[0.0, 10.0], &[0.0, 10.0])
.title("Zoom Rect")
.xlabel("X Axis")
.ylabel("Y Axis")
.xlim(0.0, 10.0)
.ylim(0.0, 10.0)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial surface frame should render");
let before_state = session
.inner
.state
.lock()
.expect("InteractivePlotSession state lock poisoned")
.clone();
let before_visible = visible_bounds(&before_state);
let geometry = session
.geometry_snapshot()
.expect("geometry should be available before zoom rect");
let start_px = ViewportPoint::new(
geometry.plot_area.left() as f64 + 48.0,
geometry.plot_area.top() as f64 + 36.0,
);
let end_px = ViewportPoint::new(
geometry.plot_area.left() as f64 + 212.0,
geometry.plot_area.top() as f64 + 168.0,
);
let start_data = screen_to_data(before_visible, geometry.plot_area, start_px);
let end_data = screen_to_data(before_visible, geometry.plot_area, end_px);
session.apply_input(PlotInputEvent::ZoomRect {
region_px: ViewportRect::from_points(start_px, end_px),
});
let after_state = session
.inner
.state
.lock()
.expect("InteractivePlotSession state lock poisoned")
.clone();
let after_visible = visible_bounds(&after_state);
assert!((after_visible.x_min - start_data.x.min(end_data.x)).abs() < 1e-9);
assert!((after_visible.x_max - start_data.x.max(end_data.x)).abs() < 1e-9);
assert!((after_visible.y_min - start_data.y.min(end_data.y)).abs() < 1e-9);
assert!((after_visible.y_max - start_data.y.max(end_data.y)).abs() < 1e-9);
}
#[test]
fn test_reversed_manual_limits_survive_zoom_and_zoom_rect() {
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();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial reversed surface frame should render");
let initial_geometry = session
.geometry_snapshot()
.expect("geometry should be available for reversed limits");
assert_eq!(initial_geometry.x_bounds, (4.0, 0.0));
assert_eq!(initial_geometry.y_bounds, (4.0, 0.0));
let anchor_px = ViewportPoint::new(
initial_geometry.plot_area.left() as f64
+ f64::from(initial_geometry.plot_area.width()) * 0.5,
initial_geometry.plot_area.top() as f64
+ f64::from(initial_geometry.plot_area.height()) * 0.5,
);
session.apply_input(PlotInputEvent::Zoom {
factor: 2.0,
center_px: anchor_px,
});
let after_zoom_state = session
.inner
.state
.lock()
.expect("InteractivePlotSession state lock poisoned")
.clone();
let after_zoom_visible = visible_bounds(&after_zoom_state);
assert!(after_zoom_visible.width() < 0.0);
assert!(after_zoom_visible.height() < 0.0);
let zoom_geometry = session
.interaction_geometry()
.expect("displayed geometry should remain available after reversed zoom");
let start_px = ViewportPoint::new(
zoom_geometry.plot_area.left() as f64 + 32.0,
zoom_geometry.plot_area.top() as f64 + 28.0,
);
let end_px = ViewportPoint::new(
zoom_geometry.plot_area.left() as f64 + 180.0,
zoom_geometry.plot_area.top() as f64 + 144.0,
);
let start_data = screen_to_data(after_zoom_visible, zoom_geometry.plot_area, start_px);
let end_data = screen_to_data(after_zoom_visible, zoom_geometry.plot_area, end_px);
session.apply_input(PlotInputEvent::ZoomRect {
region_px: ViewportRect::from_points(start_px, end_px),
});
let after_zoom_rect_state = session
.inner
.state
.lock()
.expect("InteractivePlotSession state lock poisoned")
.clone();
let after_zoom_rect_visible = visible_bounds(&after_zoom_rect_state);
assert!((after_zoom_rect_visible.x_min - start_data.x).abs() < 1e-9);
assert!((after_zoom_rect_visible.x_max - end_data.x).abs() < 1e-9);
assert!((after_zoom_rect_visible.y_min - end_data.y).abs() < 1e-9);
assert!((after_zoom_rect_visible.y_max - start_data.y).abs() < 1e-9);
assert!(after_zoom_rect_visible.width() < 0.0);
assert!(after_zoom_rect_visible.height() < 0.0);
}
#[test]
fn test_pan_uses_plot_area_dimensions() {
let plot: Plot = Plot::new()
.line(&[0.0, 10.0], &[0.0, 10.0])
.title("Pan Scale")
.xlabel("X Axis")
.ylabel("Y Axis")
.xlim(0.0, 10.0)
.ylim(0.0, 10.0)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial surface frame should render");
let before_state = session
.inner
.state
.lock()
.expect("InteractivePlotSession state lock poisoned")
.clone();
let before_visible = visible_bounds(&before_state);
let geometry = session
.geometry_snapshot()
.expect("geometry should be available before pan");
let delta_px = ViewportPoint::new(40.0, 24.0);
session.apply_input(PlotInputEvent::Pan { delta_px });
let after_state = session
.inner
.state
.lock()
.expect("InteractivePlotSession state lock poisoned")
.clone();
let after_visible = visible_bounds(&after_state);
let expected_dx =
-(delta_px.x / f64::from(geometry.plot_area.width())) * before_visible.width();
let expected_dy =
(delta_px.y / f64::from(geometry.plot_area.height())) * before_visible.height();
assert!(((after_visible.x_min - before_visible.x_min) - expected_dx).abs() < 1e-9);
assert!(((after_visible.x_max - before_visible.x_max) - expected_dx).abs() < 1e-9);
assert!(((after_visible.y_min - before_visible.y_min) - expected_dy).abs() < 1e-9);
assert!(((after_visible.y_max - before_visible.y_max) - expected_dy).abs() < 1e-9);
}
#[test]
fn test_pan_accumulates_before_the_next_render() {
let plot: Plot = Plot::new()
.line(&[1.0, 1000.0], &[-100.0, 100.0])
.xscale(crate::axes::AxisScale::Log)
.yscale(crate::axes::AxisScale::symlog(1.0))
.xlim(1.0, 1000.0)
.ylim(-100.0, 100.0)
.ticks(false)
.grid(false)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial nonlinear frame should render");
let tracked_data = ViewportPoint::new(10.0, 0.0);
let before = session.data_to_screen(tracked_data).unwrap().unwrap();
let first_delta = ViewportPoint::new(12.0, 7.0);
let second_delta = ViewportPoint::new(9.0, 5.0);
session.apply_input(PlotInputEvent::Pan {
delta_px: first_delta,
});
session.apply_input(PlotInputEvent::Pan {
delta_px: second_delta,
});
session
.render_to_surface(render_target())
.expect("accumulated nonlinear pan should render");
let after = session.data_to_screen(tracked_data).unwrap().unwrap();
assert!((after.x - before.x - first_delta.x - second_delta.x).abs() < 1e-4);
assert!((after.y - before.y - first_delta.y - second_delta.y).abs() < 1e-4);
}
#[test]
fn test_temporal_layout_content_uses_current_frame_labels() {
let xlabel = signal::of(|time| {
if time < 1.0 {
"baseline".to_string()
} else {
"updated temporal label".to_string()
}
});
let plot: Plot = Plot::new()
.line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 4.0])
.xlabel(xlabel)
.into();
let visible = DataBounds::from_limits(0.0, 2.0, 0.0, 4.0);
let baseline_content = plot.create_plot_content(visible.y_min, visible.y_max);
let current_content = plot.create_plot_content_at_time(visible.y_min, visible.y_max, 1.0);
let prepared_content = plot
.prepared_frame_plot(render_target().size_px, render_target().scale_factor, 1.0)
.create_plot_content(visible.y_min, visible.y_max);
assert_eq!(baseline_content.xlabel.as_deref(), Some("baseline"));
assert_eq!(
current_content.xlabel.as_deref(),
Some("updated temporal label")
);
assert_eq!(prepared_content.xlabel, current_content.xlabel);
compute_plot_layout(
&plot,
render_target().size_px,
render_target().scale_factor,
1.0,
visible,
)
.expect("interactive layout should compute using current-frame labels");
}
#[test]
fn test_incremental_line_render_preserves_markers() {
let stream = StreamingXY::new(32);
stream.push_many(vec![(0.5, 0.5), (1.0, 1.2)]);
let plot: Plot = Plot::new()
.line_streaming(&stream)
.color(Color::from_rgb(220, 20, 20))
.line_width(1.0)
.marker(MarkerStyle::Square)
.marker_size(18.0)
.into();
let plot = plot.ticks(false).grid(false).xlim(0.0, 3.0).ylim(0.0, 3.0);
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial line+marker surface frame should render");
stream.push(2.0, 2.3);
let incremental = session
.render_to_surface(render_target())
.expect("incremental line+marker surface frame should render");
assert!(incremental.layer_state.used_incremental_data);
let geometry = session
.geometry_snapshot()
.expect("geometry should be available after incremental render");
let (px, py) = map_data_to_pixels(
2.0,
2.3,
geometry.x_bounds.0,
geometry.x_bounds.1,
geometry.y_bounds.0,
geometry.y_bounds.1,
geometry.plot_area,
);
let point = ViewportPoint::new(px as f64, py as f64);
let incremental_pixels =
count_matching_pixels_near(incremental.layers.base.as_ref(), point, 12, |pixel| {
pixel[3] > 0 && pixel[0] > 150 && pixel[1] < 100 && pixel[2] < 100
});
let full = session
.prepared_plot()
.plot()
.prepared_frame_plot(render_target().size_px, render_target().scale_factor, 0.0)
.xlim(0.0, 3.0)
.ylim(0.0, 3.0)
.render()
.expect("full line+marker render should succeed");
let full_pixels = count_matching_pixels_near(&full, point, 12, |pixel| {
pixel[3] > 0 && pixel[0] > 150 && pixel[1] < 100 && pixel[2] < 100
});
assert!(incremental_pixels > 0);
let marker_pixel_delta = (incremental_pixels as i32 - full_pixels as i32).abs();
let marker_pixel_tolerance = 12.max((full_pixels as f32 * 0.25).ceil() as i32);
assert!(
marker_pixel_delta <= marker_pixel_tolerance,
"incremental marker pixel count {incremental_pixels} differed from full render count {full_pixels}"
);
}
#[test]
fn test_incremental_stream_style_multiplies_intrinsic_and_series_alpha_once() {
let stream = StreamingXY::new(16);
stream.push_many(vec![(0.0, 0.0), (1.0, 1.0)]);
let plot: Plot = Plot::new()
.line_streaming(&stream)
.color(Color::RED.with_alpha(0.5))
.alpha(0.5)
.into();
let rendered_frame = plot.resolve_frame(0.0).expect("frame should resolve");
let rendered_streams = StreamingFrameWatermarks::capture(&rendered_frame);
rendered_frame.acknowledge_rendered(&plot);
stream.push(2.0, 2.0);
let frame = plot.resolve_frame(0.0).expect("frame should resolve");
let ops = collect_streaming_draw_ops(&plot, &frame, &rendered_streams, (320, 240), 1.0, 0.0)
.expect("draw ops should resolve")
.expect("stream should support incremental rendering");
assert_eq!(ops.len(), 1);
assert_eq!(ops[0].color.a, 63);
}
#[test]
fn test_reactive_manual_ylim_stays_pinned_across_updates() {
let y = Observable::new(vec![0.0, 0.5, 1.0, -0.25]);
let plot: Plot = Plot::new()
.line_source(vec![0.0, 1.0, 2.0, 3.0], y.clone())
.ylim(-2.0, 2.0)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial surface frame should render");
let first_geometry = session
.geometry_snapshot()
.expect("geometry should be available after initial render");
let first_ticks = derived_y_ticks(&session);
y.set(vec![12.0, -15.0, 8.0, -11.0]);
session
.render_to_surface(render_target())
.expect("surface frame after first reactive update should render");
let second_geometry = session
.geometry_snapshot()
.expect("geometry should be available after first reactive update");
let second_ticks = derived_y_ticks(&session);
y.set(vec![30.0, 5.0, -22.0, 18.0]);
session
.render_to_surface(render_target())
.expect("surface frame after second reactive update should render");
let third_geometry = session
.geometry_snapshot()
.expect("geometry should be available after second reactive update");
let third_ticks = derived_y_ticks(&session);
assert_eq!(first_geometry.y_bounds, (-2.0, 2.0));
assert_eq!(second_geometry.y_bounds, (-2.0, 2.0));
assert_eq!(third_geometry.y_bounds, (-2.0, 2.0));
assert_eq!(first_ticks, second_ticks);
assert_eq!(second_ticks, third_ticks);
}
#[test]
fn test_dashboard_like_reactive_updates_do_not_drift_manual_ylim() {
let x: Vec<f64> = (0..120).map(|index| index as f64 * 12.0 / 119.0).collect();
let primary = Observable::new(
x.iter()
.map(|value| 0.85 * value.sin() + 0.2 * (value * 3.0).cos())
.collect::<Vec<_>>(),
);
let baseline = Observable::new(
x.iter()
.map(|value| 0.4 * (value * 0.75).sin())
.collect::<Vec<_>>(),
);
let event_x = Observable::new(vec![2.0, 6.0, 9.5]);
let event_y = Observable::new(vec![1.1, -1.3, 1.4]);
let accent = Observable::new(Color::from_rgb(42, 157, 143));
let plot: Plot = Plot::new()
.line(&x, &vec![1.2; x.len()])
.color(Color::LIGHT_GRAY)
.into();
let plot: Plot = plot
.line(&x, &vec![-1.2; x.len()])
.color(Color::LIGHT_GRAY)
.into();
let plot: Plot = plot
.line_source(x.clone(), primary.clone())
.color_source(accent.clone())
.line_width(2.4)
.into();
let plot: Plot = plot
.line_source(x.clone(), baseline.clone())
.color(Color::from_rgb(38, 70, 83))
.line_width(1.6)
.into();
let plot: Plot = plot
.scatter_source(event_x.clone(), event_y.clone())
.color(Color::from_rgb(231, 111, 81))
.marker(MarkerStyle::Diamond)
.marker_size(9.0)
.xlim(0.0, 12.0)
.ylim(-2.0, 2.0)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial dashboard-like surface frame should render");
let first_geometry = session
.geometry_snapshot()
.expect("geometry should be available after initial dashboard render");
let first_ticks = derived_y_ticks(&session);
primary.set(
x.iter()
.map(|value| 1.6 * (value * 1.3).sin() + 0.5 * (value * 2.8).cos())
.collect::<Vec<_>>(),
);
baseline.set(
x.iter()
.map(|value| 0.55 * (value * 0.65).sin() - 0.2 * (value * 2.1).cos())
.collect::<Vec<_>>(),
);
event_x.set(vec![1.0, 4.5, 10.5]);
event_y.set(vec![1.5, -1.4, 1.7]);
accent.set(Color::from_rgb(231, 111, 81));
session
.render_to_surface(render_target())
.expect("dashboard-like surface frame after reactive updates should render");
let second_geometry = session
.geometry_snapshot()
.expect("geometry should be available after dashboard reactive updates");
let second_ticks = derived_y_ticks(&session);
assert_eq!(first_geometry.y_bounds, (-2.0, 2.0));
assert_eq!(second_geometry.y_bounds, (-2.0, 2.0));
assert_eq!(first_ticks, second_ticks);
}
#[test]
fn test_surface_frame_pixels_honor_manual_ylim() {
let plot: Plot = Plot::new()
.scatter(&[0.5], &[1.0])
.color(Color::from_rgb(220, 20, 20))
.marker(MarkerStyle::Square)
.marker_size(18.0)
.ticks(false)
.grid(false)
.into();
let plot: Plot = plot
.scatter(&[0.5], &[-1.0])
.color(Color::from_rgb(20, 20, 220))
.marker(MarkerStyle::Square)
.marker_size(18.0)
.ticks(false)
.grid(false)
.xlim(0.0, 1.0)
.ylim(-2.0, 2.0)
.into();
let session = plot.prepare_interactive();
let plain_plot = plot.clone().set_output_pixels(320, 240);
let render_plot = session
.prepared_plot()
.plot()
.prepared_frame_plot(render_target().size_px, render_target().scale_factor, 0.0)
.xlim(0.0, 1.0)
.ylim(-2.0, 2.0);
assert_eq!(plain_plot.layout.y_limits, Some((-2.0, 2.0)));
assert_eq!(render_plot.layout.y_limits, Some((-2.0, 2.0)));
assert_eq!(render_plot.layout.x_limits, Some((0.0, 1.0)));
let plain = plain_plot.render().expect("plain plot should render");
let plain_red_center = color_centroid(&plain, |pixel| {
pixel[3] > 0 && pixel[0] > 160 && pixel[1] < 80 && pixel[2] < 80
})
.expect("plain red marker pixels should be present");
let direct = render_plot
.render()
.expect("direct prepared frame should render");
let direct_red_center = color_centroid(&direct, |pixel| {
pixel[3] > 0 && pixel[0] > 160 && pixel[1] < 80 && pixel[2] < 80
})
.expect("direct red marker pixels should be present");
let visible = DataBounds::from_limits(0.0, 1.0, -2.0, 2.0);
let layout = compute_plot_layout(
&plain_plot,
render_target().size_px,
render_target().scale_factor,
0.0,
visible,
)
.expect("plot layout should compute for manual bounds");
let frame = session
.render_to_surface(render_target())
.expect("surface frame should render");
let base = frame.layers.base.as_ref();
let red_center = color_centroid(base, |pixel| {
pixel[3] > 0 && pixel[0] > 160 && pixel[1] < 80 && pixel[2] < 80
})
.expect("red marker pixels should be present");
let blue_center = color_centroid(base, |pixel| {
pixel[3] > 0 && pixel[0] < 80 && pixel[1] < 80 && pixel[2] > 160
})
.expect("blue marker pixels should be present");
let expected_red_y = map_data_to_pixels(
0.5,
1.0,
visible.x_min,
visible.x_max,
visible.y_min,
visible.y_max,
layout.plot_area_rect,
)
.1 as f64;
assert!(
(plain_red_center.y - expected_red_y).abs() <= 12.0,
"plain render red marker y={} should be close to expected {}",
plain_red_center.y,
expected_red_y
);
assert!(
(direct_red_center.y - expected_red_y).abs() <= 12.0,
"direct render red marker y={} should be close to expected {}",
direct_red_center.y,
expected_red_y
);
let expected_blue_y = map_data_to_pixels(
0.5,
-1.0,
visible.x_min,
visible.x_max,
visible.y_min,
visible.y_max,
layout.plot_area_rect,
)
.1 as f64;
assert!(
(red_center.y - expected_red_y).abs() <= 12.0,
"red marker y={} should be close to expected {}",
red_center.y,
expected_red_y
);
assert!(
(blue_center.y - expected_blue_y).abs() <= 12.0,
"blue marker y={} should be close to expected {}",
blue_center.y,
expected_blue_y
);
}
#[test]
fn test_surface_frame_clips_series_pixels_to_plot_area_after_zoom() {
let plot: Plot = Plot::new()
.line(&[0.0, 5.0, 10.0], &[0.0, 5.0, 10.0])
.color(Color::from_rgb(220, 20, 20))
.line_width(18.0)
.ticks(false)
.grid(false)
.xlim(0.0, 10.0)
.ylim(0.0, 10.0)
.into();
let session = plot.prepare_interactive();
session
.render_to_surface(render_target())
.expect("initial surface frame should render");
let geometry = session
.geometry_snapshot()
.expect("geometry should be available before zoom");
let zoom_region = ViewportRect::from_points(
ViewportPoint::new(
geometry.plot_area.left() as f64 + 64.0,
geometry.plot_area.top() as f64 + 40.0,
),
ViewportPoint::new(
geometry.plot_area.right() as f64 - 64.0,
geometry.plot_area.bottom() as f64 - 40.0,
),
);
session.apply_input(PlotInputEvent::ZoomRect {
region_px: zoom_region,
});
let frame = session
.render_to_surface(render_target())
.expect("zoomed surface frame should render");
let geometry = session
.geometry_snapshot()
.expect("geometry should be available after zoom");
let base = frame.layers.base.as_ref();
let red_pixels = color_centroid(base, |pixel| {
pixel[3] > 0 && pixel[0] > 160 && pixel[1] < 80 && pixel[2] < 80
});
let leaked_red_pixels = count_matching_pixels_outside_rect(base, geometry.plot_area, |pixel| {
pixel[3] > 0 && pixel[0] > 160 && pixel[1] < 80 && pixel[2] < 80
});
let red_bounds = matching_pixel_bounds(base, |pixel| {
pixel[3] > 0 && pixel[0] > 160 && pixel[1] < 80 && pixel[2] < 80
});
assert!(red_pixels.is_some(), "expected red line pixels after zoom");
assert_eq!(
leaked_red_pixels, 0,
"expected no strong red series pixels outside plot area after zoom; plot_area={:?}; red_bounds={red_bounds:?}",
geometry.plot_area
);
}
fn annotation_test_session() -> InteractivePlotSession {
let plot: Plot = Plot::new()
.line(&[0.0, 1.0], &[0.0, 1.0])
.xlim(0.0, 1.0)
.ylim(0.0, 1.0)
.ticks(false)
.grid(false)
.into();
plot.prepare_interactive()
}
fn dynamic_annotation_variants(offset: f64) -> Vec<Annotation> {
vec![
Annotation::text(0.2 + offset, 0.3, format!("label {offset}")),
Annotation::arrow(0.1 + offset, 0.2, 0.4 + offset, 0.5),
Annotation::hline(0.25 + offset),
Annotation::vline(0.25 + offset),
Annotation::rectangle(0.1 + offset, 0.2, 0.2, 0.3),
Annotation::fill_between(
vec![0.1 + offset, 0.3 + offset],
vec![0.2, 0.4],
vec![0.1, 0.2],
),
Annotation::hspan(0.1 + offset, 0.3 + offset),
Annotation::vspan(0.2 + offset, 0.4 + offset),
]
}
#[test]
fn test_dynamic_annotations_support_every_variant_lifecycle() {
let session = annotation_test_session();
for (annotation, replacement) in dynamic_annotation_variants(0.0)
.into_iter()
.zip(dynamic_annotation_variants(0.05))
{
let expected_initial = std::mem::discriminant(&annotation);
let expected_replacement = std::mem::discriminant(&replacement);
let id = session.add_annotation(annotation).unwrap();
assert_eq!(
std::mem::discriminant(&session.annotation(id).unwrap()),
expected_initial
);
session.update_annotation(id, replacement).unwrap();
assert_eq!(
std::mem::discriminant(&session.annotation(id).unwrap()),
expected_replacement
);
assert!(session.remove_annotation(id).unwrap());
assert!(matches!(
session.annotation(id),
Err(PlottingError::UnknownAnnotationId)
));
}
}
#[test]
fn test_dynamic_annotation_ids_reject_foreign_and_stale_sessions() {
let first = annotation_test_session();
let second = annotation_test_session();
let id = first.add_annotation(Annotation::vline(0.25)).unwrap();
assert!(matches!(
second.annotation(id),
Err(PlottingError::UnknownAnnotationId)
));
assert!(matches!(
second.update_annotation(id, Annotation::vline(0.5)),
Err(PlottingError::UnknownAnnotationId)
));
assert!(matches!(
second.remove_annotation(id),
Err(PlottingError::UnknownAnnotationId)
));
assert!(first.remove_annotation(id).unwrap());
assert!(matches!(
first.update_annotation(id, Annotation::vline(0.75)),
Err(PlottingError::UnknownAnnotationId)
));
assert!(matches!(
first.remove_annotation(id),
Err(PlottingError::UnknownAnnotationId)
));
}
#[test]
fn test_dynamic_annotation_validation_happens_before_mutation() {
let session = annotation_test_session();
let invalid = [
Annotation::text(f64::NAN, 0.0, "nan"),
Annotation::hline(f64::INFINITY),
Annotation::rectangle(0.0, 0.0, -1.0, 1.0),
Annotation::fill_between(Vec::new(), Vec::new(), Vec::new()),
Annotation::fill_between(vec![0.5], vec![0.5], vec![0.5]),
Annotation::fill_between(vec![0.0, 1.0], vec![0.0], vec![0.0, 1.0]),
Annotation::FillBetween {
x: vec![0.0, 1.0],
y1: vec![0.0, f64::NEG_INFINITY],
y2: vec![0.0, 0.0],
style: FillStyle::default(),
where_positive: false,
},
Annotation::hspan(1.0, 0.0),
Annotation::VLine {
x: 0.5,
style: LineStyle::Solid,
color: Color::RED,
width: -1.0,
},
];
for annotation in invalid {
assert!(matches!(
session.add_annotation(annotation),
Err(PlottingError::InvalidAnnotation { .. })
));
}
let id = session.add_annotation(Annotation::vline(0.25)).unwrap();
let dirty_before = session.dirty_domains();
assert!(matches!(
session.update_annotation(id, Annotation::vline(f64::NAN)),
Err(PlottingError::InvalidAnnotation { .. })
));
assert_eq!(session.dirty_domains(), dirty_before);
assert!(matches!(
session.annotation(id).unwrap(),
Annotation::VLine { x, .. } if x == 0.25
));
}
#[test]
fn test_dynamic_annotation_rejects_nonpositive_coords_on_log_axes() {
let plot: Plot = Plot::new()
.line(&[1.0, 100.0], &[1.0, 100.0])
.xscale(crate::axes::AxisScale::Log)
.yscale(crate::axes::AxisScale::Log)
.into();
let session = plot.prepare_interactive();
let invalid = [
Annotation::text(-5.0, 1.0, "negative x"),
Annotation::text(5.0, 0.0, "zero y"),
Annotation::hline(0.0),
Annotation::vline(-1.0),
Annotation::rectangle(0.0, 1.0, 2.0, 2.0),
Annotation::hspan(0.0, 10.0),
Annotation::vspan(-2.0, 10.0),
Annotation::arrow(1.0, 1.0, 10.0, 0.0),
Annotation::fill_between(vec![1.0, 10.0], vec![0.0, 2.0], vec![3.0, 4.0]),
];
for annotation in invalid {
assert!(matches!(
session.add_annotation(annotation),
Err(PlottingError::InvalidAnnotation { .. })
));
}
let id = session
.add_annotation(Annotation::vline(5.0))
.expect("positive coordinates are valid on log axes");
assert!(matches!(
session.update_annotation(id, Annotation::vline(0.0)),
Err(PlottingError::InvalidAnnotation { .. })
));
let linear = annotation_test_session();
linear
.add_annotation(Annotation::hline(0.0))
.expect("zero is valid on linear axes");
linear
.add_annotation(Annotation::text(-5.0, 0.0, "negative"))
.expect("negative coords are valid on linear axes");
}
#[test]
fn test_dynamic_annotation_changes_dirty_only_overlay_and_reuse_base() {
let session = annotation_test_session();
let initial = session.render_to_surface(render_target()).unwrap();
assert_eq!(session.dirty_domains(), DirtyDomains::default());
let id = session.add_annotation(Annotation::vline(0.25)).unwrap();
assert_eq!(
session.dirty_domains(),
DirtyDomains {
overlay: true,
..DirtyDomains::default()
}
);
let added = session.render_to_surface(render_target()).unwrap();
assert!(!added.layer_state.base_dirty);
assert!(added.layer_state.overlay_dirty);
assert!(Arc::ptr_eq(&initial.layers.base, &added.layers.base));
session
.update_annotation(id, Annotation::vline(0.75))
.unwrap();
let updated = session.render_to_surface(render_target()).unwrap();
assert!(!updated.layer_state.base_dirty);
assert!(updated.layer_state.overlay_dirty);
assert!(Arc::ptr_eq(&added.layers.base, &updated.layers.base));
session.remove_annotation(id).unwrap();
let removed = session.render_to_surface(render_target()).unwrap();
assert!(!removed.layer_state.base_dirty);
assert!(removed.layer_state.overlay_dirty);
assert!(Arc::ptr_eq(&updated.layers.base, &removed.layers.base));
assert!(removed.layers.overlay.is_none());
}
fn red_overlay_centroid_x(image: &Image) -> Option<f64> {
let mut total_x = 0.0;
let mut count = 0_u64;
for (index, pixel) in image.pixels.chunks_exact(4).enumerate() {
if pixel[0] > 180 && pixel[1] < 80 && pixel[2] < 80 && pixel[3] > 0 {
total_x += (index as u32 % image.width) as f64 + 0.5;
count += 1;
}
}
(count > 0).then_some(total_x / count as f64)
}
#[test]
fn test_dynamic_annotation_tracks_current_zoom_transform() {
let session = annotation_test_session();
session.render_to_surface(render_target()).unwrap();
let id = session
.add_annotation(Annotation::vline_styled(
0.25,
Color::RED,
3.0,
LineStyle::Solid,
))
.unwrap();
let before = session.render_to_surface(render_target()).unwrap();
let before_expected = session
.data_to_screen(ViewportPoint::new(0.25, 0.5))
.unwrap()
.unwrap()
.x;
let before_actual = red_overlay_centroid_x(before.layers.overlay.as_ref().unwrap()).unwrap();
assert!((before_actual - before_expected).abs() < 2.0);
let center = session
.data_to_screen(ViewportPoint::new(0.5, 0.5))
.unwrap()
.unwrap();
session.apply_input(PlotInputEvent::Zoom {
factor: 1.5,
center_px: center,
});
let after = session.render_to_surface(render_target()).unwrap();
let after_expected = session
.data_to_screen(ViewportPoint::new(0.25, 0.5))
.unwrap()
.unwrap()
.x;
let after_actual = red_overlay_centroid_x(after.layers.overlay.as_ref().unwrap()).unwrap();
assert!((after_actual - after_expected).abs() < 2.0);
assert!((after_actual - before_actual).abs() > 5.0);
assert!(matches!(
session.annotation(id).unwrap(),
Annotation::VLine { x, .. } if x == 0.25
));
}
#[test]
fn test_dynamic_annotations_are_clipped_to_displayed_plot_area() {
let session = annotation_test_session();
session.render_to_surface(render_target()).unwrap();
session
.add_annotation(Annotation::Rectangle {
x: -1.0,
y: -1.0,
width: 3.0,
height: 3.0,
style: ShapeStyle::default().fill(Color::RED),
})
.unwrap();
let frame = session.render_to_surface(render_target()).unwrap();
let overlay = frame.layers.overlay.as_ref().unwrap();
let plot_area = session.viewport_snapshot().unwrap().plot_area;
for (index, pixel) in overlay.pixels.chunks_exact(4).enumerate() {
if pixel[3] == 0 {
continue;
}
let x = (index as u32 % overlay.width) as f64 + 0.5;
let y = (index as u32 / overlay.width) as f64 + 0.5;
assert!(
plot_area.contains(ViewportPoint::new(x, y)),
"dynamic annotation pixel leaked outside plot area at ({x}, {y})"
);
}
}
#[test]
fn test_dynamic_annotations_appear_and_move_in_image_capture() {
let session = annotation_test_session();
let target = ImageTarget {
size_px: (320, 240),
scale_factor: 1.0,
time_seconds: 0.0,
};
let base = session.render_to_image(target).unwrap();
let id = session
.add_annotation(Annotation::vline_styled(
0.25,
Color::RED,
4.0,
LineStyle::Solid,
))
.unwrap();
let added = session.render_to_image(target).unwrap();
session
.update_annotation(
id,
Annotation::vline_styled(0.75, Color::RED, 4.0, LineStyle::Solid),
)
.unwrap();
let moved = session.render_to_image(target).unwrap();
assert_ne!(base.image.pixels, added.image.pixels);
assert_ne!(added.image.pixels, moved.image.pixels);
assert!(Arc::ptr_eq(&base.layers.base, &added.layers.base));
assert!(Arc::ptr_eq(&added.layers.base, &moved.layers.base));
}
#[test]
fn test_dynamic_annotation_mutations_are_thread_safe() {
let session = annotation_test_session();
let threads = (0..8)
.map(|thread_index| {
let session = session.clone();
std::thread::spawn(move || {
for iteration in 0..32 {
let x = (thread_index * 32 + iteration) as f64 / 512.0;
let id = session.add_annotation(Annotation::vline(x)).unwrap();
session
.update_annotation(id, Annotation::vline(x + 0.01))
.unwrap();
assert!(session.remove_annotation(id).unwrap());
}
})
})
.collect::<Vec<_>>();
for thread in threads {
thread.join().expect("annotation mutation thread panicked");
}
let frame = session.render_to_surface(render_target()).unwrap();
assert!(frame.layers.overlay.is_none());
}
#[test]
fn test_dynamic_spans_render_on_reversed_axes() {
let plot: Plot = Plot::new()
.line(&[0.0, 1.0], &[0.0, 1.0])
.xlim(1.0, 0.0)
.ylim(1.0, 0.0)
.ticks(false)
.grid(false)
.into();
let session = plot.prepare_interactive();
session.render_to_surface(render_target()).unwrap();
session
.add_annotation(Annotation::HSpan {
x_min: 0.2,
x_max: 0.6,
style: ShapeStyle::default().fill(Color::RED).fill_alpha(1.0),
})
.unwrap();
session
.add_annotation(Annotation::VSpan {
y_min: 0.2,
y_max: 0.6,
style: ShapeStyle::default().fill(Color::RED).fill_alpha(1.0),
})
.unwrap();
let frame = session.render_to_surface(render_target()).unwrap();
let overlay = frame
.layers
.overlay
.as_ref()
.expect("span overlay should render");
let opaque_pixels = overlay
.pixels
.chunks_exact(4)
.filter(|pixel| pixel[3] > 0)
.count();
assert!(
opaque_pixels > 0,
"reversed-axis spans must produce overlay pixels"
);
}
#[test]
fn test_translucent_dynamic_annotation_composes_with_straight_alpha() {
let session = annotation_test_session();
let target = ImageTarget {
size_px: (320, 240),
scale_factor: 1.0,
time_seconds: 0.0,
};
session.render_to_image(target).unwrap();
session
.add_annotation(Annotation::VSpan {
y_min: 0.0,
y_max: 1.0,
style: ShapeStyle::default().fill(Color::RED).fill_alpha(0.5),
})
.unwrap();
let frame = session.render_to_image(target).unwrap();
let overlay = frame
.layers
.overlay
.as_ref()
.expect("translucent overlay should render");
let translucent_pixel = overlay
.pixels
.chunks_exact(4)
.find(|pixel| pixel[3] > 0 && pixel[3] < 255)
.expect("half-alpha span should produce translucent pixels");
assert!(
translucent_pixel[0] > 200,
"red channel should stay near full intensity in straight alpha, got {:?}",
translucent_pixel
);
}