use kuva::backend::svg::SvgBackend;
use kuva::plot::bump::{BumpPlot, BumpTieBreak, CurveStyle};
use kuva::render::{layout::Layout, plots::Plot, render::render_multiple};
fn render(bp: BumpPlot, title: &str) -> String {
let plots = vec![Plot::Bump(bp)];
let layout = Layout::auto_from_plots(&plots).with_title(title);
SvgBackend.render_scene(&render_multiple(plots, layout))
}
fn clip_rect(svg: &str) -> Option<(f64, f64)> {
let start = svg.find("<clipPath")?;
let after = &svg[start..];
let rx = after.find("x=\"")? + 3;
let rx_end = after[rx..].find('"')? + rx;
let x: f64 = after[rx..rx_end].parse().ok()?;
let rw = after.find("width=\"")? + 7;
let rw_end = after[rw..].find('"')? + rw;
let w: f64 = after[rw..rw_end].parse().ok()?;
Some((x, w))
}
fn label_x_positions(svg: &str, name: &str) -> Vec<f64> {
let mut xs = Vec::new();
let mut rest = svg;
while let Some(pos) = rest.find("<text ") {
let elem_start = pos;
let end = rest[pos..]
.find("</text>")
.map(|e| pos + e + 7)
.unwrap_or(rest.len());
let elem = &rest[elem_start..end];
if elem.contains(name) {
if let Some(xp) = elem.find("x=\"") {
let xp = xp + 3;
if let Some(xe) = elem[xp..].find('"') {
if let Ok(v) = elem[xp..xp + xe].parse::<f64>() {
xs.push(v);
}
}
}
}
rest = &rest[elem_start + 1..];
}
xs
}
fn simple_bump() -> BumpPlot {
BumpPlot::new()
.with_series("Alpha", vec![1, 3, 2, 1])
.with_series("Beta", vec![2, 1, 1, 3])
.with_series("Gamma", vec![3, 2, 3, 2])
.with_x_labels(["2021", "2022", "2023", "2024"])
}
#[test]
fn test_bump_basic() {
let svg = render(simple_bump(), "Bump Basic");
std::fs::write("test_outputs/bump_basic.svg", &svg).unwrap();
assert!(svg.contains("<svg"), "should be valid SVG");
assert!(
svg.contains("<path"),
"should contain path elements (curves)"
);
assert!(
svg.contains("<circle"),
"should contain circle elements (dots)"
);
}
#[test]
fn test_bump_x_labels() {
let svg = render(simple_bump(), "Bump X Labels");
std::fs::write("test_outputs/bump_x_labels.svg", &svg).unwrap();
assert!(svg.contains("2021"), "x-axis label 2021 should appear");
assert!(svg.contains("2024"), "x-axis label 2024 should appear");
}
#[test]
fn test_bump_y_rank_labels() {
let svg = render(simple_bump(), "Bump Rank Labels");
std::fs::write("test_outputs/bump_y_rank_labels.svg", &svg).unwrap();
assert!(
svg.contains(">1<") || svg.contains(">1 <") || svg.matches(">1").count() > 0,
"rank 1 should appear in SVG"
);
}
#[test]
fn test_bump_straight_curves() {
let bp = simple_bump().with_curve_style(CurveStyle::Straight);
let svg = render(bp, "Bump Straight");
std::fs::write("test_outputs/bump_straight.svg", &svg).unwrap();
assert!(
svg.contains("<path"),
"straight lines should render as paths"
);
assert!(svg.contains(" L "), "straight lines should use L command");
assert!(
!svg.contains(" C "),
"straight lines should not use C command"
);
}
#[test]
fn test_bump_sigmoid_curves() {
let bp = simple_bump().with_curve_style(CurveStyle::Sigmoid);
let svg = render(bp, "Bump Sigmoid");
std::fs::write("test_outputs/bump_sigmoid.svg", &svg).unwrap();
assert!(
svg.contains(" C "),
"sigmoid should use C (cubic bezier) command"
);
}
#[test]
fn test_bump_show_rank_labels() {
let bp = simple_bump().with_show_rank_labels(true);
let svg = render(bp, "Bump Rank Labels Inside Dots");
std::fs::write("test_outputs/bump_rank_labels.svg", &svg).unwrap();
let text_count_with = svg.matches("<text").count();
let bp2 = simple_bump().with_show_rank_labels(false);
let svg2 = render(bp2, "");
let text_count_without = svg2.matches("<text").count();
assert!(
text_count_with > text_count_without,
"showing rank labels should add <text> elements"
);
}
#[test]
fn test_bump_no_series_labels() {
let bp = simple_bump().with_show_series_labels(false);
let svg = render(bp, "Bump No Series Labels");
std::fs::write("test_outputs/bump_no_series_labels.svg", &svg).unwrap();
let text_count_off = svg.matches("<text").count();
let bp2 = simple_bump().with_show_series_labels(true);
let svg2 = render(bp2, "");
let text_count_on = svg2.matches("<text").count();
assert!(
text_count_off <= text_count_on,
"disabling series labels should not add more text"
);
}
#[test]
fn test_bump_highlight() {
let bp = simple_bump().with_highlight("Alpha");
let svg = render(bp, "Bump Highlight Alpha");
std::fs::write("test_outputs/bump_highlight.svg", &svg).unwrap();
assert!(
svg.contains("<svg"),
"highlight mode should produce valid SVG"
);
assert!(svg.contains("<path"), "should contain path elements");
}
#[test]
fn test_bump_no_legend() {
let bp = simple_bump().with_legend(false);
let svg = render(bp, "Bump No Legend");
std::fs::write("test_outputs/bump_no_legend.svg", &svg).unwrap();
assert!(svg.contains("<svg"), "valid SVG");
}
#[test]
fn test_bump_with_legend() {
let bp = simple_bump().with_legend(true);
let svg = render(bp, "Bump With Legend");
std::fs::write("test_outputs/bump_with_legend.svg", &svg).unwrap();
assert!(svg.contains("Alpha"), "legend should contain series name");
}
#[test]
fn test_bump_dot_radius() {
let bp = simple_bump().with_dot_radius(10.0);
let svg = render(bp, "Bump Large Dots");
std::fs::write("test_outputs/bump_dot_radius.svg", &svg).unwrap();
assert!(
svg.contains("r=\"10\"") || svg.contains("r=\"10."),
"large dot radius should appear in SVG"
);
}
#[test]
fn test_bump_stroke_width() {
let bp = simple_bump().with_stroke_width(5.0);
let svg = render(bp, "Bump Thick Lines");
std::fs::write("test_outputs/bump_stroke_width.svg", &svg).unwrap();
assert!(svg.contains("<path"), "thick lines should render");
}
#[test]
fn test_bump_raw_series() {
let bp = BumpPlot::new()
.with_raw_series("A", vec![100.0, 80.0, 90.0])
.with_raw_series("B", vec![90.0, 95.0, 70.0])
.with_raw_series("C", vec![70.0, 85.0, 100.0])
.with_x_labels(["Q1", "Q2", "Q3"]);
let svg = render(bp, "Bump Raw Series");
std::fs::write("test_outputs/bump_raw_series.svg", &svg).unwrap();
assert!(
svg.contains("<circle"),
"auto-ranked series should render dots"
);
}
#[test]
fn test_bump_rank_ascending() {
let bp = BumpPlot::new()
.with_raw_series("Low", vec![10.0, 20.0, 30.0])
.with_raw_series("High", vec![90.0, 80.0, 70.0])
.with_rank_ascending(true);
let svg = render(bp, "Bump Rank Ascending");
std::fs::write("test_outputs/bump_rank_ascending.svg", &svg).unwrap();
assert!(
svg.contains("<svg"),
"ascending rank mode should produce valid SVG"
);
}
#[test]
fn test_bump_tie_break_min() {
let bp = BumpPlot::new()
.with_raw_series("A", vec![50.0, 50.0])
.with_raw_series("B", vec![50.0, 50.0])
.with_tie_break(BumpTieBreak::Min);
let svg = render(bp, "Bump Tie Min");
std::fs::write("test_outputs/bump_tie_min.svg", &svg).unwrap();
assert!(
svg.contains("<svg"),
"tie break min should produce valid SVG"
);
}
#[test]
fn test_bump_tie_break_max() {
let bp = BumpPlot::new()
.with_raw_series("A", vec![50.0])
.with_raw_series("B", vec![50.0])
.with_raw_series("C", vec![50.0])
.with_tie_break(BumpTieBreak::Max);
let svg = render(bp, "Bump Tie Max");
std::fs::write("test_outputs/bump_tie_max.svg", &svg).unwrap();
assert!(
svg.contains("<svg"),
"tie break max should produce valid SVG"
);
}
#[test]
fn test_bump_tie_break_stable() {
let bp = BumpPlot::new()
.with_raw_series("A", vec![50.0, 50.0])
.with_raw_series("B", vec![50.0, 50.0])
.with_tie_break(BumpTieBreak::Stable);
let svg = render(bp, "Bump Tie Stable");
std::fs::write("test_outputs/bump_tie_stable.svg", &svg).unwrap();
assert!(
svg.contains("<svg"),
"tie break stable should produce valid SVG"
);
}
#[test]
fn test_bump_missing_values() {
let bp = BumpPlot::new()
.with_ranked_series("Alpha", vec![Some(1.0), None, Some(2.0), Some(1.0)])
.with_ranked_series("Beta", vec![Some(2.0), Some(1.0), Some(1.0), None])
.with_x_labels(["A", "B", "C", "D"]);
let svg = render(bp, "Bump Missing Values");
std::fs::write("test_outputs/bump_missing.svg", &svg).unwrap();
assert!(
svg.contains("<svg"),
"missing values should produce valid SVG"
);
}
#[test]
fn test_bump_single_series() {
let bp = BumpPlot::new()
.with_series("Solo", vec![1, 1, 1])
.with_x_labels(["X", "Y", "Z"]);
let svg = render(bp, "Bump Single Series");
std::fs::write("test_outputs/bump_single.svg", &svg).unwrap();
assert!(svg.contains("<circle"), "single series should render dots");
}
#[test]
fn test_bump_empty() {
let bp = BumpPlot::new();
let svg = render(bp, "Bump Empty");
std::fs::write("test_outputs/bump_empty.svg", &svg).unwrap();
assert!(
svg.contains("<svg"),
"empty bump chart should produce valid SVG"
);
}
#[test]
fn test_bump_large() {
let mut bp = BumpPlot::new();
for i in 0..10 {
let ranks: Vec<u32> = (0..6).map(|t| ((i + t) % 10 + 1) as u32).collect();
bp = bp.with_series(format!("S{i}"), ranks);
}
bp = bp.with_x_labels(["T1", "T2", "T3", "T4", "T5", "T6"]);
let svg = render(bp, "Bump Large");
std::fs::write("test_outputs/bump_large.svg", &svg).unwrap();
assert!(
svg.contains("<circle"),
"large bump chart should render dots"
);
}
#[test]
fn test_bump_into_plot() {
let bp = simple_bump();
let plot = Plot::from(bp);
assert!(
matches!(plot, Plot::Bump(_)),
"From<BumpPlot> should produce Plot::Bump"
);
}
#[test]
fn test_bump_mixed_ranked_and_raw() {
let bp = BumpPlot::new()
.with_ranked_series("Pre-ranked", vec![Some(1.0), Some(2.0), Some(1.0)])
.with_raw_series("Auto-ranked", vec![10.0, 5.0, 8.0]);
let svg = render(bp, "Bump Mixed");
std::fs::write("test_outputs/bump_mixed.svg", &svg).unwrap();
assert!(
svg.contains("<svg"),
"mixed series should produce valid SVG"
);
}
#[test]
fn test_bump_long_labels_fit_auto() {
let bp = BumpPlot::new()
.with_series("Extremely Long Label", vec![1, 2, 3])
.with_series("Another Very Long Name", vec![2, 1, 2])
.with_series("Yet Another Long One", vec![3, 3, 1])
.with_x_labels(["T1", "T2", "T3"])
.with_show_series_labels(true)
.with_legend(false);
let plots = vec![Plot::Bump(bp)];
let layout = Layout::auto_from_plots(&plots).with_title("Long Labels Auto");
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("test_outputs/bump_long_labels_auto.svg", &svg).unwrap();
let (clip_x, clip_w) = clip_rect(&svg).expect("should have clip rect");
for name in &[
"Extremely Long Label",
"Another Very Long Name",
"Yet Another Long One",
] {
let xs = label_x_positions(&svg, name);
assert!(!xs.is_empty(), "label '{name}' should appear in SVG");
for x in xs {
let estimated_left = x - name.len() as f64 * 7.0;
assert!(
estimated_left >= clip_x - 2.0, "label '{name}' left edge ({estimated_left:.1}) should be inside clip_x ({clip_x:.1})"
);
assert!(
x <= clip_x + clip_w + 2.0,
"label '{name}' right edge ({x:.1}) should be within clip right ({:.1})",
clip_x + clip_w
);
}
}
}
#[test]
fn test_bump_long_labels_with_legend_fit_auto() {
let bp = BumpPlot::new()
.with_series("SuperLongSeriesNameHere", vec![1, 3, 2, 1])
.with_series("AnotherSuperLongName", vec![2, 1, 1, 2])
.with_series("ShortName", vec![3, 2, 3, 3])
.with_x_labels(["2021", "2022", "2023", "2024"])
.with_show_series_labels(true)
.with_legend(true);
let plots = vec![Plot::Bump(bp)];
let layout = Layout::auto_from_plots(&plots).with_title("Long Labels + Legend");
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("test_outputs/bump_long_labels_legend.svg", &svg).unwrap();
let (clip_x, _clip_w) = clip_rect(&svg).expect("should have clip rect");
for name in &["SuperLongSeriesNameHere", "AnotherSuperLongName"] {
let xs = label_x_positions(&svg, name);
assert!(!xs.is_empty(), "label '{name}' should appear");
for x in xs {
let estimated_left = x - name.len() as f64 * 7.0;
assert!(
estimated_left >= clip_x - 2.0,
"label '{name}' left edge ({estimated_left:.1}) clipped by clip_x ({clip_x:.1})"
);
}
}
}
#[test]
fn test_bump_right_labels_fit_auto() {
let bp = BumpPlot::new()
.with_series("SomeVeryLongEndLabel", vec![1, 2, 1])
.with_series("AnotherLongEndLabel", vec![2, 1, 2])
.with_x_labels(["Alpha", "Beta", "Gamma"])
.with_show_series_labels(true)
.with_legend(false);
let plots = vec![Plot::Bump(bp)];
let layout = Layout::auto_from_plots(&plots).with_title("Right Labels Auto");
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("test_outputs/bump_right_labels_auto.svg", &svg).unwrap();
let (clip_x, clip_w) = clip_rect(&svg).expect("should have clip rect");
let clip_right = clip_x + clip_w;
for name in &["SomeVeryLongEndLabel", "AnotherLongEndLabel"] {
let xs = label_x_positions(&svg, name);
assert!(!xs.is_empty(), "label '{name}' should appear");
for x in xs {
let estimated_right = x + name.len() as f64 * 7.0;
assert!(
estimated_right <= clip_right + 2.0,
"label '{name}' right edge ({estimated_right:.1}) should be within clip_right ({clip_right:.1})"
);
assert!(
x >= clip_x - 2.0,
"label '{name}' x ({x:.1}) should be within clip zone"
);
}
}
}