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 #[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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 #[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 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 #[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 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 #[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(); 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 #[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 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 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 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 #[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}