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