use super::*;
fn parse_svg_attr(line: &str, attr: &str) -> f32 {
let marker = format!(r#"{}=""#, attr);
let start = line
.find(&marker)
.unwrap_or_else(|| panic!("missing {} in line: {}", attr, line))
+ marker.len();
let end = line[start..]
.find('"')
.unwrap_or_else(|| panic!("unterminated {} in line: {}", attr, line))
+ start;
line[start..end]
.parse::<f32>()
.unwrap_or_else(|_| panic!("invalid {} in line: {}", attr, line))
}
fn extract_svg_text_xy(svg: &str, text: &str) -> (f32, f32) {
let marker = format!(">{}</text>", text);
let line = svg
.lines()
.find(|line| line.contains(&marker))
.unwrap_or_else(|| panic!("missing text node for {}", text));
(parse_svg_attr(line, "x"), parse_svg_attr(line, "y"))
}
fn has_svg_line(svg: &str, x1: f32, y1: f32, x2: f32, y2: f32) -> bool {
svg.lines()
.filter(|line| line.contains("<line"))
.any(|line| {
(parse_svg_attr(line, "x1") - x1).abs() <= 0.01
&& (parse_svg_attr(line, "y1") - y1).abs() <= 0.01
&& (parse_svg_attr(line, "x2") - x2).abs() <= 0.01
&& (parse_svg_attr(line, "y2") - y2).abs() <= 0.01
})
}
fn extract_typst_group_translates(svg: &str) -> Vec<(f32, f32)> {
svg.lines()
.filter(|line| line.contains(r#"data-ruviz-text-engine="typst""#))
.map(|line| {
let marker = r#"transform="translate("#;
let start = line
.find(marker)
.unwrap_or_else(|| panic!("missing translate transform in line: {}", line))
+ marker.len();
let end = line[start..]
.find(')')
.unwrap_or_else(|| panic!("unterminated translate transform in line: {}", line))
+ start;
let coords = &line[start..end];
let mut parts = coords.split(',');
let x = parts
.next()
.unwrap_or_else(|| panic!("missing translate x in line: {}", line))
.parse::<f32>()
.unwrap_or_else(|_| panic!("invalid translate x in line: {}", line));
let y = parts
.next()
.unwrap_or_else(|| panic!("missing translate y in line: {}", line))
.parse::<f32>()
.unwrap_or_else(|_| panic!("invalid translate y in line: {}", line));
(x, y)
})
.collect()
}
#[test]
fn test_svg_renderer_creation() {
let renderer = SvgRenderer::new(800.0, 600.0);
assert_eq!(renderer.width(), 800.0);
assert_eq!(renderer.height(), 600.0);
}
#[test]
fn test_color_conversion() {
let renderer = SvgRenderer::new(100.0, 100.0);
assert_eq!(renderer.color_to_svg(Color::new(255, 0, 0)), "rgb(255,0,0)");
assert_eq!(
renderer.color_to_svg(Color::new_rgba(255, 0, 0, 128)),
"rgba(255,0,0,0.502)"
);
}
#[test]
fn test_line_style_conversion() {
let renderer = SvgRenderer::new(100.0, 100.0);
assert_eq!(renderer.line_style_to_dasharray(&LineStyle::Solid), None);
assert_eq!(
renderer.line_style_to_dasharray(&LineStyle::Dashed),
Some("5,5".to_string())
);
}
#[test]
fn test_line_style_conversion_scales_with_dpi() {
let mut renderer = SvgRenderer::new(100.0, 100.0);
renderer.set_dpi_scale(2.0);
assert_eq!(
renderer.line_style_to_dasharray(&LineStyle::Dashed),
Some("10,10".to_string())
);
assert_eq!(
renderer.line_style_to_dasharray(&LineStyle::Dotted),
Some("2,4".to_string())
);
assert_eq!(
renderer.line_style_to_dasharray(&LineStyle::Custom(vec![1.5, 2.0])),
Some("3,4".to_string())
);
}
#[test]
fn test_set_dpi_scale_sanitizes_invalid_values() {
let mut renderer = SvgRenderer::new(100.0, 100.0);
renderer.set_dpi_scale(2.5);
assert!((renderer.dpi_scale() - 2.5).abs() < f32::EPSILON);
renderer.set_dpi_scale(0.0);
assert!((renderer.dpi_scale() - 1.0).abs() < f32::EPSILON);
assert_eq!(
renderer.line_style_to_dasharray(&LineStyle::Dashed),
Some("5,5".to_string())
);
renderer.set_dpi_scale(-3.0);
assert!((renderer.dpi_scale() - 1.0).abs() < f32::EPSILON);
renderer.set_dpi_scale(f32::NAN);
assert!((renderer.dpi_scale() - 1.0).abs() < f32::EPSILON);
renderer.set_dpi_scale(f32::INFINITY);
assert!((renderer.dpi_scale() - 1.0).abs() < f32::EPSILON);
}
#[test]
fn test_xml_escaping() {
let renderer = SvgRenderer::new(100.0, 100.0);
assert_eq!(
renderer.escape_xml("a < b & c > d"),
"a < b & c > d"
);
}
#[test]
fn test_svg_output() {
let mut renderer = SvgRenderer::new(200.0, 150.0);
renderer.draw_rectangle(0.0, 0.0, 200.0, 150.0, Color::WHITE, true);
renderer.draw_line(
10.0,
10.0,
190.0,
140.0,
Color::BLACK,
2.0,
LineStyle::Solid,
);
let svg = renderer.to_svg_string();
assert!(svg.contains("svg"));
assert!(svg.contains("rect"));
assert!(svg.contains("line"));
}
#[test]
fn test_polygon_outline_requires_three_points() {
let mut renderer = SvgRenderer::new(200.0, 150.0);
renderer.draw_polygon_outline(&[(10.0, 10.0), (20.0, 20.0)], Color::BLACK, 2.0);
let svg = renderer.to_svg_string();
assert!(
!svg.contains("<polygon"),
"two-point polygon outlines should be ignored to match raster rendering"
);
}
#[test]
fn test_legend_line_handle_attribute_spacing() {
let mut renderer = SvgRenderer::new(200.0, 150.0);
renderer.draw_legend_line_handle(10.0, 20.0, 30.0, Color::BLACK, &LineStyle::Solid, 1.5);
renderer.draw_legend_line_handle(10.0, 30.0, 30.0, Color::BLACK, &LineStyle::Dashed, 1.5);
let svg = renderer.to_svg_string();
let line_nodes: Vec<&str> = svg.lines().filter(|line| line.contains("<line")).collect();
assert!(
line_nodes
.iter()
.any(|line| line.contains(r#"stroke-width="1.5"/>"#)),
"solid legend handle should not include dangling whitespace before '/>'"
);
assert!(
line_nodes
.iter()
.any(|line| { line.contains(r#"stroke-width="1.5" stroke-dasharray="5,5""#) }),
"dashed legend handle should include dash attribute with one leading space"
);
assert!(
!line_nodes
.iter()
.any(|line| line.contains(r#"stroke-width="1.5" />"#)),
"legend handle should not emit dangling whitespace before '/>'"
);
}
#[test]
fn test_plain_text_uses_top_origin_baseline() {
let mut renderer = SvgRenderer::new(200.0, 150.0);
renderer
.draw_text("Top Origin", 10.0, 20.0, 12.0, Color::BLACK)
.unwrap();
renderer
.draw_text_centered("Centered Top", 100.0, 25.0, 12.0, Color::BLACK)
.unwrap();
let svg = renderer.to_svg_string();
assert!(!svg.contains("dominant-baseline=\"text-before-edge\""));
let (x1, y1) = extract_svg_text_xy(&svg, "Top Origin");
let (x2, y2) = extract_svg_text_xy(&svg, "Centered Top");
let metrics1 = renderer.plain_text_metrics("Top Origin", 12.0).unwrap();
let metrics2 = renderer.plain_text_metrics("Centered Top", 12.0).unwrap();
assert!((x1 - 10.0).abs() <= 0.01);
assert!((x2 - 100.0).abs() <= 0.01);
assert!((y1 - top_anchor_to_baseline(20.0, metrics1)).abs() <= 0.6);
assert!((y2 - top_anchor_to_baseline(25.0, metrics2)).abs() <= 0.6);
}
#[test]
fn test_tick_labels_use_layout_positions() {
let mut renderer = SvgRenderer::new(200.0, 150.0);
let x_ticks = vec![100.0];
let x_labels = vec!["1.0".to_string()];
let y_ticks = vec![75.0];
let y_labels = vec!["2.0".to_string()];
renderer
.draw_tick_labels(
&x_ticks,
&x_labels,
&y_ticks,
&y_labels,
40.0,
160.0,
20.0,
120.0,
120.0,
35.0,
Color::BLACK,
10.0,
)
.unwrap();
let svg = renderer.to_svg_string();
let (x_tick_x, x_tick_y) = extract_svg_text_xy(&svg, "1.0");
let (y_tick_x, y_tick_y) = extract_svg_text_xy(&svg, "2.0");
let x_metrics = renderer.plain_text_metrics("1.0", 10.0).unwrap();
let y_metrics = renderer.plain_text_metrics("2.0", 10.0).unwrap();
let x_top = 120.0;
let expected_x_tick_x = (100.0 - x_metrics.width / 2.0)
.max(0.0)
.min(renderer.width - x_metrics.width);
let expected_x_tick_y = top_anchor_to_baseline(x_top, x_metrics);
let y_top = 75.0 - y_metrics.height / 2.0;
let expected_y_tick_x = (35.0 - y_metrics.width).max(0.0);
let expected_y_tick_y = top_anchor_to_baseline(y_top, y_metrics);
assert!(
(x_tick_x - expected_x_tick_x).abs() <= 0.6 && (x_tick_y - expected_x_tick_y).abs() <= 0.6,
"x-axis tick label should use layout xtick baseline"
);
assert!(
(y_tick_x - expected_y_tick_x).abs() <= 0.6 && (y_tick_y - expected_y_tick_y).abs() <= 0.6,
"y-axis tick label should use layout ytick anchor and centered y"
);
}
#[test]
fn test_draw_axes_renders_ticks_on_all_sides() {
let mut renderer = SvgRenderer::new(200.0, 150.0);
renderer.draw_axes(
40.0,
160.0,
20.0,
120.0,
&[100.0],
&[75.0],
&TickDirection::Inside,
&TickSides::all(),
Color::BLACK,
);
let svg = renderer.to_svg_string();
assert!(has_svg_line(&svg, 40.0, 20.0, 160.0, 20.0));
assert!(has_svg_line(&svg, 160.0, 20.0, 160.0, 120.0));
assert!(has_svg_line(&svg, 100.0, 120.0, 100.0, 114.0));
assert!(has_svg_line(&svg, 100.0, 20.0, 100.0, 26.0));
assert!(has_svg_line(&svg, 40.0, 75.0, 46.0, 75.0));
assert!(has_svg_line(&svg, 160.0, 75.0, 154.0, 75.0));
}
#[test]
fn test_draw_axes_respects_bottom_left_tick_selection() {
let mut renderer = SvgRenderer::new(200.0, 150.0);
renderer.draw_axes(
40.0,
160.0,
20.0,
120.0,
&[100.0],
&[75.0],
&TickDirection::Inside,
&TickSides::bottom_left(),
Color::BLACK,
);
let svg = renderer.to_svg_string();
assert!(has_svg_line(&svg, 40.0, 20.0, 160.0, 20.0));
assert!(has_svg_line(&svg, 160.0, 20.0, 160.0, 120.0));
assert!(has_svg_line(&svg, 100.0, 120.0, 100.0, 114.0));
assert!(has_svg_line(&svg, 40.0, 75.0, 46.0, 75.0));
assert!(!has_svg_line(&svg, 100.0, 20.0, 100.0, 26.0));
assert!(!has_svg_line(&svg, 160.0, 75.0, 154.0, 75.0));
}
#[cfg(feature = "typst-math")]
#[test]
fn test_typst_tick_labels_follow_plain_anchor_math() {
let mut renderer = SvgRenderer::new(200.0, 150.0);
renderer.set_text_engine_mode(TextEngineMode::Typst);
let x_ticks = vec![100.0];
let x_labels = vec!["1.0".to_string()];
let y_ticks = vec![75.0];
let y_labels = vec!["2.0".to_string()];
renderer
.draw_tick_labels(
&x_ticks,
&x_labels,
&y_ticks,
&y_labels,
40.0,
160.0,
20.0,
120.0,
120.0,
35.0,
Color::BLACK,
10.0,
)
.unwrap();
let svg = renderer.to_svg_string();
assert!(svg.contains("data-ruviz-text-engine=\"typst\""));
let translates = extract_typst_group_translates(&svg);
assert_eq!(translates.len(), 2, "expected two typst text groups");
let x_snippet = typst_text::literal_text_snippet("1.0");
let y_snippet = typst_text::literal_text_snippet("2.0");
let (x_w, _x_h) = typst_text::measure_text(
&x_snippet,
10.0,
Color::BLACK,
0.0,
TypstBackendKind::Svg,
"typst tick test",
)
.unwrap();
let (y_w, y_h) = typst_text::measure_text(
&y_snippet,
10.0,
Color::BLACK,
0.0,
TypstBackendKind::Svg,
"typst tick test",
)
.unwrap();
let expected_x = (100.0 - x_w / 2.0).max(0.0).min(renderer.width - x_w);
let expected_y = (35.0 - y_w).max(0.0);
let expected_y_top = 75.0 - y_h / 2.0;
assert!((translates[0].0 - expected_x).abs() <= 0.6 && (translates[0].1 - 120.0).abs() <= 0.6);
assert!(
(translates[1].0 - expected_y).abs() <= 0.6
&& (translates[1].1 - expected_y_top).abs() <= 0.6
);
}
#[cfg(feature = "typst-math")]
#[test]
fn test_typst_rotated_text_uses_typst_rotation_path() {
let mut renderer = SvgRenderer::new(200.0, 150.0);
renderer.set_text_engine_mode(TextEngineMode::Typst);
renderer
.draw_text_rotated("Y Axis", 100.0, 75.0, 12.0, Color::BLACK, -90.0)
.unwrap();
let svg = renderer.to_svg_string();
assert!(svg.contains("data-ruviz-text-engine=\"typst\""));
assert!(!svg.contains("data-ruviz-text-engine=\"typst\" transform=\"rotate("));
}