use chartml_core::data::DataTable;
use chartml_core::element::{ChartElement, Dimensions};
use chartml_core::error::ChartError;
use chartml_core::plugin::{ChartConfig, ChartRenderer};
use chartml_core::spec::VisualizeSpec;
mod bar;
mod line;
mod area;
pub(crate) mod helpers;
pub use bar::{bar_animation_origin, render_bar};
pub use line::render_line;
pub use area::render_area;
pub struct CartesianRenderer;
impl CartesianRenderer {
pub fn new() -> Self {
Self
}
}
impl Default for CartesianRenderer {
fn default() -> Self {
Self::new()
}
}
impl ChartRenderer for CartesianRenderer {
fn render(&self, data: &DataTable, config: &ChartConfig) -> Result<ChartElement, ChartError> {
match config.visualize.chart_type.as_str() {
"bar" => bar::render_bar(data, config),
"line" => line::render_line(data, config),
"area" => area::render_area(data, config),
other => Err(ChartError::UnknownChartType(other.to_string())),
}
}
fn default_dimensions(&self, _spec: &VisualizeSpec) -> Option<Dimensions> {
Some(Dimensions::new(400.0))
}
}
#[cfg(test)]
mod tests {
use super::*;
use chartml_core::element::count_elements;
use chartml_core::data::{Row, DataTable};
use serde_json::json;
fn make_bar_rows() -> Vec<Row> {
vec![
[("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(100))].into_iter().collect(),
[("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(200))].into_iter().collect(),
[("month".to_string(), json!("Mar")), ("revenue".to_string(), json!(150))].into_iter().collect(),
]
}
fn make_bar_data() -> DataTable {
DataTable::from_rows(&make_bar_rows()).unwrap()
}
fn make_bar_config() -> ChartConfig {
let viz: VisualizeSpec = serde_yaml::from_str(r#"
type: bar
columns: month
rows: revenue
"#).unwrap();
ChartConfig {
visualize: viz,
title: Some("Test Bar".to_string()),
width: 800.0,
height: 400.0,
colors: vec!["#2E7D9A".to_string(), "#D4A445".to_string(), "#4A7C59".to_string()],
theme: chartml_core::theme::Theme::default(),
}
}
fn make_line_config() -> ChartConfig {
let viz: VisualizeSpec = serde_yaml::from_str(r#"
type: line
columns: month
rows: revenue
"#).unwrap();
ChartConfig {
visualize: viz,
title: Some("Test Line".to_string()),
width: 800.0,
height: 400.0,
colors: vec!["#2E7D9A".to_string(), "#D4A445".to_string(), "#4A7C59".to_string()],
theme: chartml_core::theme::Theme::default(),
}
}
fn make_area_config() -> ChartConfig {
let viz: VisualizeSpec = serde_yaml::from_str(r#"
type: area
columns: month
rows: revenue
"#).unwrap();
ChartConfig {
visualize: viz,
title: Some("Test Area".to_string()),
width: 800.0,
height: 400.0,
colors: vec!["#2E7D9A".to_string(), "#D4A445".to_string(), "#4A7C59".to_string()],
theme: chartml_core::theme::Theme::default(),
}
}
#[test]
fn phase4_theme_typography_flows_to_axis_label_text() {
use chartml_core::theme::{TextTransform, Theme};
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let mut config = make_bar_config();
let mut t = Theme::default();
t.label_font_family = "serif".into();
t.label_letter_spacing = 1.5;
t.label_text_transform = TextTransform::Uppercase;
t.label_font_weight = 600;
config.theme = t;
let element = renderer.render(&data, &config).unwrap();
fn walk<'a>(el: &'a ChartElement, out: &mut Vec<&'a ChartElement>) {
match el {
ChartElement::Svg { children, .. }
| ChartElement::Group { children, .. } => {
for c in children {
walk(c, out);
}
}
_ => out.push(el),
}
}
let mut leaves = Vec::new();
walk(&element, &mut leaves);
let mut axis_label_count = 0usize;
for leaf in &leaves {
if let ChartElement::Text {
class,
font_family,
letter_spacing,
text_transform,
font_weight,
..
} = leaf
{
let is_axis_label = class
.split_whitespace()
.any(|c| c == "axis-label");
if !is_axis_label {
continue;
}
axis_label_count += 1;
assert_eq!(
font_family.as_deref(),
Some("serif"),
"axis-label text must carry theme.label_font_family"
);
assert_eq!(
letter_spacing.as_deref(),
Some("1.5"),
"axis-label text must carry theme.label_letter_spacing"
);
assert_eq!(
text_transform.as_deref(),
Some("uppercase"),
"axis-label text must carry theme.label_text_transform"
);
assert_eq!(
font_weight.as_deref(),
Some("600"),
"axis-label text must carry theme.label_font_weight"
);
}
}
assert!(
axis_label_count > 0,
"bar chart should have at least one axis-label text"
);
}
#[test]
fn phase4_theme_typography_flows_to_tick_value_text() {
use chartml_core::theme::{TextTransform, Theme};
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let mut config = make_bar_config();
let mut t = Theme::default();
t.numeric_font_family = "monospace".into();
t.label_letter_spacing = 0.75;
t.label_text_transform = TextTransform::Lowercase;
config.theme = t;
let element = renderer.render(&data, &config).unwrap();
let mut found = false;
fn visit<F: FnMut(&ChartElement)>(el: &ChartElement, f: &mut F) {
f(el);
match el {
ChartElement::Svg { children, .. }
| ChartElement::Group { children, .. } => {
for c in children {
visit(c, f);
}
}
_ => {}
}
}
visit(&element, &mut |el| {
if let ChartElement::Text {
class,
font_family,
letter_spacing,
text_transform,
..
} = el
{
if class
.split_whitespace()
.any(|c| c == "tick-value")
{
found = true;
assert_eq!(
font_family.as_deref(),
Some("monospace"),
"tick-value text must carry theme.numeric_font_family"
);
assert_eq!(
letter_spacing.as_deref(),
Some("0.75"),
"tick-value text must inherit theme.label_letter_spacing"
);
assert_eq!(
text_transform.as_deref(),
Some("lowercase"),
"tick-value text must inherit theme.label_text_transform"
);
}
}
});
assert!(found, "bar chart should emit at least one tick-value text");
}
#[test]
fn bar_chart_renders() {
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let config = make_bar_config();
let result = renderer.render(&data, &config);
assert!(result.is_ok(), "Bar render failed: {:?}", result.err());
let element = result.unwrap();
let rect_count = count_elements(&element, &|e| matches!(e, ChartElement::Rect { .. }));
assert_eq!(rect_count, 3, "Should have 3 bars for 3 data points, got {}", rect_count);
}
#[test]
fn bar_chart_has_svg_root() {
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let config = make_bar_config();
let element = renderer.render(&data, &config).unwrap();
assert!(matches!(element, ChartElement::Svg { .. }), "Root should be Svg");
}
#[test]
fn bar_chart_has_no_title_in_svg() {
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let config = make_bar_config();
let element = renderer.render(&data, &config).unwrap();
let title_count = count_elements(&element, &|e| {
matches!(e, ChartElement::Text { class, .. } if class == "chart-title")
});
assert_eq!(title_count, 0, "Title must not be in the SVG element tree");
}
#[test]
fn bar_chart_has_axes() {
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let config = make_bar_config();
let element = renderer.render(&data, &config).unwrap();
let axis_line_count = count_elements(&element, &|e| {
matches!(e, ChartElement::Line { class, .. } if class == "axis-line")
});
assert!(axis_line_count >= 1, "Should have axis lines, got {}", axis_line_count);
}
#[test]
fn line_chart_renders() {
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let config = make_line_config();
let result = renderer.render(&data, &config);
assert!(result.is_ok(), "Line render failed: {:?}", result.err());
let element = result.unwrap();
let path_count = count_elements(&element, &|e| matches!(e, ChartElement::Path { .. }));
assert!(path_count >= 1, "Should have at least 1 path for the line, got {}", path_count);
}
#[test]
fn line_chart_path_has_stroke() {
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let config = make_line_config();
let element = renderer.render(&data, &config).unwrap();
fn find_path(el: &ChartElement) -> Option<&ChartElement> {
match el {
ChartElement::Path { .. } => Some(el),
ChartElement::Svg { children, .. }
| ChartElement::Group { children, .. } => {
children.iter().find_map(find_path)
}
_ => None,
}
}
let path = find_path(&element).expect("Should find a path element");
match path {
ChartElement::Path { stroke, .. } => {
assert!(stroke.is_some(), "Line path should have a stroke");
}
_ => unreachable!(),
}
}
#[test]
fn area_chart_renders() {
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let config = make_area_config();
let result = renderer.render(&data, &config);
assert!(result.is_ok(), "Area render failed: {:?}", result.err());
let element = result.unwrap();
let path_count = count_elements(&element, &|e| matches!(e, ChartElement::Path { .. }));
assert!(path_count >= 1, "Should have at least 1 path for the area, got {}", path_count);
}
#[test]
fn area_chart_path_has_fill() {
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let config = make_area_config();
let element = renderer.render(&data, &config).unwrap();
fn find_path(el: &ChartElement) -> Option<&ChartElement> {
match el {
ChartElement::Path { .. } => Some(el),
ChartElement::Svg { children, .. }
| ChartElement::Group { children, .. } => {
children.iter().find_map(find_path)
}
_ => None,
}
}
let path = find_path(&element).expect("Should find a path element");
match path {
ChartElement::Path { fill, .. } => {
assert!(fill.is_some(), "Area path should have a fill");
}
_ => unreachable!(),
}
}
#[test]
fn unknown_type_errors() {
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let mut config = make_bar_config();
config.visualize.chart_type = "unknown".to_string();
let result = renderer.render(&data, &config);
assert!(result.is_err(), "Unknown chart type should produce error");
match result.unwrap_err() {
ChartError::UnknownChartType(t) => assert_eq!(t, "unknown"),
other => panic!("Expected UnknownChartType, got {:?}", other),
}
}
#[test]
fn bar_chart_no_title() {
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let mut config = make_bar_config();
config.title = None;
let element = renderer.render(&data, &config).unwrap();
let title_count = count_elements(&element, &|e| {
matches!(e, ChartElement::Text { class, .. } if class == "chart-title")
});
assert_eq!(title_count, 0, "Should have no title element when title is None");
}
#[test]
fn default_dimensions_returns_some() {
let renderer = CartesianRenderer::new();
let viz: VisualizeSpec = serde_yaml::from_str(r#"
type: bar
columns: x
rows: y
"#).unwrap();
let dims = renderer.default_dimensions(&viz);
assert!(dims.is_some());
assert_eq!(dims.unwrap().height, 400.0);
}
#[test]
fn bar_chart_adaptive_padding_2_bars() {
let rows: Vec<Row> = vec![
[("region".to_string(), json!("US")), ("revenue".to_string(), json!(55000))].into_iter().collect(),
[("region".to_string(), json!("EU")), ("revenue".to_string(), json!(40000))].into_iter().collect(),
];
let data = DataTable::from_rows(&rows).unwrap();
let viz: VisualizeSpec = serde_yaml::from_str(r#"
type: bar
columns: region
rows: revenue
"#).unwrap();
let config = ChartConfig {
visualize: viz,
title: Some("Regional Revenue".to_string()),
width: 800.0,
height: 400.0,
colors: vec!["#2E7D9A".to_string()],
theme: chartml_core::theme::Theme::default(),
};
let renderer = CartesianRenderer::new();
let element = renderer.render(&data, &config).unwrap();
let mut bar_widths = Vec::new();
fn collect_bar_widths(el: &ChartElement, widths: &mut Vec<f64>) {
match el {
ChartElement::Rect { width, class, .. } if class.split_whitespace().any(|c| c == "bar") => {
widths.push(*width);
}
ChartElement::Svg { children, .. }
| ChartElement::Group { children, .. } => {
for child in children { collect_bar_widths(child, widths); }
}
_ => {}
}
}
collect_bar_widths(&element, &mut bar_widths);
assert_eq!(bar_widths.len(), 2, "Should have 2 bars");
let bar_width = bar_widths[0];
println!("Bar width: {:.2}px", bar_width);
assert!(
bar_width <= 150.0,
"Bar width {:.1}px exceeds maxBarWidth clamp",
bar_width
);
assert!(
bar_width > 50.0,
"Bar width {:.1}px is unreasonably narrow",
bar_width
);
}
#[test]
fn stacked_bar_chart_renders() {
let rows: Vec<Row> = vec![
[("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(100)), ("product".to_string(), json!("A"))].into_iter().collect(),
[("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(50)), ("product".to_string(), json!("B"))].into_iter().collect(),
[("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(200)), ("product".to_string(), json!("A"))].into_iter().collect(),
[("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(80)), ("product".to_string(), json!("B"))].into_iter().collect(),
];
let data = DataTable::from_rows(&rows).unwrap();
let viz: VisualizeSpec = serde_yaml::from_str(r#"
type: bar
mode: stacked
columns: month
rows: revenue
marks:
color: product
"#).unwrap();
let config = ChartConfig {
visualize: viz,
title: Some("Stacked Bar".to_string()),
width: 800.0,
height: 400.0,
colors: vec!["#2E7D9A".to_string(), "#D4A445".to_string()],
theme: chartml_core::theme::Theme::default(),
};
let renderer = CartesianRenderer::new();
let result = renderer.render(&data, &config);
assert!(result.is_ok(), "Stacked bar render failed: {:?}", result.err());
let element = result.unwrap();
let rect_count = count_elements(&element, &|e| matches!(e, ChartElement::Rect { class, .. } if class.split_whitespace().any(|c| c == "bar")));
assert_eq!(rect_count, 4, "Should have 4 bars (2 categories x 2 series), got {}", rect_count);
}
#[test]
fn grouped_bar_chart_renders() {
let rows: Vec<Row> = vec![
[("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(100)), ("product".to_string(), json!("A"))].into_iter().collect(),
[("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(50)), ("product".to_string(), json!("B"))].into_iter().collect(),
[("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(200)), ("product".to_string(), json!("A"))].into_iter().collect(),
[("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(80)), ("product".to_string(), json!("B"))].into_iter().collect(),
];
let data = DataTable::from_rows(&rows).unwrap();
let viz: VisualizeSpec = serde_yaml::from_str(r#"
type: bar
mode: grouped
columns: month
rows: revenue
marks:
color: product
"#).unwrap();
let config = ChartConfig {
visualize: viz,
title: None,
width: 800.0,
height: 400.0,
colors: vec!["#2E7D9A".to_string(), "#D4A445".to_string()],
theme: chartml_core::theme::Theme::default(),
};
let renderer = CartesianRenderer::new();
let result = renderer.render(&data, &config);
assert!(result.is_ok(), "Grouped bar render failed: {:?}", result.err());
let element = result.unwrap();
let rect_count = count_elements(&element, &|e| matches!(e, ChartElement::Rect { class, .. } if class.split_whitespace().any(|c| c == "bar")));
assert_eq!(rect_count, 4, "Should have 4 bars (2 categories x 2 series), got {}", rect_count);
}
#[test]
fn multi_series_line_chart_renders() {
let rows: Vec<Row> = vec![
[("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(100)), ("product".to_string(), json!("A"))].into_iter().collect(),
[("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(50)), ("product".to_string(), json!("B"))].into_iter().collect(),
[("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(200)), ("product".to_string(), json!("A"))].into_iter().collect(),
[("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(80)), ("product".to_string(), json!("B"))].into_iter().collect(),
];
let data = DataTable::from_rows(&rows).unwrap();
let viz: VisualizeSpec = serde_yaml::from_str(r#"
type: line
columns: month
rows: revenue
marks:
color: product
"#).unwrap();
let config = ChartConfig {
visualize: viz,
title: Some("Multi Line".to_string()),
width: 800.0,
height: 400.0,
colors: vec!["#2E7D9A".to_string(), "#D4A445".to_string()],
theme: chartml_core::theme::Theme::default(),
};
let renderer = CartesianRenderer::new();
let result = renderer.render(&data, &config);
assert!(result.is_ok(), "Multi-series line render failed: {:?}", result.err());
let element = result.unwrap();
let path_count = count_elements(&element, &|e| matches!(e, ChartElement::Path { class, .. } if class.split_whitespace().any(|c| c == "chartml-line-path")));
assert_eq!(path_count, 2, "Should have 2 line paths for 2 series, got {}", path_count);
}
#[test]
fn empty_data_returns_error() {
let renderer = CartesianRenderer::new();
let data = DataTable::from_rows(&Vec::<Row>::new()).unwrap();
let config = make_bar_config();
let result = renderer.render(&data, &config);
assert!(result.is_err(), "Empty data should produce an error");
}
#[test]
fn x_axis_horizontal_few_labels() {
use crate::helpers::{generate_x_axis, GridConfig};
let labels = vec!["A".into(), "B".into(), "C".into()];
let result = generate_x_axis(&crate::helpers::XAxisParams {
labels: &labels, display_label_overrides: None,
range: (0.0, 800.0), y_position: 350.0, available_width: 800.0,
x_format: None, chart_height: None, grid: &GridConfig::default(), axis_label: None,
theme: &chartml_core::theme::Theme::default(),
});
let text_with_transform = result.elements.iter().filter(|e| {
matches!(e, ChartElement::Text { transform: Some(_), .. })
}).count();
assert_eq!(text_with_transform, 0, "Horizontal strategy should have no transforms");
}
#[test]
fn x_axis_rotated_many_labels() {
use crate::helpers::{generate_x_axis, GridConfig};
let labels: Vec<String> = (0..20).map(|i| format!("Category Number {}", i)).collect();
let result = generate_x_axis(&crate::helpers::XAxisParams {
labels: &labels, display_label_overrides: None,
range: (0.0, 300.0), y_position: 350.0, available_width: 300.0,
x_format: None, chart_height: None, grid: &GridConfig::default(), axis_label: None,
theme: &chartml_core::theme::Theme::default(),
});
let text_with_transform = result.elements.iter().filter(|e| {
matches!(e, ChartElement::Text { transform: Some(_), .. })
}).count();
assert!(text_with_transform > 0, "Rotated strategy should have transforms");
}
#[test]
fn x_axis_sampled_100_labels() {
use crate::helpers::{generate_x_axis, GridConfig};
let labels: Vec<String> = (0..100).map(|i| format!("Long Category Name {}", i)).collect();
let result = generate_x_axis(&crate::helpers::XAxisParams {
labels: &labels, display_label_overrides: None,
range: (0.0, 400.0), y_position: 350.0, available_width: 400.0,
x_format: None, chart_height: None, grid: &GridConfig::default(), axis_label: None,
theme: &chartml_core::theme::Theme::default(),
});
let label_count = result.elements.iter().filter(|e| {
matches!(e, ChartElement::Text { class, .. } if class.split_whitespace().any(|c| c == "tick-label"))
}).count();
assert!(label_count < 100, "Sampled should show fewer labels: got {}", label_count);
assert!(label_count >= 3, "Should show at least a few labels");
}
#[test]
fn line_chart_grid_dash_array() {
let data = make_bar_data();
let viz: VisualizeSpec = serde_yaml::from_str(r#"
type: line
columns: month
rows: revenue
style:
grid:
x: true
y: true
color: '#e0e0e0'
opacity: 0.5
dashArray: 4,4
showDots: true
"#).unwrap();
let grid_spec = viz.style.as_ref().unwrap().grid.as_ref().unwrap();
assert_eq!(grid_spec.dash_array, Some("4,4".to_string()), "GridSpec.dash_array should parse from YAML");
assert_eq!(grid_spec.x, Some(true), "grid.x should be true");
assert_eq!(grid_spec.y, Some(true), "grid.y should be true");
let config = ChartConfig {
visualize: viz,
title: Some("Dashed Grid Test".to_string()),
width: 800.0,
height: 400.0,
colors: vec!["#2E7D9A".to_string()],
theme: chartml_core::theme::Theme::default(),
};
let grid_config = crate::helpers::GridConfig::from_config(&config);
assert_eq!(grid_config.dash_array, Some("4,4".to_string()), "GridConfig.dash_array should be set");
assert!(grid_config.show_x, "grid.show_x should be true");
assert!(grid_config.show_y, "grid.show_y should be true");
let renderer = CartesianRenderer::new();
let element = renderer.render(&data, &config).unwrap();
let mut dashed_grid_count = 0;
let mut total_grid_count = 0;
fn check_grid(el: &ChartElement, dashed: &mut usize, total: &mut usize) {
match el {
ChartElement::Line { class, stroke_dasharray, .. } if class.contains("grid-line") => {
*total += 1;
if let Some(da) = stroke_dasharray {
if !da.is_empty() {
*dashed += 1;
}
}
}
ChartElement::Svg { children, .. } | ChartElement::Group { children, .. } => {
for child in children {
check_grid(child, dashed, total);
}
}
_ => {}
}
}
check_grid(&element, &mut dashed_grid_count, &mut total_grid_count);
assert!(total_grid_count > 0, "Should have grid lines, got {}", total_grid_count);
assert_eq!(dashed_grid_count, total_grid_count,
"All {} grid lines should have stroke_dasharray='4,4', but only {} do",
total_grid_count, dashed_grid_count);
}
fn collect_series_stroke_widths(el: &ChartElement, out: &mut Vec<f64>) {
match el {
ChartElement::Path { stroke_width: Some(w), class, .. }
if class.split_whitespace().any(|c| c == "series-line") =>
{
out.push(*w);
}
ChartElement::Svg { children, .. }
| ChartElement::Group { children, .. } => {
for c in children {
collect_series_stroke_widths(c, out);
}
}
_ => {}
}
}
fn collect_line_stroke_widths_by_class(
el: &ChartElement,
out: &mut std::collections::HashMap<String, Vec<f64>>,
) {
match el {
ChartElement::Line { stroke_width: Some(w), class, .. } => {
for token in class.split_whitespace() {
if matches!(token, "axis-line" | "grid-line" | "tick") {
out.entry(token.to_string()).or_default().push(*w);
}
}
}
ChartElement::Svg { children, .. }
| ChartElement::Group { children, .. } => {
for c in children {
collect_line_stroke_widths_by_class(c, out);
}
}
_ => {}
}
}
fn collect_bar_corner_radii(
el: &ChartElement,
out: &mut Vec<(Option<f64>, Option<f64>)>,
) {
match el {
ChartElement::Rect { rx, ry, class, .. }
if class.split_whitespace().any(|c| c == "bar") =>
{
out.push((*rx, *ry));
}
ChartElement::Svg { children, .. }
| ChartElement::Group { children, .. } => {
for c in children {
collect_bar_corner_radii(c, out);
}
}
_ => {}
}
}
fn collect_dot_radii(el: &ChartElement, out: &mut Vec<f64>) {
match el {
ChartElement::Circle { r, class, .. }
if class.split_whitespace().any(|c| c == "dot-marker") =>
{
out.push(*r);
}
ChartElement::Svg { children, .. }
| ChartElement::Group { children, .. } => {
for c in children {
collect_dot_radii(c, out);
}
}
_ => {}
}
}
#[test]
fn phase5_bar_corner_radius_omitted_by_default() {
let renderer = CartesianRenderer::new();
let element = renderer
.render(&make_bar_data(), &make_bar_config())
.expect("render");
let mut radii = Vec::new();
collect_bar_corner_radii(&element, &mut radii);
assert!(!radii.is_empty(), "expected bar rects in default bar chart");
for (rx, ry) in &radii {
assert!(rx.is_none(), "default theme must leave Rect.rx == None");
assert!(ry.is_none(), "default theme must leave Rect.ry == None");
}
}
#[test]
fn phase5_custom_bar_corner_radius_emits_rx_ry() {
use chartml_core::theme::{BarCornerRadius, Theme};
let renderer = CartesianRenderer::new();
let mut config = make_bar_config();
let mut t = Theme::default();
t.bar_corner_radius = BarCornerRadius::Uniform(8.0);
config.theme = t;
let element = renderer.render(&make_bar_data(), &config).expect("render");
let mut radii = Vec::new();
collect_bar_corner_radii(&element, &mut radii);
assert!(!radii.is_empty());
for (rx, ry) in &radii {
assert_eq!(*rx, Some(8.0), "rx must match theme.bar_corner_radius");
assert_eq!(*ry, Some(8.0), "ry must match theme.bar_corner_radius");
}
}
fn collect_bar_elements<'a>(el: &'a ChartElement, out: &mut Vec<&'a ChartElement>) {
match el {
ChartElement::Rect { class, .. } | ChartElement::Path { class, .. }
if class.split_whitespace().any(|c| c == "bar-rect") =>
{
out.push(el);
}
ChartElement::Svg { children, .. } | ChartElement::Group { children, .. } => {
for c in children {
collect_bar_elements(c, out);
}
}
_ => {}
}
}
#[test]
fn phase_followup_bar_top_rounding_zero_is_plain_rect() {
use chartml_core::theme::{BarCornerRadius, Theme};
let renderer = CartesianRenderer::new();
let mut config = make_bar_config();
let mut t = Theme::default();
t.bar_corner_radius = BarCornerRadius::Top(0.0);
config.theme = t;
let element = renderer.render(&make_bar_data(), &config).expect("render");
let mut bars = Vec::new();
collect_bar_elements(&element, &mut bars);
assert!(!bars.is_empty());
for b in &bars {
match b {
ChartElement::Rect { rx, ry, .. } => {
assert!(rx.is_none(), "Top(0.0) must emit Rect with rx=None");
assert!(ry.is_none(), "Top(0.0) must emit Rect with ry=None");
}
other => panic!("Top(0.0) must emit Rect, got {:?}", other),
}
}
}
#[test]
fn phase_followup_bar_top_rounding_vertical() {
use chartml_core::theme::{BarCornerRadius, Theme};
let renderer = CartesianRenderer::new();
let mut config = make_bar_config();
let mut t = Theme::default();
t.bar_corner_radius = BarCornerRadius::Top(8.0);
config.theme = t;
let element = renderer.render(&make_bar_data(), &config).expect("render");
let mut bars = Vec::new();
collect_bar_elements(&element, &mut bars);
assert!(!bars.is_empty(), "expected bar elements");
for b in &bars {
match b {
ChartElement::Path { d, .. } => {
assert_eq!(
d.matches("A 8,8").count(),
2,
"vertical Top(8) must produce 2 arcs, got d={d}"
);
}
other => panic!("vertical Top(8) must emit Path, got {:?}", other),
}
}
}
#[test]
fn phase_followup_bar_top_rounding_horizontal() {
use chartml_core::theme::{BarCornerRadius, Theme};
let renderer = CartesianRenderer::new();
let mut config = make_bar_config();
config.visualize.orientation = Some(chartml_core::spec::Orientation::Horizontal);
let mut t = Theme::default();
t.bar_corner_radius = BarCornerRadius::Top(8.0);
config.theme = t;
let element = renderer.render(&make_bar_data(), &config).expect("render");
let mut bars = Vec::new();
collect_bar_elements(&element, &mut bars);
assert!(!bars.is_empty(), "expected bar elements (horizontal)");
for b in &bars {
match b {
ChartElement::Path { d, .. } => {
assert_eq!(
d.matches("A 8,8").count(),
2,
"horizontal Top(8) must produce 2 arcs, got d={d}"
);
}
other => panic!("horizontal Top(8) must emit Path, got {:?}", other),
}
}
}
#[test]
fn phase_followup_bar_top_rounding_negative_vertical() {
use chartml_core::theme::{BarCornerRadius, Theme};
use crate::bar::{build_bar_element, BarRectSpec};
let mut theme = Theme::default();
theme.bar_corner_radius = BarCornerRadius::Top(8.0);
let pos = build_bar_element(
BarRectSpec {
x: 100.0, y: 50.0, width: 40.0, height: 200.0,
is_horizontal: false, is_negative: false,
fill: "#000".into(),
class: "bar bar-rect".into(),
data: None,
},
&theme,
);
let neg = build_bar_element(
BarRectSpec {
x: 100.0, y: 50.0, width: 40.0, height: 200.0,
is_horizontal: false, is_negative: true,
fill: "#000".into(),
class: "bar bar-rect".into(),
data: None,
},
&theme,
);
let pos_d = match &pos {
ChartElement::Path { d, .. } => d.clone(),
_ => panic!("pos must be Path"),
};
let neg_d = match &neg {
ChartElement::Path { d, .. } => d.clone(),
_ => panic!("neg must be Path"),
};
assert_eq!(pos_d.matches("A 8,8").count(), 2);
assert_eq!(neg_d.matches("A 8,8").count(), 2);
assert!(
pos_d.starts_with("M 100,58"),
"pos vertical Top path should start at y+r=58, got {pos_d}"
);
assert!(
neg_d.starts_with("M 100,50"),
"neg vertical Top path should start at (x, y)=(100, 50), got {neg_d}"
);
assert!(
neg_d.contains(",242"),
"neg vertical Top path should contain y1-r=242, got {neg_d}"
);
}
#[test]
fn phase5_custom_series_line_weight_flows_to_line_path() {
use chartml_core::theme::Theme;
let renderer = CartesianRenderer::new();
let mut config = make_line_config();
let mut t = Theme::default();
t.series_line_weight = 4.0;
config.theme = t;
let element = renderer
.render(&make_bar_data(), &config)
.expect("render");
let mut widths = Vec::new();
collect_series_stroke_widths(&element, &mut widths);
assert!(!widths.is_empty(), "expected at least one series-line path");
for w in &widths {
assert_eq!(*w, 4.0, "series-line stroke_width must read from theme");
}
}
#[test]
fn phase5_custom_series_line_weight_flows_to_area_outline() {
use chartml_core::theme::Theme;
let renderer = CartesianRenderer::new();
let mut config = make_area_config();
let mut t = Theme::default();
t.series_line_weight = 3.5;
config.theme = t;
let element = renderer.render(&make_bar_data(), &config).expect("render");
let mut widths = Vec::new();
collect_series_stroke_widths(&element, &mut widths);
assert!(!widths.is_empty(), "expected area outline series-line path");
for w in &widths {
assert_eq!(*w, 3.5);
}
}
#[test]
fn phase5_custom_dot_radius_flows_to_line_markers() {
use chartml_core::theme::Theme;
let renderer = CartesianRenderer::new();
let mut config = make_line_config();
let mut t = Theme::default();
t.dot_radius = 10.0;
config.theme = t;
let element = renderer.render(&make_bar_data(), &config).expect("render");
let mut radii = Vec::new();
collect_dot_radii(&element, &mut radii);
assert!(!radii.is_empty(), "expected dot-marker circles on line chart");
for r in &radii {
assert_eq!(*r, 10.0);
}
}
#[test]
fn phase5_custom_axis_and_grid_line_weights_flow_to_line_strokes() {
use chartml_core::theme::Theme;
let renderer = CartesianRenderer::new();
let mut config = make_bar_config();
let mut t = Theme::default();
t.axis_line_weight = 2.5;
t.grid_line_weight = 0.5;
config.theme = t;
let element = renderer.render(&make_bar_data(), &config).expect("render");
let mut by_class: std::collections::HashMap<String, Vec<f64>> =
std::collections::HashMap::new();
collect_line_stroke_widths_by_class(&element, &mut by_class);
let axis = by_class.get("axis-line").cloned().unwrap_or_default();
let ticks = by_class.get("tick").cloned().unwrap_or_default();
let grid = by_class.get("grid-line").cloned().unwrap_or_default();
assert!(!axis.is_empty(), "expected axis-line elements");
assert!(!ticks.is_empty(), "expected tick elements");
assert!(!grid.is_empty(), "expected grid-line elements");
for w in &axis {
assert_eq!(*w, 2.5, "axis-line stroke_width must read from theme.axis_line_weight");
}
for w in &ticks {
assert_eq!(*w, 2.5, "tick stroke_width must read from theme.axis_line_weight");
}
for w in &grid {
assert_eq!(*w, 0.5, "grid-line stroke_width must read from theme.grid_line_weight");
}
}
#[test]
fn x_axis_date_labels_reformatted() {
use crate::helpers::{generate_x_axis, GridConfig};
let labels: Vec<String> = vec![
"2024-01-01".into(), "2024-01-02".into(), "2024-01-03".into()
];
let result = generate_x_axis(&crate::helpers::XAxisParams {
labels: &labels, display_label_overrides: None,
range: (0.0, 800.0), y_position: 350.0, available_width: 800.0,
x_format: None, chart_height: None, grid: &GridConfig::default(), axis_label: None,
theme: &chartml_core::theme::Theme::default(),
});
let has_reformatted = result.elements.iter().any(|e| {
matches!(e, ChartElement::Text { content, .. } if content.starts_with("Jan"))
});
assert!(has_reformatted, "Date labels should be reformatted");
}
fn count_grid_lines(el: &ChartElement) -> (usize, usize) {
let (mut vx, mut hy) = (0usize, 0usize);
fn visit(el: &ChartElement, vx: &mut usize, hy: &mut usize) {
match el {
ChartElement::Line { class, .. } => {
let has_x = class.split_whitespace().any(|c| c == "grid-line-x");
let has_y = class.split_whitespace().any(|c| c == "grid-line-y");
if has_x {
*vx += 1;
}
if has_y {
*hy += 1;
}
}
ChartElement::Svg { children, .. }
| ChartElement::Group { children, .. } => {
for c in children {
visit(c, vx, hy);
}
}
_ => {}
}
}
visit(el, &mut vx, &mut hy);
(vx, hy)
}
fn make_bar_config_both_grids() -> ChartConfig {
let viz: VisualizeSpec = serde_yaml::from_str(r#"
type: bar
columns: month
rows: revenue
style:
grid:
x: true
y: true
"#).unwrap();
ChartConfig {
visualize: viz,
title: Some("Test Bar GridStyle".to_string()),
width: 800.0,
height: 400.0,
colors: vec!["#2E7D9A".to_string()],
theme: chartml_core::theme::Theme::default(),
}
}
#[test]
fn phase6_grid_style_both_default_emits_both_orientations() {
use chartml_core::theme::{GridStyle, Theme};
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let mut config = make_bar_config_both_grids();
let mut t = Theme::default();
t.grid_style = GridStyle::Both;
config.theme = t;
let element = renderer.render(&data, &config).unwrap();
let (vx, hy) = count_grid_lines(&element);
assert!(vx > 0, "Both: expected vertical gridlines (grid-line-x)");
assert!(hy > 0, "Both: expected horizontal gridlines (grid-line-y)");
}
#[test]
fn phase6_grid_style_horizontal_only_skips_vertical() {
use chartml_core::theme::{GridStyle, Theme};
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let mut config = make_bar_config_both_grids();
let mut t = Theme::default();
t.grid_style = GridStyle::HorizontalOnly;
config.theme = t;
let element = renderer.render(&data, &config).unwrap();
let (vx, hy) = count_grid_lines(&element);
assert_eq!(vx, 0, "HorizontalOnly: no grid-line-x expected, got {}", vx);
assert!(hy > 0, "HorizontalOnly: expected grid-line-y lines");
}
#[test]
fn phase6_grid_style_vertical_only_skips_horizontal() {
use chartml_core::theme::{GridStyle, Theme};
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let mut config = make_bar_config_both_grids();
let mut t = Theme::default();
t.grid_style = GridStyle::VerticalOnly;
config.theme = t;
let element = renderer.render(&data, &config).unwrap();
let (vx, hy) = count_grid_lines(&element);
assert!(vx > 0, "VerticalOnly: expected grid-line-x lines");
assert_eq!(hy, 0, "VerticalOnly: no grid-line-y expected, got {}", hy);
}
#[test]
fn phase6_grid_style_none_skips_all_gridlines() {
use chartml_core::theme::{GridStyle, Theme};
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let mut config = make_bar_config_both_grids();
let mut t = Theme::default();
t.grid_style = GridStyle::None;
config.theme = t;
let element = renderer.render(&data, &config).unwrap();
let (vx, hy) = count_grid_lines(&element);
assert_eq!(vx, 0, "None: no grid-line-x expected, got {}", vx);
assert_eq!(hy, 0, "None: no grid-line-y expected, got {}", hy);
}
fn make_bar_data_crossing_zero() -> DataTable {
let rows = vec![
[("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(-5))].into_iter().collect(),
[("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(0))].into_iter().collect(),
[("month".to_string(), json!("Mar")), ("revenue".to_string(), json!(10))].into_iter().collect(),
];
DataTable::from_rows(&rows).unwrap()
}
fn count_zero_lines(el: &ChartElement) -> usize {
count_elements(el, &|e| {
matches!(e, ChartElement::Line { class, .. } if class.split_whitespace().any(|c| c == "zero-line"))
})
}
#[test]
fn phase7_default_theme_emits_no_zero_line() {
let renderer = CartesianRenderer::new();
let data = make_bar_data_crossing_zero();
let config = make_bar_config();
let element = renderer.render(&data, &config).unwrap();
assert_eq!(count_zero_lines(&element), 0, "default theme must not emit zero-line");
}
#[test]
fn phase7_bar_crossing_zero_emits_one_zero_line() {
use chartml_core::theme::{Theme, ZeroLineSpec};
let renderer = CartesianRenderer::new();
let data = make_bar_data_crossing_zero();
let mut config = make_bar_config();
let mut t = Theme::default();
t.zero_line = Some(ZeroLineSpec { color: "#ff0000".into(), width: 1.5 });
config.theme = t;
let element = renderer.render(&data, &config).unwrap();
assert_eq!(count_zero_lines(&element), 1, "expected exactly one zero-line");
fn find_zero_line(el: &ChartElement) -> Option<(String, Option<f64>)> {
match el {
ChartElement::Line { class, stroke, stroke_width, .. }
if class.split_whitespace().any(|c| c == "zero-line") =>
{
Some((stroke.clone(), *stroke_width))
}
ChartElement::Group { children, .. } | ChartElement::Svg { children, .. } => {
children.iter().find_map(find_zero_line)
}
_ => None,
}
}
let (stroke, width) = find_zero_line(&element).expect("zero-line present");
assert_eq!(stroke, "#ff0000");
assert_eq!(width, Some(1.5));
}
#[test]
fn phase7_horizontal_bar_crossing_zero_emits_one_zero_line() {
use chartml_core::theme::{Theme, ZeroLineSpec};
let renderer = CartesianRenderer::new();
let data = make_bar_data_crossing_zero();
let viz: VisualizeSpec = serde_yaml::from_str(r#"
type: bar
orientation: horizontal
columns: month
rows: revenue
"#).unwrap();
let mut theme = Theme::default();
theme.zero_line = Some(ZeroLineSpec { color: "#ff0000".into(), width: 1.5 });
let config = ChartConfig {
visualize: viz,
title: Some("Test Horizontal Bar".to_string()),
width: 800.0,
height: 400.0,
colors: vec!["#2E7D9A".to_string()],
theme,
};
let element = renderer.render(&data, &config).unwrap();
assert_eq!(count_zero_lines(&element), 1, "expected exactly one zero-line");
struct ZeroLineGeom {
x1: f64,
y1: f64,
x2: f64,
y2: f64,
stroke: String,
stroke_width: Option<f64>,
}
fn find_zero_line_geom(el: &ChartElement) -> Option<ZeroLineGeom> {
match el {
ChartElement::Line { class, x1, y1, x2, y2, stroke, stroke_width, .. }
if class.split_whitespace().any(|c| c == "zero-line") =>
{
Some(ZeroLineGeom {
x1: *x1,
y1: *y1,
x2: *x2,
y2: *y2,
stroke: stroke.clone(),
stroke_width: *stroke_width,
})
}
ChartElement::Group { children, .. } | ChartElement::Svg { children, .. } => {
children.iter().find_map(find_zero_line_geom)
}
_ => None,
}
}
let ZeroLineGeom { x1, y1, x2, y2, stroke, stroke_width: width } =
find_zero_line_geom(&element).expect("zero-line present");
assert!(
(x1 - x2).abs() < f64::EPSILON,
"horizontal-bar zero-line must be vertical: x1={x1} x2={x2}",
);
assert!(
(y1 - y2).abs() > f64::EPSILON,
"horizontal-bar zero-line must have non-zero height: y1={y1} y2={y2}",
);
assert_eq!(stroke, "#ff0000");
assert_eq!(width, Some(1.5));
}
#[test]
fn phase7_bar_all_positive_emits_no_zero_line() {
use chartml_core::theme::{Theme, ZeroLineSpec};
let renderer = CartesianRenderer::new();
let data = make_bar_data(); let mut config = make_bar_config();
let mut t = Theme::default();
t.zero_line = Some(ZeroLineSpec { color: "#ff0000".into(), width: 1.5 });
config.theme = t;
let element = renderer.render(&data, &config).unwrap();
assert_eq!(
count_zero_lines(&element),
0,
"all-positive data must not emit a zero-line",
);
}
#[test]
fn phase7_line_crossing_zero_emits_one_zero_line() {
use chartml_core::theme::{Theme, ZeroLineSpec};
let renderer = CartesianRenderer::new();
let data = make_bar_data_crossing_zero();
let mut config = make_line_config();
let mut t = Theme::default();
t.zero_line = Some(ZeroLineSpec { color: "#00ff00".into(), width: 2.0 });
config.theme = t;
let element = renderer.render(&data, &config).unwrap();
assert_eq!(count_zero_lines(&element), 1);
}
fn count_halos(el: &ChartElement) -> usize {
count_elements(el, &|e| matches!(e, ChartElement::Path { class, .. } if class == "dot-halo"))
}
fn count_dot_markers(el: &ChartElement) -> usize {
count_elements(el, &|e| matches!(e, ChartElement::Circle { class, .. } if class.contains("dot-marker")))
}
#[test]
fn phase8_line_default_theme_emits_no_halo() {
let renderer = CartesianRenderer::new();
let element = renderer.render(&make_bar_data(), &make_line_config()).unwrap();
assert_eq!(count_halos(&element), 0, "default theme line chart must emit zero halos");
}
#[test]
fn phase8_line_halo_matches_dot_count_and_ordering() {
use chartml_core::theme::Theme;
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let mut config = make_line_config();
let mut t = Theme::default();
t.dot_halo_color = Some("#ffffff".to_string());
t.dot_halo_width = 1.5;
config.theme = t;
let element = renderer.render(&data, &config).unwrap();
let dot_n = count_dot_markers(&element);
let halo_n = count_halos(&element);
assert!(dot_n > 0, "line chart should produce at least one dot-marker");
assert_eq!(halo_n, dot_n, "one halo per dot-marker required");
fn walk_lines_group(el: &ChartElement) -> Option<&Vec<ChartElement>> {
match el {
ChartElement::Group { class, children, .. } if class == "lines" => Some(children),
ChartElement::Svg { children, .. } | ChartElement::Group { children, .. } => {
children.iter().find_map(walk_lines_group)
}
_ => None,
}
}
let lines = walk_lines_group(&element).expect("lines group");
let mut pair = 0;
let mut iter = lines.iter().peekable();
while let Some(el) = iter.next() {
if let ChartElement::Path { class, .. } = el {
if class == "dot-halo" {
match iter.peek() {
Some(ChartElement::Circle { class: cc, .. }) => {
assert!(cc.contains("dot-marker"));
pair += 1;
}
other => panic!("halo not followed by dot: {:?}", other.map(|_| "other")),
}
}
}
}
assert_eq!(pair, dot_n);
fn first_halo(el: &ChartElement) -> Option<(String, f64)> {
match el {
ChartElement::Path { class, stroke, stroke_width, .. } if class == "dot-halo" => {
Some((stroke.clone().unwrap_or_default(), stroke_width.unwrap_or(-1.0)))
}
ChartElement::Svg { children, .. } | ChartElement::Group { children, .. } => {
children.iter().find_map(first_halo)
}
_ => None,
}
}
let (stroke, width) = first_halo(&element).unwrap();
assert_eq!(stroke, "#ffffff");
assert!((width - 1.5).abs() < 1e-9);
}
#[test]
fn phase7_area_crossing_zero_emits_one_zero_line() {
use chartml_core::theme::{Theme, ZeroLineSpec};
let renderer = CartesianRenderer::new();
let data = make_bar_data_crossing_zero();
let mut config = make_area_config();
let mut t = Theme::default();
t.zero_line = Some(ZeroLineSpec { color: "#0000ff".into(), width: 1.0 });
config.theme = t;
let element = renderer.render(&data, &config).unwrap();
assert_eq!(count_zero_lines(&element), 1);
}
}