use kuva::backend::svg::SvgBackend;
use kuva::plot::DensityPlot;
use kuva::render::layout::Layout;
use kuva::render::palette::Palette;
use kuva::render::plots::Plot;
use kuva::render::render::render_multiple;
use std::fs;
fn render_svg(plots: Vec<Plot>, layout: Layout) -> String {
let scene = render_multiple(plots, layout);
SvgBackend.render_scene(&scene)
}
fn outdir() {
fs::create_dir_all("test_outputs").ok();
}
#[test]
fn test_density_basic() {
outdir();
let data: Vec<f64> = (0..100).map(|i| (i as f64) * 0.1).collect();
let dp = DensityPlot::new().with_data(data).with_color("steelblue");
let plots = vec![Plot::Density(dp)];
let layout = Layout::auto_from_plots(&plots)
.with_title("Density Basic")
.with_x_label("Value")
.with_y_label("Density");
let svg = render_svg(plots, layout);
fs::write("test_outputs/density_basic.svg", &svg).unwrap();
assert!(svg.contains("<svg"), "output should contain <svg tag");
assert!(
svg.contains("<path"),
"output should contain a <path element"
);
}
#[test]
fn test_density_filled() {
outdir();
let data: Vec<f64> = vec![1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 2.0, 2.5, 3.0];
let dp = DensityPlot::new()
.with_data(data)
.with_color("coral")
.with_filled(true)
.with_opacity(0.4);
let plots = vec![Plot::Density(dp)];
let layout = Layout::auto_from_plots(&plots).with_title("Density Filled");
let svg = render_svg(plots, layout);
fs::write("test_outputs/density_filled.svg", &svg).unwrap();
assert!(svg.contains("<svg"));
assert!(
svg.contains('Z'),
"filled density path should contain 'Z' close command"
);
}
#[test]
fn test_density_bandwidth() {
outdir();
let data: Vec<f64> = vec![1.0, 2.0, 2.1, 2.9, 3.0, 3.1, 4.0, 4.5, 5.0];
let dp = DensityPlot::new()
.with_data(data)
.with_color("purple")
.with_bandwidth(0.3);
let plots = vec![Plot::Density(dp)];
let layout = Layout::auto_from_plots(&plots).with_title("Density Custom Bandwidth");
let svg = render_svg(plots, layout);
fs::write("test_outputs/density_bandwidth.svg", &svg).unwrap();
assert!(svg.contains("<svg"));
assert!(svg.contains("<path"));
}
#[test]
fn test_density_legend() {
outdir();
let data: Vec<f64> = vec![1.0, 2.0, 2.5, 3.0, 3.5, 4.0, 2.2, 2.8, 3.2];
let dp = DensityPlot::new()
.with_data(data)
.with_color("teal")
.with_legend("Group A");
let plots = vec![Plot::Density(dp)];
let layout = Layout::auto_from_plots(&plots)
.with_title("Density Legend")
.with_legend_position(kuva::plot::LegendPosition::OutsideRightTop);
let svg = render_svg(plots, layout);
fs::write("test_outputs/density_legend.svg", &svg).unwrap();
assert!(svg.contains("<svg"));
assert!(
svg.contains("Group A"),
"legend label 'Group A' should appear in SVG"
);
}
#[test]
fn test_density_multigroup() {
outdir();
let pal = Palette::category10();
let group_a: Vec<f64> = vec![1.0, 1.5, 2.0, 2.5, 3.0];
let group_b: Vec<f64> = vec![2.5, 3.0, 3.5, 4.0, 4.5];
let group_c: Vec<f64> = vec![0.5, 1.0, 1.5, 2.0, 2.5, 3.0];
let plots = vec![
Plot::Density(
DensityPlot::new()
.with_data(group_a)
.with_color(pal[0].to_string())
.with_legend("A"),
),
Plot::Density(
DensityPlot::new()
.with_data(group_b)
.with_color(pal[1].to_string())
.with_legend("B"),
),
Plot::Density(
DensityPlot::new()
.with_data(group_c)
.with_color(pal[2].to_string())
.with_legend("C"),
),
];
let layout = Layout::auto_from_plots(&plots)
.with_title("Density Multigroup")
.with_legend_position(kuva::plot::LegendPosition::OutsideRightTop);
let svg = render_svg(plots, layout);
fs::write("test_outputs/density_multigroup.svg", &svg).unwrap();
assert!(svg.contains("<svg"));
let path_count = svg.matches("<path").count();
assert!(
path_count >= 3,
"expected at least 3 path elements, got {path_count}"
);
}
#[test]
fn test_density_precomputed() {
outdir();
let x = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0];
let y = vec![0.05, 0.2, 0.5, 0.4, 0.2, 0.05];
let dp = DensityPlot::from_curve(x, y).with_color("orange");
let plots = vec![Plot::Density(dp)];
let layout = Layout::auto_from_plots(&plots).with_title("Density Precomputed");
let svg = render_svg(plots, layout);
fs::write("test_outputs/density_precomputed.svg", &svg).unwrap();
assert!(svg.contains("<svg"));
assert!(
svg.contains("<path"),
"precomputed density should emit a path"
);
}
#[test]
fn test_density_unbounded_extends_below_zero() {
let data: Vec<f64> = (0..100).map(|i| i as f64 / 100.0).collect(); let dp = DensityPlot::new().with_data(data);
let plot = Plot::Density(dp);
let ((x_min, _), _) = plot.bounds().unwrap();
assert!(
x_min < 0.0,
"without x_range, KDE tail should extend below data_min=0.0; got x_min={x_min}"
);
}
#[test]
fn test_density_x_range_clamps_bounds() {
let data: Vec<f64> = (0..100).map(|i| i as f64 / 100.0).collect();
let dp = DensityPlot::new().with_data(data).with_x_range(0.0, 1.0);
let plot = Plot::Density(dp);
let ((x_min, x_max), (y_min, y_max)) = plot.bounds().unwrap();
assert_eq!(
x_min, 0.0,
"x_min should be exactly the lower bound of x_range"
);
assert_eq!(
x_max, 1.0,
"x_max should be exactly the upper bound of x_range"
);
assert_eq!(y_min, 0.0, "y_min should be 0.0 for a density");
assert!(
y_max > 0.0,
"y_max should be positive; KDE peak was not found"
);
}
#[test]
fn test_density_x_range_renders_cleanly() {
outdir();
let mut data: Vec<f64> = (0..50).map(|i| i as f64 * 0.01).collect(); data.extend((51..100).map(|i| i as f64 * 0.01)); let dp = DensityPlot::new()
.with_data(data)
.with_x_range(0.0, 1.0)
.with_filled(true);
let plots = vec![Plot::Density(dp)];
let layout = Layout::auto_from_plots(&plots)
.with_title("Methylation-like density")
.with_x_label("β-value")
.with_y_label("Density");
let svg = render_svg(plots, layout);
fs::write("test_outputs/density_x_range.svg", &svg).unwrap();
assert!(svg.contains("<svg"));
assert!(
svg.contains("<path"),
"density with x_range should produce a path"
);
assert!(!svg.contains("NaN"), "SVG should not contain NaN");
}
#[test]
fn test_density_narrow_bandwidth_bounds_nonzero() {
let data: Vec<f64> = (0..60).map(|i| 2.0 + i as f64 * 0.01).collect(); let dp = DensityPlot::new()
.with_data(data)
.with_bandwidth(0.02)
.with_kde_samples(200);
let plot = Plot::Density(dp);
let (_, (_, y_max)) = plot.bounds().unwrap();
assert!(
y_max > 1.0,
"bounds() y_max should reflect the KDE peak; got y_max={y_max}. \
This fails if bounds() uses too few samples and misses the peak."
);
}
#[test]
fn test_density_multigroup_x_range() {
outdir();
let pal = Palette::category10();
let group_a: Vec<f64> = (0..50).map(|i| i as f64 * 0.01).collect(); let group_b: Vec<f64> = (50..100).map(|i| i as f64 * 0.01).collect(); let plots = vec![
Plot::Density(
DensityPlot::new()
.with_data(group_a)
.with_color(pal[0].to_string())
.with_legend("Low")
.with_x_range(0.0, 1.0),
),
Plot::Density(
DensityPlot::new()
.with_data(group_b)
.with_color(pal[1].to_string())
.with_legend("High")
.with_x_range(0.0, 1.0),
),
];
let layout = Layout::auto_from_plots(&plots).with_title("Density multigroup bounded");
let svg = render_svg(plots, layout);
fs::write("test_outputs/density_multigroup_x_range.svg", &svg).unwrap();
assert!(svg.contains("<svg"));
assert!(!svg.contains("NaN"), "no NaN in multigroup bounded density");
let path_count = svg.matches("<path").count();
assert!(
path_count >= 2,
"expected at least one path per group; got {path_count}"
);
}
#[test]
fn test_density_reflection_raises_boundary_density() {
let data: Vec<f64> = (0..50).map(|i| i as f64 * 0.02).collect();
let dp_plain = DensityPlot::new()
.with_data(data.clone())
.with_bandwidth(0.1);
let dp_reflect = DensityPlot::new()
.with_data(data)
.with_bandwidth(0.1)
.with_x_lo(0.0);
let (_, (_, y_plain)) = Plot::Density(dp_plain).bounds().unwrap();
let (_, (_, y_reflect)) = Plot::Density(dp_reflect).bounds().unwrap();
assert!(
y_reflect > y_plain,
"reflection should raise peak density; plain={y_plain:.4} reflect={y_reflect:.4}"
);
}
#[test]
fn test_density_x_lo_only() {
outdir();
let data: Vec<f64> = (0..80).map(|i| i as f64 * 0.01).collect(); let dp = DensityPlot::new()
.with_data(data)
.with_x_lo(0.0)
.with_filled(true);
let plot = Plot::Density(dp);
let ((x_min, x_max), _) = plot.bounds().unwrap();
assert_eq!(x_min, 0.0, "x_lo should clamp x_min to exactly 0.0");
assert!(
x_max > 0.79,
"right tail should still extend past data_max (one-sided bound)"
);
let plots = vec![plot];
let layout = Layout::auto_from_plots(&plots);
let svg = render_svg(plots, layout);
fs::write("test_outputs/density_x_lo_only.svg", &svg).unwrap();
assert!(!svg.contains("NaN"));
assert!(svg.contains("<path"));
}
#[test]
fn test_density_x_hi_only() {
outdir();
let data: Vec<f64> = (20..100).map(|i| i as f64 * 0.01).collect(); let dp = DensityPlot::new().with_data(data).with_x_hi(1.0);
let plot = Plot::Density(dp);
let ((x_min, x_max), _) = plot.bounds().unwrap();
assert_eq!(x_max, 1.0, "x_hi should clamp x_max to exactly 1.0");
assert!(
x_min < 0.2,
"left tail should still extend past data_min (one-sided bound)"
);
let plots = vec![plot];
let layout = Layout::auto_from_plots(&plots);
let svg = render_svg(plots, layout);
fs::write("test_outputs/density_x_hi_only.svg", &svg).unwrap();
assert!(!svg.contains("NaN"));
assert!(svg.contains("<path"));
}
#[test]
fn test_density_bounded_bimodal_identity_scores() {
outdir();
let mut data: Vec<f64> = (0..40).map(|i| 0.05 + i as f64 * 0.005).collect(); data.extend((0..40).map(|i| 0.85 + i as f64 * 0.005)); let dp = DensityPlot::new()
.with_data(data)
.with_x_range(0.0, 1.0)
.with_filled(true)
.with_color("steelblue");
let plot = Plot::Density(dp);
let ((x_min, x_max), _) = plot.bounds().unwrap();
assert_eq!(x_min, 0.0);
assert_eq!(x_max, 1.0);
let plots = vec![plot];
let layout = Layout::auto_from_plots(&plots);
let svg = render_svg(plots, layout);
fs::write("test_outputs/density_bounded_bimodal.svg", &svg).unwrap();
assert!(!svg.contains("NaN"));
assert!(svg.contains("<path"));
}
#[test]
fn test_density_default_anchors_y_zero() {
let data: Vec<f64> = (0..50).map(|i| 5.0 + i as f64 * 0.1).collect();
let dp = DensityPlot::new().with_data(data);
let plots = vec![Plot::Density(dp)];
let layout = Layout::auto_from_plots(&plots);
assert!(
layout.anchor_y_zero,
"density without with_fit() should anchor y at zero"
);
assert_eq!(
layout.y_range.0, 0.0,
"y_min should be 0.0 when anchored (got {})",
layout.y_range.0
);
}
#[test]
fn test_density_fit_disables_zero_anchor() {
let data: Vec<f64> = (0..50).map(|i| 5.0 + i as f64 * 0.1).collect();
let dp = DensityPlot::new().with_data(data).with_fit();
let plots = vec![Plot::Density(dp)];
let layout = Layout::auto_from_plots(&plots);
assert!(
!layout.anchor_y_zero,
"density with with_fit() should not anchor y at zero"
);
}
#[test]
fn test_density_fit_ymin_tracks_data() {
let x = vec![0.0, 1.0, 2.0, 3.0, 4.0];
let y = vec![0.5, 0.8, 1.0, 0.8, 0.5];
let dp_default = DensityPlot::from_curve(x.clone(), y.clone());
let dp_fit = DensityPlot::from_curve(x, y).with_fit();
let layout_default = Layout::auto_from_plots(&[Plot::Density(dp_default)]);
let layout_fit = Layout::auto_from_plots(&[Plot::Density(dp_fit)]);
assert_eq!(
layout_default.y_range.0, 0.0,
"without with_fit(), y_min should be 0.0"
);
assert!(
layout_fit.y_range.0 > 0.0,
"with with_fit(), y_min should be > 0.0 for data that never touches zero; got {}",
layout_fit.y_range.0
);
}
#[test]
fn test_density_fit_overridden_by_bar() {
use kuva::plot::BarPlot;
let dp = DensityPlot::new().with_data(vec![1.0, 2.0, 3.0]).with_fit();
let bar = BarPlot::new().with_bar("A", 5.0);
let plots = vec![Plot::Density(dp), Plot::Bar(bar)];
let layout = Layout::auto_from_plots(&plots);
assert!(
layout.anchor_y_zero,
"bar plot should keep anchor_y_zero true even when a density uses with_fit()"
);
}
#[test]
fn test_density_fit_renders_cleanly() {
outdir();
let x: Vec<f64> = (0..9).map(|i| i as f64 * 0.5).collect();
let y = vec![0.30, 0.45, 0.70, 0.90, 1.00, 0.90, 0.70, 0.45, 0.30];
let dp = DensityPlot::from_curve(x, y)
.with_color("steelblue")
.with_filled(true)
.with_fit();
let plots = vec![Plot::Density(dp)];
let layout = Layout::auto_from_plots(&plots).with_title("Density fit-to-data");
let svg = render_svg(plots, layout);
fs::write("test_outputs/density_fit.svg", &svg).unwrap();
assert!(svg.contains("<svg"));
assert!(svg.contains("<path"));
assert!(!svg.contains("NaN"), "SVG should not contain NaN");
let layout2 = Layout::auto_from_plots(&[Plot::Density(
DensityPlot::from_curve(
(0..9).map(|i| i as f64 * 0.5).collect(),
vec![0.30, 0.45, 0.70, 0.90, 1.00, 0.90, 0.70, 0.45, 0.30],
)
.with_fit(),
)]);
assert!(
layout2.y_range.0 > 0.0,
"y-axis should start above 0 when fit_y is set; got {}",
layout2.y_range.0
);
}