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