Skip to main content

chartml_chart_cartesian/
lib.rs

1use chartml_core::data::DataTable;
2use chartml_core::element::{ChartElement, Dimensions};
3use chartml_core::error::ChartError;
4use chartml_core::plugin::{ChartConfig, ChartRenderer};
5use chartml_core::spec::VisualizeSpec;
6
7mod bar;
8mod line;
9mod area;
10pub(crate) mod helpers;
11
12pub use bar::{bar_animation_origin, render_bar};
13pub use line::render_line;
14pub use area::render_area;
15
16pub struct CartesianRenderer;
17
18impl CartesianRenderer {
19    pub fn new() -> Self {
20        Self
21    }
22}
23
24impl Default for CartesianRenderer {
25    fn default() -> Self {
26        Self::new()
27    }
28}
29
30impl ChartRenderer for CartesianRenderer {
31    fn render(&self, data: &DataTable, config: &ChartConfig) -> Result<ChartElement, ChartError> {
32        match config.visualize.chart_type.as_str() {
33            "bar" => bar::render_bar(data, config),
34            "line" => line::render_line(data, config),
35            "area" => area::render_area(data, config),
36            other => Err(ChartError::UnknownChartType(other.to_string())),
37        }
38    }
39
40    fn default_dimensions(&self, _spec: &VisualizeSpec) -> Option<Dimensions> {
41        Some(Dimensions::new(400.0))
42    }
43}
44
45#[cfg(test)]
46mod tests {
47    #![allow(clippy::unwrap_used)]
48    use super::*;
49    use chartml_core::element::count_elements;
50    use chartml_core::data::{Row, DataTable};
51    use serde_json::json;
52
53    fn make_bar_rows() -> Vec<Row> {
54        vec![
55            [("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(100))].into_iter().collect(),
56            [("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(200))].into_iter().collect(),
57            [("month".to_string(), json!("Mar")), ("revenue".to_string(), json!(150))].into_iter().collect(),
58        ]
59    }
60
61    fn make_bar_data() -> DataTable {
62        DataTable::from_rows(&make_bar_rows()).unwrap()
63    }
64
65    fn make_bar_config() -> ChartConfig {
66        let viz: VisualizeSpec = serde_yaml::from_str(r#"
67            type: bar
68            columns: month
69            rows: revenue
70        "#).unwrap();
71        ChartConfig {
72            visualize: viz,
73            title: Some("Test Bar".to_string()),
74            width: 800.0,
75            height: 400.0,
76            colors: vec!["#2E7D9A".to_string(), "#D4A445".to_string(), "#4A7C59".to_string()],
77            theme: chartml_core::theme::Theme::default(),
78        }
79    }
80
81    fn make_line_config() -> ChartConfig {
82        let viz: VisualizeSpec = serde_yaml::from_str(r#"
83            type: line
84            columns: month
85            rows: revenue
86        "#).unwrap();
87        ChartConfig {
88            visualize: viz,
89            title: Some("Test Line".to_string()),
90            width: 800.0,
91            height: 400.0,
92            colors: vec!["#2E7D9A".to_string(), "#D4A445".to_string(), "#4A7C59".to_string()],
93            theme: chartml_core::theme::Theme::default(),
94        }
95    }
96
97    fn make_area_config() -> ChartConfig {
98        let viz: VisualizeSpec = serde_yaml::from_str(r#"
99            type: area
100            columns: month
101            rows: revenue
102        "#).unwrap();
103        ChartConfig {
104            visualize: viz,
105            title: Some("Test Area".to_string()),
106            width: 800.0,
107            height: 400.0,
108            colors: vec!["#2E7D9A".to_string(), "#D4A445".to_string(), "#4A7C59".to_string()],
109            theme: chartml_core::theme::Theme::default(),
110        }
111    }
112
113    // ----- Phase 4: theme typography wiring -----
114
115    /// Verify that non-default Theme typography values flow through to the
116    /// emitted `ChartElement::Text` nodes. Specifically: a custom label
117    /// family, letter-spacing, and text-transform must appear on every
118    /// `axis-label` / `tick-value` / `legend-label` text element, while
119    /// defaults continue to produce the omitted-attribute legacy path.
120    #[test]
121    fn phase4_theme_typography_flows_to_axis_label_text() {
122        use chartml_core::theme::{TextTransform, Theme};
123
124        let renderer = CartesianRenderer::new();
125        let data = make_bar_data();
126        let mut config = make_bar_config();
127        let mut t = Theme::default();
128        t.label_font_family = "serif".into();
129        t.label_letter_spacing = 1.5;
130        t.label_text_transform = TextTransform::Uppercase;
131        t.label_font_weight = 600;
132        config.theme = t;
133
134        let element = renderer.render(&data, &config).unwrap();
135
136        // Walk the tree: for every axis-label text, assert the new fields
137        // are set from the theme override.
138        fn walk<'a>(el: &'a ChartElement, out: &mut Vec<&'a ChartElement>) {
139            match el {
140                ChartElement::Svg { children, .. }
141                | ChartElement::Group { children, .. } => {
142                    for c in children {
143                        walk(c, out);
144                    }
145                }
146                _ => out.push(el),
147            }
148        }
149        let mut leaves = Vec::new();
150        walk(&element, &mut leaves);
151
152        let mut axis_label_count = 0usize;
153        for leaf in &leaves {
154            if let ChartElement::Text {
155                class,
156                font_family,
157                letter_spacing,
158                text_transform,
159                font_weight,
160                ..
161            } = leaf
162            {
163                // Only inspect axis-label role (which reads the label_* group).
164                let is_axis_label = class
165                    .split_whitespace()
166                    .any(|c| c == "axis-label");
167                if !is_axis_label {
168                    continue;
169                }
170                axis_label_count += 1;
171
172                assert_eq!(
173                    font_family.as_deref(),
174                    Some("serif"),
175                    "axis-label text must carry theme.label_font_family"
176                );
177                assert_eq!(
178                    letter_spacing.as_deref(),
179                    Some("1.5"),
180                    "axis-label text must carry theme.label_letter_spacing"
181                );
182                assert_eq!(
183                    text_transform.as_deref(),
184                    Some("uppercase"),
185                    "axis-label text must carry theme.label_text_transform"
186                );
187                assert_eq!(
188                    font_weight.as_deref(),
189                    Some("600"),
190                    "axis-label text must carry theme.label_font_weight"
191                );
192            }
193        }
194        assert!(
195            axis_label_count > 0,
196            "bar chart should have at least one axis-label text"
197        );
198    }
199
200    /// Verify the same properties propagate to `tick-value` (numeric) text
201    /// elements, which read from the `numeric_*` group for family/size but
202    /// inherit `label_*` for weight, letter-spacing, and text-transform.
203    #[test]
204    fn phase4_theme_typography_flows_to_tick_value_text() {
205        use chartml_core::theme::{TextTransform, Theme};
206
207        let renderer = CartesianRenderer::new();
208        let data = make_bar_data();
209        let mut config = make_bar_config();
210        let mut t = Theme::default();
211        t.numeric_font_family = "monospace".into();
212        t.label_letter_spacing = 0.75;
213        t.label_text_transform = TextTransform::Lowercase;
214        config.theme = t;
215
216        let element = renderer.render(&data, &config).unwrap();
217
218        let mut found = false;
219        fn visit<F: FnMut(&ChartElement)>(el: &ChartElement, f: &mut F) {
220            f(el);
221            match el {
222                ChartElement::Svg { children, .. }
223                | ChartElement::Group { children, .. } => {
224                    for c in children {
225                        visit(c, f);
226                    }
227                }
228                _ => {}
229            }
230        }
231        visit(&element, &mut |el| {
232            if let ChartElement::Text {
233                class,
234                font_family,
235                letter_spacing,
236                text_transform,
237                ..
238            } = el
239            {
240                if class
241                    .split_whitespace()
242                    .any(|c| c == "tick-value")
243                {
244                    found = true;
245                    assert_eq!(
246                        font_family.as_deref(),
247                        Some("monospace"),
248                        "tick-value text must carry theme.numeric_font_family"
249                    );
250                    assert_eq!(
251                        letter_spacing.as_deref(),
252                        Some("0.75"),
253                        "tick-value text must inherit theme.label_letter_spacing"
254                    );
255                    assert_eq!(
256                        text_transform.as_deref(),
257                        Some("lowercase"),
258                        "tick-value text must inherit theme.label_text_transform"
259                    );
260                }
261            }
262        });
263        assert!(found, "bar chart should emit at least one tick-value text");
264    }
265
266    #[test]
267    fn bar_chart_renders() {
268        let renderer = CartesianRenderer::new();
269        let data = make_bar_data();
270        let config = make_bar_config();
271        let result = renderer.render(&data, &config);
272        assert!(result.is_ok(), "Bar render failed: {:?}", result.err());
273        let element = result.unwrap();
274        let rect_count = count_elements(&element, &|e| matches!(e, ChartElement::Rect { .. }));
275        assert_eq!(rect_count, 3, "Should have 3 bars for 3 data points, got {}", rect_count);
276    }
277
278    #[test]
279    fn bar_chart_has_svg_root() {
280        let renderer = CartesianRenderer::new();
281        let data = make_bar_data();
282        let config = make_bar_config();
283        let element = renderer.render(&data, &config).unwrap();
284        assert!(matches!(element, ChartElement::Svg { .. }), "Root should be Svg");
285    }
286
287    #[test]
288    fn bar_chart_has_no_title_in_svg() {
289        // Title is rendered as HTML outside the SVG (matching JS chartml).
290        // The SVG element tree must NOT contain a chart-title text element.
291        let renderer = CartesianRenderer::new();
292        let data = make_bar_data();
293        let config = make_bar_config();
294        let element = renderer.render(&data, &config).unwrap();
295        let title_count = count_elements(&element, &|e| {
296            matches!(e, ChartElement::Text { class, .. } if class == "chart-title")
297        });
298        assert_eq!(title_count, 0, "Title must not be in the SVG element tree");
299    }
300
301    #[test]
302    fn bar_chart_has_axes() {
303        let renderer = CartesianRenderer::new();
304        let data = make_bar_data();
305        let config = make_bar_config();
306        let element = renderer.render(&data, &config).unwrap();
307        let axis_line_count = count_elements(&element, &|e| {
308            matches!(e, ChartElement::Line { class, .. } if class == "axis-line")
309        });
310        assert!(axis_line_count >= 1, "Should have axis lines, got {}", axis_line_count);
311    }
312
313    #[test]
314    fn line_chart_renders() {
315        let renderer = CartesianRenderer::new();
316        let data = make_bar_data();
317        let config = make_line_config();
318        let result = renderer.render(&data, &config);
319        assert!(result.is_ok(), "Line render failed: {:?}", result.err());
320        let element = result.unwrap();
321        let path_count = count_elements(&element, &|e| matches!(e, ChartElement::Path { .. }));
322        assert!(path_count >= 1, "Should have at least 1 path for the line, got {}", path_count);
323    }
324
325    #[test]
326    fn line_chart_path_has_stroke() {
327        let renderer = CartesianRenderer::new();
328        let data = make_bar_data();
329        let config = make_line_config();
330        let element = renderer.render(&data, &config).unwrap();
331        // Find the path and check it has a stroke
332        fn find_path(el: &ChartElement) -> Option<&ChartElement> {
333            match el {
334                ChartElement::Path { .. } => Some(el),
335                ChartElement::Svg { children, .. }
336                | ChartElement::Group { children, .. } => {
337                    children.iter().find_map(find_path)
338                }
339                _ => None,
340            }
341        }
342        let path = find_path(&element).expect("Should find a path element");
343        match path {
344            ChartElement::Path { stroke, .. } => {
345                assert!(stroke.is_some(), "Line path should have a stroke");
346            }
347            _ => unreachable!(),
348        }
349    }
350
351    #[test]
352    fn area_chart_renders() {
353        let renderer = CartesianRenderer::new();
354        let data = make_bar_data();
355        let config = make_area_config();
356        let result = renderer.render(&data, &config);
357        assert!(result.is_ok(), "Area render failed: {:?}", result.err());
358        let element = result.unwrap();
359        let path_count = count_elements(&element, &|e| matches!(e, ChartElement::Path { .. }));
360        assert!(path_count >= 1, "Should have at least 1 path for the area, got {}", path_count);
361    }
362
363    #[test]
364    fn area_chart_path_has_fill() {
365        let renderer = CartesianRenderer::new();
366        let data = make_bar_data();
367        let config = make_area_config();
368        let element = renderer.render(&data, &config).unwrap();
369        fn find_path(el: &ChartElement) -> Option<&ChartElement> {
370            match el {
371                ChartElement::Path { .. } => Some(el),
372                ChartElement::Svg { children, .. }
373                | ChartElement::Group { children, .. } => {
374                    children.iter().find_map(find_path)
375                }
376                _ => None,
377            }
378        }
379        let path = find_path(&element).expect("Should find a path element");
380        match path {
381            ChartElement::Path { fill, .. } => {
382                assert!(fill.is_some(), "Area path should have a fill");
383            }
384            _ => unreachable!(),
385        }
386    }
387
388    #[test]
389    fn unknown_type_errors() {
390        let renderer = CartesianRenderer::new();
391        let data = make_bar_data();
392        let mut config = make_bar_config();
393        config.visualize.chart_type = "unknown".to_string();
394        let result = renderer.render(&data, &config);
395        assert!(result.is_err(), "Unknown chart type should produce error");
396        match result.unwrap_err() {
397            ChartError::UnknownChartType(t) => assert_eq!(t, "unknown"),
398            other => panic!("Expected UnknownChartType, got {:?}", other),
399        }
400    }
401
402    #[test]
403    fn bar_chart_no_title() {
404        let renderer = CartesianRenderer::new();
405        let data = make_bar_data();
406        let mut config = make_bar_config();
407        config.title = None;
408        let element = renderer.render(&data, &config).unwrap();
409        let title_count = count_elements(&element, &|e| {
410            matches!(e, ChartElement::Text { class, .. } if class == "chart-title")
411        });
412        assert_eq!(title_count, 0, "Should have no title element when title is None");
413    }
414
415    #[test]
416    fn default_dimensions_returns_some() {
417        let renderer = CartesianRenderer::new();
418        let viz: VisualizeSpec = serde_yaml::from_str(r#"
419            type: bar
420            columns: x
421            rows: y
422        "#).unwrap();
423        let dims = renderer.default_dimensions(&viz);
424        assert!(dims.is_some());
425        assert_eq!(dims.unwrap().height, 400.0);
426    }
427
428    #[test]
429    fn bar_chart_adaptive_padding_2_bars() {
430        // With n=2 bars and adaptive padding=0.2, each bar should be ~36.4% of inner_width.
431        // inner_width = 800 - left_margin - right_margin ≈ 800 - 60 - 20 = 720
432        // bandwidth = 0.8/2.2 * inner_width ≈ 0.3636 * inner_width ≈ 261px
433        // Bar should NOT be close to 50% (which would indicate no padding).
434        let rows: Vec<Row> = vec![
435            [("region".to_string(), json!("US")), ("revenue".to_string(), json!(55000))].into_iter().collect(),
436            [("region".to_string(), json!("EU")), ("revenue".to_string(), json!(40000))].into_iter().collect(),
437        ];
438        let data = DataTable::from_rows(&rows).unwrap();
439        let viz: VisualizeSpec = serde_yaml::from_str(r#"
440            type: bar
441            columns: region
442            rows: revenue
443        "#).unwrap();
444        let config = ChartConfig {
445            visualize: viz,
446            title: Some("Regional Revenue".to_string()),
447            width: 800.0,
448            height: 400.0,
449            colors: vec!["#2E7D9A".to_string()],
450            theme: chartml_core::theme::Theme::default(),
451        };
452        let renderer = CartesianRenderer::new();
453        let element = renderer.render(&data, &config).unwrap();
454
455        // Find all Rect elements (bars)
456        let mut bar_widths = Vec::new();
457        fn collect_bar_widths(el: &ChartElement, widths: &mut Vec<f64>) {
458            match el {
459                ChartElement::Rect { width, class, .. } if class.split_whitespace().any(|c| c == "bar") => {
460                    widths.push(*width);
461                }
462                ChartElement::Svg { children, .. }
463                | ChartElement::Group { children, .. } => {
464                    for child in children { collect_bar_widths(child, widths); }
465                }
466                _ => {}
467            }
468        }
469        collect_bar_widths(&element, &mut bar_widths);
470
471        assert_eq!(bar_widths.len(), 2, "Should have 2 bars");
472        let bar_width = bar_widths[0];
473        println!("Bar width: {:.2}px", bar_width);
474
475        // JS applies maxBarWidth = inner_width * 0.2 clamp.
476        // With y_tick_labels pre-computation: for revenue values 100/200, the
477        // tick label "200" ≈ 21px + 15px buffer = 36px left margin.
478        // inner_width = 800 - 36 - 30 = 734px → maxBarWidth = 146.8px.
479        // bandwidth for 2 bars, padding=0.2 = ~234px → clamped to ~146.8px.
480        assert!(
481            bar_width <= 150.0,
482            "Bar width {:.1}px exceeds maxBarWidth clamp",
483            bar_width
484        );
485        assert!(
486            bar_width > 50.0,
487            "Bar width {:.1}px is unreasonably narrow",
488            bar_width
489        );
490    }
491
492    #[test]
493    fn stacked_bar_chart_renders() {
494        let rows: Vec<Row> = vec![
495            [("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(100)), ("product".to_string(), json!("A"))].into_iter().collect(),
496            [("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(50)), ("product".to_string(), json!("B"))].into_iter().collect(),
497            [("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(200)), ("product".to_string(), json!("A"))].into_iter().collect(),
498            [("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(80)), ("product".to_string(), json!("B"))].into_iter().collect(),
499        ];
500        let data = DataTable::from_rows(&rows).unwrap();
501        let viz: VisualizeSpec = serde_yaml::from_str(r#"
502            type: bar
503            mode: stacked
504            columns: month
505            rows: revenue
506            marks:
507              color: product
508        "#).unwrap();
509        let config = ChartConfig {
510            visualize: viz,
511            title: Some("Stacked Bar".to_string()),
512            width: 800.0,
513            height: 400.0,
514            colors: vec!["#2E7D9A".to_string(), "#D4A445".to_string()],
515            theme: chartml_core::theme::Theme::default(),
516        };
517        let renderer = CartesianRenderer::new();
518        let result = renderer.render(&data, &config);
519        assert!(result.is_ok(), "Stacked bar render failed: {:?}", result.err());
520        let element = result.unwrap();
521        let rect_count = count_elements(&element, &|e| matches!(e, ChartElement::Rect { class, .. } if class.split_whitespace().any(|c| c == "bar")));
522        assert_eq!(rect_count, 4, "Should have 4 bars (2 categories x 2 series), got {}", rect_count);
523    }
524
525    #[test]
526    fn grouped_bar_chart_renders() {
527        let rows: Vec<Row> = vec![
528            [("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(100)), ("product".to_string(), json!("A"))].into_iter().collect(),
529            [("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(50)), ("product".to_string(), json!("B"))].into_iter().collect(),
530            [("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(200)), ("product".to_string(), json!("A"))].into_iter().collect(),
531            [("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(80)), ("product".to_string(), json!("B"))].into_iter().collect(),
532        ];
533        let data = DataTable::from_rows(&rows).unwrap();
534        let viz: VisualizeSpec = serde_yaml::from_str(r#"
535            type: bar
536            mode: grouped
537            columns: month
538            rows: revenue
539            marks:
540              color: product
541        "#).unwrap();
542        let config = ChartConfig {
543            visualize: viz,
544            title: None,
545            width: 800.0,
546            height: 400.0,
547            colors: vec!["#2E7D9A".to_string(), "#D4A445".to_string()],
548            theme: chartml_core::theme::Theme::default(),
549        };
550        let renderer = CartesianRenderer::new();
551        let result = renderer.render(&data, &config);
552        assert!(result.is_ok(), "Grouped bar render failed: {:?}", result.err());
553        let element = result.unwrap();
554        let rect_count = count_elements(&element, &|e| matches!(e, ChartElement::Rect { class, .. } if class.split_whitespace().any(|c| c == "bar")));
555        assert_eq!(rect_count, 4, "Should have 4 bars (2 categories x 2 series), got {}", rect_count);
556    }
557
558    #[test]
559    fn multi_series_line_chart_renders() {
560        let rows: Vec<Row> = vec![
561            [("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(100)), ("product".to_string(), json!("A"))].into_iter().collect(),
562            [("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(50)), ("product".to_string(), json!("B"))].into_iter().collect(),
563            [("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(200)), ("product".to_string(), json!("A"))].into_iter().collect(),
564            [("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(80)), ("product".to_string(), json!("B"))].into_iter().collect(),
565        ];
566        let data = DataTable::from_rows(&rows).unwrap();
567        let viz: VisualizeSpec = serde_yaml::from_str(r#"
568            type: line
569            columns: month
570            rows: revenue
571            marks:
572              color: product
573        "#).unwrap();
574        let config = ChartConfig {
575            visualize: viz,
576            title: Some("Multi Line".to_string()),
577            width: 800.0,
578            height: 400.0,
579            colors: vec!["#2E7D9A".to_string(), "#D4A445".to_string()],
580            theme: chartml_core::theme::Theme::default(),
581        };
582        let renderer = CartesianRenderer::new();
583        let result = renderer.render(&data, &config);
584        assert!(result.is_ok(), "Multi-series line render failed: {:?}", result.err());
585        let element = result.unwrap();
586        let path_count = count_elements(&element, &|e| matches!(e, ChartElement::Path { class, .. } if class.split_whitespace().any(|c| c == "chartml-line-path")));
587        assert_eq!(path_count, 2, "Should have 2 line paths for 2 series, got {}", path_count);
588    }
589
590    #[test]
591    fn empty_data_returns_error() {
592        let renderer = CartesianRenderer::new();
593        let data = DataTable::from_rows(&Vec::<Row>::new()).unwrap();
594        let config = make_bar_config();
595        let result = renderer.render(&data, &config);
596        assert!(result.is_err(), "Empty data should produce an error");
597    }
598
599    #[test]
600    fn x_axis_horizontal_few_labels() {
601        use crate::helpers::{generate_x_axis, GridConfig};
602        let labels = vec!["A".into(), "B".into(), "C".into()];
603        let result = generate_x_axis(&crate::helpers::XAxisParams {
604            labels: &labels, display_label_overrides: None,
605            range: (0.0, 800.0), y_position: 350.0, available_width: 800.0,
606            x_format: None, chart_height: None, grid: &GridConfig::default(), axis_label: None,
607            theme: &chartml_core::theme::Theme::default(),
608        });
609        // Should be horizontal — no transforms on text elements
610        let text_with_transform = result.elements.iter().filter(|e| {
611            matches!(e, ChartElement::Text { transform: Some(_), .. })
612        }).count();
613        assert_eq!(text_with_transform, 0, "Horizontal strategy should have no transforms");
614    }
615
616    #[test]
617    fn x_axis_rotated_many_labels() {
618        use crate::helpers::{generate_x_axis, GridConfig};
619        let labels: Vec<String> = (0..20).map(|i| format!("Category Number {}", i)).collect();
620        let result = generate_x_axis(&crate::helpers::XAxisParams {
621            labels: &labels, display_label_overrides: None,
622            range: (0.0, 300.0), y_position: 350.0, available_width: 300.0,
623            x_format: None, chart_height: None, grid: &GridConfig::default(), axis_label: None,
624            theme: &chartml_core::theme::Theme::default(),
625        });
626        // Should be rotated — text elements have transforms
627        let text_with_transform = result.elements.iter().filter(|e| {
628            matches!(e, ChartElement::Text { transform: Some(_), .. })
629        }).count();
630        assert!(text_with_transform > 0, "Rotated strategy should have transforms");
631    }
632
633    #[test]
634    fn x_axis_rotated_labels_preserve_full_text() {
635        use crate::helpers::{generate_x_axis, GridConfig};
636        // Long date-like labels matching the long_temporal_labels test case.
637        // These are 25+ chars and must NOT be truncated when rotated.
638        let labels: Vec<String> = vec![
639            "Monday, January 6th, 2025".into(),
640            "Monday, January 13th, 2025".into(),
641            "Monday, January 20th, 2025".into(),
642            "Monday, January 27th, 2025".into(),
643            "Monday, February 3rd, 2025".into(),
644            "Monday, February 10th, 2025".into(),
645            "Monday, February 17th, 2025".into(),
646            "Monday, February 24th, 2025".into(),
647            "Monday, March 3rd, 2025".into(),
648            "Monday, March 10th, 2025".into(),
649            "Monday, March 17th, 2025".into(),
650            "Monday, March 24th, 2025".into(),
651        ];
652        let result = generate_x_axis(&crate::helpers::XAxisParams {
653            labels: &labels, display_label_overrides: None,
654            range: (0.0, 600.0), y_position: 350.0, available_width: 600.0,
655            x_format: None, chart_height: None, grid: &GridConfig::default(), axis_label: None,
656            theme: &chartml_core::theme::Theme::default(),
657        });
658        // Collect all visible tick-label text content
659        let tick_texts: Vec<&str> = result.elements.iter().filter_map(|e| {
660            if let ChartElement::Text { content, class, .. } = e {
661                if class.split_whitespace().any(|c| c == "tick-label") {
662                    return Some(content.as_str());
663                }
664            }
665            None
666        }).collect();
667        // Every visible label must contain its full original text — no ellipsis truncation
668        for text in &tick_texts {
669            assert!(!text.contains('\u{2026}'),
670                "Rotated label should NOT be truncated but got: {text:?}");
671        }
672        // Check that at least some of the full labels appear verbatim
673        assert!(tick_texts.iter().any(|t| *t == "Monday, January 6th, 2025"),
674            "Expected full label text in output, got: {:?}", tick_texts);
675    }
676
677    #[test]
678    fn x_axis_sampled_100_labels() {
679        use crate::helpers::{generate_x_axis, GridConfig};
680        let labels: Vec<String> = (0..100).map(|i| format!("Long Category Name {}", i)).collect();
681        let result = generate_x_axis(&crate::helpers::XAxisParams {
682            labels: &labels, display_label_overrides: None,
683            range: (0.0, 400.0), y_position: 350.0, available_width: 400.0,
684            x_format: None, chart_height: None, grid: &GridConfig::default(), axis_label: None,
685            theme: &chartml_core::theme::Theme::default(),
686        });
687        // Should be sampled — fewer label texts than total categories
688        let label_count = result.elements.iter().filter(|e| {
689            matches!(e, ChartElement::Text { class, .. } if class.split_whitespace().any(|c| c == "tick-label"))
690        }).count();
691        assert!(label_count < 100, "Sampled should show fewer labels: got {}", label_count);
692        assert!(label_count >= 3, "Should show at least a few labels");
693    }
694
695    #[test]
696    fn line_chart_grid_dash_array() {
697        let data = make_bar_data();
698        // Use unquoted dashArray value (matching the examples_source.md spec)
699        let viz: VisualizeSpec = serde_yaml::from_str(r#"
700type: line
701columns: month
702rows: revenue
703style:
704  grid:
705    x: true
706    y: true
707    color: '#e0e0e0'
708    opacity: 0.5
709    dashArray: 4,4
710  showDots: true
711"#).unwrap();
712
713        // Verify the grid spec parsed correctly
714        let grid_spec = viz.style.as_ref().unwrap().grid.as_ref().unwrap();
715        assert_eq!(grid_spec.dash_array, Some("4,4".to_string()), "GridSpec.dash_array should parse from YAML");
716        assert_eq!(grid_spec.x, Some(true), "grid.x should be true");
717        assert_eq!(grid_spec.y, Some(true), "grid.y should be true");
718
719        let config = ChartConfig {
720            visualize: viz,
721            title: Some("Dashed Grid Test".to_string()),
722            width: 800.0,
723            height: 400.0,
724            colors: vec!["#2E7D9A".to_string()],
725            theme: chartml_core::theme::Theme::default(),
726        };
727
728        // Verify GridConfig resolves correctly
729        let grid_config = crate::helpers::GridConfig::from_config(&config);
730        assert_eq!(grid_config.dash_array, Some("4,4".to_string()), "GridConfig.dash_array should be set");
731        assert!(grid_config.show_x, "grid.show_x should be true");
732        assert!(grid_config.show_y, "grid.show_y should be true");
733
734        let renderer = CartesianRenderer::new();
735        let element = renderer.render(&data, &config).unwrap();
736
737        // Count grid lines and verify ALL have stroke_dasharray set
738        let mut dashed_grid_count = 0;
739        let mut total_grid_count = 0;
740        fn check_grid(el: &ChartElement, dashed: &mut usize, total: &mut usize) {
741            match el {
742                ChartElement::Line { class, stroke_dasharray, .. } if class.contains("grid-line") => {
743                    *total += 1;
744                    if let Some(da) = stroke_dasharray {
745                        if !da.is_empty() {
746                            *dashed += 1;
747                        }
748                    }
749                }
750                ChartElement::Svg { children, .. } | ChartElement::Group { children, .. } => {
751                    for child in children {
752                        check_grid(child, dashed, total);
753                    }
754                }
755                _ => {}
756            }
757        }
758        check_grid(&element, &mut dashed_grid_count, &mut total_grid_count);
759
760        assert!(total_grid_count > 0, "Should have grid lines, got {}", total_grid_count);
761        assert_eq!(dashed_grid_count, total_grid_count,
762            "All {} grid lines should have stroke_dasharray='4,4', but only {} do",
763            total_grid_count, dashed_grid_count);
764    }
765
766    // ----- Phase 5: theme shape/stroke wiring -----
767
768    /// Collect every `Path` stroke_width on elements whose class matches
769    /// `series-line` (the series-weight role wired in Phase 5).
770    fn collect_series_stroke_widths(el: &ChartElement, out: &mut Vec<f64>) {
771        match el {
772            ChartElement::Path { stroke_width: Some(w), class, .. }
773                if class.split_whitespace().any(|c| c == "series-line") =>
774            {
775                out.push(*w);
776            }
777            ChartElement::Svg { children, .. }
778            | ChartElement::Group { children, .. } => {
779                for c in children {
780                    collect_series_stroke_widths(c, out);
781                }
782            }
783            _ => {}
784        }
785    }
786
787    /// Collect every `Line` stroke_width bucketed by role
788    /// (`axis-line`, `grid-line`, `tick`).
789    fn collect_line_stroke_widths_by_class(
790        el: &ChartElement,
791        out: &mut std::collections::HashMap<String, Vec<f64>>,
792    ) {
793        match el {
794            ChartElement::Line { stroke_width: Some(w), class, .. } => {
795                for token in class.split_whitespace() {
796                    if matches!(token, "axis-line" | "grid-line" | "tick") {
797                        out.entry(token.to_string()).or_default().push(*w);
798                    }
799                }
800            }
801            ChartElement::Svg { children, .. }
802            | ChartElement::Group { children, .. } => {
803                for c in children {
804                    collect_line_stroke_widths_by_class(c, out);
805                }
806            }
807            _ => {}
808        }
809    }
810
811    /// Collect all `(rx, ry)` pairs on `Rect` elements with a `bar` class.
812    fn collect_bar_corner_radii(
813        el: &ChartElement,
814        out: &mut Vec<(Option<f64>, Option<f64>)>,
815    ) {
816        match el {
817            ChartElement::Rect { rx, ry, class, .. }
818                if class.split_whitespace().any(|c| c == "bar") =>
819            {
820                out.push((*rx, *ry));
821            }
822            ChartElement::Svg { children, .. }
823            | ChartElement::Group { children, .. } => {
824                for c in children {
825                    collect_bar_corner_radii(c, out);
826                }
827            }
828            _ => {}
829        }
830    }
831
832    /// Collect every `Circle.r` on elements whose class contains `dot-marker`.
833    fn collect_dot_radii(el: &ChartElement, out: &mut Vec<f64>) {
834        match el {
835            ChartElement::Circle { r, class, .. }
836                if class.split_whitespace().any(|c| c == "dot-marker") =>
837            {
838                out.push(*r);
839            }
840            ChartElement::Svg { children, .. }
841            | ChartElement::Group { children, .. } => {
842                for c in children {
843                    collect_dot_radii(c, out);
844                }
845            }
846            _ => {}
847        }
848    }
849
850    #[test]
851    fn phase5_bar_corner_radius_omitted_by_default() {
852        // Default theme MUST NOT emit rx/ry on bar rects (byte-identical contract).
853        let renderer = CartesianRenderer::new();
854        let element = renderer
855            .render(&make_bar_data(), &make_bar_config())
856            .expect("render");
857
858        let mut radii = Vec::new();
859        collect_bar_corner_radii(&element, &mut radii);
860        assert!(!radii.is_empty(), "expected bar rects in default bar chart");
861        for (rx, ry) in &radii {
862            assert!(rx.is_none(), "default theme must leave Rect.rx == None");
863            assert!(ry.is_none(), "default theme must leave Rect.ry == None");
864        }
865    }
866
867    #[test]
868    fn phase5_custom_bar_corner_radius_emits_rx_ry() {
869        use chartml_core::theme::{BarCornerRadius, Theme};
870        let renderer = CartesianRenderer::new();
871        let mut config = make_bar_config();
872        let mut t = Theme::default();
873        t.bar_corner_radius = BarCornerRadius::Uniform(8.0);
874        config.theme = t;
875        let element = renderer.render(&make_bar_data(), &config).expect("render");
876
877        let mut radii = Vec::new();
878        collect_bar_corner_radii(&element, &mut radii);
879        assert!(!radii.is_empty());
880        for (rx, ry) in &radii {
881            assert_eq!(*rx, Some(8.0), "rx must match theme.bar_corner_radius");
882            assert_eq!(*ry, Some(8.0), "ry must match theme.bar_corner_radius");
883        }
884    }
885
886    // ---- Phase follow-up: BarCornerRadius::Top top-only rounding ----
887
888    fn collect_bar_elements<'a>(el: &'a ChartElement, out: &mut Vec<&'a ChartElement>) {
889        match el {
890            ChartElement::Rect { class, .. } | ChartElement::Path { class, .. }
891                if class.split_whitespace().any(|c| c == "bar-rect") =>
892            {
893                out.push(el);
894            }
895            ChartElement::Svg { children, .. } | ChartElement::Group { children, .. } => {
896                for c in children {
897                    collect_bar_elements(c, out);
898                }
899            }
900            _ => {}
901        }
902    }
903
904    #[test]
905    fn phase_followup_bar_top_rounding_zero_is_plain_rect() {
906        use chartml_core::theme::{BarCornerRadius, Theme};
907        let renderer = CartesianRenderer::new();
908        let mut config = make_bar_config();
909        let mut t = Theme::default();
910        t.bar_corner_radius = BarCornerRadius::Top(0.0);
911        config.theme = t;
912        let element = renderer.render(&make_bar_data(), &config).expect("render");
913
914        let mut bars = Vec::new();
915        collect_bar_elements(&element, &mut bars);
916        assert!(!bars.is_empty());
917        for b in &bars {
918            match b {
919                ChartElement::Rect { rx, ry, .. } => {
920                    assert!(rx.is_none(), "Top(0.0) must emit Rect with rx=None");
921                    assert!(ry.is_none(), "Top(0.0) must emit Rect with ry=None");
922                }
923                other => panic!("Top(0.0) must emit Rect, got {:?}", other),
924            }
925        }
926    }
927
928    #[test]
929    fn phase_followup_bar_top_rounding_vertical() {
930        use chartml_core::theme::{BarCornerRadius, Theme};
931        let renderer = CartesianRenderer::new();
932        let mut config = make_bar_config();
933        let mut t = Theme::default();
934        t.bar_corner_radius = BarCornerRadius::Top(8.0);
935        config.theme = t;
936        let element = renderer.render(&make_bar_data(), &config).expect("render");
937
938        let mut bars = Vec::new();
939        collect_bar_elements(&element, &mut bars);
940        assert!(!bars.is_empty(), "expected bar elements");
941        for b in &bars {
942            match b {
943                ChartElement::Path { d, .. } => {
944                    assert_eq!(
945                        d.matches("A 8,8").count(),
946                        2,
947                        "vertical Top(8) must produce 2 arcs, got d={d}"
948                    );
949                }
950                other => panic!("vertical Top(8) must emit Path, got {:?}", other),
951            }
952        }
953    }
954
955    #[test]
956    fn phase_followup_bar_top_rounding_horizontal() {
957        use chartml_core::theme::{BarCornerRadius, Theme};
958        let renderer = CartesianRenderer::new();
959        let mut config = make_bar_config();
960        config.visualize.orientation = Some(chartml_core::spec::Orientation::Horizontal);
961        let mut t = Theme::default();
962        t.bar_corner_radius = BarCornerRadius::Top(8.0);
963        config.theme = t;
964        let element = renderer.render(&make_bar_data(), &config).expect("render");
965
966        let mut bars = Vec::new();
967        collect_bar_elements(&element, &mut bars);
968        assert!(!bars.is_empty(), "expected bar elements (horizontal)");
969        for b in &bars {
970            match b {
971                ChartElement::Path { d, .. } => {
972                    assert_eq!(
973                        d.matches("A 8,8").count(),
974                        2,
975                        "horizontal Top(8) must produce 2 arcs, got d={d}"
976                    );
977                }
978                other => panic!("horizontal Top(8) must emit Path, got {:?}", other),
979            }
980        }
981    }
982
983    #[test]
984    fn phase_followup_bar_top_rounding_negative_vertical() {
985        // Drive build_bar_element directly so the test doesn't depend on a
986        // chart spec that emits negative values.
987        use chartml_core::theme::{BarCornerRadius, Theme};
988        use crate::bar::{build_bar_element, BarRectSpec};
989
990        let mut theme = Theme::default();
991        theme.bar_corner_radius = BarCornerRadius::Top(8.0);
992
993        let pos = build_bar_element(
994            BarRectSpec {
995                x: 100.0, y: 50.0, width: 40.0, height: 200.0,
996                is_horizontal: false, is_negative: false,
997                fill: "#000".into(),
998                class: "bar bar-rect".into(),
999                data: None,
1000            },
1001            &theme,
1002        );
1003        let neg = build_bar_element(
1004            BarRectSpec {
1005                x: 100.0, y: 50.0, width: 40.0, height: 200.0,
1006                is_horizontal: false, is_negative: true,
1007                fill: "#000".into(),
1008                class: "bar bar-rect".into(),
1009                data: None,
1010            },
1011            &theme,
1012        );
1013
1014        let pos_d = match &pos {
1015            ChartElement::Path { d, .. } => d.clone(),
1016            _ => panic!("pos must be Path"),
1017        };
1018        let neg_d = match &neg {
1019            ChartElement::Path { d, .. } => d.clone(),
1020            _ => panic!("neg must be Path"),
1021        };
1022
1023        assert_eq!(pos_d.matches("A 8,8").count(), 2);
1024        assert_eq!(neg_d.matches("A 8,8").count(), 2);
1025
1026        // Positive vertical: top rounding → path starts at (x, y+r) = (100, 58).
1027        assert!(
1028            pos_d.starts_with("M 100,58"),
1029            "pos vertical Top path should start at y+r=58, got {pos_d}"
1030        );
1031        // Negative vertical: bottom rounding → path starts at the (square)
1032        // top-left corner (100, 50).
1033        assert!(
1034            neg_d.starts_with("M 100,50"),
1035            "neg vertical Top path should start at (x, y)=(100, 50), got {neg_d}"
1036        );
1037        // Negative path must reference the bottom-edge-minus-r coordinate
1038        // y1-r = 50+200-8 = 242 where its arcs live.
1039        assert!(
1040            neg_d.contains(",242"),
1041            "neg vertical Top path should contain y1-r=242, got {neg_d}"
1042        );
1043    }
1044
1045    #[test]
1046    fn phase5_custom_series_line_weight_flows_to_line_path() {
1047        use chartml_core::theme::Theme;
1048        let renderer = CartesianRenderer::new();
1049        let mut config = make_line_config();
1050        let mut t = Theme::default();
1051        t.series_line_weight = 4.0;
1052        config.theme = t;
1053        let element = renderer
1054            .render(&make_bar_data(), &config)
1055            .expect("render");
1056
1057        let mut widths = Vec::new();
1058        collect_series_stroke_widths(&element, &mut widths);
1059        assert!(!widths.is_empty(), "expected at least one series-line path");
1060        for w in &widths {
1061            assert_eq!(*w, 4.0, "series-line stroke_width must read from theme");
1062        }
1063    }
1064
1065    #[test]
1066    fn phase5_custom_series_line_weight_flows_to_area_outline() {
1067        use chartml_core::theme::Theme;
1068        let renderer = CartesianRenderer::new();
1069        let mut config = make_area_config();
1070        let mut t = Theme::default();
1071        t.series_line_weight = 3.5;
1072        config.theme = t;
1073        let element = renderer.render(&make_bar_data(), &config).expect("render");
1074
1075        let mut widths = Vec::new();
1076        collect_series_stroke_widths(&element, &mut widths);
1077        assert!(!widths.is_empty(), "expected area outline series-line path");
1078        for w in &widths {
1079            assert_eq!(*w, 3.5);
1080        }
1081    }
1082
1083    #[test]
1084    fn phase5_custom_dot_radius_flows_to_line_markers() {
1085        use chartml_core::theme::Theme;
1086        let renderer = CartesianRenderer::new();
1087        let mut config = make_line_config();
1088        let mut t = Theme::default();
1089        t.dot_radius = 10.0;
1090        config.theme = t;
1091        let element = renderer.render(&make_bar_data(), &config).expect("render");
1092
1093        let mut radii = Vec::new();
1094        collect_dot_radii(&element, &mut radii);
1095        assert!(!radii.is_empty(), "expected dot-marker circles on line chart");
1096        for r in &radii {
1097            assert_eq!(*r, 10.0);
1098        }
1099    }
1100
1101    #[test]
1102    fn phase5_custom_axis_and_grid_line_weights_flow_to_line_strokes() {
1103        use chartml_core::theme::Theme;
1104        let renderer = CartesianRenderer::new();
1105        let mut config = make_bar_config();
1106        let mut t = Theme::default();
1107        t.axis_line_weight = 2.5;
1108        t.grid_line_weight = 0.5;
1109        config.theme = t;
1110
1111        let element = renderer.render(&make_bar_data(), &config).expect("render");
1112
1113        let mut by_class: std::collections::HashMap<String, Vec<f64>> =
1114            std::collections::HashMap::new();
1115        collect_line_stroke_widths_by_class(&element, &mut by_class);
1116
1117        let axis = by_class.get("axis-line").cloned().unwrap_or_default();
1118        let ticks = by_class.get("tick").cloned().unwrap_or_default();
1119        let grid = by_class.get("grid-line").cloned().unwrap_or_default();
1120
1121        assert!(!axis.is_empty(), "expected axis-line elements");
1122        assert!(!ticks.is_empty(), "expected tick elements");
1123        assert!(!grid.is_empty(), "expected grid-line elements");
1124
1125        for w in &axis {
1126            assert_eq!(*w, 2.5, "axis-line stroke_width must read from theme.axis_line_weight");
1127        }
1128        for w in &ticks {
1129            assert_eq!(*w, 2.5, "tick stroke_width must read from theme.axis_line_weight");
1130        }
1131        for w in &grid {
1132            assert_eq!(*w, 0.5, "grid-line stroke_width must read from theme.grid_line_weight");
1133        }
1134    }
1135
1136    #[test]
1137    fn x_axis_date_labels_reformatted() {
1138        use crate::helpers::{generate_x_axis, GridConfig};
1139        let labels: Vec<String> = vec![
1140            "2024-01-01".into(), "2024-01-02".into(), "2024-01-03".into()
1141        ];
1142        let result = generate_x_axis(&crate::helpers::XAxisParams {
1143            labels: &labels, display_label_overrides: None,
1144            range: (0.0, 800.0), y_position: 350.0, available_width: 800.0,
1145            x_format: None, chart_height: None, grid: &GridConfig::default(), axis_label: None,
1146            theme: &chartml_core::theme::Theme::default(),
1147        });
1148        // Labels should be reformatted as "Jan 01", "Jan 02", etc.
1149        let has_reformatted = result.elements.iter().any(|e| {
1150            matches!(e, ChartElement::Text { content, .. } if content.starts_with("Jan"))
1151        });
1152        assert!(has_reformatted, "Date labels should be reformatted");
1153    }
1154
1155    // ----- Phase 6: theme.grid_style gating -----
1156
1157    /// Walk an element tree and count grid-line-x (vertical) and grid-line-y
1158    /// (horizontal) gridlines emitted by the cartesian renderer.
1159    fn count_grid_lines(el: &ChartElement) -> (usize, usize) {
1160        let (mut vx, mut hy) = (0usize, 0usize);
1161        fn visit(el: &ChartElement, vx: &mut usize, hy: &mut usize) {
1162            match el {
1163                ChartElement::Line { class, .. } => {
1164                    let has_x = class.split_whitespace().any(|c| c == "grid-line-x");
1165                    let has_y = class.split_whitespace().any(|c| c == "grid-line-y");
1166                    if has_x {
1167                        *vx += 1;
1168                    }
1169                    if has_y {
1170                        *hy += 1;
1171                    }
1172                }
1173                ChartElement::Svg { children, .. }
1174                | ChartElement::Group { children, .. } => {
1175                    for c in children {
1176                        visit(c, vx, hy);
1177                    }
1178                }
1179                _ => {}
1180            }
1181        }
1182        visit(el, &mut vx, &mut hy);
1183        (vx, hy)
1184    }
1185
1186    /// Build a bar-chart config with both horizontal and vertical gridlines
1187    /// enabled (show_y defaults to true; explicitly force show_x via spec).
1188    fn make_bar_config_both_grids() -> ChartConfig {
1189        let viz: VisualizeSpec = serde_yaml::from_str(r#"
1190            type: bar
1191            columns: month
1192            rows: revenue
1193            style:
1194              grid:
1195                x: true
1196                y: true
1197        "#).unwrap();
1198        ChartConfig {
1199            visualize: viz,
1200            title: Some("Test Bar GridStyle".to_string()),
1201            width: 800.0,
1202            height: 400.0,
1203            colors: vec!["#2E7D9A".to_string()],
1204            theme: chartml_core::theme::Theme::default(),
1205        }
1206    }
1207
1208    #[test]
1209    fn phase6_grid_style_both_default_emits_both_orientations() {
1210        use chartml_core::theme::{GridStyle, Theme};
1211        let renderer = CartesianRenderer::new();
1212        let data = make_bar_data();
1213        let mut config = make_bar_config_both_grids();
1214        let mut t = Theme::default();
1215        t.grid_style = GridStyle::Both;
1216        config.theme = t;
1217
1218        let element = renderer.render(&data, &config).unwrap();
1219        let (vx, hy) = count_grid_lines(&element);
1220        assert!(vx > 0, "Both: expected vertical gridlines (grid-line-x)");
1221        assert!(hy > 0, "Both: expected horizontal gridlines (grid-line-y)");
1222    }
1223
1224    #[test]
1225    fn phase6_grid_style_horizontal_only_skips_vertical() {
1226        use chartml_core::theme::{GridStyle, Theme};
1227        let renderer = CartesianRenderer::new();
1228        let data = make_bar_data();
1229        let mut config = make_bar_config_both_grids();
1230        let mut t = Theme::default();
1231        t.grid_style = GridStyle::HorizontalOnly;
1232        config.theme = t;
1233
1234        let element = renderer.render(&data, &config).unwrap();
1235        let (vx, hy) = count_grid_lines(&element);
1236        assert_eq!(vx, 0, "HorizontalOnly: no grid-line-x expected, got {}", vx);
1237        assert!(hy > 0, "HorizontalOnly: expected grid-line-y lines");
1238    }
1239
1240    #[test]
1241    fn phase6_grid_style_vertical_only_skips_horizontal() {
1242        use chartml_core::theme::{GridStyle, Theme};
1243        let renderer = CartesianRenderer::new();
1244        let data = make_bar_data();
1245        let mut config = make_bar_config_both_grids();
1246        let mut t = Theme::default();
1247        t.grid_style = GridStyle::VerticalOnly;
1248        config.theme = t;
1249
1250        let element = renderer.render(&data, &config).unwrap();
1251        let (vx, hy) = count_grid_lines(&element);
1252        assert!(vx > 0, "VerticalOnly: expected grid-line-x lines");
1253        assert_eq!(hy, 0, "VerticalOnly: no grid-line-y expected, got {}", hy);
1254    }
1255
1256    #[test]
1257    fn phase6_grid_style_none_skips_all_gridlines() {
1258        use chartml_core::theme::{GridStyle, Theme};
1259        let renderer = CartesianRenderer::new();
1260        let data = make_bar_data();
1261        let mut config = make_bar_config_both_grids();
1262        let mut t = Theme::default();
1263        t.grid_style = GridStyle::None;
1264        config.theme = t;
1265
1266        let element = renderer.render(&data, &config).unwrap();
1267        let (vx, hy) = count_grid_lines(&element);
1268        assert_eq!(vx, 0, "None: no grid-line-x expected, got {}", vx);
1269        assert_eq!(hy, 0, "None: no grid-line-y expected, got {}", hy);
1270    }
1271
1272    // ----- Phase 7: zero-line wiring -----
1273
1274    fn make_bar_data_crossing_zero() -> DataTable {
1275        let rows = vec![
1276            [("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(-5))].into_iter().collect(),
1277            [("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(0))].into_iter().collect(),
1278            [("month".to_string(), json!("Mar")), ("revenue".to_string(), json!(10))].into_iter().collect(),
1279        ];
1280        DataTable::from_rows(&rows).unwrap()
1281    }
1282
1283    fn count_zero_lines(el: &ChartElement) -> usize {
1284        count_elements(el, &|e| {
1285            matches!(e, ChartElement::Line { class, .. } if class.split_whitespace().any(|c| c == "zero-line"))
1286        })
1287    }
1288
1289    /// With the default theme (`zero_line: None`), no zero-line element must
1290    /// ever be emitted — even when the data range obviously crosses zero.
1291    #[test]
1292    fn phase7_default_theme_emits_no_zero_line() {
1293        let renderer = CartesianRenderer::new();
1294        let data = make_bar_data_crossing_zero();
1295        let config = make_bar_config();
1296        let element = renderer.render(&data, &config).unwrap();
1297        assert_eq!(count_zero_lines(&element), 0, "default theme must not emit zero-line");
1298    }
1299
1300    /// With a non-default `zero_line` spec AND data that strictly crosses zero,
1301    /// exactly one `zero-line` Line must be emitted with the spec'd color/width.
1302    #[test]
1303    fn phase7_bar_crossing_zero_emits_one_zero_line() {
1304        use chartml_core::theme::{Theme, ZeroLineSpec};
1305        let renderer = CartesianRenderer::new();
1306        let data = make_bar_data_crossing_zero();
1307        let mut config = make_bar_config();
1308        let mut t = Theme::default();
1309        t.zero_line = Some(ZeroLineSpec { color: "#ff0000".into(), width: 1.5 });
1310        config.theme = t;
1311
1312        let element = renderer.render(&data, &config).unwrap();
1313        assert_eq!(count_zero_lines(&element), 1, "expected exactly one zero-line");
1314
1315        // Verify the emitted element has the configured stroke + width.
1316        fn find_zero_line(el: &ChartElement) -> Option<(String, Option<f64>)> {
1317            match el {
1318                ChartElement::Line { class, stroke, stroke_width, .. }
1319                    if class.split_whitespace().any(|c| c == "zero-line") =>
1320                {
1321                    Some((stroke.clone(), *stroke_width))
1322                }
1323                ChartElement::Group { children, .. } | ChartElement::Svg { children, .. } => {
1324                    children.iter().find_map(find_zero_line)
1325                }
1326                _ => None,
1327            }
1328        }
1329        let (stroke, width) = find_zero_line(&element).expect("zero-line present");
1330        assert_eq!(stroke, "#ff0000");
1331        assert_eq!(width, Some(1.5));
1332    }
1333
1334    /// Horizontal bar parity: crossing-zero data + non-default zero_line must
1335    /// emit exactly one zero-line, and for a horizontal bar chart (numeric axis
1336    /// is x) that line must run vertically — x1 == x2 and y1 != y2.
1337    #[test]
1338    fn phase7_horizontal_bar_crossing_zero_emits_one_zero_line() {
1339        use chartml_core::theme::{Theme, ZeroLineSpec};
1340        let renderer = CartesianRenderer::new();
1341        let data = make_bar_data_crossing_zero();
1342        let viz: VisualizeSpec = serde_yaml::from_str(r#"
1343            type: bar
1344            orientation: horizontal
1345            columns: month
1346            rows: revenue
1347        "#).unwrap();
1348        let mut theme = Theme::default();
1349        theme.zero_line = Some(ZeroLineSpec { color: "#ff0000".into(), width: 1.5 });
1350        let config = ChartConfig {
1351            visualize: viz,
1352            title: Some("Test Horizontal Bar".to_string()),
1353            width: 800.0,
1354            height: 400.0,
1355            colors: vec!["#2E7D9A".to_string()],
1356            theme,
1357        };
1358
1359        let element = renderer.render(&data, &config).unwrap();
1360        assert_eq!(count_zero_lines(&element), 1, "expected exactly one zero-line");
1361
1362        // Find the emitted zero-line Line and assert it runs vertically with
1363        // the spec'd stroke + width.
1364        struct ZeroLineGeom {
1365            x1: f64,
1366            y1: f64,
1367            x2: f64,
1368            y2: f64,
1369            stroke: String,
1370            stroke_width: Option<f64>,
1371        }
1372        fn find_zero_line_geom(el: &ChartElement) -> Option<ZeroLineGeom> {
1373            match el {
1374                ChartElement::Line { class, x1, y1, x2, y2, stroke, stroke_width, .. }
1375                    if class.split_whitespace().any(|c| c == "zero-line") =>
1376                {
1377                    Some(ZeroLineGeom {
1378                        x1: *x1,
1379                        y1: *y1,
1380                        x2: *x2,
1381                        y2: *y2,
1382                        stroke: stroke.clone(),
1383                        stroke_width: *stroke_width,
1384                    })
1385                }
1386                ChartElement::Group { children, .. } | ChartElement::Svg { children, .. } => {
1387                    children.iter().find_map(find_zero_line_geom)
1388                }
1389                _ => None,
1390            }
1391        }
1392        let ZeroLineGeom { x1, y1, x2, y2, stroke, stroke_width: width } =
1393            find_zero_line_geom(&element).expect("zero-line present");
1394        assert!(
1395            (x1 - x2).abs() < f64::EPSILON,
1396            "horizontal-bar zero-line must be vertical: x1={x1} x2={x2}",
1397        );
1398        assert!(
1399            (y1 - y2).abs() > f64::EPSILON,
1400            "horizontal-bar zero-line must have non-zero height: y1={y1} y2={y2}",
1401        );
1402        assert_eq!(stroke, "#ff0000");
1403        assert_eq!(width, Some(1.5));
1404    }
1405
1406    /// With a non-default `zero_line` spec BUT data entirely positive (so the
1407    /// domain floor is 0 and doesn't strictly cross zero), no zero-line is emitted.
1408    #[test]
1409    fn phase7_bar_all_positive_emits_no_zero_line() {
1410        use chartml_core::theme::{Theme, ZeroLineSpec};
1411        let renderer = CartesianRenderer::new();
1412        let data = make_bar_data(); // values: 100, 200, 150 — all positive
1413        let mut config = make_bar_config();
1414        let mut t = Theme::default();
1415        t.zero_line = Some(ZeroLineSpec { color: "#ff0000".into(), width: 1.5 });
1416        config.theme = t;
1417
1418        let element = renderer.render(&data, &config).unwrap();
1419        assert_eq!(
1420            count_zero_lines(&element),
1421            0,
1422            "all-positive data must not emit a zero-line",
1423        );
1424    }
1425
1426    /// Line chart parity: crossing-zero data + non-default zero_line emits one line.
1427    #[test]
1428    fn phase7_line_crossing_zero_emits_one_zero_line() {
1429        use chartml_core::theme::{Theme, ZeroLineSpec};
1430        let renderer = CartesianRenderer::new();
1431        let data = make_bar_data_crossing_zero();
1432        let mut config = make_line_config();
1433        let mut t = Theme::default();
1434        t.zero_line = Some(ZeroLineSpec { color: "#00ff00".into(), width: 2.0 });
1435        config.theme = t;
1436        let element = renderer.render(&data, &config).unwrap();
1437        assert_eq!(count_zero_lines(&element), 1);
1438    }
1439
1440    // ----- Phase 8: dot_halo wiring -----
1441
1442    fn count_halos(el: &ChartElement) -> usize {
1443        count_elements(el, &|e| matches!(e, ChartElement::Path { class, .. } if class == "dot-halo"))
1444    }
1445
1446    fn count_dot_markers(el: &ChartElement) -> usize {
1447        count_elements(el, &|e| matches!(e, ChartElement::Circle { class, .. } if class.contains("dot-marker")))
1448    }
1449
1450    #[test]
1451    fn phase8_line_default_theme_emits_no_halo() {
1452        let renderer = CartesianRenderer::new();
1453        let element = renderer.render(&make_bar_data(), &make_line_config()).unwrap();
1454        assert_eq!(count_halos(&element), 0, "default theme line chart must emit zero halos");
1455    }
1456
1457    #[test]
1458    fn phase8_line_halo_matches_dot_count_and_ordering() {
1459        use chartml_core::theme::Theme;
1460        let renderer = CartesianRenderer::new();
1461        let data = make_bar_data();
1462        let mut config = make_line_config();
1463        let mut t = Theme::default();
1464        t.dot_halo_color = Some("#ffffff".to_string());
1465        t.dot_halo_width = 1.5;
1466        config.theme = t;
1467        let element = renderer.render(&data, &config).unwrap();
1468
1469        let dot_n = count_dot_markers(&element);
1470        let halo_n = count_halos(&element);
1471        assert!(dot_n > 0, "line chart should produce at least one dot-marker");
1472        assert_eq!(halo_n, dot_n, "one halo per dot-marker required");
1473
1474        // Halo must precede its dot: in the lines group, walk children and
1475        // assert every dot-halo Path is immediately followed by a Circle.
1476        fn walk_lines_group(el: &ChartElement) -> Option<&Vec<ChartElement>> {
1477            match el {
1478                ChartElement::Group { class, children, .. } if class == "lines" => Some(children),
1479                ChartElement::Svg { children, .. } | ChartElement::Group { children, .. } => {
1480                    children.iter().find_map(walk_lines_group)
1481                }
1482                _ => None,
1483            }
1484        }
1485        let lines = walk_lines_group(&element).expect("lines group");
1486        let mut pair = 0;
1487        let mut iter = lines.iter().peekable();
1488        while let Some(el) = iter.next() {
1489            if let ChartElement::Path { class, .. } = el {
1490                if class == "dot-halo" {
1491                    match iter.peek() {
1492                        Some(ChartElement::Circle { class: cc, .. }) => {
1493                            assert!(cc.contains("dot-marker"));
1494                            pair += 1;
1495                        }
1496                        other => panic!("halo not followed by dot: {:?}", other.map(|_| "other")),
1497                    }
1498                }
1499            }
1500        }
1501        assert_eq!(pair, dot_n);
1502
1503        // Verify stroke / stroke-width on first halo.
1504        fn first_halo(el: &ChartElement) -> Option<(String, f64)> {
1505            match el {
1506                ChartElement::Path { class, stroke, stroke_width, .. } if class == "dot-halo" => {
1507                    Some((stroke.clone().unwrap_or_default(), stroke_width.unwrap_or(-1.0)))
1508                }
1509                ChartElement::Svg { children, .. } | ChartElement::Group { children, .. } => {
1510                    children.iter().find_map(first_halo)
1511                }
1512                _ => None,
1513            }
1514        }
1515        let (stroke, width) = first_halo(&element).unwrap();
1516        assert_eq!(stroke, "#ffffff");
1517        assert!((width - 1.5).abs() < 1e-9);
1518    }
1519
1520    /// Area chart parity: crossing-zero data + non-default zero_line emits one line.
1521    #[test]
1522    fn phase7_area_crossing_zero_emits_one_zero_line() {
1523        use chartml_core::theme::{Theme, ZeroLineSpec};
1524        let renderer = CartesianRenderer::new();
1525        let data = make_bar_data_crossing_zero();
1526        let mut config = make_area_config();
1527        let mut t = Theme::default();
1528        t.zero_line = Some(ZeroLineSpec { color: "#0000ff".into(), width: 1.0 });
1529        config.theme = t;
1530        let element = renderer.render(&data, &config).unwrap();
1531        assert_eq!(count_zero_lines(&element), 1);
1532    }
1533}