1mod atlas;
2mod calendar;
3mod color;
4mod contour;
5mod describe;
6mod diverging;
7mod error;
8mod layout;
9mod metrics;
10mod noise;
11mod ohlc;
12#[cfg(feature = "png")]
13mod png;
14pub mod qr;
15mod rangebar;
16mod reference;
17mod render;
18mod scene;
19mod sha256;
20mod social;
21mod spec;
22mod stripes;
23mod text;
24mod time;
25
26use describe::automatic_description;
27pub use error::{ChartError, ChartWarning};
28pub use metrics::{BuiltinMetrics, TextMetrics};
29pub use sha256::sha256;
30pub use spec::{
31 AxisScale, CalendarDay, CalendarLayout, CalendarSpec, CartoucheSpec, CategoryAxisSpec,
32 ChartSpec, ChartType, Corner, Curve, Dash, DataPoint, Gaps, LayerSpec, LegendPlacement, Mark,
33 MobileSpec, OhlcPoint, Orientation, PaneSpec, RangeSpec, ReferenceSpec, SeriesSpec, Shape,
34 Stack, StripesSpec, Stroke, Theme, TimeAxisKind, TimeAxisSpec, TimePoint, TimePrecision,
35 Tooltips, TopicLinkSpec, TopicMapSpec, TopicSpec, ValueAxisSpec, ValueFormat, ZoomBound,
36 ZoomStep,
37};
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum RenderFormat {
41 Svg,
42 Html,
43}
44
45#[derive(Debug, Clone, Default)]
46pub struct RenderOptions {
47 pub id_prefix: Option<String>,
48 pub table_mode: TableMode,
49 pub variant: Variant,
50 pub manifest: bool,
52 pub styles: Styles,
54 pub hooks: bool,
59}
60
61#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
63pub enum Styles {
64 #[default]
66 Inline,
67 External,
70}
71
72#[must_use]
75pub fn stylesheet() -> String {
76 render::shared_stylesheet(&ChartType::ALL, true)
77}
78
79#[must_use]
83pub fn stylesheet_for(chart_types: &[ChartType]) -> String {
84 render::shared_stylesheet(chart_types, true)
85}
86
87#[must_use]
90pub fn stylesheet_common() -> String {
91 render::shared_stylesheet(&[], true)
92}
93
94#[must_use]
98pub fn stylesheet_types(chart_types: &[ChartType]) -> String {
99 render::shared_stylesheet(chart_types, false)
100}
101
102#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
105pub enum Variant {
106 #[default]
108 Desktop,
109 Mobile,
111 Print,
115 Social,
119}
120
121#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
122pub enum TableMode {
123 #[default]
124 Details,
125 Visible,
126}
127
128#[derive(Debug, Clone)]
129pub struct RenderOutput {
130 pub content: String,
131 pub warnings: Vec<ChartWarning>,
132 pub manifest: Option<Manifest>,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct Manifest {
141 pub chartlet: &'static str,
143 pub schema_version: u8,
144 pub spec_hash: String,
147 pub output_hash: String,
149 pub format: RenderFormat,
150 pub variant: Variant,
151 pub id_prefix: String,
155 pub warnings: Vec<ChartWarning>,
156}
157
158impl Manifest {
159 #[must_use]
167 pub fn to_json(&self) -> String {
168 #[derive(serde::Serialize)]
169 #[serde(rename_all = "camelCase")]
170 struct Json<'a> {
171 chartlet: &'a str,
172 schema_version: u8,
173 spec_hash: &'a str,
174 output_hash: &'a str,
175 format: &'a str,
176 variant: &'a str,
177 id_prefix: &'a str,
178 warnings: Vec<Warning<'a>>,
179 }
180 #[derive(serde::Serialize)]
181 struct Warning<'a> {
182 code: &'a str,
183 path: &'a str,
184 message: &'a str,
185 }
186 let json = Json {
187 chartlet: self.chartlet,
188 schema_version: self.schema_version,
189 spec_hash: &self.spec_hash,
190 output_hash: &self.output_hash,
191 format: match self.format {
192 RenderFormat::Svg => "svg",
193 RenderFormat::Html => "html",
194 },
195 variant: match self.variant {
196 Variant::Desktop => "desktop",
197 Variant::Mobile => "mobile",
198 Variant::Print => "print",
199 Variant::Social => "social",
200 },
201 id_prefix: &self.id_prefix,
202 warnings: self
203 .warnings
204 .iter()
205 .map(|warning| Warning {
206 code: warning.code,
207 path: &warning.path,
208 message: &warning.message,
209 })
210 .collect(),
211 };
212 serde_json::to_string_pretty(&json).expect("a manifest always serializes")
213 }
214}
215
216#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct TextAlternative {
220 pub description: String,
221 pub table: DataTable,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq)]
226pub struct DataTable {
227 pub caption: String,
228 pub columns: Vec<String>,
230 pub rows: Vec<Vec<String>>,
232}
233
234pub fn text_alternative(spec: &ChartSpec) -> Result<TextAlternative, ChartError> {
240 spec.validate()?;
241 Ok(TextAlternative {
242 description: spec
243 .description
244 .clone()
245 .unwrap_or_else(|| automatic_description(spec)),
246 table: render::data_table(spec),
247 })
248}
249
250pub fn render_json(
256 input: &str,
257 format: RenderFormat,
258 options: &RenderOptions,
259) -> Result<RenderOutput, ChartError> {
260 let spec = ChartSpec::from_json(input)?;
261 render(&spec, format, options)
262}
263
264pub fn render(
270 spec: &ChartSpec,
271 format: RenderFormat,
272 options: &RenderOptions,
273) -> Result<RenderOutput, ChartError> {
274 render_with_metrics(spec, format, options, &BuiltinMetrics)
275}
276
277pub fn render_with_metrics(
283 spec: &ChartSpec,
284 format: RenderFormat,
285 options: &RenderOptions,
286 metrics: &impl TextMetrics,
287) -> Result<RenderOutput, ChartError> {
288 let mut output = render_content(spec, format, options, metrics)?;
289 if options.manifest {
290 let id_prefix = match &options.id_prefix {
291 Some(id_prefix) => id_prefix.clone(),
292 None => default_id_prefix(spec)?,
293 };
294 output.manifest = Some(Manifest {
295 chartlet: env!("CARGO_PKG_VERSION"),
296 schema_version: spec.schema_version,
297 spec_hash: format!("sha256:{}", sha256::hex(&sha256(&canonical_json(spec)?))),
298 output_hash: format!("sha256:{}", sha256::hex(&sha256(output.content.as_bytes()))),
299 format,
300 variant: options.variant,
301 id_prefix,
302 warnings: output.warnings.clone(),
303 });
304 }
305 Ok(output)
306}
307
308fn render_content(
309 spec: &ChartSpec,
310 format: RenderFormat,
311 options: &RenderOptions,
312 metrics: &impl TextMetrics,
313) -> Result<RenderOutput, ChartError> {
314 let mut warnings = spec.validate()?;
315 let id_prefix = match &options.id_prefix {
316 Some(id_prefix) => {
317 validate_id_prefix(id_prefix)?;
318 id_prefix.clone()
319 }
320 None => default_id_prefix(spec)?,
321 };
322
323 let mobile = spec.mobile_variant();
324 let mobile_prefix = |prefix: &str| format!("{prefix}-m");
325 let hooks = hook_mode(options.hooks, format);
326 let panel = |spec: &ChartSpec, prefix: &str, warnings: &mut Vec<ChartWarning>| {
327 render_panel(
328 spec,
329 format,
330 prefix,
331 options.styles.into(),
332 hooks,
333 metrics,
334 warnings,
335 )
336 };
337
338 match options.variant {
339 Variant::Desktop => {}
340 Variant::Mobile => {
341 return render_mobile_alone(
342 mobile,
343 format,
344 &mobile_prefix(&id_prefix),
345 options.styles,
346 hooks,
347 metrics,
348 );
349 }
350 Variant::Print => return render_print(spec, format, &id_prefix, warnings, metrics),
351 Variant::Social => return render_social(spec, format, &id_prefix, metrics),
352 }
353
354 let mut mobile_warnings = match (&mobile, format) {
357 (Some(mobile), RenderFormat::Html) => mobile.validate_mobile_variant()?,
358 _ => Vec::new(),
359 };
360
361 if format == RenderFormat::Html && spec.zoom_steps.len() > 1 {
364 let mut panels = Vec::new();
365 for (index, step) in spec.zoom_steps.iter().enumerate() {
366 let sliced = zoom_variant(spec, step);
367 let panel_prefix = format!("{id_prefix}-z{index}");
368 let svg = panel(&sliced, &panel_prefix, &mut warnings);
369 let mobile = mobile.as_ref().map(|mobile| {
370 panel(
371 &zoom_variant(mobile, step),
372 &mobile_prefix(&panel_prefix),
373 &mut mobile_warnings,
374 )
375 });
376 panels.push(render::Panel {
377 label: step.label.clone(),
378 svg,
379 mobile,
380 });
381 }
382 dedupe_warnings(&mut warnings);
383 merge_mobile_warnings(&mut warnings, mobile_warnings);
384 return Ok(RenderOutput {
385 content: render::html_zoom(
386 &panels,
387 spec,
388 options.table_mode,
389 &id_prefix,
390 options.hooks,
391 ),
392 warnings,
393 manifest: None,
394 });
395 }
396
397 let svg = panel(spec, &id_prefix, &mut warnings);
398 let content = match format {
399 RenderFormat::Svg => svg,
400 RenderFormat::Html => {
401 let mobile = mobile
402 .as_ref()
403 .map(|mobile| panel(mobile, &mobile_prefix(&id_prefix), &mut mobile_warnings));
404 merge_mobile_warnings(&mut warnings, mobile_warnings);
405 render::html(
406 render::Panel {
407 label: String::new(),
408 svg,
409 mobile,
410 },
411 spec,
412 options.table_mode,
413 &id_prefix,
414 options.hooks,
415 )
416 }
417 };
418 Ok(RenderOutput {
419 content,
420 warnings,
421 manifest: None,
422 })
423}
424
425fn zoom_variant(spec: &ChartSpec, step: &ZoomStep) -> ChartSpec {
428 if spec.chart_type == ChartType::Time {
429 let zone = spec.time_zone().unwrap_or_default();
430 let bound = |bound: &ZoomBound| {
431 bound
432 .resolve(zone)
433 .expect("validated zoom steps resolve to timestamps")
434 };
435 spec.windowed(bound(&step.from), bound(&step.to))
436 } else {
437 let index = |bound: &ZoomBound| {
438 bound
439 .index()
440 .expect("validated zoom steps of a category chart are indices")
441 };
442 spec.sliced(index(&step.from), index(&step.to))
443 }
444}
445
446fn render_mobile_alone(
448 mobile: Option<ChartSpec>,
449 format: RenderFormat,
450 prefix: &str,
451 styles: Styles,
452 hooks: render::Hooks,
453 metrics: &impl TextMetrics,
454) -> Result<RenderOutput, ChartError> {
455 if format == RenderFormat::Html {
456 return Err(ChartError::new(
457 "option_not_supported",
458 "/render/variant",
459 "the HTML profile carries both variants; render the mobile variant alone as SVG",
460 ));
461 }
462 let Some(mobile) = mobile else {
463 return Err(ChartError::new(
464 "missing_mobile",
465 "/mobile",
466 "the specification has no mobile variant; add \"mobile\": { \"width\": 360 }",
467 ));
468 };
469 let mut warnings = mobile.validate_mobile_variant()?;
470 let content = render_panel(
471 &mobile,
472 format,
473 prefix,
474 styles.into(),
475 hooks,
476 metrics,
477 &mut warnings,
478 );
479 Ok(RenderOutput {
480 content,
481 warnings,
482 manifest: None,
483 })
484}
485
486fn render_print(
488 spec: &ChartSpec,
489 format: RenderFormat,
490 id_prefix: &str,
491 mut warnings: Vec<ChartWarning>,
492 metrics: &impl TextMetrics,
493) -> Result<RenderOutput, ChartError> {
494 if format == RenderFormat::Html {
495 return Err(ChartError::new(
496 "option_not_supported",
497 "/render/variant",
498 "the print variant is a standalone SVG; render it with the SVG format",
499 ));
500 }
501 let content = render_panel(
502 spec,
503 format,
504 &format!("{id_prefix}-p"),
505 render::StyleMode::Print,
506 render::Hooks::Off,
507 metrics,
508 &mut warnings,
509 );
510 warn_unresolved_colors(spec, "print", &mut warnings);
511 Ok(RenderOutput {
512 content,
513 warnings,
514 manifest: None,
515 })
516}
517
518fn render_social(
521 spec: &ChartSpec,
522 format: RenderFormat,
523 id_prefix: &str,
524 metrics: &impl TextMetrics,
525) -> Result<RenderOutput, ChartError> {
526 if format == RenderFormat::Html {
527 return Err(ChartError::new(
528 "option_not_supported",
529 "/render/variant",
530 "the social variant is a standalone SVG; render it with the SVG format",
531 ));
532 }
533 let mut frame_warnings = Vec::new();
534 let frame = social::Frame::new(spec, metrics, &mut frame_warnings);
535 let chart = ChartSpec {
536 width: frame.chart_width,
537 height: frame.chart_height,
538 show_title: false,
539 mobile: None,
540 ..spec.clone()
541 };
542 let mut warnings = chart.validate_mobile_variant()?;
543 warnings.append(&mut frame_warnings);
544 let content = render_panel(
545 &chart,
546 format,
547 &format!("{id_prefix}-s"),
548 render::StyleMode::Print,
549 render::Hooks::Off,
550 metrics,
551 &mut warnings,
552 );
553 warn_unresolved_colors(spec, "social", &mut warnings);
554 Ok(RenderOutput {
555 content: frame.compose(&content),
556 warnings,
557 manifest: None,
558 })
559}
560
561fn warn_unresolved_colors(spec: &ChartSpec, variant: &str, warnings: &mut Vec<ChartWarning>) {
564 for entry in spec.indexed_layers() {
565 if entry
566 .layer
567 .resolved_color()
568 .is_some_and(color::lacks_fallback)
569 {
570 warnings.push(ChartWarning::new(
571 "color_not_resolved",
572 format!("/panes/{}/layers/{}/color", entry.pane, entry.local),
573 format!("the {variant} variant cannot resolve a var() color and draws this layer in the text color; give it a fallback such as var(--name, #2563eb)"),
574 ));
575 }
576 }
577}
578
579#[cfg(feature = "png")]
581#[derive(Debug, Clone, Copy, PartialEq)]
582pub struct PngOptions {
583 pub variant: Variant,
587 pub scale: f32,
589}
590
591#[cfg(feature = "png")]
592impl Default for PngOptions {
593 fn default() -> Self {
594 Self {
595 variant: Variant::Print,
596 scale: 1.0,
597 }
598 }
599}
600
601#[cfg(feature = "png")]
603#[derive(Debug, Clone)]
604pub struct PngOutput {
605 pub png: Vec<u8>,
606 pub warnings: Vec<ChartWarning>,
607}
608
609#[cfg(feature = "png")]
618pub fn render_png(spec: &ChartSpec, options: &PngOptions) -> Result<PngOutput, ChartError> {
619 if !(0.25..=png::MAX_SCALE).contains(&options.scale) {
620 return Err(ChartError::new(
621 "invalid_scale",
622 "/render/scale",
623 "scale must be between 0.25 and 4",
624 ));
625 }
626 let mobile;
629 let (spec, variant) = match options.variant {
630 Variant::Desktop | Variant::Print => (spec, Variant::Print),
631 Variant::Social => (spec, Variant::Social),
632 Variant::Mobile => {
633 spec.validate()?;
634 mobile = spec.mobile_variant().ok_or_else(|| {
635 ChartError::new(
636 "option_not_supported",
637 "/render/variant",
638 "the chart has no mobile variant; add mobile to the specification",
639 )
640 })?;
641 (&mobile, Variant::Print)
642 }
643 };
644 let output = render(
645 spec,
646 RenderFormat::Svg,
647 &RenderOptions {
648 variant,
649 ..RenderOptions::default()
650 },
651 )?;
652 Ok(PngOutput {
653 png: png::rasterize(&output.content, options.scale)?,
654 warnings: output.warnings,
655 })
656}
657
658fn render_panel(
662 spec: &ChartSpec,
663 format: RenderFormat,
664 id_prefix: &str,
665 styles: render::StyleMode,
666 hooks: render::Hooks,
667 metrics: &impl TextMetrics,
668 warnings: &mut Vec<ChartWarning>,
669) -> String {
670 let description = spec
671 .description
672 .clone()
673 .unwrap_or_else(|| automatic_description(spec));
674 let scene = if format == RenderFormat::Html && spec.show_title {
675 let untitled = ChartSpec {
676 show_title: false,
677 ..spec.clone()
678 };
679 layout::layout(&untitled, warnings, metrics)
680 } else {
681 layout::layout(spec, warnings, metrics)
682 };
683 render::svg(&scene, spec, &description, id_prefix, styles, hooks)
684}
685
686const fn hook_mode(hooks: bool, format: RenderFormat) -> render::Hooks {
689 match (hooks, format) {
690 (false, _) => render::Hooks::Off,
691 (true, RenderFormat::Html) => render::Hooks::Attributes,
692 (true, RenderFormat::Svg) => render::Hooks::WithData,
693 }
694}
695
696fn dedupe_warnings(warnings: &mut Vec<ChartWarning>) {
698 let mut seen = std::collections::BTreeSet::new();
699 warnings.retain(|warning| seen.insert((warning.code, warning.path.clone())));
700}
701
702fn merge_mobile_warnings(warnings: &mut Vec<ChartWarning>, mut mobile: Vec<ChartWarning>) {
705 dedupe_warnings(&mut mobile);
706 let reported = warnings
707 .iter()
708 .map(|warning| (warning.code, warning.path.clone()))
709 .collect::<std::collections::BTreeSet<_>>();
710 warnings.extend(
711 mobile
712 .into_iter()
713 .filter(|warning| !reported.contains(&(warning.code, warning.path.clone())))
714 .map(|warning| ChartWarning {
715 message: format!("mobile variant: {}", warning.message),
716 ..warning
717 }),
718 );
719}
720
721fn validate_id_prefix(value: &str) -> Result<(), ChartError> {
722 let mut characters = value.chars();
723 let valid_start = characters
724 .next()
725 .is_some_and(|character| character.is_ascii_alphabetic());
726 let valid_rest = characters
727 .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'));
728 if !valid_start || !valid_rest || value.len() > 64 {
729 return Err(ChartError::new(
730 "invalid_id_prefix",
731 "/render/idPrefix",
732 "use 1–64 ASCII letters, digits, hyphens, or underscores, starting with a letter",
733 ));
734 }
735 Ok(())
736}
737
738fn canonical_json(spec: &ChartSpec) -> Result<Vec<u8>, ChartError> {
741 serde_json::to_vec(spec).map_err(|error| {
742 ChartError::new(
743 "serialization_failed",
744 "/",
745 format!("could not canonicalize the chart specification: {error}"),
746 )
747 })
748}
749
750fn default_id_prefix(spec: &ChartSpec) -> Result<String, ChartError> {
751 let hash = canonical_json(spec)?
752 .iter()
753 .fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
754 (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3)
755 });
756 Ok(format!("chartlet-{hash:016x}"))
757}
758
759#[cfg(test)]
760mod tests {
761 use super::{
762 BuiltinMetrics, ChartSpec, RenderFormat, RenderOptions, TextMetrics, Variant, render_json,
763 render_with_metrics, text_alternative,
764 };
765
766 const SPEC: &str = r#"{
767 "schemaVersion": 1,
768 "type": "bar",
769 "title": "Profit & loss",
770 "data": [
771 {"label": "North <East>", "value": 12},
772 {"label": "South", "value": -4}
773 ]
774 }"#;
775
776 #[test]
777 fn renders_accessible_deterministic_svg() {
778 let first = render_json(SPEC, RenderFormat::Svg, &RenderOptions::default()).unwrap();
779 let second = render_json(SPEC, RenderFormat::Svg, &RenderOptions::default()).unwrap();
780 assert_eq!(first.content, second.content);
781 assert!(first.content.contains("role=\"img\""));
782 assert!(first.content.contains("<title id="));
783 assert!(first.content.contains("<desc id="));
784 assert!(first.content.contains("North <East>"));
785 assert!(first.content.contains("height=\""));
786 }
787
788 #[test]
789 fn html_contains_complete_data_table() {
790 let output = render_json(SPEC, RenderFormat::Html, &RenderOptions::default()).unwrap();
791 assert!(output.content.contains("<figure"));
792 assert!(output.content.contains("<details"));
793 assert!(output.content.contains("<table>"));
794 assert!(output.content.contains("<td>−4</td>"));
795 }
796
797 #[test]
798 fn html_captions_the_title_instead_of_drawing_it() {
799 let svg = render_json(SPEC, RenderFormat::Svg, &RenderOptions::default()).unwrap();
800 let html = render_json(SPEC, RenderFormat::Html, &RenderOptions::default()).unwrap();
801 assert!(
802 svg.content
803 .contains("class=\"chartlet-title\">Profit & loss")
804 );
805 assert!(!html.content.contains("class=\"chartlet-title\""));
806 assert!(
807 html.content
808 .contains("<figcaption>Profit & loss</figcaption>")
809 );
810 assert!(html.content.contains("-title\">Profit & loss</title>"));
811 let ids = |content: &str| {
812 content
813 .split("id=\"")
814 .skip(1)
815 .filter_map(|rest| rest.split('"').next())
816 .filter(|id| id.starts_with("chartlet-"))
817 .map(str::to_owned)
818 .collect::<std::collections::BTreeSet<_>>()
819 };
820 assert!(ids(&svg.content).is_subset(&ids(&html.content)));
821 }
822
823 #[test]
824 fn show_title_false_leaves_the_title_out_of_any_svg() {
825 let spec = SPEC.replacen(
826 "\"type\": \"bar\",",
827 "\"type\": \"bar\", \"showTitle\": false,",
828 1,
829 );
830 let output = render_json(&spec, RenderFormat::Svg, &RenderOptions::default()).unwrap();
831 assert!(!output.content.contains("class=\"chartlet-title\""));
832 assert!(
833 output
834 .content
835 .contains("-title\">Profit & loss</title>")
836 );
837 }
838
839 #[test]
840 fn html_does_not_shorten_the_title() {
841 let title = "A very long title ".repeat(10);
842 let spec = SPEC.replacen("Profit & loss", title.trim(), 1);
843 let truncated = |format| {
844 render_json(&spec, format, &RenderOptions::default())
845 .unwrap()
846 .warnings
847 .iter()
848 .any(|warning| warning.code == "text_truncated" && warning.path == "/title")
849 };
850 assert!(truncated(RenderFormat::Svg));
851 assert!(!truncated(RenderFormat::Html));
852 }
853
854 #[test]
855 fn deserialization_errors_use_json_pointers() {
856 for (input, code, path) in [
857 (
858 SPEC.replace("\"title\"", "\"colour\": 1, \"title\""),
859 "invalid_spec",
860 "/colour",
861 ),
862 (
863 SPEC.replace("12}", "\"12\"}"),
864 "invalid_spec",
865 "/data/0/value",
866 ),
867 (
868 SPEC.replace("\"title\": \"Profit & loss\",", ""),
869 "invalid_spec",
870 "/",
871 ),
872 ("{\"schemaVersion\": 1,".to_owned(), "invalid_json", "/"),
873 ] {
874 let error =
875 render_json(&input, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
876 assert_eq!((error.code, error.path.as_str()), (code, path), "{input}");
877 }
878 }
879
880 #[test]
881 fn rejects_duplicate_labels_with_path() {
882 let duplicate = SPEC.replace("South", "North <East>");
883 let error =
884 render_json(&duplicate, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
885 assert_eq!(error.code, "duplicate_label");
886 assert_eq!(error.path, "/data/1/label");
887 }
888
889 #[test]
890 fn explicit_prefix_prevents_duplicate_ids() {
891 let first = render_json(
892 SPEC,
893 RenderFormat::Svg,
894 &RenderOptions {
895 id_prefix: Some("first".to_owned()),
896 ..RenderOptions::default()
897 },
898 )
899 .unwrap();
900 let second = render_json(
901 SPEC,
902 RenderFormat::Svg,
903 &RenderOptions {
904 id_prefix: Some("second".to_owned()),
905 ..RenderOptions::default()
906 },
907 )
908 .unwrap();
909 assert!(first.content.contains("first-title"));
910 assert!(second.content.contains("second-title"));
911 }
912
913 #[test]
914 fn preserves_small_values_in_every_text_alternative() {
915 let small = SPEC.replace("12}", "0.001}");
916 let output = render_json(&small, RenderFormat::Html, &RenderOptions::default()).unwrap();
917 assert!(output.content.contains("<td>0.001</td>"));
918 assert!(output.content.contains("0.001"));
919 }
920
921 #[test]
922 fn rejects_values_outside_the_supported_scale_range() {
923 let extreme = SPEC.replace("12}", "1e308}");
924 let error =
925 render_json(&extreme, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
926 assert_eq!(error.code, "unsupported_numeric_range");
927 assert_eq!(error.path, "/data/0/value");
928 }
929
930 #[test]
931 fn rejects_xml_control_characters() {
932 let invalid = SPEC.replace("Profit & loss", "Bad\\u0000title");
933 let error =
934 render_json(&invalid, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
935 assert_eq!(error.code, "invalid_xml_character");
936 assert_eq!(error.path, "/title");
937 }
938
939 #[test]
940 fn equal_values_do_not_invent_extrema() {
941 let tied = SPEC.replace("-4", "12");
942 let output = render_json(&tied, RenderFormat::Svg, &RenderOptions::default()).unwrap();
943 assert!(
944 output
945 .content
946 .contains("Bar chart with 2 equal values: 12 each.")
947 );
948 }
949
950 #[test]
951 fn visible_table_mode_omits_details_disclosure() {
952 let output = render_json(
953 SPEC,
954 RenderFormat::Html,
955 &RenderOptions {
956 table_mode: super::TableMode::Visible,
957 ..RenderOptions::default()
958 },
959 )
960 .unwrap();
961 assert!(
962 output
963 .content
964 .contains("<div class=\"chartlet-data\" data-viz-text>")
965 );
966 assert!(!output.content.contains("<details"));
967 }
968
969 #[test]
970 fn line_chart_preserves_missing_values_as_gaps() {
971 let specification = include_str!("../examples/monthly-trend.json");
972 let output =
973 render_json(specification, RenderFormat::Html, &RenderOptions::default()).unwrap();
974 assert_eq!(output.content.matches("<polyline").count(), 2);
975 assert_eq!(output.content.matches("<circle").count(), 5);
976 assert!(output.content.contains("<td>Missing</td>"));
977 }
978
979 const GROUPED: &str = r#"{
980 "schemaVersion": 1,
981 "type": "bar",
982 "title": "Budget and actual",
983 "categories": ["Jan", "Feb", "Mar"],
984 "series": [
985 {"name": "Budget", "values": [120, 150, 140]},
986 {"name": "Actual", "values": [130, 145, null]}
987 ]
988 }"#;
989
990 #[test]
991 fn grouped_bars_render_legend_bars_and_series_table() {
992 let output = render_json(GROUPED, RenderFormat::Html, &RenderOptions::default()).unwrap();
993 assert_eq!(
995 output
996 .content
997 .matches("class=\"chartlet-bar chartlet-series-1\"")
998 .count(),
999 4
1000 );
1001 assert_eq!(
1002 output
1003 .content
1004 .matches("class=\"chartlet-bar chartlet-series-2\"")
1005 .count(),
1006 3
1007 );
1008 assert!(
1009 output
1010 .content
1011 .contains("<th scope=\"col\">Budget</th><th scope=\"col\">Actual</th>")
1012 );
1013 assert!(
1014 output
1015 .content
1016 .contains("<th scope=\"row\">Mar</th><td>140</td><td>Missing</td>")
1017 );
1018 assert!(output.content.contains(
1019 "Bar chart with 3 categories and 2 series (Budget, Actual). Highest: 150 (Budget in Feb). Lowest: 120 (Budget in Jan). 1 value is missing."
1020 ));
1021 }
1022
1023 #[test]
1024 fn grouped_bars_reject_mismatched_series_length() {
1025 let short = GROUPED.replace("[130, 145, null]", "[130, 145]");
1026 let error = render_json(&short, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
1027 assert_eq!(error.code, "series_length_mismatch");
1028 assert_eq!(error.path, "/series/1/values");
1029 }
1030
1031 #[test]
1032 fn data_and_series_are_mutually_exclusive() {
1033 let both = GROUPED.replace(
1034 "\"categories\"",
1035 "\"data\": [{\"label\": \"Jan\", \"value\": 1}], \"categories\"",
1036 );
1037 let error = render_json(&both, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
1038 assert_eq!(error.code, "conflicting_data_shape");
1039 assert_eq!(error.path, "/data");
1040 }
1041
1042 #[test]
1043 fn line_charts_draw_several_series_apart_by_color_and_pattern() {
1044 let line = GROUPED.replace("\"bar\"", "\"line\"");
1045 let svg = render_ok(&line).content;
1046 assert!(svg.contains("chartlet-line chartlet-line-series-1\""));
1047 assert!(svg.contains("chartlet-line chartlet-line-series-2 chartlet-line-dashed"));
1048 assert!(svg.contains("class=\"chartlet-legend\""));
1049 assert!(!svg.contains("class=\"chartlet-value\""));
1050 }
1051
1052 #[test]
1053 fn patterns_outline_every_other_series() {
1054 let spec = GROUPED.replacen('{', "{\"patterns\": true,", 1);
1055 let svg = render_ok(&spec).content;
1056 assert!(svg.contains("chartlet-bar chartlet-series-2 chartlet-outline"));
1057 assert!(svg.contains(".chartlet-outline{fill:var(--chartlet-background)"));
1058 let line = spec.replace("\"bar\"", "\"line\"");
1059 assert_eq!(
1060 render_err(&line),
1061 ("option_not_supported", "/patterns".to_owned())
1062 );
1063 }
1064
1065 #[test]
1066 fn stacks_add_up_and_a_percent_stack_takes_no_negative_values() {
1067 let stacked = GROUPED.replacen('{', "{\"stack\": \"normal\",", 1);
1068 let svg = render_ok(&stacked).content;
1069 assert!(
1070 svg.contains("The series are stacked. Highest total:"),
1071 "{svg}"
1072 );
1073 let percent = GROUPED.replacen('{', "{\"stack\": \"percent\",", 1);
1074 assert!(render_ok(&percent).content.contains("shares of the series"));
1075 assert_eq!(
1076 render_err(&percent.replace("120", "-120")),
1077 ("invalid_value", "/series/0/values/0".to_owned())
1078 );
1079 let single = SPEC.replacen('{', "{\"stack\": \"normal\",", 1);
1080 assert_eq!(
1081 render_err(&single),
1082 ("option_not_supported", "/stack".to_owned())
1083 );
1084 }
1085
1086 #[test]
1087 fn stretched_stripes_fill_the_canvas_and_stretch() {
1088 let spec = r#"{"schemaVersion": 1, "type": "stripes", "title": "Band",
1089 "stripes": {"firstYear": 2000, "values": [0.1, -0.2, 0.3, 0.5], "stretch": true}}"#;
1090 let svg = render_ok(spec).content;
1091 assert!(svg.contains(" preserveAspectRatio=\"none\""));
1092 assert!(svg.contains("<rect x=\"0\" y=\"0\""), "{svg}");
1093 assert!(!svg.contains("class=\"chartlet-title\""));
1094 assert!(!svg.contains("class=\"chartlet-tick\""));
1095 }
1096
1097 #[test]
1098 fn tooltips_markers_leaves_a_dense_line_without_targets() {
1099 let points: Vec<String> = (0..120)
1100 .map(|minute| {
1101 format!(
1102 r#"{{"time": "2026-03-01T{:02}:{:02}:00Z", "value": {}}}"#,
1103 minute / 60,
1104 minute % 60,
1105 minute % 7
1106 )
1107 })
1108 .collect();
1109 let spec = format!(
1110 r#"{{"schemaVersion": 1, "type": "time", "title": "Visits", "panes": [{{"layers":
1111 [{{"mark": "line", "name": "Visits", "points": [{}]}}]}}]}}"#,
1112 points.join(",")
1113 );
1114 let every = render_ok(&spec).content;
1115 assert_eq!(every.matches("class=\"chartlet-hit\"").count(), 120);
1116 let markers = spec.replacen('{', "{\"tooltips\": \"markers\",", 1);
1117 let svg = render_ok(&markers).content;
1118 assert!(!svg.contains("class=\"chartlet-hit\""));
1119 assert!(svg.len() * 2 < every.len());
1120 let bar = SPEC.replacen('{', "{\"tooltips\": \"markers\",", 1);
1121 assert_eq!(
1122 render_err(&bar),
1123 ("option_not_supported", "/tooltips".to_owned())
1124 );
1125 }
1126
1127 #[test]
1128 fn a_point_layer_draws_dots_without_a_line() {
1129 let spec = include_str!("../examples/resting-heart-rate.json");
1130 let svg = render_ok(spec).content;
1131 assert_eq!(svg.matches("<polyline").count(), 2, "{svg}");
1133 assert_eq!(
1134 svg.matches("class=\"chartlet-point chartlet-point-series-1\"")
1135 .count(),
1136 28
1137 );
1138 let dashed = spec.replacen(
1139 "\"mark\": \"point\",",
1140 "\"mark\": \"point\", \"dash\": \"dotted\",",
1141 1,
1142 );
1143 assert_eq!(
1144 render_err(&dashed),
1145 ("option_not_supported", "/panes/0/layers/0/dash".to_owned())
1146 );
1147 }
1148
1149 #[test]
1150 fn a_horizontal_log_axis_writes_only_labels_that_fit_and_keeps_its_end_label() {
1151 let spec = r#"{"schemaVersion":1,"type":"rangebar","orientation":"horizontal","title":"t",
1152 "width":480,"height":240,"valueAxis":{"scale":"log"},"ranges":[
1153 {"label":"A","low":1000000,"high":10000000},{"label":"B","low":10,"high":300}]}"#;
1154 let svg = render_ok(spec).content;
1155 assert_eq!(svg.matches("class=\"chartlet-grid\"").count(), 7, "{svg}");
1157 assert_eq!(svg.matches("class=\"chartlet-tick\"").count(), 4, "{svg}");
1158 let end = svg.find(">1000000 to 10000000<").expect("the end label");
1159 let x: f64 = svg[..end]
1160 .rsplit("<text x=\"")
1161 .next()
1162 .and_then(|tail| tail.split('"').next())
1163 .and_then(|x| x.parse().ok())
1164 .expect("the label's x");
1165 assert!(
1166 x + crate::metrics::TextMetrics::width(
1167 &crate::metrics::BuiltinMetrics,
1168 "1000000 to 10000000",
1169 12.0
1170 ) <= 480.0,
1171 "{svg}"
1172 );
1173 }
1174
1175 #[test]
1176 fn every_layer_writes_its_times_as_finely_as_it_needs() {
1177 let spec = r#"{"schemaVersion": 1, "type": "time", "title": "CO2", "panes": [{"layers": [
1179 {"mark": "line", "name": "Monthly", "points": [{"time": "2020-01", "value": 1},
1180 {"time": "2020-02", "value": 2}, {"time": "2020-03", "value": 3}]},
1181 {"mark": "line", "name": "Yearly", "precision": "year", "points": [
1182 {"time": "2019-07-01", "value": 1}, {"time": "2020-07-01", "value": 2}]}]}]}"#;
1183 let html = render_json(spec, RenderFormat::Html, &RenderOptions::default())
1184 .expect("renders")
1185 .content;
1186 assert!(html.contains("<title>2020 – Yearly: 2</title>"), "{html}");
1187 assert!(
1188 html.contains("<title>2020-02 – Monthly: 2</title>"),
1189 "{html}"
1190 );
1191 assert!(html.contains("<th scope=\"row\">2019</th>"), "{html}");
1192
1193 let numeric = r#"{"schemaVersion": 1, "type": "time", "title": "Quakes",
1195 "timeAxis": {"kind": "number"}, "panes": [{"layers": [
1196 {"mark": "point", "name": "Counted", "points": [{"time": 4.5, "value": 3},
1197 {"time": 5, "value": 2}]},
1198 {"mark": "line", "name": "Fit", "points": [{"time": 4.45, "value": 3},
1199 {"time": 5.05, "value": 2}]}]}]}"#;
1200 let svg = render_ok(numeric).content;
1201 assert!(svg.contains("<title>4.5 – Counted: 3</title>"), "{svg}");
1202 assert!(svg.contains("<title>4.45 – Fit: 3</title>"), "{svg}");
1203 let on_numeric = numeric.replacen(
1204 "\"name\": \"Fit\",",
1205 "\"name\": \"Fit\", \"precision\": \"year\",",
1206 1,
1207 );
1208 assert_eq!(
1209 render_err(&on_numeric),
1210 (
1211 "option_not_supported",
1212 "/panes/0/layers/1/precision".to_owned()
1213 )
1214 );
1215 }
1216
1217 #[test]
1218 fn a_line_without_markers_keeps_its_tooltips() {
1219 let spec = r#"{"schemaVersion": 1, "type": "time", "title": "Fit", "showValues": false,
1220 "panes": [{"layers": [{"mark": "line", "name": "Fit", "markers": false, "points": [
1221 {"time": "2020", "value": 1}, {"time": "2021", "value": 2}]}]}]}"#;
1222 let svg = render_ok(spec).content;
1223 assert!(!svg.contains("class=\"chartlet-point"), "{svg}");
1224 assert_eq!(svg.matches("class=\"chartlet-hit\"").count(), 2, "{svg}");
1225 let point = spec.replace("\"mark\": \"line\"", "\"mark\": \"point\"");
1226 assert_eq!(
1227 render_err(&point),
1228 (
1229 "option_not_supported",
1230 "/panes/0/layers/0/markers".to_owned()
1231 )
1232 );
1233 }
1234
1235 #[test]
1236 fn a_declared_precision_names_times_but_keeps_the_ticks() {
1237 let points: Vec<String> = (1989..=2022)
1238 .map(|year| format!(r#"{{"time": "{year}-07-01", "value": {}}}"#, year % 7))
1239 .collect();
1240 let spec = format!(
1241 r#"{{"schemaVersion": 1, "type": "time", "title": "pH", "width": 720, "height": 340,
1242 "panes": [{{"layers": [{{"mark": "line", "name": "pH", "points": [{}]}}]}}]}}"#,
1243 points.join(",")
1244 );
1245 let declared = spec.replacen('{', "{\"timeAxis\": {\"precision\": \"year\"},", 1);
1246 let ticks = |svg: &str| svg.matches("class=\"chartlet-tick\"").count();
1247 assert_eq!(
1248 ticks(&render_ok(&declared).content),
1249 ticks(&render_ok(&spec).content)
1250 );
1251 }
1252
1253 #[test]
1254 fn the_last_step_holds_until_its_end_without_an_observation_there() {
1255 let spec = r#"{"schemaVersion": 1, "type": "time", "title": "Annual means",
1256 "showValues": false, "panes": [{"layers": [{"mark": "line", "name": "Mean",
1257 "curve": "step", "stepEnd": "2026", "points": [{"time": "2024", "value": 1},
1258 {"time": "2025", "value": 2}]}]}]}"#;
1259 let html = render_json(spec, RenderFormat::Html, &RenderOptions::default())
1260 .expect("renders")
1261 .content;
1262 assert_eq!(html.matches("<title>20").count(), 2, "{html}");
1264 assert!(!html.contains("<th scope=\"row\">2026</th>"), "{html}");
1265 let line = html
1266 .split("<polyline points=\"")
1267 .nth(1)
1268 .and_then(|rest| rest.split('"').next())
1269 .expect("the line");
1270 assert_eq!(line.split(' ').count(), 4, "{line}");
1271
1272 let linear = spec.replace("\"curve\": \"step\", ", "");
1273 assert_eq!(
1274 render_err(&linear),
1275 (
1276 "option_not_supported",
1277 "/panes/0/layers/0/stepEnd".to_owned()
1278 )
1279 );
1280 let early = spec.replace("\"stepEnd\": \"2026\"", "\"stepEnd\": \"2025\"");
1281 assert_eq!(
1282 render_err(&early),
1283 ("unordered_time", "/panes/0/layers/0/stepEnd".to_owned())
1284 );
1285 }
1286
1287 #[test]
1288 fn a_compact_chart_keeps_room_for_its_plot() {
1289 let spec = SPEC.replacen('{', "{\"width\": 240,", 1);
1290 let svg = render_ok(&spec).content;
1291 assert!(svg.contains("<line x1=\"56\""), "{svg}");
1293 let too_narrow = SPEC.replacen('{', "{\"width\": 199,", 1);
1294 assert_eq!(
1295 render_err(&too_narrow),
1296 ("invalid_dimension", "/width".to_owned())
1297 );
1298 }
1299
1300 #[test]
1301 fn panel_titles_wrap_and_notes_sit_below_them() {
1302 let panel = |title: &str, note: &str| {
1303 format!(
1304 r#"{{"title": "{title}", "note": "{note}", "layers": [{{"mark": "line",
1305 "points": [{{"time": "2000", "value": 1}}, {{"time": "2010", "value": 2}}]}}]}}"#
1306 )
1307 };
1308 let spec = format!(
1309 r#"{{"schemaVersion": 1, "type": "multiples", "title": "Causes", "width": 720,
1310 "height": 360, "columns": 3, "panes": [{}, {}]}}"#,
1311 panel("The sun and its long cycles of activity", "Does not match"),
1312 panel("Greenhouse gases", "Matches"),
1313 );
1314 let output = render_ok(&spec);
1315 assert!(output.warnings.is_empty(), "{:?}", output.warnings);
1316 let svg = &output.content;
1317 assert_eq!(
1319 svg.matches("class=\"chartlet-panel-title\"").count(),
1320 3,
1321 "{svg}"
1322 );
1323 assert_eq!(
1324 svg.matches("class=\"chartlet-panel-note\"").count(),
1325 2,
1326 "{svg}"
1327 );
1328 assert!(svg.contains(".chartlet-panel-note{"));
1329 assert!(svg.contains("Notes: The sun and its long cycles of activity: Does not match"));
1330 let strong = spec.replacen(
1331 "\"note\": \"Matches\"",
1332 "\"note\": \"Matches\", \"noteEmphasis\": true",
1333 1,
1334 );
1335 assert!(
1336 render_ok(&strong)
1337 .content
1338 .contains("class=\"chartlet-panel-note chartlet-panel-note-strong\">Matches<")
1339 );
1340 let bare = spec.replacen("\"note\": \"Matches\"", "\"noteEmphasis\": true", 1);
1341 assert_eq!(
1342 render_err(&bare),
1343 ("option_not_supported", "/panes/1/noteEmphasis".to_owned())
1344 );
1345
1346 let time = SPEC_TIME_FOR_NOTE.to_owned();
1347 assert_eq!(
1348 render_err(&time),
1349 ("option_not_supported", "/panes/0/note".to_owned())
1350 );
1351 }
1352
1353 const SPEC_TIME_FOR_NOTE: &str = r#"{"schemaVersion": 1, "type": "time", "title": "t",
1354 "panes": [{"note": "n", "layers": [{"mark": "line", "points": [{"time": "2000",
1355 "value": 1}, {"time": "2010", "value": 2}]}]}]}"#;
1356
1357 #[test]
1358 fn a_declared_time_step_places_the_ticks() {
1359 let points: Vec<String> = (1850..=2019)
1360 .map(|year| format!(r#"{{"time": "{year}", "value": {}}}"#, year % 3))
1361 .collect();
1362 let spec = format!(
1363 r#"{{"schemaVersion": 1, "type": "time", "title": "Forcing", "width": 260,
1364 "height": 240, "showValues": false, "timeAxis": {{"step": 50}},
1365 "panes": [{{"layers": [{{"mark": "line", "points": [{}]}}]}}]}}"#,
1366 points.join(",")
1367 );
1368 let svg = render_ok(&spec).content;
1369 for year in ["1850", "1900", "1950", "2000"] {
1370 assert!(svg.contains(&format!(">{year}</text>")), "{year}: {svg}");
1371 }
1372 let yearly = spec.replace("\"step\": 50", "\"step\": 1");
1374 let svg = render_ok(&yearly).content;
1375 assert!(svg.matches("class=\"chartlet-tick\"").count() < 20, "{svg}");
1376 let fraction = spec.replace("\"step\": 50", "\"step\": 2.5");
1377 assert_eq!(
1378 render_err(&fraction),
1379 ("invalid_axis_range", "/timeAxis/step".to_owned())
1380 );
1381 }
1382
1383 #[test]
1384 fn a_declared_end_extends_the_time_axis() {
1385 let points: Vec<String> = (1991..=2025)
1386 .map(|year| format!(r#"{{"time": "{year}", "value": {}}}"#, year % 5))
1387 .collect();
1388 let spec = format!(
1389 r#"{{"schemaVersion": 1, "type": "time", "title": "O2", "width": 380, "height": 190,
1390 "showValues": false, "timeAxis": {{"step": 10, "min": "1990"}},
1391 "panes": [{{"layers": [{{"mark": "line", "points": [{}]}}]}}]}}"#,
1392 points.join(",")
1393 );
1394 let output = render_ok(&spec);
1395 assert!(
1396 output.content.contains(">1990</text>"),
1397 "{}",
1398 output.content
1399 );
1400 assert!(output.content.contains("1991"), "{}", output.content);
1402 let reversed = spec.replace("\"min\": \"1990\"", "\"min\": \"1990\", \"max\": \"1980\"");
1403 assert_eq!(
1404 render_err(&reversed),
1405 ("invalid_axis_range", "/timeAxis/max".to_owned())
1406 );
1407 let collapsed = spec.replace("\"step\": 10,", "\"step\": 10, \"gaps\": \"collapse\",");
1408 assert_eq!(
1409 render_err(&collapsed),
1410 ("option_not_supported", "/timeAxis/min".to_owned())
1411 );
1412 }
1413
1414 #[test]
1415 fn a_medium_line_and_a_bare_panel_suit_small_charts() {
1416 let spec = r#"{"schemaVersion": 1, "type": "time", "title": "Panel", "showTitle": false,
1417 "width": 260, "height": 180, "showValues": false, "panes": [{"layers": [
1418 {"mark": "line", "stroke": "medium", "markers": false, "points": [
1419 {"time": "2000", "value": 1}, {"time": "2010", "value": 2}]}]}]}"#;
1420 let svg = render_ok(spec).content;
1421 assert!(
1422 svg.contains("class=\"chartlet-line chartlet-line-medium "),
1423 "{svg}"
1424 );
1425 assert!(
1426 svg.contains(".chartlet-line-medium{stroke-width:2}"),
1427 "{svg}"
1428 );
1429 assert!(svg.contains(" y1=\"14\""), "{svg}");
1431 let left: f64 = svg
1433 .split("<line x1=\"")
1434 .nth(1)
1435 .and_then(|rest| rest.split('"').next())
1436 .and_then(|x| x.parse().ok())
1437 .expect("the first gridline");
1438 assert!(left < 56.0, "{left}");
1439 }
1440
1441 #[test]
1442 fn a_unit_follows_the_top_tick_and_names_the_columns() {
1443 let spec = r#"{"schemaVersion": 1, "type": "time", "title": "Sun", "showTitle": false,
1444 "width": 340, "height": 190, "showValues": false, "panes": [{"valueAxis": {"min": -2,
1445 "max": 4, "exact": true, "step": 1, "unit": "W/m²"}, "layers": [{"mark": "line",
1446 "name": "Sun", "points": [{"time": "2000", "value": 1}, {"time": "2010", "value": 2}]}]}]}"#;
1447 let html = render_json(spec, RenderFormat::Html, &RenderOptions::default())
1448 .expect("renders")
1449 .content;
1450 assert!(html.contains(">4 W/m²</text>"), "{html}");
1451 assert_eq!(html.matches("W/m²</text>").count(), 1, "{html}");
1452 assert!(html.contains(">Sun (W/m²)</th>"), "{html}");
1453 assert!(html.contains(" y1=\"14\""), "{html}");
1455 let bar = SPEC.replacen('{', "{\"valueAxis\": {\"unit\": \"€\"},", 1);
1456 assert_eq!(
1457 render_err(&bar),
1458 ("option_not_supported", "/valueAxis/unit".to_owned())
1459 );
1460 }
1461
1462 #[test]
1463 fn range_groups_take_palette_colors_and_a_legend() {
1464 let spec = include_str!("../examples/soil-animals.json");
1465 let svg = render_ok(spec).content;
1466 for (series, spans) in [(1, 2), (2, 3), (3, 3)] {
1468 assert_eq!(
1469 svg.matches(&format!(
1470 "class=\"chartlet-range chartlet-range-series-{series}\""
1471 ))
1472 .count(),
1473 spans + 1,
1474 "{svg}"
1475 );
1476 }
1477 assert!(svg.contains(">Mesofauna</text>"));
1478 let html = render_json(spec, RenderFormat::Html, &RenderOptions::default())
1479 .expect("renders")
1480 .content;
1481 assert!(html.contains("Nematodes (Microfauna)"), "{html}");
1482 let partial = spec.replacen(", \"group\": \"Microfauna\"", "", 1);
1483 assert_eq!(
1484 render_err(&partial),
1485 ("missing_group", "/ranges/0/group".to_owned())
1486 );
1487 let five =
1488 spec.replacen("\"Microfauna\"", "\"A\"", 1)
1489 .replacen("\"Mesofauna\"", "\"B\"", 1);
1490 assert_eq!(
1491 render_err(&five),
1492 ("too_many_series", "/ranges/5/group".to_owned())
1493 );
1494 }
1495
1496 #[test]
1497 fn stacked_areas_rest_on_each_other() {
1498 let spec = include_str!("../examples/generation-mix.json");
1499 let options = RenderOptions {
1500 hooks: true,
1501 ..RenderOptions::default()
1502 };
1503 let output = render_json(spec, RenderFormat::Html, &options).expect("renders");
1504 assert!(
1506 output.content.contains(">3,000</text>"),
1507 "{}",
1508 output.content
1509 );
1510 assert_eq!(output.content.matches(" data-stacked=\"\"").count(), 4);
1511 assert!(
1512 output
1513 .content
1514 .contains("Stacked areas, from the bottom up: Fossil, Nuclear")
1515 );
1516 assert!(
1518 output.content.contains("Wind and solar: 760"),
1519 "{}",
1520 output.content
1521 );
1522
1523 let mut extra = String::new();
1525 for (index, color) in ["#0a0", "#a0a", "#aa0", "#0aa"].iter().enumerate() {
1526 let points = [2000, 2004, 2008, 2012, 2016, 2020, 2024]
1527 .map(|year| format!(r#"{{"time": "{year}", "value": 10}}"#))
1528 .join(",");
1529 std::fmt::Write::write_fmt(
1530 &mut extra,
1531 format_args!(r#",{{"mark": "area", "name": "Extra {index}", "color": "{color}", "points": [{points}]}}"#),
1532 )
1533 .expect("writing to String cannot fail");
1534 }
1535 let eight = spec.replacen(
1536 "\n ]\n }\n ]",
1537 &format!("{extra}\n ]\n }}\n ]"),
1538 1,
1539 );
1540 assert_eq!(
1541 render_ok(&eight)
1542 .content
1543 .matches("class=\"chartlet-legend\"")
1544 .count(),
1545 8
1546 );
1547 let nine = eight.replacen(",{\"mark\": \"area\", \"name\": \"Extra 0\"", ",{\"mark\": \"area\", \"name\": \"Extra 9\", \"color\": \"#123\", \"points\": [{\"time\": \"2000\", \"value\": 1}, {\"time\": \"2024\", \"value\": 1}]},{\"mark\": \"area\", \"name\": \"Extra 0\"", 1);
1548 assert_eq!(
1549 render_err(&nine),
1550 ("too_many_layers", "/panes/0/layers".to_owned())
1551 );
1552
1553 let percent = spec.replace("\"stack\": \"normal\"", "\"stack\": \"percent\"");
1554 assert_eq!(
1555 render_err(&percent),
1556 ("option_not_supported", "/panes/0/stack".to_owned())
1557 );
1558 let gap = spec.replacen("\"value\": 1520", "\"value\": null", 1);
1559 assert_eq!(
1560 render_err(&gap),
1561 (
1562 "unaligned_stack",
1563 "/panes/0/layers/0/points/1/value".to_owned()
1564 )
1565 );
1566 let negative = spec.replacen("\"value\": 1520", "\"value\": -1", 1);
1567 assert_eq!(
1568 render_err(&negative),
1569 (
1570 "negative_in_stack",
1571 "/panes/0/layers/0/points/1/value".to_owned()
1572 )
1573 );
1574 let shifted = spec.replacen(
1575 "\"time\": \"2024\", \"value\": 620",
1576 "\"time\": \"2023\", \"value\": 620",
1577 1,
1578 );
1579 assert_eq!(
1580 render_err(&shifted),
1581 ("unaligned_stack", "/panes/0/layers/1/points".to_owned())
1582 );
1583 }
1584
1585 #[test]
1586 fn a_sparkline_draws_only_its_line_at_a_small_size() {
1587 let spec = r#"{"schemaVersion": 1, "type": "time", "title": "Visitors", "sparkline": true,
1588 "width": 120, "height": 32, "panes": [{"layers": [{"mark": "line", "name": "Visitors",
1589 "points": [{"time": "2026-03-01", "value": 3}, {"time": "2026-03-02", "value": 5},
1590 {"time": "2026-03-03", "value": 4}]}]}]}"#;
1591 let svg = render_ok(spec).content;
1592 assert!(svg.contains("width=\"120\" height=\"32\""));
1593 assert!(svg.contains("<title id="));
1594 assert!(!svg.contains("class=\"chartlet-tick\""));
1595 assert!(!svg.contains("class=\"chartlet-grid\""));
1596 assert_eq!(
1598 svg.matches("class=\"chartlet-point chartlet-point-series-1\"")
1599 .count(),
1600 1,
1601 "{svg}"
1602 );
1603 assert_eq!(svg.matches("class=\"chartlet-hit\"").count(), 3, "{svg}");
1604 let wide = spec.replace("\"width\": 120", "\"width\": 800");
1605 assert_eq!(
1606 render_err(&wide),
1607 ("invalid_dimension", "/width".to_owned())
1608 );
1609 let bar = SPEC.replacen('{', "{\"sparkline\": true,", 1);
1610 assert_eq!(render_err(&bar).0, "option_not_supported");
1611 }
1612
1613 #[test]
1614 fn names_at_the_end_of_the_lines_replace_the_legend() {
1615 let spec = r#"{"schemaVersion": 1, "type": "time", "title": "Two lines", "legend": "end",
1616 "panes": [{"layers": [
1617 {"mark": "line", "name": "North", "points": [{"time": "2020", "value": 1}, {"time": "2021", "value": 3}]},
1618 {"mark": "line", "name": "South", "points": [{"time": "2020", "value": 2}, {"time": "2021", "value": 3}]}]}]}"#;
1619 let svg = render_ok(spec).content;
1620 let names: Vec<&str> = svg
1621 .split("<text ")
1622 .filter(|text| text.contains("class=\"chartlet-legend\""))
1623 .collect();
1624 assert_eq!(names.len(), 2, "{svg}");
1625 let ys: Vec<f64> = names
1627 .iter()
1628 .filter_map(|text| text.split("y=\"").nth(1)?.split('"').next()?.parse().ok())
1629 .collect();
1630 assert!((ys[0] - ys[1]).abs() >= 14.0, "{ys:?}");
1631 let bar = SPEC.replacen('{', "{\"legend\": \"end\",", 1);
1632 assert_eq!(
1633 render_err(&bar),
1634 ("option_not_supported", "/legend".to_owned())
1635 );
1636 }
1637
1638 #[test]
1639 fn an_exact_axis_ends_where_it_says_and_ticks_at_multiples_of_its_step() {
1640 let spec = r#"{"schemaVersion": 1, "type": "time", "title": "Forcing", "panes": [{
1641 "valueAxis": {"min": -2.5, "max": 3.5, "exact": true, "step": 1},
1642 "layers": [{"mark": "line", "name": "V", "points": [
1643 {"time": "2020", "value": -1}, {"time": "2021", "value": 2.2}]}]}]}"#;
1644 let svg = render_ok(spec).content;
1645 let ticks: Vec<&str> = svg
1646 .split("class=\"chartlet-tick\">")
1647 .skip(1)
1648 .filter_map(|rest| rest.split('<').next())
1649 .filter(|tick| tick.chars().count() <= 2)
1651 .collect();
1652 assert_eq!(ticks, ["\u{2212}2", "\u{2212}1", "0", "1", "2", "3"]);
1654 assert!(svg.contains("class=\"chartlet-zero\""));
1655 let outside = spec.replace("\"value\": 2.2", "\"value\": 4");
1656 assert_eq!(
1657 render_err(&outside),
1658 (
1659 "value_outside_axis",
1660 "/panes/0/layers/0/points/1/value".to_owned()
1661 )
1662 );
1663 let half = spec.replace("\"max\": 3.5, ", "");
1664 assert_eq!(render_err(&half).0, "invalid_axis_range");
1665 let bars = SPEC.replacen(
1666 '{',
1667 "{\"valueAxis\": {\"min\": 5, \"max\": 20, \"exact\": true},",
1668 1,
1669 );
1670 assert_eq!(render_err(&bars).0, "invalid_axis_range");
1671 }
1672
1673 #[test]
1674 fn a_dense_axis_labels_every_few_categories() {
1675 let data: Vec<String> = (0..43)
1676 .map(|index| format!("{{\"label\": \"Country {index}\", \"value\": {index}}}"))
1677 .collect();
1678 let spec = format!(
1679 "{{\"schemaVersion\": 1, \"type\": \"bar\", \"title\": \"Dense\", \"width\": 720, \"data\": [{}]}}",
1680 data.join(",")
1681 );
1682 let output = render_ok(&spec);
1683 let labels = output.content.matches("class=\"chartlet-label\"").count();
1684 assert!(labels < 43 && labels > 5, "{labels} labels");
1685 assert!(
1686 output
1687 .warnings
1688 .iter()
1689 .any(|warning| warning.code == "labels_thinned")
1690 );
1691 assert!(
1692 !output
1693 .warnings
1694 .iter()
1695 .any(|warning| warning.code == "text_truncated")
1696 );
1697 }
1698
1699 #[test]
1700 fn months_read_as_months_and_the_precision_can_be_set() {
1701 let spec = r#"{"schemaVersion": 1, "type": "time", "title": "Monthly", "panes": [{"layers": [
1702 {"mark": "line", "name": "V", "points": [{"time": "2026-01", "value": 1},
1703 {"time": "2026-02", "value": 2}, {"time": "2026-03", "value": 3}]}]}]}"#;
1704 let svg = render_ok(spec).content;
1705 assert!(svg.contains("<title>2026-02 – V: 2</title>"), "{svg}");
1706 let german = render_ok(&spec.replace(
1707 "\"type\": \"time\",",
1708 "\"type\": \"time\", \"locale\": \"de\",",
1709 ));
1710 assert!(
1711 german.content.contains("<title>02.2026 – V: 2</title>"),
1712 "{}",
1713 german.content
1714 );
1715 let mid_year = r#"{"schemaVersion": 1, "type": "time", "title": "Annual",
1716 "timeAxis": {"precision": "year"}, "panes": [{"layers": [
1717 {"mark": "line", "name": "V", "points": [{"time": "1950-07-01", "value": 1},
1718 {"time": "1951-07-01", "value": 2}]}]}]}"#;
1719 assert!(
1720 render_ok(mid_year)
1721 .content
1722 .contains("<title>1950 – V: 1</title>")
1723 );
1724 }
1725
1726 #[test]
1727 fn a_step_line_holds_each_value_until_the_next() {
1728 let spec = r#"{"schemaVersion": 1, "type": "time", "title": "Steps", "panes": [{"layers": [
1729 {"mark": "line", "name": "Mean", "curve": "step",
1730 "points": [{"time": "2020", "value": 1}, {"time": "2021", "value": 3},
1731 {"time": "2022", "value": 2}]}]}]}"#;
1732 let svg = render_ok(spec).content;
1733 let points = svg
1734 .split("<polyline points=\"")
1735 .nth(1)
1736 .and_then(|rest| rest.split('"').next())
1737 .unwrap_or_default();
1738 assert_eq!(points.split(' ').count(), 5, "{points}");
1740 let annotation = spec.replace(
1741 r#"{"mark": "line", "name": "Mean", "curve": "step","#,
1742 r#"{"mark": "annotation", "label": "A", "value": 1, "curve": "step"}, {"mark": "line", "name": "Mean","#,
1743 );
1744 assert_eq!(render_err(&annotation).0, "option_not_supported");
1745 }
1746
1747 #[test]
1748 fn a_numeric_axis_reads_plain_numbers_and_can_run_backwards() {
1749 let spec = r#"{"schemaVersion": 1, "type": "time", "title": "Profile",
1750 "timeAxis": {"kind": "number", "reverse": true, "title": "Distance (km)"},
1751 "panes": [{"layers": [{"mark": "line", "name": "Height",
1752 "points": [{"time": 0, "value": 10}, {"time": 2.5, "value": 30},
1753 {"time": 10, "value": 20}]}]}]}"#;
1754 let html = html_ok(spec);
1755 assert!(
1756 html.contains("Line chart with 3 points from 0.0 to 10.0."),
1757 "{html}"
1758 );
1759 assert!(html.contains("<th scope=\"col\">Distance (km)</th>"));
1760 let svg = render_ok(spec).content;
1761 let first = svg.find("<title>0.0 – ").map(|_| ()).is_some();
1763 assert!(first, "{svg}");
1764 assert_eq!(
1765 render_err(&spec.replace(
1766 r#""kind": "number""#,
1767 r#""kind": "number", "timezone": "+02:00""#
1768 )),
1769 ("option_not_supported", "/timeAxis/timezone".to_owned())
1770 );
1771 assert_eq!(
1772 render_err(&spec.replace(r#""time": 2.5"#, r#""time": "2026-01-01""#)).0,
1773 "invalid_time"
1774 );
1775 }
1776
1777 #[test]
1778 fn the_mobile_variant_of_small_multiples_takes_its_own_columns() {
1779 let spec = r#"{"schemaVersion": 1, "type": "multiples", "title": "Panels", "columns": 2,
1780 "mobile": {"width": 360, "height": 600, "columns": 1}, "panes": [
1781 {"title": "A", "layers": [{"mark": "line", "name": "V",
1782 "points": [{"time": "2020", "value": 1}, {"time": "2021", "value": 2}]}]},
1783 {"title": "B", "layers": [{"mark": "line", "name": "V",
1784 "points": [{"time": "2020", "value": 3}, {"time": "2021", "value": 4}]}]}]}"#;
1785 let mobile = render_json(
1786 spec,
1787 RenderFormat::Svg,
1788 &RenderOptions {
1789 variant: Variant::Mobile,
1790 ..RenderOptions::default()
1791 },
1792 )
1793 .unwrap()
1794 .content;
1795 let xs: Vec<&str> = mobile
1797 .split("<text ")
1798 .filter(|text| text.contains("class=\"chartlet-panel-title\""))
1799 .filter_map(|text| text.split('"').nth(1))
1800 .collect();
1801 assert_eq!(xs.len(), 2, "{mobile}");
1802 assert_eq!(xs[0], xs[1]);
1803 let bar = SPEC.replacen('{', "{\"mobile\": {\"width\": 360, \"columns\": 1},", 1);
1804 assert_eq!(
1805 render_err(&bar),
1806 ("option_not_supported", "/mobile/columns".to_owned())
1807 );
1808 }
1809
1810 #[test]
1811 fn panels_on_axes_of_their_own_are_not_compared() {
1812 let spec = r#"{"schemaVersion": 1, "type": "multiples", "title": "Two units",
1813 "independentAxes": true, "panes": [
1814 {"title": "Small", "layers": [{"mark": "line", "name": "V",
1815 "points": [{"time": "2020", "value": 1}, {"time": "2021", "value": 2}]}]},
1816 {"title": "Large", "layers": [{"mark": "line", "name": "V",
1817 "points": [{"time": "2020", "value": 1000}, {"time": "2021", "value": 2000}]}]}]}"#;
1818 let svg = render_ok(spec).content;
1819 assert!(svg.contains("with a value axis of their own"), "{svg}");
1820 assert!(!svg.contains("Highest:"));
1821 assert!(
1822 svg.contains(">2.0</text>") || svg.contains(">2</text>"),
1823 "{svg}"
1824 );
1825 }
1826
1827 #[test]
1828 fn a_logarithmic_axis_takes_positive_values_only() {
1829 let log = SPEC.replacen('{', "{\"valueAxis\": {\"scale\": \"log\"},", 1);
1830 assert_eq!(render_err(&log).0, "invalid_value");
1832 let positive = log.replace("-4", "4");
1833 let hooked = render_json(
1834 &positive.replace("\"type\": \"bar\"", "\"type\": \"line\""),
1835 RenderFormat::Svg,
1836 &RenderOptions {
1837 hooks: true,
1838 ..RenderOptions::default()
1839 },
1840 )
1841 .unwrap();
1842 assert!(hooked.content.contains(" data-y-scale=\"log\""));
1843 let svg = render_ok(&positive).content;
1844 assert!(
1845 svg.contains(">10</text>") || svg.contains(">1</text>"),
1846 "{svg}"
1847 );
1848 }
1849
1850 #[test]
1851 fn single_series_output_carries_no_series_styles() {
1852 let output = render_json(SPEC, RenderFormat::Svg, &RenderOptions::default()).unwrap();
1853 assert!(!output.content.contains("chartlet-series-"));
1854 }
1855
1856 #[test]
1857 fn short_negative_bar_keeps_its_label_readable() {
1858 let specification = r#"{
1859 "schemaVersion": 1,
1860 "type": "bar",
1861 "orientation": "horizontal",
1862 "title": "Change",
1863 "valueAxis": {"format": "percent"},
1864 "data": [
1865 {"label": "A", "value": 0.3},
1866 {"label": "B", "value": -0.005}
1867 ]
1868 }"#;
1869 let output =
1870 render_json(specification, RenderFormat::Svg, &RenderOptions::default()).unwrap();
1871 assert!(
1872 output
1873 .content
1874 .contains("text-anchor=\"end\" class=\"chartlet-value\">−0.5%</text>")
1875 );
1876 assert!(!output.content.contains("class=\"chartlet-value-inverse\""));
1877 }
1878
1879 #[test]
1880 fn bar_chart_rejects_missing_values() {
1881 let missing = SPEC.replace("12}", "null}");
1882 let error =
1883 render_json(&missing, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
1884 assert_eq!(error.code, "missing_bar_value");
1885 assert_eq!(error.path, "/data/0/value");
1886 }
1887
1888 #[test]
1889 fn grouped_bars_render_series_filter_checkboxes() {
1890 let output = render_json(GROUPED, RenderFormat::Html, &RenderOptions::default()).unwrap();
1891 assert!(
1892 output
1893 .content
1894 .contains("<fieldset class=\"chartlet-filter\">")
1895 );
1896 assert!(output.content.contains("class=\"series-0\" checked"));
1897 assert!(output.content.contains("data-series=\"0\""));
1898 assert!(output.content.contains("<text data-series=\"0\""));
1899 assert!(output.content.contains("<title>Jan – Budget: 120</title>"));
1900 }
1901
1902 #[test]
1903 fn single_series_bars_carry_native_tooltips() {
1904 let output = render_json(SPEC, RenderFormat::Svg, &RenderOptions::default()).unwrap();
1905 assert!(
1906 output
1907 .content
1908 .contains("<title>North <East>: 12</title>")
1909 );
1910 assert!(!output.content.contains("data-series"));
1911 }
1912
1913 #[test]
1914 fn zoom_steps_render_radio_selectable_panels() {
1915 let specification = r#"{
1916 "schemaVersion": 1,
1917 "type": "bar",
1918 "title": "Quarterly revenue",
1919 "data": [
1920 {"label": "Q1", "value": 320},
1921 {"label": "Q2", "value": 345},
1922 {"label": "Q3", "value": 380},
1923 {"label": "Q4", "value": 410}
1924 ],
1925 "zoomSteps": [
1926 {"label": "First half", "from": 0, "to": 1},
1927 {"label": "All", "from": 0, "to": 3}
1928 ]
1929 }"#;
1930 let output =
1931 render_json(specification, RenderFormat::Html, &RenderOptions::default()).unwrap();
1932 assert!(
1933 output
1934 .content
1935 .contains("<fieldset class=\"chartlet-zoom\">")
1936 );
1937 assert!(
1938 output
1939 .content
1940 .contains("class=\"chartlet-panel chartlet-panel-0\"")
1941 );
1942 assert!(
1943 output
1944 .content
1945 .contains("class=\"chartlet-panel chartlet-panel-1\"")
1946 );
1947 assert!(output.content.contains("class=\"zoom-0\" checked"));
1948 assert_eq!(output.content.matches("<title id=").count(), 2);
1950 }
1951
1952 #[test]
1953 fn a_single_zoom_step_is_rejected() {
1954 let specification = r#"{
1955 "schemaVersion": 1,
1956 "type": "bar",
1957 "title": "Quarterly revenue",
1958 "data": [
1959 {"label": "Q1", "value": 320},
1960 {"label": "Q2", "value": 345}
1961 ],
1962 "zoomSteps": [
1963 {"label": "All", "from": 0, "to": 1}
1964 ]
1965 }"#;
1966 let error = render_json(specification, RenderFormat::Svg, &RenderOptions::default())
1967 .expect_err("one zoom step cannot provide a choice");
1968 assert_eq!(error.code, "not_enough_zoom_steps");
1969 assert_eq!(error.path, "/zoomSteps");
1970 }
1971
1972 const TIME: &str = r#"{
1973 "schemaVersion": 1,
1974 "type": "time",
1975 "title": "Daily orders",
1976 "timeAxis": {"timezone": "UTC", "title": "Day"},
1977 "panes": [
1978 {
1979 "valueAxis": {"title": "Orders"},
1980 "layers": [
1981 {
1982 "mark": "line",
1983 "name": "Orders",
1984 "points": [
1985 {"time": "2026-03-01", "value": 10},
1986 {"time": "2026-03-02", "value": 15},
1987 {"time": "2026-03-03", "value": 20}
1988 ]
1989 }
1990 ]
1991 }
1992 ]
1993 }"#;
1994
1995 fn hourly(points: usize, extra: &str) -> String {
1997 let sample = (0..points)
1998 .map(|index| {
1999 let offset = i64::try_from(index).expect("test sizes fit in an i64") * 3_600;
2000 format!(
2001 "{{\"time\": {}, \"value\": {}}}",
2002 1_770_000_000 + offset,
2003 index % 5 + 1
2004 )
2005 })
2006 .collect::<Vec<_>>()
2007 .join(",");
2008 format!(
2009 r#"{{
2010 "schemaVersion": 1,
2011 "type": "time",
2012 "title": "Hourly load",
2013 "timeAxis": {{"timezone": "UTC"}},
2014 "panes": [
2015 {{
2016 "valueAxis": {{"title": "Load"}},
2017 "layers": [{{"mark": "line", "name": "Load"{extra}, "points": [{sample}]}}]
2018 }}
2019 ]
2020 }}"#
2021 )
2022 }
2023
2024 #[test]
2025 fn german_locale_writes_description_table_and_numbers_in_german() {
2026 let spec = TIME
2027 .replace(
2028 "\"type\": \"time\",",
2029 "\"type\": \"time\", \"locale\": \"de\", \"source\": \"Shop\",",
2030 )
2031 .replace(
2032 "\"valueAxis\": {\"title\": \"Orders\"}",
2033 "\"valueAxis\": {\"title\": \"Orders\", \"decimals\": 1}",
2034 )
2035 .replace("\"value\": 15}", "\"value\": -1.5}");
2036 let html = render_json(&spec, RenderFormat::Html, &RenderOptions::default()).unwrap();
2037 let content = &html.content;
2038 assert!(content.contains(
2039 "Zeitreihe mit 3 Punkten von 01.03.2026 bis 03.03.2026. Höchster Wert: 20,0 (03.03.2026). Niedrigster Wert: \u{2212}1,5 (02.03.2026)."
2040 ));
2041 assert!(content.contains("<summary>Diagrammdaten anzeigen</summary>"));
2042 assert!(content.contains("<caption>Daten zu Daily orders</caption>"));
2043 assert!(content.contains("<th scope=\"col\">Zeit</th>"));
2044 assert!(content.contains("<td>\u{2212}1,5</td>"));
2045 assert!(content.contains("<p class=\"chartlet-source\">Quelle: Shop</p>"));
2046 }
2047
2048 #[test]
2049 fn a_thin_line_is_drawn_thin_in_the_plot_and_in_the_legend() {
2050 let spec = TIME.replace(
2051 "\"name\": \"Orders\",",
2052 "\"name\": \"Orders\", \"stroke\": \"thin\",",
2053 ).replace(
2054 "]\n }\n ]",
2055 ", {\"mark\": \"line\", \"name\": \"Mean\", \"points\": [{\"time\": \"2026-03-01\", \"value\": 12}, {\"time\": \"2026-03-03\", \"value\": 18}]}]\n }\n ]",
2056 );
2057 let svg = render_ok(&spec).content;
2058 assert!(svg.contains(".chartlet-line-thin{stroke-width:1}"));
2059 assert_eq!(
2061 svg.matches("class=\"chartlet-line chartlet-line-thin chartlet-line-series-1\"")
2062 .count(),
2063 2
2064 );
2065 assert_eq!(
2066 svg.matches("class=\"chartlet-line chartlet-line-series-2\"")
2067 .count(),
2068 2
2069 );
2070 let rule = TIME.replace(
2071 "\"name\": \"Orders\",",
2072 "\"name\": \"Orders\", \"points\": [{\"time\": \"2026-03-01\", \"value\": 1}, {\"time\": \"2026-03-02\", \"value\": 2}]}, {\"mark\": \"annotation\", \"value\": 1, \"label\": \"Limit\", \"stroke\": \"thin\"}, {\"mark\": \"line\", \"name\": \"Other\",",
2073 );
2074 assert_eq!(
2075 render_err(&rule),
2076 (
2077 "option_not_supported",
2078 "/panes/0/layers/1/stroke".to_owned()
2079 )
2080 );
2081 }
2082
2083 fn german(spec: &str, chart_type: &str) -> String {
2085 spec.replace(
2086 &format!("\"type\": \"{chart_type}\","),
2087 &format!("\"type\": \"{chart_type}\", \"locale\": \"de\","),
2088 )
2089 }
2090
2091 fn html_ok(spec: &str) -> String {
2092 render_json(spec, RenderFormat::Html, &RenderOptions::default())
2093 .unwrap()
2094 .content
2095 }
2096
2097 #[test]
2098 fn german_bar_chart_writes_description_table_and_numbers_in_german() {
2099 let html = html_ok(&german(SPEC, "bar").replace("\"value\": -4}", "\"value\": -4.5}"));
2100 assert!(html.contains(
2101 "Balkendiagramm mit 2 Kategorien. Höchster Wert: 12 (North <East>). Niedrigster Wert: \u{2212}4,5 (South)."
2102 ));
2103 assert!(html.contains("<th scope=\"col\">Kategorie</th><th scope=\"col\">Wert</th>"));
2104 assert!(html.contains("<td>\u{2212}4,5</td>"));
2105 }
2106
2107 #[test]
2108 fn german_stripes_write_description_tooltips_and_table_in_german() {
2109 let html = html_ok(&german(STRIPES, "stripes"));
2110 assert!(html.contains("<title>1851: \u{2212}0,5</title>"));
2111 assert!(html.contains(
2112 "Wärmestreifen von 1850 bis 1855, ein Streifen pro Jahr, auf einer divergierenden Farbskala um 0 mit den äußersten Stufen bei \u{2212}1 und 1. Niedrigster Wert: \u{2212}1 (1850). Höchster Wert: 2 (1855). 1 Jahr hat keinen Wert."
2113 ));
2114 assert!(html.contains("<th scope=\"col\">Jahr</th>"));
2115 assert!(html.contains("<th scope=\"row\">1852</th><td>fehlt</td>"));
2116 }
2117
2118 #[test]
2119 fn german_calendar_names_months_and_weekdays_in_german() {
2120 let days = DAYS.replace("\"value\": -2}", "\"value\": -2.5}");
2121 let months = html_ok(&german(&calendar("months", &days), "calendar"));
2122 for month in ["Jan", "Mär", "Mai", "Okt", "Dez"] {
2123 assert!(months.contains(&format!(">{month}</text>")), "{month}");
2124 }
2125 assert!(!months.contains(">Mar</text>"));
2126 assert!(months.contains("<title>2024-01-01: \u{2212}2,5</title>"));
2127 assert!(months.contains("<title>2024-12-31: kein Wert</title>"));
2128 assert!(months.contains(
2129 "Kalender 2024 mit einer Zeile pro Monat, auf einer divergierenden Farbskala um "
2130 ));
2131 assert!(months.contains(
2132 "2 von 366 Tagen haben einen Wert. Niedrigster Wert: \u{2212}2,5 (2024-01-01). Höchster Wert: 4 (2024-02-29)."
2133 ));
2134 assert!(months.contains("<th scope=\"col\">Datum</th>"));
2135 let weeks = render_ok(&german(&calendar("weeks", DAYS), "calendar")).content;
2136 for weekday in ["Mo", "Mi", "Fr", "So"] {
2137 assert!(weeks.contains(&format!(">{weekday}</text>")), "{weekday}");
2138 }
2139 assert!(weeks.contains(">Mär</text>"));
2140 assert!(weeks.contains("einer Zeile pro Wochentag und einer Spalte pro Woche"));
2141 }
2142
2143 #[test]
2144 fn german_range_chart_writes_spans_legend_and_table_in_german() {
2145 let html = html_ok(&german(RANGES, "rangebar"));
2146 assert!(html.contains("<title>Observed: 1,05 (0,9 bis 1,2)</title>"));
2147 assert!(html.contains("<title>Natural: \u{2212}0,1 bis 0,1, modelliert</title>"));
2148 assert!(html.contains(">Schraffiert: modelliert<"));
2149 assert!(html.contains(
2150 "Spannweitendiagramm mit 2 Kategorien, jeweils eine Spanne vom unteren zum oberen Wert mit einem mittleren Wert. Niedrigster unterer Wert: \u{2212}0,1 (Natural). Höchster oberer Wert: 1,2 (Observed). Modelliert, schraffiert gezeichnet: Natural."
2151 ));
2152 assert!(html.contains(
2153 "<th scope=\"col\">Kategorie</th><th scope=\"col\">Unterer Wert</th><th scope=\"col\">Mittlerer Wert</th><th scope=\"col\">Oberer Wert</th>"
2154 ));
2155 assert!(html.contains("<th scope=\"row\">Natural (modelliert)</th>"));
2156 }
2157
2158 #[test]
2159 fn german_maps_write_description_picker_and_table_in_german() {
2160 let topicmap = html_ok(&german(TOPICMAP, "topicmap"));
2161 assert!(topicmap.contains(
2162 "Themenkarte mit 5 Gebieten und einer Insel. Größtes: AI in practice mit 291 Einträgen, kleinstes: Management mit 69. Engste Nachbarn: AI in practice und Engineering."
2163 ));
2164 assert!(topicmap.contains("<legend>Gebiet</legend>"));
2165 assert!(topicmap.contains(
2166 "<th scope=\"col\">Thema</th><th scope=\"col\">Einträge</th><th scope=\"col\">Pfade</th><th scope=\"col\">Anteil</th>"
2167 ));
2168 let atlas = html_ok(&german(ATLAS, "atlas"));
2169 assert!(atlas.contains(
2170 "Wissenslandschaft aus 2 Bereichen und 4 Regionen; die größte ist Models mit 40. 1 Ort ist markiert."
2171 ));
2172 assert!(atlas.contains(
2173 "<th scope=\"col\">Region</th><th scope=\"col\">Einträge</th><th scope=\"col\">Orte</th>"
2174 ));
2175 }
2176
2177 #[test]
2178 fn the_text_alternative_matches_the_description_and_the_table() {
2179 let spec = ChartSpec::from_json(&german(TIME, "time")).unwrap();
2180 let alternative = text_alternative(&spec).unwrap();
2181 let html = html_ok(&german(TIME, "time"));
2182 assert!(html.contains(&crate::render::escape(&alternative.description)));
2183 assert!(html.contains(&format!(
2184 "<caption>{}</caption>",
2185 crate::render::escape(&alternative.table.caption)
2186 )));
2187 assert_eq!(alternative.table.columns[0], "Zeit");
2188 assert_eq!(alternative.table.rows.len(), 3);
2189 assert!(alternative.table.rows.iter().all(|row| row.len() == 2));
2190 }
2191
2192 #[test]
2193 fn a_reference_line_crosses_the_bars_and_is_described() {
2194 let spec = SPEC.replacen(
2195 '{',
2196 "{\"references\": [{\"value\": 20, \"label\": \"Target\"}],",
2197 1,
2198 );
2199 let output = render_json(&spec, RenderFormat::Svg, &RenderOptions::default()).unwrap();
2200 assert!(output.content.contains("class=\"chartlet-rule\""));
2201 assert!(output.content.contains(">Target</text>"));
2202 assert!(output.content.contains("Reference lines: Target at 20."));
2203 let line = SPEC
2204 .replace("\"type\": \"bar\"", "\"type\": \"line\"")
2205 .replacen(
2206 '{',
2207 "{\"references\": [{\"value\": 1, \"label\": \"T\"}],",
2208 1,
2209 );
2210 assert_eq!(
2211 render_err(&line),
2212 ("option_not_supported", "/references".to_owned())
2213 );
2214 }
2215
2216 #[test]
2217 fn a_value_axis_range_must_be_ordered_and_in_its_place() {
2218 let reversed = TIME.replace(
2219 "\"valueAxis\": {\"title\": \"Orders\"}",
2220 "\"valueAxis\": {\"title\": \"Orders\", \"min\": 10, \"max\": 5}",
2221 );
2222 assert_eq!(
2223 render_err(&reversed),
2224 ("invalid_axis_range", "/panes/0/valueAxis/max".to_owned())
2225 );
2226 let top_level = TIME.replacen('{', "{\"valueAxis\": {\"min\": 0},", 1);
2227 assert_eq!(
2228 render_err(&top_level),
2229 ("option_not_supported", "/valueAxis".to_owned())
2230 );
2231 }
2232
2233 #[test]
2234 fn too_many_decimals_are_refused() {
2235 let spec = TIME.replace(
2236 "\"valueAxis\": {\"title\": \"Orders\"}",
2237 "\"valueAxis\": {\"title\": \"Orders\", \"decimals\": 9}",
2238 );
2239 assert_eq!(
2240 render_err(&spec),
2241 ("invalid_decimals", "/panes/0/valueAxis/decimals".to_owned())
2242 );
2243 }
2244
2245 #[test]
2246 fn time_chart_renders_a_line_marks_and_a_time_axis() {
2247 let svg = render_json(TIME, RenderFormat::Svg, &RenderOptions::default()).unwrap();
2248 assert!(svg.content.contains("<polyline"));
2249 assert_eq!(
2250 svg.content
2251 .matches("class=\"chartlet-point chartlet-point-series-1\"")
2252 .count(),
2253 3
2254 );
2255 assert!(
2256 svg.content
2257 .contains("<title>2026-03-01 – Orders: 10</title>")
2258 );
2259 assert!(svg.content.contains(">2026-03-01<"));
2260 assert!(svg.content.contains(">Day<"));
2261 assert!(svg.content.contains(">Orders<"));
2262 assert!(svg.warnings.is_empty(), "{:?}", svg.warnings);
2263
2264 let html = render_json(TIME, RenderFormat::Html, &RenderOptions::default()).unwrap();
2265 assert!(
2266 html.content
2267 .contains("<th scope=\"col\">Time</th><th scope=\"col\">Orders</th>")
2268 );
2269 assert!(
2270 html.content
2271 .contains("<th scope=\"row\">2026-03-03</th><td>20</td>")
2272 );
2273 }
2274
2275 #[test]
2276 fn time_chart_without_a_description_gets_a_generated_one() {
2277 let output = render_json(TIME, RenderFormat::Svg, &RenderOptions::default()).unwrap();
2278 assert!(output.content.contains(
2279 "Time chart with 3 points from 2026-03-01 to 2026-03-03. Highest: 20 (2026-03-03). Lowest: 10 (2026-03-01)."
2280 ));
2281 }
2282
2283 #[test]
2284 fn the_timezone_moves_the_wall_clock_labels() {
2285 let bare = TIME.replace("\"timezone\": \"UTC\"", "\"timezone\": \"+02:00\"");
2288 let bare = render_json(&bare, RenderFormat::Svg, &RenderOptions::default()).unwrap();
2289 assert!(bare.content.contains(">2026-03-01<"));
2290 assert!(!bare.content.contains(">2026-03-01 02:00<"));
2291
2292 let seconds = TIME
2295 .replace("\"time\": \"2026-03-01\"", "\"time\": 1772323200")
2296 .replace("\"time\": \"2026-03-02\"", "\"time\": 1772409600")
2297 .replace("\"time\": \"2026-03-03\"", "\"time\": 1772496000");
2298 let utc = render_json(&seconds, RenderFormat::Svg, &RenderOptions::default()).unwrap();
2299 assert!(utc.content.contains(">2026-03-01<"));
2300 assert!(!utc.content.contains("00:00<"));
2301
2302 let shifted = seconds.replace("\"timezone\": \"UTC\"", "\"timezone\": \"+02:00\"");
2303 let shifted = render_json(&shifted, RenderFormat::Svg, &RenderOptions::default()).unwrap();
2304 assert!(shifted.content.contains(">2026-03-02 00:00<"));
2305 assert!(shifted.content.contains(">2026-03-01 12:00<"));
2306
2307 let zone = seconds.replace("\"timezone\": \"UTC\"", "\"timezone\": \"+02:00\"");
2309 let html = render_json(&zone, RenderFormat::Html, &RenderOptions::default()).unwrap();
2310 assert!(
2311 html.content
2312 .contains("<th scope=\"row\">2026-03-01 02:00</th>")
2313 );
2314 }
2315
2316 #[test]
2317 fn timestamps_that_do_not_increase_are_rejected() {
2318 let reversed = TIME.replace("\"value\": 10", "\"value\": 99").replace(
2319 "\"time\": \"2026-03-01\", \"value\": 99",
2320 "\"time\": \"2026-03-03\", \"value\": 99",
2321 );
2322 let error = render_json(&reversed, RenderFormat::Svg, &RenderOptions::default())
2323 .expect_err("a backwards series has no direction");
2324 assert_eq!(error.code, "unordered_time");
2325 assert_eq!(error.path, "/panes/0/layers/0/points/1/time");
2326 }
2327
2328 #[test]
2329 fn fractional_and_out_of_range_timestamps_are_rejected() {
2330 for (time, message) in [
2331 ("12.5", "Unix seconds must be a whole number"),
2332 (
2333 "8000000000",
2334 "timestamps must lie between 1700-01-01 and 2200-01-01",
2335 ),
2336 (
2337 "\"March 1st\"",
2338 "expected an ISO 8601 date such as 2026-03-01 or 2026-03-01T12:00:00Z, a month such as 2026-03, or a year such as 1850",
2339 ),
2340 ] {
2341 let broken = TIME.replace("\"time\": \"2026-03-01\"", &format!("\"time\": {time}"));
2342 let error = render_json(&broken, RenderFormat::Svg, &RenderOptions::default())
2343 .expect_err("a timestamp outside the contract is refused");
2344 assert_eq!(
2345 (error.code, error.path.as_str()),
2346 ("invalid_time", "/panes/0/layers/0/points/0/time")
2347 );
2348 assert_eq!(error.message, message, "{time}");
2349 }
2350 }
2351
2352 #[test]
2353 fn a_line_needs_two_observations() {
2354 let error = render_json(&hourly(1, ""), RenderFormat::Svg, &RenderOptions::default())
2355 .expect_err("a single observation is not a line");
2356 assert_eq!(error.code, "empty_series");
2357 assert_eq!(error.path, "/panes/0/layers/0/points");
2358 }
2359
2360 #[test]
2361 fn dense_layers_are_drawn_as_a_line_without_markers() {
2362 let sparse = render_json(
2363 &hourly(60, ""),
2364 RenderFormat::Svg,
2365 &RenderOptions::default(),
2366 )
2367 .expect("60 observations stay readable");
2368 assert_eq!(
2369 sparse
2370 .content
2371 .matches("class=\"chartlet-point chartlet-point-series-1\"")
2372 .count(),
2373 60
2374 );
2375
2376 let dense = render_json(
2378 &hourly(61, ""),
2379 RenderFormat::Svg,
2380 &RenderOptions::default(),
2381 )
2382 .unwrap();
2383 assert!(dense.content.contains("<polyline"));
2384 assert_eq!(
2385 dense
2386 .content
2387 .matches("class=\"chartlet-point chartlet-point-series-1\"")
2388 .count(),
2389 0
2390 );
2391 }
2392
2393 #[test]
2394 fn dense_layers_are_reported_once_they_outnumber_the_plot_pixels() {
2395 let output = render_json(
2397 &hourly(1_000, ""),
2398 RenderFormat::Svg,
2399 &RenderOptions::default(),
2400 )
2401 .unwrap();
2402 assert_eq!(output.warnings.len(), 1);
2403 assert_eq!(output.warnings[0].code, "dense_chart");
2404 assert_eq!(output.warnings[0].path, "/panes/0/layers/0/points");
2405 assert!(
2406 output.warnings[0]
2407 .message
2408 .contains("1000 observations exceed the 704 horizontal pixels"),
2409 "{}",
2410 output.warnings[0].message
2411 );
2412 }
2413
2414 #[test]
2415 fn the_shared_stylesheet_is_the_common_part_and_one_part_per_type() {
2416 let common = crate::stylesheet_common();
2417 assert!(!common.contains(".chartlet-type-"));
2418 let (bar, time) = (
2419 crate::stylesheet_types(&[crate::ChartType::Bar]),
2420 crate::stylesheet_types(&[crate::ChartType::Time]),
2421 );
2422 assert_eq!(
2423 crate::stylesheet_for(&[crate::ChartType::Time, crate::ChartType::Bar]),
2424 format!("{common}{time}{bar}")
2425 );
2426 let every_type: String = crate::ChartType::ALL
2427 .iter()
2428 .map(|chart_type| crate::stylesheet_types(&[*chart_type]))
2429 .collect();
2430 assert_eq!(crate::stylesheet(), format!("{common}{every_type}"));
2431 }
2432
2433 #[test]
2434 fn declared_layer_colors_reach_the_stylesheet() {
2435 let specification = include_str!("../examples/revenue-vs-forecast.json");
2436 let svg = render_json(specification, RenderFormat::Svg, &RenderOptions::default()).unwrap();
2437 assert!(svg.content.contains(".chartlet-style-0{stroke:#7ea6ff}"));
2438 assert!(
2440 svg.content
2441 .contains(".chartlet-style-1{stroke:var(--chart-forecast,currentColor)}")
2442 );
2443 assert_eq!(svg.content.matches("chartlet-theme-dark").count(), 2);
2444 assert!(svg.content.contains("class=\"chartlet-background\""));
2445 assert!(svg.content.contains("class=\"chartlet-legend\""));
2446 }
2447
2448 #[test]
2449 fn declared_layer_colors_stay_inside_their_chart() {
2450 let specification = include_str!("../examples/revenue-vs-forecast.json");
2451 let svg = render_json(
2452 specification,
2453 RenderFormat::Svg,
2454 &RenderOptions {
2455 id_prefix: Some("first".to_owned()),
2456 ..RenderOptions::default()
2457 },
2458 )
2459 .unwrap();
2460 assert!(svg.content.contains("id=\"first\" role=\"img\""));
2463 assert!(
2464 svg.content
2465 .contains("#first .chartlet-style-0{stroke:#7ea6ff}")
2466 );
2467 assert!(!svg.content.contains("}.chartlet-style-0{"));
2468 assert!(svg.content.contains("#first .chartlet-title{"));
2471 assert!(
2472 svg.content
2473 .contains(".chartlet-root.chartlet-theme-dark{--chartlet-text:")
2474 );
2475 assert!(
2476 svg.content
2477 .contains("<style>.chartlet-root{--chartlet-text:")
2478 );
2479 }
2480
2481 #[test]
2482 fn an_invalid_color_is_reported_and_replaced_by_the_neutral_tone() {
2483 let declared = hourly(3, ", \"color\": \"red\"");
2484 let output = render_json(&declared, RenderFormat::Svg, &RenderOptions::default()).unwrap();
2485 assert_eq!(output.warnings.len(), 1);
2486 assert_eq!(output.warnings[0].code, "color_not_supported");
2487 assert_eq!(output.warnings[0].path, "/panes/0/layers/0/color");
2488 assert!(output.content.contains(".chartlet-style-0{stroke:#667085}"));
2489 }
2490
2491 #[test]
2492 fn fields_that_belong_to_another_mark_are_rejected_by_name() {
2493 let annotated = TIME.replace(
2494 "\"name\": \"Orders\"",
2495 "\"name\": \"Orders\", \"label\": \"Peak\"",
2496 );
2497 let error = render_json(&annotated, RenderFormat::Svg, &RenderOptions::default())
2498 .expect_err("a label belongs to an annotation");
2499 assert_eq!(error.code, "option_not_supported");
2500 assert_eq!(error.path, "/panes/0/layers/0/label");
2501 assert!(error.message.contains("annotation"), "{}", error.message);
2502 }
2503
2504 #[test]
2505 fn time_charts_reject_the_bar_and_line_fields() {
2506 for (field, path) in [
2507 ("\"data\": [{\"label\": \"Jan\", \"value\": 1}],", "/data"),
2508 ("\"categories\": [\"Jan\"],", "/categories"),
2509 ] {
2510 let mixed = TIME.replace(
2511 "\"schemaVersion\": 1,",
2512 &format!("\"schemaVersion\": 1, {field}"),
2513 );
2514 let error = render_json(&mixed, RenderFormat::Svg, &RenderOptions::default())
2515 .expect_err("a time chart is drawn from panes and layers");
2516 assert_eq!(error.code, "option_not_supported", "{field}");
2517 assert_eq!(error.path, path, "{field}");
2518 }
2519 }
2520
2521 #[test]
2522 fn layer_names_are_required_and_unique_within_a_pane() {
2523 let second = "{\"mark\": \"line\", \"name\": \"Orders\", \"points\": [{\"time\": \"2026-03-01\", \"value\": 1}, {\"time\": \"2026-03-02\", \"value\": 2}]}";
2524 let duplicated = TIME.replace("\"layers\": [", &format!("\"layers\": [{second},"));
2525 let error = render_json(&duplicated, RenderFormat::Svg, &RenderOptions::default())
2526 .expect_err("two layers with one name cannot be told apart");
2527 assert_eq!(error.code, "duplicate_series");
2528 assert_eq!(error.path, "/panes/0/layers/1/name");
2529
2530 let anonymous = TIME.replace("\"name\": \"Orders\", ", "");
2531 let error = render_json(&anonymous, RenderFormat::Svg, &RenderOptions::default()).unwrap();
2532 assert_eq!(error.warnings, []);
2533 }
2534
2535 #[test]
2538 fn a_manifest_is_returned_only_on_request_and_hashes_the_parsed_specification() {
2539 assert!(render_ok(SPEC).manifest.is_none());
2540
2541 let options = RenderOptions {
2542 manifest: true,
2543 variant: Variant::Desktop,
2544 ..RenderOptions::default()
2545 };
2546 let output = render_json(SPEC, RenderFormat::Html, &options).unwrap();
2547 let manifest = output.manifest.expect("the manifest was asked for");
2548 assert_eq!(manifest.chartlet, env!("CARGO_PKG_VERSION"));
2549 assert_eq!(manifest.format, RenderFormat::Html);
2550 assert_eq!(
2551 manifest.output_hash,
2552 format!(
2553 "sha256:{}",
2554 super::sha256::hex(&super::sha256(output.content.as_bytes()))
2555 )
2556 );
2557 assert!(manifest.id_prefix.starts_with("chartlet-"));
2558
2559 let value: serde_json::Value = serde_json::from_str(SPEC).unwrap();
2561 let compact = render_json(&value.to_string(), RenderFormat::Html, &options).unwrap();
2562 assert_eq!(compact.manifest, Some(manifest.clone()));
2563
2564 let json = manifest.to_json();
2565 let keys = [
2566 "chartlet",
2567 "schemaVersion",
2568 "specHash",
2569 "outputHash",
2570 "format",
2571 "variant",
2572 "idPrefix",
2573 "warnings",
2574 ];
2575 let positions: Vec<usize> = keys
2576 .iter()
2577 .map(|key| json.find(&format!("\"{key}\"")).unwrap())
2578 .collect();
2579 assert!(positions.is_sorted(), "{json}");
2580 assert!(json.contains("\"format\": \"html\""), "{json}");
2581 }
2582
2583 fn render_ok(spec: &str) -> super::RenderOutput {
2584 render_json(spec, RenderFormat::Svg, &RenderOptions::default())
2585 .unwrap_or_else(|error| panic!("should render: {error}"))
2586 }
2587
2588 fn render_err(spec: &str) -> (&'static str, String) {
2589 let error = render_json(spec, RenderFormat::Svg, &RenderOptions::default())
2590 .expect_err("should be refused");
2591 (error.code, error.path)
2592 }
2593
2594 fn projection(band: &str, extra: &str) -> String {
2597 format!(
2598 r#"{{
2599 "schemaVersion": 1,
2600 "type": "time",
2601 "title": "Projection",
2602 "showValues": false,
2603 "panes": [{{
2604 "layers": [
2605 {{"mark": "line", "name": "Measured", "points": [
2606 {{"time": "2000", "value": 0.4}},
2607 {{"time": "2010", "value": 0.6}},
2608 {{"time": "2020", "value": 1.0}}
2609 ]}},
2610 {{"mark": "line", "name": "Scenario", "modeled": true{extra}, "points": [
2611 {{"time": "2020", "value": 1.0{band}}},
2612 {{"time": "2050", "value": 1.8{band}}}
2613 ]}}
2614 ]
2615 }}]
2616 }}"#
2617 )
2618 }
2619
2620 const BAND: &str = r#", "lower": 0.5, "upper": 2.5"#;
2621
2622 #[test]
2623 fn a_modeled_band_is_hatched_dashed_and_named_modeled() {
2624 let output = render_ok(&projection(BAND, ""));
2625 let svg = &output.content;
2626 assert!(output.warnings.is_empty(), "{:?}", output.warnings);
2627 assert!(svg.contains("class=\"chartlet-band chartlet-band-series-2\""));
2629 assert!(svg.contains("<pattern id=\""));
2630 assert!(svg.contains("-hatch-1\" width=\"6\""));
2631 assert!(svg.contains("fill=\"url(#chartlet-"));
2632 assert!(svg.contains("class=\"chartlet-hatch chartlet-style-1\""));
2633 assert!(svg.contains("chartlet-line chartlet-line-modeled chartlet-line-series-2"));
2634 assert!(svg.contains(">Scenario (modeled)<"));
2635 assert!(svg.contains("<title>2050 – Scenario: 1.8 (range 0.5 to 2.5)</title>"));
2636 assert!(svg.contains(
2637 "One line has a shaded band from its lower to its upper bound, hatched where modeled"
2638 ));
2639 assert!(svg.contains("from 2000 to 2050"));
2641
2642 let html = render_json(
2643 &projection(BAND, ""),
2644 RenderFormat::Html,
2645 &RenderOptions::default(),
2646 )
2647 .unwrap();
2648 assert!(html.content.contains(
2649 "<th scope=\"col\">Scenario</th><th scope=\"col\">Scenario (lower)</th><th scope=\"col\">Scenario (upper)</th>"
2650 ));
2651 assert!(html.content.contains(
2652 "<th scope=\"row\">2050</th><td>Missing</td><td>1.8</td><td>0.5</td><td>2.5</td>"
2653 ));
2654 }
2655
2656 #[test]
2657 fn a_band_widens_the_value_scale() {
2658 let svg = render_ok(&projection(BAND, "")).content;
2659 assert!(svg.contains(">2.5<"), "{svg}");
2661 }
2662
2663 #[test]
2664 fn a_band_needs_both_edges_on_every_point() {
2665 let half = projection(r#", "lower": 0.5"#, "");
2666 assert_eq!(
2667 render_err(&half),
2668 (
2669 "incomplete_band",
2670 "/panes/0/layers/1/points/0/upper".to_owned()
2671 )
2672 );
2673 let mixed = projection(BAND, "").replacen(
2674 r#""value": 1.8, "lower": 0.5, "upper": 2.5"#,
2675 r#""value": 1.8"#,
2676 1,
2677 );
2678 assert_eq!(
2679 render_err(&mixed),
2680 (
2681 "incomplete_band",
2682 "/panes/0/layers/1/points/1/lower".to_owned()
2683 )
2684 );
2685 let inverted = projection(r#", "lower": 2.5, "upper": 0.5"#, "");
2686 assert_eq!(
2687 render_err(&inverted),
2688 (
2689 "invalid_band",
2690 "/panes/0/layers/1/points/0/upper".to_owned()
2691 )
2692 );
2693 }
2694
2695 #[test]
2696 fn a_value_outside_its_band_is_reported() {
2697 let output = render_ok(&projection(r#", "lower": 0.1, "upper": 0.2"#, ""));
2698 assert_eq!(output.warnings.len(), 2);
2699 assert_eq!(output.warnings[0].code, "value_outside_band");
2700 assert_eq!(output.warnings[0].path, "/panes/0/layers/1/points/0/value");
2701 }
2702
2703 #[test]
2704 fn a_declared_color_paints_line_band_and_hatch() {
2705 let svg = render_ok(&projection(BAND, r##", "color": "#7c3aed""##)).content;
2706 assert!(svg.contains(".chartlet-band.chartlet-style-1{fill:#7c3aed;stroke:none}"));
2707 assert!(svg.contains("class=\"chartlet-hatch-line chartlet-style-1\""));
2708 }
2709
2710 #[test]
2711 fn the_band_mark_points_to_the_line_band() {
2712 let error = render_json(
2713 &with_rules(
2714 r#"{"mark": "band", "label": "Z", "bottom": 0, "top": 1,
2715 "points": [{"time": "2026-03-01", "value": 1}]}"#,
2716 ),
2717 RenderFormat::Svg,
2718 &RenderOptions::default(),
2719 )
2720 .expect_err("a zone has no points");
2721 assert_eq!(error.code, "option_not_supported");
2722 assert_eq!(error.path, "/panes/0/layers/0/points");
2723 assert!(
2724 error.message.contains("lower and upper"),
2725 "{}",
2726 error.message
2727 );
2728 }
2729
2730 fn with_rules(rules: &str) -> String {
2731 TIME.replace("\"layers\": [", &format!("\"layers\": [{rules},"))
2732 }
2733
2734 #[test]
2735 fn reference_lines_are_drawn_labelled_and_described() {
2736 let spec = with_rules(
2737 r#"{"mark": "annotation", "value": 30, "label": "Target"},
2738 {"mark": "annotation", "time": "2026-03-02", "label": "Launch"}"#,
2739 );
2740 let output = render_ok(&spec);
2741 let svg = &output.content;
2742 assert!(output.warnings.is_empty(), "{:?}", output.warnings);
2743 assert_eq!(svg.matches("class=\"chartlet-rule\"").count(), 2);
2744 assert!(svg.contains(">Target</text>"));
2745 assert!(svg.contains(">Launch</text>"));
2746 assert!(svg.contains("<title>Target: 30</title>"));
2747 assert!(svg.contains("<title>Launch: 2026-03-02</title>"));
2748 assert!(svg.contains(">30<"));
2750 assert!(svg.contains("Reference lines: Target at 30; Launch at 2026-03-02."));
2751 assert!(!svg.contains("class=\"chartlet-legend\""));
2753 let html = render_json(&spec, RenderFormat::Html, &RenderOptions::default()).unwrap();
2754 assert!(
2755 html.content
2756 .contains("<th scope=\"col\">Time</th><th scope=\"col\">Orders</th></tr>")
2757 );
2758 }
2759
2760 #[test]
2761 fn reference_lines_are_validated_by_name() {
2762 for (rule, code, path) in [
2763 (
2764 r#"{"mark": "annotation", "value": 1}"#,
2765 "missing_label",
2766 "/panes/0/layers/0/label",
2767 ),
2768 (
2769 r#"{"mark": "annotation", "label": "Nowhere"}"#,
2770 "missing_position",
2771 "/panes/0/layers/0",
2772 ),
2773 (
2774 r#"{"mark": "annotation", "value": 1, "label": "L", "shape": "circle"}"#,
2775 "option_not_supported",
2776 "/panes/0/layers/0/shape",
2777 ),
2778 (
2779 r#"{"mark": "annotation", "value": 1, "label": "L", "name": "N"}"#,
2780 "option_not_supported",
2781 "/panes/0/layers/0/name",
2782 ),
2783 (
2784 r#"{"mark": "annotation", "time": "1600-01-01", "label": "L"}"#,
2785 "invalid_time",
2786 "/panes/0/layers/0/time",
2787 ),
2788 ] {
2789 assert_eq!(
2790 render_err(&with_rules(rule)),
2791 (code, path.to_owned()),
2792 "{rule}"
2793 );
2794 }
2795 let only_rules = r#"{"schemaVersion": 1, "type": "time", "title": "T", "panes": [
2796 {"layers": [{"mark": "annotation", "value": 1, "label": "L"}]}]}"#;
2797 assert_eq!(
2798 render_err(only_rules),
2799 ("empty_data", "/panes/0/layers".to_owned())
2800 );
2801 let seven = (0..7)
2802 .map(|index| {
2803 format!(r#"{{"mark": "annotation", "value": {index}, "label": "L{index}"}}"#)
2804 })
2805 .collect::<Vec<_>>()
2806 .join(",");
2807 assert_eq!(
2808 render_err(&with_rules(&seven)),
2809 ("too_many_annotations", "/panes/0/layers".to_owned())
2810 );
2811 }
2812
2813 #[test]
2814 fn zones_are_drawn_behind_the_data_labelled_and_described() {
2815 let spec = with_rules(
2816 r##"{"mark": "band", "label": "Quiet", "from": "2026-03-01", "to": "2026-03-02"},
2817 {"mark": "band", "label": "Target", "bottom": 25, "top": 40, "color": "#16a34a"}"##,
2818 );
2819 let output = render_ok(&spec);
2820 let svg = &output.content;
2821 assert!(output.warnings.is_empty(), "{:?}", output.warnings);
2822 assert_eq!(svg.matches("class=\"chartlet-zone").count(), 2);
2823 assert!(svg.contains("class=\"chartlet-zone chartlet-style-1-swatch\""));
2824 assert!(svg.contains(".chartlet-style-1-swatch{fill:#16a34a}"));
2825 assert!(svg.rfind("chartlet-zone\"").unwrap() < svg.find("class=\"chartlet-line").unwrap());
2827 assert!(svg.contains(">Quiet</text>"));
2828 assert!(svg.contains(">Target</text>"));
2829 assert!(svg.contains("<title>Quiet: 2026-03-01 to 2026-03-02</title>"));
2830 assert!(svg.contains("<title>Target: values 25 to 40</title>"));
2831 assert!(svg.contains(">40<"));
2833 assert!(svg.contains("Zones: Quiet, 2026-03-01 to 2026-03-02; Target, values 25 to 40."));
2834 assert!(!svg.contains("class=\"chartlet-legend\""));
2836 let html = render_json(&spec, RenderFormat::Html, &RenderOptions::default()).unwrap();
2837 assert!(
2838 html.content
2839 .contains("<th scope=\"col\">Time</th><th scope=\"col\">Orders</th></tr>")
2840 );
2841 }
2842
2843 #[test]
2844 fn a_zone_with_one_open_end_reaches_the_plot_edge_and_says_so() {
2845 let spec = with_rules(
2846 r#"{"mark": "band", "label": "Later", "from": "2026-03-02", "bottom": 0, "top": 5}"#,
2847 );
2848 let svg = render_ok(&spec).content;
2849 assert!(svg.contains("<title>Later: from 2026-03-02, values 0 to 5</title>"));
2850 }
2851
2852 #[test]
2853 fn zones_are_validated_by_name() {
2854 for (zone, code, path) in [
2855 (
2856 r#"{"mark": "band", "bottom": 0, "top": 1}"#,
2857 "missing_label",
2858 "/panes/0/layers/0/label",
2859 ),
2860 (
2861 r#"{"mark": "band", "label": "Z"}"#,
2862 "missing_position",
2863 "/panes/0/layers/0",
2864 ),
2865 (
2866 r#"{"mark": "band", "label": "Z", "bottom": 2, "top": 2}"#,
2867 "invalid_band",
2868 "/panes/0/layers/0/top",
2869 ),
2870 (
2871 r#"{"mark": "band", "label": "Z", "from": "2026-03-02", "to": "2026-03-01"}"#,
2872 "invalid_band",
2873 "/panes/0/layers/0/to",
2874 ),
2875 (
2876 r#"{"mark": "band", "label": "Z", "from": "1600-01-01", "to": "2026-03-01"}"#,
2877 "invalid_time",
2878 "/panes/0/layers/0/from",
2879 ),
2880 (
2881 r#"{"mark": "band", "label": "Z", "bottom": 0, "top": 1, "name": "N"}"#,
2882 "option_not_supported",
2883 "/panes/0/layers/0/name",
2884 ),
2885 (
2886 r#"{"mark": "band", "label": "Z", "bottom": 0, "top": 1, "value": 1}"#,
2887 "option_not_supported",
2888 "/panes/0/layers/0/value",
2889 ),
2890 (
2891 r#"{"mark": "band", "label": "Z", "bottom": 0, "top": 1, "shape": "circle"}"#,
2892 "option_not_supported",
2893 "/panes/0/layers/0/shape",
2894 ),
2895 (
2896 r#"{"mark": "band", "label": "Z", "bottom": 0, "top": 1, "dash": "dotted"}"#,
2897 "option_not_supported",
2898 "/panes/0/layers/0/dash",
2899 ),
2900 ] {
2901 assert_eq!(
2902 render_err(&with_rules(zone)),
2903 (code, path.to_owned()),
2904 "{zone}"
2905 );
2906 }
2907 let seven = (0..7)
2909 .map(|index| match index % 3 {
2910 0 => format!(r#"{{"mark": "band", "bottom": {index}, "top": 50, "label": "Z{index}"}}"#),
2911 1 => format!(r#"{{"mark": "annotation", "value": {index}, "label": "R{index}"}}"#),
2912 _ => format!(
2913 r#"{{"mark": "annotation", "time": "2026-03-02", "value": {index}, "label": "M{index}"}}"#
2914 ),
2915 })
2916 .collect::<Vec<_>>()
2917 .join(",");
2918 assert_eq!(
2919 render_err(&with_rules(&seven)),
2920 ("too_many_annotations", "/panes/0/layers".to_owned())
2921 );
2922 }
2923
2924 #[test]
2925 fn point_markers_are_drawn_in_their_shape_labelled_and_described() {
2926 let spec = with_rules(
2927 r##"{"mark": "annotation", "time": "2026-03-02", "value": 50, "label": "Launch", "shape": "triangle-up"},
2928 {"mark": "annotation", "time": "2026-03-01", "value": 12, "label": "Start"},
2929 {"mark": "annotation", "time": "2026-03-02", "value": 5, "label": "Dip", "shape": "diamond", "color": "#16a34a"}"##,
2930 );
2931 let output = render_ok(&spec);
2932 let svg = &output.content;
2933 assert!(output.warnings.is_empty(), "{:?}", output.warnings);
2934 assert_eq!(svg.matches("class=\"chartlet-marker").count(), 3);
2936 assert!(svg.contains("<circle cx="));
2937 assert!(svg.contains("class=\"chartlet-marker\"><title>Start: 2026-03-01, 12</title>"));
2938 assert!(svg.contains("<title>Launch: 2026-03-02, 50</title>"));
2939 assert!(svg.contains(
2940 "class=\"chartlet-marker chartlet-style-2\"><title>Dip: 2026-03-02, 5</title>"
2941 ));
2942 assert!(svg.contains(
2943 ".chartlet-marker.chartlet-style-2{fill:#16a34a;stroke:var(--chartlet-background)}"
2944 ));
2945 assert!(svg.contains(">Launch</text>"));
2946 assert!(svg.contains(">50<"));
2948 assert!(svg.contains(
2949 "Markers: Launch at 2026-03-02, 50; Start at 2026-03-01, 12; Dip at 2026-03-02, 5."
2950 ));
2951 assert!(!svg.contains("Reference lines"));
2952 assert!(!svg.contains("class=\"chartlet-legend\""));
2953 }
2954
2955 #[test]
2956 fn a_point_marker_needs_a_label_and_only_it_takes_a_shape() {
2957 assert_eq!(
2958 render_err(&with_rules(
2959 r#"{"mark": "annotation", "time": "2026-03-02", "value": 5}"#
2960 )),
2961 ("missing_label", "/panes/0/layers/0/label".to_owned())
2962 );
2963 let error = render_json(
2964 &with_rules(
2965 r#"{"mark": "annotation", "time": "2026-03-02", "label": "L", "shape": "square"}"#,
2966 ),
2967 RenderFormat::Svg,
2968 &RenderOptions::default(),
2969 )
2970 .expect_err("a reference line has no shape");
2971 assert_eq!(error.code, "option_not_supported");
2972 assert_eq!(error.path, "/panes/0/layers/0/shape");
2973 assert!(error.message.contains("point marker"), "{}", error.message);
2974 }
2975
2976 #[test]
2977 fn a_marker_label_moves_to_the_left_at_the_right_edge() {
2978 let spec = with_rules(
2979 r#"{"mark": "annotation", "time": "2026-03-03", "value": 20, "label": "Peak"}"#,
2980 );
2981 let output = render_ok(&spec);
2982 assert!(output.warnings.is_empty(), "{:?}", output.warnings);
2983 assert!(
2984 output
2985 .content
2986 .contains("text-anchor=\"end\" class=\"chartlet-rule-label\">Peak</text>")
2987 );
2988 }
2989
2990 #[test]
2991 fn overlapping_annotation_labels_are_reported_not_dropped() {
2992 let spec = with_rules(
2993 r#"{"mark": "annotation", "value": 12, "label": "First"},
2994 {"mark": "annotation", "value": 12, "label": "Second"}"#,
2995 );
2996 let output = render_ok(&spec);
2997 assert!(output.content.contains(">First</text>"));
2998 assert!(output.content.contains(">Second</text>"));
2999 assert_eq!(output.warnings.len(), 1, "{:?}", output.warnings);
3000 assert_eq!(output.warnings[0].code, "label_overlap");
3001 assert_eq!(output.warnings[0].path, "/panes/0/layers/1");
3002 }
3003
3004 #[test]
3005 fn a_reference_line_label_across_a_data_line_is_reported() {
3006 let spec = TIME
3007 .replace("\"value\": 10}", "\"value\": 10.4}")
3008 .replace("\"value\": 15}", "\"value\": 10.4}")
3009 .replace("\"value\": 20}", "\"value\": 0}")
3010 .replace(
3011 "\"layers\": [",
3012 r#""layers": [{"mark": "annotation", "value": 10, "label": "Limit"},"#,
3013 );
3014 let output = render_ok(&spec);
3015 assert_eq!(output.warnings.len(), 1, "{:?}", output.warnings);
3016 assert_eq!(output.warnings[0].code, "label_overlap");
3017 assert_eq!(output.warnings[0].path, "/panes/0/layers/0");
3018 assert!(output.warnings[0].message.contains("data line"));
3019 }
3020
3021 #[test]
3022 fn a_label_outside_the_plot_is_reported() {
3023 let spec = TIME
3026 .replace("\"mark\": \"line\"", "\"mark\": \"area\"")
3027 .replace(
3028 "\"layers\": [",
3029 r#""layers": [{"mark": "annotation", "value": 20, "label": "Ceiling"},"#,
3030 );
3031 let output = render_ok(&spec);
3032 assert_eq!(output.warnings.len(), 1, "{:?}", output.warnings);
3033 assert_eq!(output.warnings[0].code, "label_overlap");
3034 assert_eq!(output.warnings[0].path, "/panes/0/layers/0");
3035 assert!(output.warnings[0].message.contains("outside"));
3036 }
3037
3038 #[test]
3039 fn label_collisions_are_measured_with_the_injected_metrics() {
3040 struct Wide;
3042 impl TextMetrics for Wide {
3043 fn width(&self, text: &str, font_size: f64) -> f64 {
3044 BuiltinMetrics.width(text, font_size) * 4.0
3045 }
3046 }
3047 let spec = ChartSpec::from_json(&with_rules(
3048 r#"{"mark": "annotation", "value": 24, "label": "Ceiling"},
3049 {"mark": "annotation", "time": "2026-03-01T06:00:00Z", "label": "Deploy"}"#,
3050 ))
3051 .unwrap();
3052 let narrow = render_ok(&serde_json::to_string(&spec).unwrap());
3053 assert!(narrow.warnings.is_empty(), "{:?}", narrow.warnings);
3054 let wide = render_with_metrics(&spec, RenderFormat::Svg, &RenderOptions::default(), &Wide)
3055 .unwrap();
3056 assert!(
3057 wide.warnings
3058 .iter()
3059 .any(|warning| warning.code == "label_overlap"),
3060 "{:?}",
3061 wide.warnings
3062 );
3063 }
3064
3065 #[test]
3066 fn zones_and_markers_are_described_in_german() {
3067 let spec = with_rules(
3068 r#"{"mark": "band", "label": "Ruhe", "from": "2026-03-01", "to": "2026-03-02"},
3069 {"mark": "band", "label": "Ziel", "bottom": 2.5},
3070 {"mark": "annotation", "time": "2026-03-02", "value": 12.5, "label": "Start"}"#,
3071 )
3072 .replace(
3073 "\"schemaVersion\": 1,",
3074 "\"schemaVersion\": 1, \"locale\": \"de\",",
3075 );
3076 let open = render_ok(&spec).content;
3078 assert!(open.contains("Ziel, Werte über 2,5."), "{open}");
3079 let bare = spec.replace(r#", "bottom": 2.5}"#, "}");
3080 assert_eq!(
3081 render_err(&bare),
3082 ("missing_position", "/panes/0/layers/1".to_owned())
3083 );
3084 let spec = spec.replace(
3085 r#""bottom": 2.5}"#,
3086 r#""bottom": 2.5, "from": "2026-03-02", "to": "2026-03-03"}"#,
3087 );
3088 let svg = render_ok(&spec).content;
3089 assert!(svg.contains(
3090 "Bereiche: Ruhe, 01.03.2026 bis 02.03.2026; Ziel, 02.03.2026 bis 03.03.2026, Werte über 2,5."
3091 ), "{svg}");
3092 assert!(svg.contains("Markierungen: Start bei 02.03.2026, 12,5."));
3093 assert!(svg.contains("<title>Start: 02.03.2026, 12,5</title>"));
3094 }
3095
3096 #[test]
3097 fn a_zoom_window_cuts_zones_and_leaves_out_those_it_misses() {
3098 let spec = with_rules(
3099 r#"{"mark": "band", "label": "Early", "from": "2026-03-01", "to": "2026-03-02"},
3100 {"mark": "band", "label": "Wide", "from": "2026-02-20", "to": "2026-03-10"}"#,
3101 )
3102 .replace(
3103 "\"timeAxis\"",
3104 r#""zoomSteps": [{"label": "All", "from": "2026-03-01", "to": "2026-03-03"},
3105 {"label": "Late", "from": "2026-03-02", "to": "2026-03-03"}],
3106 "timeAxis""#,
3107 );
3108 let html = render_json(&spec, RenderFormat::Html, &RenderOptions::default())
3109 .unwrap()
3110 .content;
3111 assert_eq!(html.matches("class=\"chartlet-zone\"").count(), 3);
3113 assert!(html.contains("<title>Wide: 2026-03-02 to 2026-03-03</title>"));
3114 }
3115
3116 const STRIPES: &str = r#"{
3117 "schemaVersion": 1,
3118 "type": "stripes",
3119 "title": "Stripes",
3120 "stripes": {"firstYear": 1850, "values": [-1, -0.5, null, 0, 0.5, 2], "min": -1, "max": 1}
3121 }"#;
3122
3123 #[test]
3124 fn stripes_draw_one_colored_stripe_per_year() {
3125 let output = render_ok(STRIPES);
3126 let svg = &output.content;
3127 assert!(output.warnings.is_empty(), "{:?}", output.warnings);
3128 assert_eq!(svg.matches("class=\"chartlet-diverging-").count(), 5);
3130 assert!(svg.contains("class=\"chartlet-diverging-0\"><title>1850: −1</title>"));
3131 assert!(svg.contains("class=\"chartlet-diverging-4\"><title>1851: −0.5</title>"));
3132 assert!(svg.contains("class=\"chartlet-diverging-8\"><title>1853: 0</title>"));
3133 assert!(svg.contains("class=\"chartlet-diverging-16\"><title>1855: 2</title>"));
3135 assert!(svg.contains(">1850</text>"));
3136 assert!(svg.contains(">1855</text>"));
3137 assert!(svg.contains("--chartlet-diverging-8:#eeeeee"));
3138 assert!(svg.contains(
3139 "Warming stripes from 1850 to 1855, one stripe per year, on a diverging color scale around 0 with its outermost steps at −1 and 1. Lowest: −1 (1850). Highest: 2 (1855). 1 year has no value."
3140 ));
3141 let html = render_json(STRIPES, RenderFormat::Html, &RenderOptions::default()).unwrap();
3142 assert!(html.content.contains("<th scope=\"col\">Year</th>"));
3143 assert!(
3144 html.content
3145 .contains("<th scope=\"row\">1852</th><td>Missing</td>")
3146 );
3147 }
3148
3149 #[test]
3150 fn stripes_are_validated_by_name() {
3151 for (from, to, code, path) in [
3152 ("\"min\": -1", "\"min\": 0", "invalid_scale", "/stripes/min"),
3153 ("\"max\": 1", "\"max\": -2", "invalid_scale", "/stripes/max"),
3154 (
3155 "\"firstYear\": 1850",
3156 "\"firstYear\": 9998",
3157 "invalid_year",
3158 "/stripes/firstYear",
3159 ),
3160 (
3161 "[-1, -0.5, null, 0, 0.5, 2]",
3162 "[null]",
3163 "empty_series",
3164 "/stripes/values",
3165 ),
3166 (
3167 "[-1, -0.5, null, 0, 0.5, 2]",
3168 "[]",
3169 "empty_data",
3170 "/stripes/values",
3171 ),
3172 (
3173 "\"title\": \"Stripes\",",
3174 "\"title\": \"Stripes\", \"panes\": [{}],",
3175 "option_not_supported",
3176 "/panes",
3177 ),
3178 ] {
3179 assert_eq!(
3180 render_err(&STRIPES.replace(from, to)),
3181 (code, path.to_owned()),
3182 "{to}"
3183 );
3184 }
3185 let missing = STRIPES.replace(
3186 r#""stripes": {"firstYear": 1850, "values": [-1, -0.5, null, 0, 0.5, 2], "min": -1, "max": 1}"#,
3187 "\"width\": 800",
3188 );
3189 assert_eq!(
3190 render_err(&missing),
3191 ("missing_stripes", "/stripes".to_owned())
3192 );
3193 }
3194
3195 #[test]
3196 fn a_block_of_another_type_is_rejected() {
3197 let foreign = SPEC.replace(
3198 "\"title\": \"Profit & loss\",",
3199 "\"title\": \"Profit & loss\", \"stripes\": {\"firstYear\": 2000, \"values\": [1]},",
3200 );
3201 assert_eq!(
3202 render_err(&foreign),
3203 ("option_not_supported", "/stripes".to_owned())
3204 );
3205 let columns = TIME.replace("\"type\": \"time\",", "\"type\": \"time\", \"columns\": 2,");
3206 assert_eq!(
3207 render_err(&columns),
3208 ("option_not_supported", "/columns".to_owned())
3209 );
3210 }
3211
3212 fn calendar(layout: &str, days: &str) -> String {
3213 format!(
3214 r#"{{
3215 "schemaVersion": 1,
3216 "type": "calendar",
3217 "title": "Calendar",
3218 "calendar": {{"year": 2024, "layout": "{layout}", "days": [{days}]}}
3219 }}"#
3220 )
3221 }
3222
3223 const DAYS: &str = r#"{"date": "2024-01-01", "value": -2}, {"date": "2024-02-29", "value": 4}, {"date": "2024-12-31", "value": null}"#;
3224
3225 #[test]
3226 fn a_calendar_has_a_cell_for_every_day_and_leaves_gaps_empty() {
3227 for layout in ["months", "weeks"] {
3228 let output = render_ok(&calendar(layout, DAYS));
3229 let svg = &output.content;
3230 assert!(output.warnings.is_empty(), "{:?}", output.warnings);
3231 assert_eq!(svg.matches("<rect").count(), 366 + 17, "{layout}");
3233 assert_eq!(
3234 svg.matches("class=\"chartlet-calendar-empty\"").count(),
3235 364
3236 );
3237 assert!(svg.contains("class=\"chartlet-diverging-4\"><title>2024-01-01: −2</title>"));
3238 assert!(svg.contains("class=\"chartlet-diverging-16\"><title>2024-02-29: 4</title>"));
3239 assert!(svg.contains("<title>2024-12-31: no value</title>"));
3240 assert!(svg.contains(
3241 "2 of 366 days have a value. Lowest: −2 (2024-01-01). Highest: 4 (2024-02-29)."
3242 ));
3243 }
3244 let weeks = render_ok(&calendar("weeks", DAYS)).content;
3245 assert!(weeks.contains(">Mon</text>"));
3246 let months = render_ok(&calendar("months", DAYS)).content;
3247 assert!(months.contains(">Feb</text>"));
3248 let html = render_json(
3249 &calendar("months", DAYS),
3250 RenderFormat::Html,
3251 &RenderOptions::default(),
3252 )
3253 .unwrap();
3254 assert!(
3255 html.content
3256 .contains("<th scope=\"row\">2024-02-29</th><td>4</td>")
3257 );
3258 }
3259
3260 #[test]
3261 fn calendar_days_are_validated_by_name() {
3262 for (days, code, path) in [
3263 (
3264 r#"{"date": "2023-12-31", "value": 1}"#,
3265 "date_outside_year",
3266 "/calendar/days/0/date",
3267 ),
3268 (
3269 r#"{"date": "2024-02-30", "value": 1}"#,
3270 "invalid_date",
3271 "/calendar/days/0/date",
3272 ),
3273 (
3274 r#"{"date": "2024-03-01T12:00", "value": 1}"#,
3275 "invalid_date",
3276 "/calendar/days/0/date",
3277 ),
3278 (
3279 r#"{"date": "2024-03-01", "value": 1}, {"date": "2024-03-01", "value": 2}"#,
3280 "duplicate_date",
3281 "/calendar/days/1/date",
3282 ),
3283 (
3284 r#"{"date": "2024-03-01", "value": null}"#,
3285 "empty_series",
3286 "/calendar/days",
3287 ),
3288 ("", "empty_data", "/calendar/days"),
3289 ] {
3290 assert_eq!(
3291 render_err(&calendar("months", days)),
3292 (code, path.to_owned()),
3293 "{days}"
3294 );
3295 }
3296 }
3297
3298 const RANGES: &str = r#"{
3299 "schemaVersion": 1,
3300 "type": "rangebar",
3301 "orientation": "horizontal",
3302 "title": "Contributions",
3303 "ranges": [
3304 {"label": "Observed", "low": 0.9, "high": 1.2, "mid": 1.05},
3305 {"label": "Natural", "low": -0.1, "high": 0.1, "modeled": true}
3306 ]
3307 }"#;
3308
3309 #[test]
3310 fn range_bars_draw_spans_marks_and_hatching() {
3311 for orientation in ["horizontal", "vertical"] {
3312 let spec = RANGES.replace("\"horizontal\"", &format!("\"{orientation}\""));
3313 let output = render_ok(&spec);
3314 let svg = &output.content;
3315 assert_eq!(
3316 svg.matches("class=\"chartlet-range\"").count(),
3317 3,
3318 "{orientation}"
3319 );
3320 assert_eq!(svg.matches("class=\"chartlet-range-hatch\"").count(), 2);
3321 assert_eq!(svg.matches("class=\"chartlet-range-mid\"").count(), 1);
3322 assert!(svg.contains("-hatch-range\" width=\"6\""));
3323 assert!(svg.contains("<title>Observed: 1.05 (0.9 to 1.2)</title>"));
3324 assert!(svg.contains("<title>Natural: −0.1 to 0.1, modeled</title>"));
3325 assert!(svg.contains(">Hatched: modeled<"));
3326 assert!(svg.contains(".chartlet-legend{font-size:12px;fill:var(--chartlet-muted)}"));
3328 assert!(svg.contains(
3329 "Range chart with 2 categories, each a span from low to high and a central value. Lowest low: −0.1 (Natural). Highest high: 1.2 (Observed). Modeled, drawn hatched: Natural."
3330 ));
3331 }
3332 let html = render_json(RANGES, RenderFormat::Html, &RenderOptions::default()).unwrap();
3333 assert!(html.content.contains(
3334 "<th scope=\"col\">Low</th><th scope=\"col\">Mid</th><th scope=\"col\">High</th>"
3335 ));
3336 assert!(html.content.contains(
3337 "<th scope=\"row\">Natural (modeled)</th><td>−0.1</td><td>Missing</td><td>0.1</td>"
3338 ));
3339 }
3340
3341 #[test]
3342 fn ranges_are_validated_by_name() {
3343 for (from, to, code, path) in [
3344 (
3345 "\"low\": 0.9, \"high\": 1.2",
3346 "\"low\": 1.3, \"high\": 1.2",
3347 "invalid_range",
3348 "/ranges/0/high",
3349 ),
3350 (
3351 "\"mid\": 1.05",
3352 "\"mid\": 2",
3353 "mid_outside_range",
3354 "/ranges/0/mid",
3355 ),
3356 (
3357 "\"label\": \"Natural\"",
3358 "\"label\": \"Observed\"",
3359 "duplicate_label",
3360 "/ranges/1/label",
3361 ),
3362 (
3363 "\"orientation\": \"horizontal\",",
3364 "\"orientation\": \"horizontal\", \"data\": [{\"label\": \"A\", \"value\": 1}],",
3365 "option_not_supported",
3366 "/data",
3367 ),
3368 ] {
3369 assert_eq!(
3370 render_err(&RANGES.replace(from, to)),
3371 (code, path.to_owned()),
3372 "{to}"
3373 );
3374 }
3375 }
3376
3377 fn multiples(panes: &str) -> String {
3378 format!(
3379 r#"{{
3380 "schemaVersion": 1,
3381 "type": "multiples",
3382 "title": "Sectors",
3383 "valueAxis": {{"title": "Emissions"}},
3384 "panes": [{panes}]
3385 }}"#
3386 )
3387 }
3388
3389 fn panel(title: &str, scale: f64) -> String {
3390 format!(
3391 r#"{{"title": "{title}", "layers": [
3392 {{"mark": "line", "name": "History", "points": [{{"time": "2000", "value": {}}}, {{"time": "2010", "value": {}}}]}},
3393 {{"mark": "line", "name": "Pathway", "modeled": true, "points": [{{"time": "2010", "value": {}, "lower": 0, "upper": {}}}, {{"time": "2050", "value": 0, "lower": 0, "upper": {}}}]}},
3394 {{"mark": "annotation", "value": 0, "label": "Net zero"}}
3395 ]}}"#,
3396 10.0 * scale,
3397 12.0 * scale,
3398 12.0 * scale,
3399 14.0 * scale,
3400 2.0 * scale
3401 )
3402 }
3403
3404 #[test]
3405 fn small_multiples_share_one_scale_and_one_legend() {
3406 let spec = multiples(
3407 &[
3408 panel("Energy", 2.0),
3409 panel("Industry", 1.0),
3410 panel("Transport", 0.5),
3411 ]
3412 .join(","),
3413 );
3414 let output = render_ok(&spec);
3415 let svg = &output.content;
3416 assert!(output.warnings.is_empty(), "{:?}", output.warnings);
3417 assert!(svg.contains("class=\"chartlet-root chartlet-multiples\""));
3418 for title in ["Energy", "Industry", "Transport"] {
3419 assert!(svg.contains(&format!(">{title}</text>")));
3420 }
3421 assert_eq!(svg.matches(">30</text>").count(), 3);
3423 assert_eq!(svg.matches("class=\"chartlet-legend\"").count(), 2);
3425 assert!(svg.contains(">Pathway (modeled)<"));
3426 assert_eq!(
3427 svg.matches("chartlet-line chartlet-line-modeled chartlet-line-series-2")
3428 .count(),
3429 4
3431 );
3432 assert!(svg.contains("<title>2050 – Industry · Pathway: 0 (range 0 to 2)</title>"));
3433 assert!(svg.contains(
3434 "Small multiples of 3 panels (Energy, Industry, Transport) with a shared value axis, each from 2000 to 2050, showing History, Pathway (modeled)."
3435 ));
3436 let html = render_json(&spec, RenderFormat::Html, &RenderOptions::default()).unwrap();
3437 assert!(
3438 html.content
3439 .contains("<th scope=\"col\">Energy · History</th>")
3440 );
3441 assert!(
3442 html.content
3443 .contains("<th scope=\"col\">Transport · Pathway (upper)</th>")
3444 );
3445 }
3446
3447 #[test]
3448 fn small_multiples_are_validated_by_name() {
3449 let two = [panel("A", 1.0), panel("B", 1.0)].join(",");
3450 assert_eq!(
3451 render_err(&multiples(&panel("A", 1.0))),
3452 ("not_enough_panes", "/panes".to_owned())
3453 );
3454 assert_eq!(
3455 render_err(&multiples(&two.replacen("\"title\": \"A\", ", "", 1))),
3456 ("missing_title", "/panes/0/title".to_owned())
3457 );
3458 assert_eq!(
3459 render_err(&multiples(
3460 &two.replace("\"title\": \"B\"", "\"title\": \"A\"")
3461 )),
3462 ("duplicate_title", "/panes/1/title".to_owned())
3463 );
3464 assert_eq!(
3465 render_err(&multiples(&two.replacen(
3466 "\"title\": \"A\",",
3467 "\"title\": \"A\", \"valueAxis\": {\"title\": \"Own\"},",
3468 1
3469 ))),
3470 ("option_not_supported", "/panes/0/valueAxis".to_owned())
3471 );
3472 let columns = multiples(&two).replace(
3473 "\"type\": \"multiples\",",
3474 "\"type\": \"multiples\", \"columns\": 7,",
3475 );
3476 assert_eq!(
3477 render_err(&columns),
3478 ("invalid_columns", "/columns".to_owned())
3479 );
3480 let thirteen = (0..13)
3481 .map(|index| panel(&format!("P{index}"), 1.0))
3482 .collect::<Vec<_>>()
3483 .join(",");
3484 assert_eq!(
3485 render_err(&multiples(&thirteen)),
3486 ("too_many_panes", "/panes".to_owned())
3487 );
3488 let titled = TIME.replace(
3489 "\"valueAxis\": {\"title\": \"Orders\"},",
3490 "\"title\": \"Pane\",",
3491 );
3492 assert_eq!(
3493 render_err(&titled),
3494 ("option_not_supported", "/panes/0/title".to_owned())
3495 );
3496 }
3497
3498 const ATLAS: &str = r#"{
3499 "schemaVersion": 1,
3500 "type": "atlas",
3501 "title": "Knowledge landscape",
3502 "atlas": {
3503 "realms": [
3504 {
3505 "label": "Machines",
3506 "regions": [
3507 {
3508 "label": "Models",
3509 "value": 40,
3510 "places": [{ "label": "A first look", "weight": 2 }]
3511 },
3512 { "label": "Agents", "value": 12 }
3513 ]
3514 },
3515 {
3516 "label": "Craft",
3517 "regions": [
3518 { "label": "Tooling", "value": 30 },
3519 { "label": "Accessibility", "value": 8 }
3520 ]
3521 }
3522 ],
3523 "links": [{ "from": "Agents", "to": "Tooling", "weight": 0.4 }],
3524 "seed": 7
3525 }
3526 }"#;
3527
3528 const TOPICMAP: &str = r#"{
3529 "schemaVersion": 1,
3530 "type": "topicmap",
3531 "title": "Insights themes",
3532 "topicmap": {
3533 "topics": [
3534 {"label": "AI in practice", "value": 291, "points": 41},
3535 {"label": "Engineering", "value": 282, "points": 24},
3536 {"label": "Cloud native", "value": 100, "points": 9},
3537 {"label": "Web marketing", "value": 95, "points": 7},
3538 {"label": "Management", "value": 69, "points": 5}
3539 ],
3540 "links": [
3541 {"from": "AI in practice", "to": "Engineering", "weight": 0.6}
3542 ],
3543 "islands": [
3544 {"label": "WASM", "value": 6, "points": 1}
3545 ],
3546 "cartouche": {"heading": "Topic map", "meta": "843 entries · 87 paths · 2026"}
3547 }
3548 }"#;
3549
3550 #[test]
3551 fn topicmap_renders_areas_labels_and_links() {
3552 let output = render_json(TOPICMAP, RenderFormat::Svg, &RenderOptions::default()).unwrap();
3553 assert_eq!(
3554 output
3555 .content
3556 .matches("class=\"chartlet-topic-area")
3557 .count(),
3558 6
3559 );
3560 assert_eq!(
3561 output
3562 .content
3563 .matches("class=\"chartlet-topic-link\"")
3564 .count(),
3565 1
3566 );
3567 assert!(output.content.contains(
3568 "Topic map with 5 areas and one island. Largest: AI in practice with 291 entries, smallest: Management with 69. Closest neighbours: AI in practice and Engineering."
3569 ));
3570 assert_eq!(
3573 output
3574 .content
3575 .matches("class=\"chartlet-topic-band\"")
3576 .count(),
3577 12
3578 );
3579 assert_eq!(
3580 output
3581 .content
3582 .matches("class=\"chartlet-topic-halo")
3583 .count(),
3584 6
3585 );
3586 assert_eq!(
3587 output
3588 .content
3589 .matches("class=\"chartlet-topic-point")
3590 .count(),
3591 41 + 24 + 9 + 7 + 5 + 1
3592 );
3593 assert!(!output.content.contains("class=\"chartlet-topic-area\"/>"));
3595 let centers = output
3597 .content
3598 .split("class=\"chartlet-topic-area")
3599 .skip(1)
3600 .map(|rest| {
3601 let attribute = |name: &str| -> f64 {
3602 let start = rest.find(name).expect("center attribute") + name.len();
3603 let end = start + rest[start..].find('"').expect("closing quote");
3604 rest[start..end].parse().expect("a number")
3605 };
3606 (attribute("data-cx=\""), attribute("data-cy=\""))
3607 })
3608 .collect::<Vec<_>>();
3609 assert_eq!(centers.len(), 6);
3610 for (cx, cy) in centers {
3611 assert!((0.0..=800.0).contains(&cx) && (0.0..=450.0).contains(&cy));
3612 }
3613
3614 for label in [
3617 "AI in practice",
3618 "Engineering",
3619 "Cloud native",
3620 "Web marketing",
3621 "Management",
3622 "WASM",
3623 ] {
3624 assert!(output.content.contains(&format!(">{label}<")), "{label}");
3625 }
3626 assert_eq!(texts(&output.content, "chartlet-topic-outside").len(), 6);
3627 assert_eq!(
3628 output
3629 .content
3630 .matches("class=\"chartlet-topic-leader\"")
3631 .count(),
3632 6
3633 );
3634 assert_eq!(
3635 output
3636 .content
3637 .matches("class=\"chartlet-topic-label")
3638 .count(),
3639 0
3640 );
3641 assert_eq!(
3642 output
3643 .warnings
3644 .iter()
3645 .map(|warning| (warning.code, warning.path.as_str()))
3646 .collect::<Vec<_>>(),
3647 vec![
3648 ("label_outside_area", "/topicmap/topics/0/label"),
3649 ("label_outside_area", "/topicmap/topics/1/label"),
3650 ("label_outside_area", "/topicmap/topics/2/label"),
3651 ("label_outside_area", "/topicmap/topics/3/label"),
3652 ("label_outside_area", "/topicmap/topics/4/label"),
3653 ("label_outside_area", "/topicmap/islands/0/label"),
3654 ]
3655 );
3656 }
3657
3658 #[test]
3659 fn topicmap_draws_its_map_furniture_in_order() {
3660 let output = render_json(TOPICMAP, RenderFormat::Svg, &RenderOptions::default()).unwrap();
3661 let at = |needle: &str| {
3662 output
3663 .content
3664 .find(needle)
3665 .unwrap_or_else(|| panic!("{needle} is missing"))
3666 };
3667 assert!(at("class=\"chartlet-sea\"") < at("class=\"chartlet-topic-band\""));
3670 assert!(at("class=\"chartlet-graticule\"") < at("class=\"chartlet-topic-area"));
3671 assert!(at("class=\"chartlet-topic-area") < at("class=\"chartlet-compass-ring\""));
3672 assert!(at("class=\"chartlet-compass-ring\"") < at("class=\"chartlet-cartouche\""));
3673 assert_eq!(
3674 output
3675 .content
3676 .matches("class=\"chartlet-graticule\"")
3677 .count(),
3678 6
3679 );
3680 assert!(output.content.contains(">Topic map</text>"));
3681 assert!(
3682 output
3683 .content
3684 .contains(">843 entries · 87 paths · 2026</text>")
3685 );
3686 assert!(output.content.contains(">N</text>"));
3687 }
3688
3689 #[test]
3690 fn topicmap_leaves_out_the_furniture_it_was_not_asked_for() {
3691 let bare = r#"{
3692 "schemaVersion": 1,
3693 "type": "topicmap",
3694 "title": "Plain",
3695 "topicmap": {
3696 "graticule": false,
3697 "compass": false,
3698 "topics": [
3699 {"label": "One", "value": 40},
3700 {"label": "Two", "value": 20}
3701 ]
3702 }
3703 }"#;
3704 let output = render_json(bare, RenderFormat::Svg, &RenderOptions::default()).unwrap();
3706 assert!(!output.content.contains("class=\"chartlet-graticule\""));
3707 assert!(!output.content.contains("class=\"chartlet-compass-ring\""));
3708 assert!(!output.content.contains("class=\"chartlet-cartouche\""));
3709 assert!(output.content.contains("class=\"chartlet-sea\""));
3711 }
3712
3713 #[test]
3714 fn topicmap_html_offers_a_radio_group_for_the_areas() {
3715 let output = render_json(TOPICMAP, RenderFormat::Html, &RenderOptions::default()).unwrap();
3716 assert!(
3717 output
3718 .content
3719 .contains("<fieldset class=\"chartlet-topic-picker\"><legend>Area</legend>")
3720 );
3721 assert_eq!(output.content.matches("type=\"radio\"").count(), 5);
3724 assert_eq!(
3725 output.content.matches("class=\"topic-0\" checked").count(),
3726 1
3727 );
3728 assert!(
3729 output
3730 .content
3731 .contains("class=\"topic-4\"> Management</label>")
3732 );
3733 assert!(!output.content.contains("WASM</label>"));
3734 assert!(output.content.contains("name=\"topic-chartlet-"));
3736 }
3737
3738 #[test]
3739 fn topicmap_selection_styles_only_apply_once_something_is_selected() {
3740 let output = render_json(TOPICMAP, RenderFormat::Html, &RenderOptions::default()).unwrap();
3741 for rule in output
3745 .content
3746 .split('}')
3747 .filter(|rule| rule.contains(".chartlet-topic-panel"))
3748 {
3749 assert!(
3750 rule.contains(":has("),
3751 "a panel rule applies unconditionally: {rule}"
3752 );
3753 }
3754 let root = output.content.split(" id=\"").nth(1).unwrap();
3757 let root = &root[..root.find('"').unwrap()];
3758 assert!(output.content.contains(&format!(
3759 ".chartlet-wrapper:has(.chartlet-topic-picker input.topic-2:checked) #{root} .chartlet-topic-area.chartlet-topic-2{{fill:var(--chartlet-accent)}}"
3760 )));
3761 let svg = render_json(TOPICMAP, RenderFormat::Svg, &RenderOptions::default()).unwrap();
3763 assert!(!svg.content.contains("chartlet-topic-picker"));
3764 }
3765
3766 #[test]
3767 fn topicmap_table_carries_entries_paths_and_share() {
3768 let output = render_json(TOPICMAP, RenderFormat::Html, &RenderOptions::default()).unwrap();
3769 assert!(output.content.contains(
3770 "<th scope=\"col\">Topic</th><th scope=\"col\">Entries</th><th scope=\"col\">Paths</th><th scope=\"col\">Share</th>"
3771 ));
3772 assert!(output.content.contains(
3774 "<th scope=\"row\">AI in practice</th><td>291</td><td>41</td><td>34.52%</td>"
3775 ));
3776 assert!(
3777 output
3778 .content
3779 .contains("<th scope=\"row\">WASM</th><td>6</td><td>1</td><td>0.71%</td>")
3780 );
3781 }
3782
3783 #[test]
3784 fn topicmap_layout_is_deterministic() {
3785 let first = render_json(TOPICMAP, RenderFormat::Svg, &RenderOptions::default()).unwrap();
3786 let second = render_json(TOPICMAP, RenderFormat::Svg, &RenderOptions::default()).unwrap();
3787 assert_eq!(first.content, second.content);
3788 }
3789
3790 fn texts(svg: &str, class: &str) -> Vec<(f64, f64, String, String)> {
3792 let mut found = Vec::new();
3793 for chunk in svg.split("<text ").skip(1) {
3794 let attributes = chunk.split('>').next().expect("a tag ends");
3795 let classes = attributes
3798 .split("class=\"")
3799 .nth(1)
3800 .and_then(|rest| rest.split('"').next())
3801 .unwrap_or_default();
3802 if !classes.split_whitespace().any(|name| name == class) {
3803 continue;
3804 }
3805 let read = |name: &str| {
3806 attributes
3807 .split(&format!("{name}=\""))
3808 .nth(1)
3809 .and_then(|rest| rest.split('"').next())
3810 .map(str::to_owned)
3811 };
3812 let content = chunk
3813 .split_once('>')
3814 .and_then(|(_, rest)| rest.split_once("</text>"))
3815 .map(|(content, _)| content.to_owned())
3816 .expect("a text element has content");
3817 found.push((
3818 read("x").expect("x").parse().expect("a number"),
3819 read("font-size")
3820 .expect("font-size")
3821 .parse()
3822 .expect("a number"),
3823 read("text-anchor").expect("anchor"),
3824 content,
3825 ));
3826 }
3827 found
3828 }
3829
3830 #[test]
3831 fn topicmap_labels_moved_outside_stay_on_the_canvas_and_clear_of_each_other() {
3832 let topics = (0..24)
3835 .map(|index| {
3836 format!(
3837 r#"{{"label": "Topic number {index}", "value": {}}}"#,
3838 20 + (index * 37) % 260
3839 )
3840 })
3841 .collect::<Vec<_>>()
3842 .join(",");
3843 let specification = format!(
3844 r#"{{"schemaVersion": 1, "type": "topicmap", "title": "Crowded",
3845 "topicmap": {{"topics": [{topics}]}}}}"#
3846 );
3847 let output =
3848 render_json(&specification, RenderFormat::Svg, &RenderOptions::default()).unwrap();
3849
3850 let metrics = BuiltinMetrics;
3851 let mut placed: Vec<(f64, f64, f64, f64)> = Vec::new();
3852 for (x, size, anchor, content) in texts(&output.content, "chartlet-topic-outside") {
3853 let width = metrics.width(&content, size);
3854 let (left, right) = if anchor == "start" {
3855 (x, x + width)
3856 } else {
3857 (x - width, x)
3858 };
3859 assert!(left >= 0.0, "{content} starts off the canvas at {left}");
3860 assert!(right <= 800.0, "{content} ends off the canvas at {right}");
3861 placed.push((left, right, x, size));
3862 }
3863 assert!(placed.len() > 12, "most labels should sit outside");
3864
3865 let reported = output
3867 .warnings
3868 .iter()
3869 .filter(|warning| warning.code == "dense_chart")
3870 .count();
3871 assert_eq!(placed.len() + reported, 24);
3872 }
3873
3874 #[test]
3875 fn topicmap_caps_links_per_topic_and_drops_the_weakest() {
3876 let specification = r#"{
3877 "schemaVersion": 1,
3878 "type": "topicmap",
3879 "title": "Busy hub",
3880 "topicmap": {
3881 "topics": [
3882 {"label": "Hub", "value": 200},
3883 {"label": "A", "value": 50},
3884 {"label": "B", "value": 50},
3885 {"label": "C", "value": 50},
3886 {"label": "D", "value": 50},
3887 {"label": "E", "value": 50}
3888 ],
3889 "links": [
3890 {"from": "Hub", "to": "A", "weight": 0.9},
3891 {"from": "Hub", "to": "B", "weight": 0.8},
3892 {"from": "Hub", "to": "C", "weight": 0.7},
3893 {"from": "Hub", "to": "D", "weight": 0.6},
3894 {"from": "Hub", "to": "E", "weight": 0.5}
3895 ]
3896 }
3897 }"#;
3898 let output =
3899 render_json(specification, RenderFormat::Svg, &RenderOptions::default()).unwrap();
3900 assert_eq!(
3901 output
3902 .content
3903 .matches("class=\"chartlet-topic-link\"")
3904 .count(),
3905 3
3906 );
3907 assert_eq!(
3908 output
3909 .warnings
3910 .iter()
3911 .map(|warning| (warning.code, warning.path.as_str()))
3912 .collect::<Vec<_>>(),
3913 vec![
3914 ("link_dropped", "/topicmap/links/3"),
3915 ("link_dropped", "/topicmap/links/4"),
3916 ]
3917 );
3918 }
3919
3920 #[test]
3921 fn atlas_renders_and_describes_its_landscape() {
3922 let svg = render_json(ATLAS, RenderFormat::Svg, &RenderOptions::default()).unwrap();
3923 assert!(
3924 svg.content
3925 .contains("Knowledge landscape of 2 realms and 4 regions")
3926 );
3927 assert!(svg.content.contains("largest is Models with 40"));
3928 assert!(svg.content.contains("1 place is marked"));
3929 }
3930
3931 #[test]
3932 fn atlas_draws_its_terrain_unless_it_is_turned_off() {
3933 let drawn = render_json(ATLAS, RenderFormat::Svg, &RenderOptions::default()).unwrap();
3934 assert!(drawn.content.contains("class=\"chartlet-atlas-contour\""));
3936 let flat = ATLAS.replace(r#""seed": 7"#, r#""seed": 7, "contours": false"#);
3937 let plain = render_json(&flat, RenderFormat::Svg, &RenderOptions::default()).unwrap();
3938 assert!(!plain.content.contains("class=\"chartlet-atlas-contour\""));
3939 assert!(plain.content.contains("class=\"chartlet-atlas-coast\""));
3941 }
3942
3943 #[test]
3944 fn atlas_requires_an_atlas_block() {
3945 let missing = r#"{"schemaVersion": 1, "type": "atlas", "title": "Empty"}"#;
3946 let error = render_json(missing, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
3947 assert_eq!(error.code, "missing_atlas");
3948 assert_eq!(error.path, "/atlas");
3949 }
3950
3951 #[test]
3952 fn atlas_rejects_a_realm_without_regions() {
3953 let empty = ATLAS.replace(r#"{ "label": "Tooling", "value": 30 },"#, "");
3954 let empty = empty.replace(r#"{ "label": "Accessibility", "value": 8 }"#, "");
3955 let error = render_json(&empty, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
3956 assert_eq!(error.code, "empty_realm");
3957 assert_eq!(error.path, "/atlas/realms/1/regions");
3958 }
3959
3960 #[test]
3961 fn atlas_rejects_a_region_that_repeats_a_realm_name() {
3962 let clash = ATLAS.replace(r#""label": "Tooling""#, r#""label": "Machines""#);
3963 let error = render_json(&clash, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
3964 assert_eq!(error.code, "duplicate_atlas_label");
3965 assert_eq!(error.path, "/atlas/realms/1/regions/0/label");
3966 }
3967
3968 #[test]
3969 fn atlas_rejects_links_to_unknown_regions() {
3970 let broken = ATLAS.replace(r#""to": "Tooling""#, r#""to": "Machines""#);
3971 let error = render_json(&broken, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
3972 assert_eq!(error.code, "unknown_region_link");
3973 assert_eq!(error.path, "/atlas/links/0/to");
3974 }
3975
3976 #[test]
3977 fn atlas_rejects_area_damping_outside_its_range() {
3978 let broken = ATLAS.replace(r#""seed": 7"#, r#""seed": 7, "areaDamping": 1.5"#);
3979 let error = render_json(&broken, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
3980 assert_eq!(error.code, "invalid_area_damping");
3981 assert_eq!(error.path, "/atlas/areaDamping");
3982 }
3983
3984 #[test]
3985 fn atlas_warns_when_a_region_lists_more_places_than_it_claims() {
3986 let crowded = r#"{
3987 "schemaVersion": 1,
3988 "type": "atlas",
3989 "title": "Miscounted",
3990 "atlas": {
3991 "realms": [
3992 {
3993 "label": "Machines",
3994 "regions": [
3995 {
3996 "label": "Models",
3997 "value": 1,
3998 "places": [{ "label": "One" }, { "label": "Two" }]
3999 }
4000 ]
4001 }
4002 ]
4003 }
4004 }"#;
4005 let rendered = render_json(crowded, RenderFormat::Svg, &RenderOptions::default()).unwrap();
4006 assert!(
4007 rendered
4008 .warnings
4009 .iter()
4010 .any(|warning| warning.code == "more_places_than_value"),
4011 "expected the miscount warning, got {:?}",
4012 rendered.warnings
4013 );
4014 }
4015
4016 #[test]
4017 fn atlas_rejects_a_place_weight_outside_its_range() {
4018 let broken = ATLAS.replace(r#""weight": 2 }]"#, r#""weight": 9 }]"#);
4019 let error = render_json(&broken, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
4020 assert_eq!(error.code, "place_weight_out_of_range");
4021 assert_eq!(error.path, "/atlas/realms/0/regions/0/places/0/weight");
4022 }
4023
4024 #[test]
4025 fn atlas_rejects_series_alongside_its_realms() {
4026 let mixed = ATLAS.replace(
4027 r#""title": "Knowledge landscape","#,
4028 r#""title": "Knowledge landscape", "data": [{"label": "A", "value": 1}],"#,
4029 );
4030 let error = render_json(&mixed, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
4031 assert_eq!(error.code, "option_not_supported");
4032 assert_eq!(error.path, "/data");
4033 assert!(
4034 error
4035 .message
4036 .contains("an atlas chart is drawn from atlas.realms")
4037 );
4038 }
4039
4040 #[test]
4041 fn topicmap_requires_a_topicmap_block() {
4042 let missing = r#"{"schemaVersion": 1, "type": "topicmap", "title": "Empty"}"#;
4043 let error = render_json(missing, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
4044 assert_eq!(error.code, "missing_topicmap");
4045 assert_eq!(error.path, "/topicmap");
4046 }
4047
4048 #[test]
4049 fn topicmap_rejects_too_many_topics() {
4050 let topics = (0..41)
4051 .map(|i| format!(r#"{{"label": "T{i}", "value": 10}}"#))
4052 .collect::<Vec<_>>()
4053 .join(",");
4054 let spec = format!(
4055 r#"{{"schemaVersion": 1, "type": "topicmap", "title": "Big", "topicmap": {{"topics": [{topics}]}}}}"#
4056 );
4057 let error = render_json(&spec, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
4058 assert_eq!(error.code, "too_many_topics");
4059 assert_eq!(error.path, "/topicmap/topics");
4060 }
4061
4062 #[test]
4063 fn topicmap_rejects_duplicate_labels_across_topics_and_islands() {
4064 let duplicate = TOPICMAP.replace(r#""label": "WASM""#, r#""label": "Management""#);
4065 let error =
4066 render_json(&duplicate, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
4067 assert_eq!(error.code, "duplicate_topic_label");
4068 assert_eq!(error.path, "/topicmap/islands/0/label");
4069 }
4070
4071 #[test]
4072 fn topicmap_rejects_links_to_unknown_labels() {
4073 let broken = TOPICMAP.replace(r#""to": "Engineering""#, r#""to": "Nonexistent""#);
4074 let error = render_json(&broken, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
4075 assert_eq!(error.code, "unknown_topic_link");
4076 assert_eq!(error.path, "/topicmap/links/0/to");
4077 }
4078
4079 #[test]
4080 fn topicmap_rejects_link_weight_outside_zero_to_one() {
4081 let broken = TOPICMAP.replace(r#""weight": 0.6"#, r#""weight": 1.5"#);
4082 let error = render_json(&broken, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
4083 assert_eq!(error.code, "invalid_link_weight");
4084 assert_eq!(error.path, "/topicmap/links/0/weight");
4085 }
4086
4087 #[test]
4088 fn topicmap_rejects_a_value_below_one() {
4089 let broken = TOPICMAP.replace(r#""value": 69"#, r#""value": 0"#);
4090 let error = render_json(&broken, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
4091 assert_eq!(error.code, "topic_value_out_of_range");
4092 assert_eq!(error.path, "/topicmap/topics/4/value");
4093 }
4094
4095 #[test]
4096 fn topicmap_warns_when_a_topic_is_too_small_for_its_label() {
4097 let small = TOPICMAP.replace(r#""value": 69, "points": 5"#, r#""value": 5, "points": 5"#);
4098 let output = render_json(&small, RenderFormat::Svg, &RenderOptions::default()).unwrap();
4099 assert!(
4100 output
4101 .warnings
4102 .iter()
4103 .any(|warning| warning.code == "topic_too_small_for_label"
4104 && warning.path == "/topicmap/topics/4/value"),
4105 "{:?}",
4106 output.warnings
4107 );
4108 }
4109
4110 #[test]
4111 fn topicmap_rejects_bar_line_and_time_only_fields() {
4112 for (field, path) in [
4113 (r#""data": [{"label": "A", "value": 1}],"#, "/data"),
4114 (r#""categories": ["A"],"#, "/categories"),
4115 (r#""series": [{"name": "S", "values": [1]}],"#, "/series"),
4116 (
4117 r#""zoomSteps": [{"label": "A", "from": 0, "to": 0}, {"label": "B", "from": 0, "to": 1}],"#,
4118 "/zoomSteps",
4119 ),
4120 (r#""panes": [{}],"#, "/panes"),
4121 ] {
4122 let mixed = TOPICMAP.replace(
4123 "\"schemaVersion\": 1,",
4124 &format!("\"schemaVersion\": 1, {field}"),
4125 );
4126 let error = render_json(&mixed, RenderFormat::Svg, &RenderOptions::default())
4127 .expect_err("a topicmap chart is drawn from topicmap.topics");
4128 assert_eq!(error.code, "option_not_supported", "{field}");
4129 assert_eq!(error.path, path, "{field}");
4130 }
4131 }
4132
4133 #[test]
4134 fn topicmap_html_lists_every_area_with_entries_and_paths() {
4135 let output = render_json(TOPICMAP, RenderFormat::Html, &RenderOptions::default()).unwrap();
4136 assert!(output.content.contains(
4137 "<th scope=\"col\">Topic</th><th scope=\"col\">Entries</th><th scope=\"col\">Paths</th>"
4138 ));
4139 assert!(
4140 output
4141 .content
4142 .contains("<th scope=\"row\">WASM</th><td>6</td><td>1</td>")
4143 );
4144 }
4145
4146 fn week(layers: &str, extra: &str) -> String {
4150 format!(
4151 r#"{{
4152 "schemaVersion": 1,
4153 "type": "time",
4154 "title": "Sensors",
4155 "showValues": false{extra},
4156 "panes": [{{"layers": [{layers}]}}]
4157 }}"#
4158 )
4159 }
4160
4161 fn sensor(name: &str, fields: &str, values: [&str; 5]) -> String {
4164 let points = values
4165 .iter()
4166 .enumerate()
4167 .map(|(day, value)| format!(r#"{{"time": "2026-03-0{}", "value": {value}}}"#, day + 1))
4168 .collect::<Vec<_>>()
4169 .join(", ");
4170 format!(r#"{{"mark": "line", "name": "{name}"{fields}, "points": [{points}]}}"#)
4171 }
4172
4173 const GAPPED: [&str; 5] = ["10", "12", "null", "14", "15"];
4174
4175 #[test]
4176 fn a_null_value_breaks_the_line_and_counts_as_missing() {
4177 let spec = week(&sensor("Load", "", GAPPED), "");
4178 let output = render_ok(&spec);
4179 let svg = &output.content;
4180 assert!(output.warnings.is_empty(), "{:?}", output.warnings);
4181 assert_eq!(svg.matches("<polyline").count(), 2);
4183 assert_eq!(
4184 svg.matches("class=\"chartlet-point chartlet-point-series-1\"")
4185 .count(),
4186 4
4187 );
4188 assert!(svg.contains("1 value is missing."), "{svg}");
4189
4190 let html = html_ok(&spec);
4191 assert!(html.contains("<th scope=\"row\">2026-03-03</th><td>Missing</td>"));
4192
4193 let german = html_ok(&german(&spec, "time"));
4194 assert!(german.contains("1 Wert fehlt."));
4195 assert!(german.contains("<th scope=\"row\">03.03.2026</th><td>fehlt</td>"));
4196
4197 let lone = week(&sensor("Load", "", ["10", "null", "12", "null", "15"]), "");
4199 let svg = render_ok(&lone).content;
4200 assert!(!svg.contains("<polyline"), "{svg}");
4201 assert_eq!(
4202 svg.matches("class=\"chartlet-point chartlet-point-series-1\"")
4203 .count(),
4204 3
4205 );
4206 assert!(svg.contains("2 values are missing."));
4207
4208 let gapped = panel("B", 1.0).replacen(
4210 r#"{"time": "2010", "value": 12}"#,
4211 r#"{"time": "2005", "value": null}, {"time": "2010", "value": 12}"#,
4212 1,
4213 );
4214 let spec = multiples(&[panel("A", 1.0), gapped].join(","));
4215 assert!(render_ok(&spec).content.contains("1 value is missing."));
4216 }
4217
4218 #[test]
4219 fn a_band_breaks_at_a_missing_value_and_a_missing_value_has_no_band() {
4220 let banded = |gap: &str| {
4221 let points = [
4222 r#"{"time": "2026-03-01", "value": 1, "lower": 0, "upper": 2}"#,
4223 r#"{"time": "2026-03-02", "value": 2, "lower": 1, "upper": 3}"#,
4224 gap,
4225 r#"{"time": "2026-03-04", "value": 2, "lower": 1, "upper": 3}"#,
4226 r#"{"time": "2026-03-05", "value": 3, "lower": 2, "upper": 4}"#,
4227 ]
4228 .join(", ");
4229 week(
4230 &format!(r#"{{"mark": "line", "name": "Load", "points": [{points}]}}"#),
4231 "",
4232 )
4233 };
4234 let svg = render_ok(&banded(r#"{"time": "2026-03-03", "value": null}"#)).content;
4235 assert_eq!(
4236 svg.matches("class=\"chartlet-band chartlet-band-series-1\"")
4237 .count(),
4238 2
4239 );
4240 assert_eq!(
4241 render_err(&banded(
4242 r#"{"time": "2026-03-03", "value": null, "lower": 1, "upper": 3}"#
4243 )),
4244 (
4245 "invalid_band",
4246 "/panes/0/layers/0/points/2/lower".to_owned()
4247 )
4248 );
4249 }
4250
4251 #[test]
4252 fn a_line_takes_its_declared_pattern_and_weight_in_plot_and_legend() {
4253 let spec = week(
4254 &[
4255 sensor("Dotted", r#", "dash": "dotted""#, GAPPED),
4256 sensor("Bold", r#", "stroke": "bold""#, GAPPED),
4257 sensor("Dashed", r#", "dash": "dashed""#, GAPPED),
4258 sensor(
4259 "Solid model",
4260 r#", "modeled": true, "dash": "solid""#,
4261 GAPPED,
4262 ),
4263 ]
4264 .join(", "),
4265 "",
4266 );
4267 let svg = render_ok(&spec).content;
4268 assert!(svg.contains(".chartlet-line-dotted{stroke-dasharray:0 7}"));
4269 assert!(svg.contains(".chartlet-line-bold{stroke-width:4.5}"));
4270 for class in [
4272 "chartlet-line chartlet-line-dotted chartlet-line-series-1",
4273 "chartlet-line chartlet-line-bold chartlet-line-series-2",
4274 "chartlet-line chartlet-line-dashed chartlet-line-series-3",
4275 "chartlet-line chartlet-line-series-4",
4276 ] {
4277 assert_eq!(
4278 svg.matches(&format!("class=\"{class}\"")).count(),
4279 3,
4280 "{class}"
4281 );
4282 }
4283 assert!(svg.contains(">Solid model (modeled)<"));
4285 assert!(!svg.contains("class=\"chartlet-line chartlet-line-modeled"));
4286
4287 let plain = render_ok(&week(&sensor("Load", "", GAPPED), "")).content;
4289 assert!(!plain.contains("chartlet-line-dotted"));
4290
4291 let rule = week(
4292 &format!(
4293 r#"{}, {{"mark": "annotation", "value": 1, "label": "Limit", "dash": "dotted"}}"#,
4294 sensor("Load", "", GAPPED)
4295 ),
4296 "",
4297 );
4298 assert_eq!(
4299 render_err(&rule),
4300 ("option_not_supported", "/panes/0/layers/1/dash".to_owned())
4301 );
4302 }
4303
4304 #[test]
4305 fn an_area_is_filled_down_to_zero_and_described() {
4306 let area = sensor("Load", "", ["10", "12", "null", "14", "15"])
4307 .replace("\"mark\": \"line\"", "\"mark\": \"area\"");
4308 let spec = week(
4309 &[
4310 area.clone(),
4311 sensor("Base", "", ["8", "9", "9", "10", "11"]),
4312 ]
4313 .join(", "),
4314 "",
4315 );
4316 let output = render_ok(&spec);
4317 let svg = &output.content;
4318 assert!(output.warnings.is_empty(), "{:?}", output.warnings);
4319 assert!(svg.contains(".chartlet-area{stroke:none;fill-opacity:.18}"));
4320 assert_eq!(
4322 svg.matches("<polyline points=\"").count(),
4323 7
4325 );
4326 assert_eq!(
4327 svg.matches("class=\"chartlet-area chartlet-band-series-1\"")
4328 .count(),
4329 3
4330 );
4331 assert!(svg.contains("class=\"chartlet-tick\">0</text>"), "{svg}");
4333 assert!(svg.contains("The area below Load is filled down to zero."));
4334 assert!(
4335 html_ok(&german(&spec, "time")).contains("Die Fläche unter Load ist bis null gefüllt.")
4336 );
4337
4338 let colored = week(
4339 &area.replace(
4340 "\"name\": \"Load\"",
4341 "\"name\": \"Load\", \"color\": \"#0f766e\"",
4342 ),
4343 "",
4344 );
4345 let svg = render_ok(&colored).content;
4346 assert!(svg.contains(".chartlet-area.chartlet-style-0{fill:#0f766e;stroke:none}"));
4347 assert!(svg.contains("The area below Load is filled down to zero."));
4348 }
4349
4350 #[test]
4351 fn a_time_chart_zooms_into_windows_of_time() {
4352 let zoom = r#", "zoomSteps": [
4353 {"label": "All", "from": "2026-03-01", "to": "2026-03-05"},
4354 {"label": "End", "from": "2026-03-04", "to": 1772668800}
4355 ]"#;
4356 let spec = week(&sensor("Load", "", GAPPED), zoom);
4357 let html = html_ok(&spec);
4358 assert!(html.contains("<fieldset class=\"chartlet-zoom\">"));
4359 assert_eq!(html.matches("<svg").count(), 2);
4360 let end = html
4361 .split("chartlet-panel chartlet-panel-1")
4362 .nth(1)
4363 .and_then(|panel| panel.split("</div>").next())
4364 .expect("the second window has a panel");
4365 assert!(end.contains("Time chart with 2 points from 2026-03-04 to 2026-03-05."));
4366 assert!(!end.contains("2026-03-01"));
4367 assert!(html.contains("<th scope=\"row\">2026-03-01</th>"));
4369 assert_eq!(render_ok(&spec).content.matches("<svg").count(), 1);
4371
4372 for (steps, code, path) in [
4373 (
4374 r#"[{"label": "A", "from": "2026-03-04", "to": "2026-03-04"}, {"label": "B", "from": "2026-03-01", "to": "2026-03-05"}]"#,
4375 "invalid_zoom_step",
4376 "/zoomSteps/0/from",
4377 ),
4378 (
4379 r#"[{"label": "A", "from": "2026-03-01", "to": "2026-03-05"}, {"label": "B", "from": "2026-03-02", "to": "2026-03-03"}]"#,
4380 "zoom_out_of_range",
4381 "/zoomSteps/1",
4382 ),
4383 (
4384 r#"[{"label": "A", "from": "March", "to": "2026-03-05"}, {"label": "B", "from": "2026-03-01", "to": "2026-03-05"}]"#,
4385 "invalid_time",
4386 "/zoomSteps/0/from",
4387 ),
4388 (
4389 r#"[{"label": "A", "from": "2026-03-01", "to": "2026-03-05"}]"#,
4390 "not_enough_zoom_steps",
4391 "/zoomSteps",
4392 ),
4393 ] {
4394 let spec = week(
4395 &sensor("Load", "", GAPPED),
4396 &format!(r#", "zoomSteps": {steps}"#),
4397 );
4398 assert_eq!(render_err(&spec), (code, path.to_owned()), "{steps}");
4399 }
4400
4401 let dated = SPEC.replace(
4403 "\"schemaVersion\": 1,",
4404 r#""schemaVersion": 1, "zoomSteps": [{"label": "A", "from": "2026-03-01", "to": 1}, {"label": "B", "from": 0, "to": 1}],"#,
4405 );
4406 assert_eq!(
4407 render_err(&dated),
4408 ("invalid_spec", "/zoomSteps/0/from".to_owned())
4409 );
4410 let panels = [panel("A", 1.0), panel("B", 1.0)].join(",");
4411 let zoomed = multiples(&panels).replace(
4412 "\"schemaVersion\": 1,",
4413 r#""schemaVersion": 1, "zoomSteps": [{"label": "A", "from": "2000", "to": "2010"}, {"label": "B", "from": "2000", "to": "2050"}],"#,
4414 );
4415 assert_eq!(
4416 render_err(&zoomed),
4417 ("option_not_supported", "/zoomSteps".to_owned())
4418 );
4419 }
4420
4421 #[test]
4422 fn a_pane_takes_six_layers_when_two_bring_their_own_color() {
4423 let colored = r##", "color": "#0f766e""##;
4424 let layers = |names: &[(&str, &str)]| {
4425 names
4426 .iter()
4427 .map(|(name, fields)| sensor(name, fields, GAPPED))
4428 .collect::<Vec<_>>()
4429 .join(", ")
4430 };
4431 let six = [
4432 ("Own", colored),
4433 ("A", ""),
4434 ("B", ""),
4435 ("C", ""),
4436 ("Also own", colored),
4437 ("D", ""),
4438 ];
4439 let output = render_ok(&week(&layers(&six), ""));
4440 let svg = &output.content;
4441 assert!(output.warnings.is_empty(), "{:?}", output.warnings);
4442 assert!(svg.contains("class=\"chartlet-line chartlet-line-series-1\""));
4444 for series in 2..=4 {
4445 assert!(svg.contains(&format!("chartlet-line-series-{series}")));
4446 }
4447 assert_eq!(svg.matches("class=\"chartlet-legend\"").count(), 6);
4448
4449 let seven = [six.as_slice(), &[("E", colored)]].concat();
4450 assert_eq!(
4451 render_err(&week(&layers(&seven), "")),
4452 ("too_many_layers", "/panes/0/layers".to_owned())
4453 );
4454 let five_in_palette = [("A", ""), ("B", ""), ("C", ""), ("D", ""), ("E", "")];
4455 assert_eq!(
4456 render_err(&week(&layers(&five_in_palette), "")),
4457 ("too_many_layers", "/panes/0/layers/4/color".to_owned())
4458 );
4459 }
4460
4461 fn placed_texts(svg: &str, class: &str) -> Vec<(f64, f64, String)> {
4463 svg.split("<text ")
4464 .skip(1)
4465 .filter(|chunk| chunk.contains(&format!("class=\"{class}\"")))
4466 .map(|chunk| {
4467 let read = |name: &str| -> f64 {
4468 chunk
4469 .split(&format!(" {name}=\""))
4470 .nth(1)
4471 .or_else(|| chunk.strip_prefix(&format!("{name}=\"")))
4472 .and_then(|rest| rest.split('"').next())
4473 .expect("a coordinate")
4474 .parse()
4475 .expect("a number")
4476 };
4477 let content = chunk
4478 .split_once('>')
4479 .and_then(|(_, rest)| rest.split_once("</text>"))
4480 .map(|(content, _)| content.to_owned())
4481 .expect("a text element has content");
4482 (read("x"), read("y"), content)
4483 })
4484 .collect()
4485 }
4486
4487 #[test]
4488 fn a_long_legend_wraps_into_rows_without_overlap() {
4489 let names = [
4490 "Server hall north",
4491 "Server hall south",
4492 "Cooling plant",
4493 "Office floors",
4494 "Lighting",
4495 "Charging",
4496 ];
4497 let layers = names
4498 .iter()
4499 .enumerate()
4500 .map(|(index, name)| {
4501 let fields = if index < 4 {
4502 String::new()
4503 } else {
4504 format!(r##", "color": "#12345{index}""##)
4505 };
4506 sensor(name, &fields, GAPPED)
4507 })
4508 .collect::<Vec<_>>()
4509 .join(", ");
4510 let svg = render_ok(&week(&layers, r#", "width": 480"#)).content;
4511 let legend = placed_texts(&svg, "chartlet-legend");
4512 let written: Vec<&str> = legend
4513 .iter()
4514 .map(|(_, _, content)| content.as_str())
4515 .collect();
4516 assert_eq!(written, names, "every name is written in full");
4517 let rows: std::collections::BTreeSet<u64> =
4518 legend.iter().map(|(_, y, _)| y.to_bits()).collect();
4519 assert!(rows.len() > 1, "{legend:?}");
4520 let width = |content: &str| BuiltinMetrics.width(content, 12.0);
4521 for (x, y, content) in &legend {
4522 assert!(x + width(content) <= 480.0, "{content} leaves the chart");
4523 for (other_x, other_y, other) in &legend {
4524 if other_y.to_bits() == y.to_bits() && other_x > x {
4525 assert!(
4526 x + width(content) < *other_x - 24.0,
4527 "{content} runs into {other}"
4528 );
4529 }
4530 }
4531 }
4532 let lowest = legend.iter().map(|(_, y, _)| *y).fold(0.0, f64::max);
4534 let ticks = placed_texts(&svg, "chartlet-tick");
4535 assert!(ticks.iter().all(|(_, y, _)| *y > lowest), "{ticks:?}");
4536 }
4537
4538 const CANDLES: &str = r#"[
4542 {"time": "2026-03-02", "open": 10, "high": 12, "low": 9, "close": 11},
4543 {"time": "2026-03-03", "open": 11, "high": 11.5, "low": 8, "close": 9},
4544 {"time": "2026-03-04", "open": 9, "high": 10, "low": 8.5, "close": 9}
4545 ]"#;
4546
4547 fn stacked(panes: &str, extra: &str) -> String {
4549 format!(
4550 r#"{{
4551 "schemaVersion": 1,
4552 "type": "time",
4553 "title": "Share price",
4554 "timeAxis": {{"title": "Day"}}{extra},
4555 "panes": [{panes}]
4556 }}"#
4557 )
4558 }
4559
4560 fn candle_pane(extra_layers: &str) -> String {
4561 format!(
4562 r#"{{"heightRatio": 3, "valueAxis": {{"title": "Price", "decimals": 2}}, "layers": [{{"mark": "ohlc", "name": "Price", "data": {CANDLES}}}{extra_layers}]}}"#
4563 )
4564 }
4565
4566 const VOLUME_PANE: &str = r#"{"valueAxis": {"title": "Volume", "format": "percent"}, "layers": [{"mark": "area", "name": "Volume", "points": [{"time": "2026-03-02", "value": 0.12}, {"time": "2026-03-03", "value": 0.2}, {"time": "2026-03-04", "value": 0.08}]}]}"#;
4567
4568 #[test]
4569 fn candles_differ_in_shape_and_color_by_direction() {
4570 let output = render_ok(&stacked(&candle_pane(""), ""));
4571 assert!(output.warnings.is_empty(), "{:?}", output.warnings);
4572 let svg = output.content;
4573 assert_eq!(
4575 svg.matches("class=\"chartlet-candle chartlet-candle-rise\"")
4576 .count(),
4577 3,
4578 "two rising or level candles and the legend sample"
4579 );
4580 assert_eq!(
4581 svg.matches("class=\"chartlet-candle chartlet-candle-fall\"")
4582 .count(),
4583 2
4584 );
4585 assert!(svg.contains(
4586 ".chartlet-candle-rise{fill:var(--chartlet-background);stroke:var(--chartlet-rise)}"
4587 ));
4588 assert!(svg.contains(".chartlet-theme-dark{--chartlet-rise:"));
4589 assert!(svg.contains("Price (hollow: rising, filled: falling)</text>"));
4590 assert!(svg.contains(
4591 "<title>2026-03-02 – Price: open 10.00, high 12.00, low 9.00, close 11.00</title>"
4592 ));
4593 assert!(svg.contains(" height=\"1\" class=\"chartlet-candle chartlet-candle-rise\""));
4595 assert!(svg.contains(
4596 "opened at 10.00 on 2026-03-02 and closed at 9.00 on 2026-03-04, a change of \u{2212}1.00 (\u{2212}10.0%); high 12.00 on 2026-03-02, low 8.00 on 2026-03-03."
4597 ), "{svg}");
4598 let html = html_ok(&stacked(&candle_pane(""), ""));
4599 assert!(html.contains("<th scope=\"col\">Price (open)</th><th scope=\"col\">Price (high)</th><th scope=\"col\">Price (low)</th><th scope=\"col\">Price (close)</th>"));
4600 assert!(html.contains("<td>11.00</td><td>11.50</td><td>8.00</td><td>9.00</td>"));
4601 assert!(!render_ok(TIME).content.contains("chartlet-rise"));
4603 }
4604
4605 #[test]
4606 fn dense_candles_are_drawn_as_wicks_only() {
4607 let data = (0..240)
4609 .map(|index| {
4610 format!(
4611 r#"{{"time": {}, "open": 10, "high": 12, "low": 9, "close": 11}}"#,
4612 1_770_000_000 + index * 86_400
4613 )
4614 })
4615 .collect::<Vec<_>>()
4616 .join(",");
4617 let output = render_ok(&stacked(
4618 &format!(r#"{{"layers": [{{"mark": "ohlc", "data": [{data}]}}]}}"#),
4619 "",
4620 ));
4621 assert_eq!(output.warnings.len(), 1, "{:?}", output.warnings);
4622 assert_eq!(output.warnings[0].code, "dense_chart");
4623 assert_eq!(output.warnings[0].path, "/panes/0/layers/0/data");
4624 let svg = output.content;
4625 assert_eq!(
4626 svg.matches("class=\"chartlet-wick chartlet-wick-rise\"")
4627 .count(),
4628 241
4629 );
4630 assert_eq!(
4632 svg.matches("class=\"chartlet-candle chartlet-candle-rise\"")
4633 .count(),
4634 1
4635 );
4636 }
4637
4638 #[test]
4639 fn invalid_candles_are_refused_with_their_path() {
4640 let refuse = |data: &str, layer: &str| {
4641 render_err(&stacked(
4642 &format!(r#"{{"layers": [{{"mark": "ohlc", "data": {data}{layer}}}]}}"#),
4643 "",
4644 ))
4645 };
4646 let valid = CANDLES;
4647 assert_eq!(
4648 refuse(&valid.replace("\"low\": 8,", "\"low\": 9.5,"), ""),
4649 ("invalid_candle", "/panes/0/layers/0/data/1/low".to_owned())
4650 );
4651 assert_eq!(
4652 refuse(&valid.replace("\"high\": 12,", "\"high\": 10.5,"), ""),
4653 ("invalid_candle", "/panes/0/layers/0/data/0/high".to_owned())
4654 );
4655 assert_eq!(
4656 refuse(&valid.replace("2026-03-03", "2026-03-02"), ""),
4657 ("unordered_time", "/panes/0/layers/0/data/1/time".to_owned())
4658 );
4659 assert_eq!(
4660 refuse(&valid.replace("\"open\": 10,", "\"open\": 1e101,"), ""),
4661 (
4662 "unsupported_numeric_range",
4663 "/panes/0/layers/0/data/0/open".to_owned()
4664 )
4665 );
4666 assert_eq!(
4667 refuse(
4668 r#"[{"time": "2026-03-02", "open": 1, "high": 2, "low": 0, "close": 1}]"#,
4669 ""
4670 ),
4671 ("empty_series", "/panes/0/layers/0/data".to_owned())
4672 );
4673 assert_eq!(
4674 refuse(valid, r##", "color": "#123""##),
4675 ("option_not_supported", "/panes/0/layers/0/color".to_owned())
4676 );
4677 assert_eq!(
4678 refuse(valid, r#", "points": [{"time": "2026-03-02", "value": 1}]"#),
4679 (
4680 "option_not_supported",
4681 "/panes/0/layers/0/points".to_owned()
4682 )
4683 );
4684 assert_eq!(
4685 render_err(&multiples(&format!(
4686 r#"{{"title": "A", "layers": [{{"mark": "ohlc", "data": {valid}}}]}}, {}"#,
4687 panel("B", 1.0)
4688 ))),
4689 ("option_not_supported", "/panes/0/layers/0/mark".to_owned())
4690 );
4691 }
4692
4693 #[test]
4694 fn panes_stack_by_their_ratio_and_share_one_time_axis_at_the_bottom() {
4695 let svg = render_ok(&stacked(&format!("{}, {VOLUME_PANE}", candle_pane("")), "")).content;
4696 let titles = placed_texts(&svg, "chartlet-axis-title");
4699 let at = |name: &str| {
4700 titles
4701 .iter()
4702 .find(|(_, _, content)| content == name)
4703 .map(|(_, y, _)| *y)
4704 .expect("the axis title is drawn")
4705 };
4706 assert!((at("Price") - 82.0).abs() < 1e-9);
4707 assert!((at("Volume") - 310.0).abs() < 1e-9);
4708 let ticks = placed_texts(&svg, "chartlet-tick");
4710 let time_ticks: Vec<_> = ticks.iter().filter(|(x, _, _)| *x > 72.0).collect();
4711 assert_ne!(time_ticks.len(), 0);
4712 assert!(
4713 time_ticks.iter().all(|(_, y, _)| (*y - 418.0).abs() < 1e-9),
4714 "{time_ticks:?}"
4715 );
4716 assert!(
4717 ticks
4718 .iter()
4719 .any(|(x, _, content)| *x <= 72.0 && !content.ends_with('%'))
4720 );
4721 assert!(ticks.iter().any(|(_, _, content)| content.ends_with('%')));
4722 assert!(svg.contains("y1=\"102\" x2="));
4724 assert!(svg.contains("y1=\"330\" x2="));
4725 assert!(svg.contains("y2=\"394\" class=\"chartlet-grid\""));
4726 assert!(svg.contains("Volume</text>"));
4729 assert!(svg.contains("chartlet-area chartlet-band-series-1"));
4730 assert!(svg.contains(" 2 stacked panes on one time axis: Price, Volume."));
4731 assert!(
4732 svg.contains(
4733 " Volume – Highest: 20% (Volume in 2026-03-03). Lowest: 8% (Volume in 2026-03-04)."
4734 ),
4735 "{svg}"
4736 );
4737 }
4738
4739 #[test]
4740 fn every_pane_writes_its_values_by_its_own_axis() {
4741 let html = html_ok(&stacked(
4742 &format!(
4743 "{}, {VOLUME_PANE}",
4744 candle_pane(
4745 r#", {"mark": "line", "name": "Average", "points": [{"time": "2026-03-02", "value": 10.5}, {"time": "2026-03-04", "value": 10}]}, {"mark": "annotation", "label": "Target", "value": 11}"#
4746 )
4747 ),
4748 "",
4749 ));
4750 assert!(html.contains("<td>10.50</td>"), "{html}");
4751 assert!(html.contains("<td>12%</td>"));
4752 assert!(html.contains("Reference lines: Target at 11.00."));
4753 assert!(
4754 html.contains("chartlet-line-series-1"),
4755 "the line takes the first palette color"
4756 );
4757 }
4758
4759 #[test]
4760 fn pane_limits_are_refused_by_name() {
4761 let line = |name: &str| {
4762 format!(
4763 r#"{{"layers": [{{"mark": "line", "name": "{name}", "points": [{{"time": "2026-03-02", "value": 1}}, {{"time": "2026-03-03", "value": 2}}]}}]}}"#
4764 )
4765 };
4766 let five = (0..5)
4767 .map(|index| line(&format!("L{index}")))
4768 .collect::<Vec<_>>()
4769 .join(",");
4770 assert_eq!(
4771 render_err(&stacked(&five, "")),
4772 ("too_many_panes", "/panes".to_owned())
4773 );
4774 let four = (0..4)
4775 .map(|index| line(&format!("L{index}")))
4776 .collect::<Vec<_>>()
4777 .join(",");
4778 assert_eq!(render_ok(&stacked(&four, "")).warnings, []);
4779 let tall = format!(
4780 "{}, {}",
4781 line("A"),
4782 line("B").replacen('{', r#"{"heightRatio": 11, "#, 1)
4783 );
4784 assert_eq!(
4785 render_err(&stacked(&tall, "")),
4786 ("invalid_height_ratio", "/panes/1/heightRatio".to_owned())
4787 );
4788 assert_eq!(
4789 render_err(&stacked(&format!("{}, {}", line("A"), line("A")), "")),
4790 ("duplicate_series", "/panes/1/layers/0/name".to_owned())
4791 );
4792 let anonymous = line("A").replace(r#""name": "A", "#, "");
4793 assert_eq!(
4794 render_err(&stacked(&format!("{anonymous}, {}", line("B")), "")),
4795 ("missing_name", "/panes/0/layers/0/name".to_owned())
4796 );
4797 let two = r#"{"layers": [{"mark": "line", "name": "X", "points": [{"time": "2026-03-02", "value": 1}, {"time": "2026-03-03", "value": 2}]}, {"mark": "line", "name": "Y", "points": [{"time": "2026-03-02", "value": 1}, {"time": "2026-03-03", "value": 2}]}]}"#;
4798 assert_eq!(
4799 render_err(&stacked(
4800 &format!("{four}, {two}")
4801 .replacen(&line("L3"), "", 1)
4802 .replace(",,", ","),
4803 ""
4804 )),
4805 ("too_many_layers", "/panes/3/layers/1/color".to_owned())
4806 );
4807 let late = line("Late")
4808 .replace("2026-03-02", "2026-03-10")
4809 .replace("2026-03-03", "2026-03-11");
4810 let zoomed = stacked(
4811 &format!("{}, {late}", line("Early")),
4812 r#", "zoomSteps": [{"label": "All", "from": "2026-03-01", "to": "2026-03-12"}, {"label": "Early", "from": "2026-03-01", "to": "2026-03-04"}]"#,
4813 );
4814 assert_eq!(
4815 render_err(&zoomed),
4816 ("zoom_out_of_range", "/zoomSteps/1".to_owned())
4817 );
4818 let squeezed = stacked(&tall.replace("11", "10"), r#", "height": 240"#);
4819 let output = render_ok(&squeezed);
4820 assert!(
4821 output
4822 .warnings
4823 .iter()
4824 .any(|warning| warning.code == "dense_chart"
4825 && warning.path == "/panes/0/heightRatio"),
4826 "{:?}",
4827 output.warnings
4828 );
4829 }
4830
4831 #[test]
4832 fn german_candles_and_panes_write_german_texts() {
4833 let spec = stacked(
4834 &format!("{}, {VOLUME_PANE}", candle_pane("")),
4835 r#", "locale": "de""#,
4836 );
4837 let html = html_ok(&spec);
4838 assert!(html.contains("Price (hohl: steigend, gefüllt: fallend)</text>"));
4839 assert!(html.contains(
4840 "<title>02.03.2026 – Price: Eröffnung 10,00; Hoch 12,00; Tief 9,00; Schluss 11,00</title>"
4841 ));
4842 assert!(
4843 html.contains(
4844 " 2 übereinanderliegende Teildiagramme auf einer Zeitachse: Price, Volume."
4845 )
4846 );
4847 assert!(html.contains(
4848 "Price: Eröffnung 10,00 am 02.03.2026, Schluss 9,00 am 04.03.2026, Veränderung \u{2212}1,00 (\u{2212}10,0\u{202f}%); Hoch 12,00 am 02.03.2026, Tief 8,00 am 03.03.2026."
4849 ), "{html}");
4850 assert!(html.contains("<th scope=\"col\">Price (Eröffnung)</th><th scope=\"col\">Price (Hoch)</th><th scope=\"col\">Price (Tief)</th><th scope=\"col\">Price (Schluss)</th>"));
4851 assert!(html.contains("<td>12\u{202f}%</td>"));
4852 }
4853
4854 fn with_mobile(specification: &str, mobile: &str) -> String {
4856 specification.replacen('{', &format!("{{\"mobile\": {mobile},"), 1)
4857 }
4858
4859 fn mobile_svg(specification: &str) -> Result<super::RenderOutput, super::ChartError> {
4860 render_json(
4861 specification,
4862 RenderFormat::Svg,
4863 &RenderOptions {
4864 variant: Variant::Mobile,
4865 ..RenderOptions::default()
4866 },
4867 )
4868 }
4869
4870 #[test]
4871 fn mobile_sizes_are_validated_with_their_paths() {
4872 for (mobile, path) in [
4873 (r#"{"width": 199}"#, "/mobile/width"),
4874 (r#"{"width": 601}"#, "/mobile/width"),
4875 (r#"{"width": 360, "height": 159}"#, "/mobile/height"),
4876 (r#"{"width": 360, "height": 1601}"#, "/mobile/height"),
4877 (r#"{"width": 360, "breakpoint": 319}"#, "/mobile/breakpoint"),
4878 (
4879 r#"{"width": 360, "breakpoint": 1601}"#,
4880 "/mobile/breakpoint",
4881 ),
4882 ] {
4883 let error = render_json(
4884 &with_mobile(GROUPED, mobile),
4885 RenderFormat::Html,
4886 &RenderOptions::default(),
4887 )
4888 .unwrap_err();
4889 assert_eq!(
4890 (error.code, error.path.as_str()),
4891 ("invalid_dimension", path)
4892 );
4893 }
4894 let missing = render_json(
4895 &with_mobile(GROUPED, r#"{"height": 360}"#),
4896 RenderFormat::Html,
4897 &RenderOptions::default(),
4898 )
4899 .unwrap_err();
4900 assert_eq!(
4901 (missing.code, missing.path.as_str()),
4902 ("invalid_spec", "/mobile")
4903 );
4904 }
4905
4906 #[test]
4907 fn a_missing_mobile_field_keeps_the_default_id() {
4908 let plain = render_json(GROUPED, RenderFormat::Svg, &RenderOptions::default()).unwrap();
4909 let spec = ChartSpec::from_json(GROUPED).unwrap();
4910 assert!(spec.mobile.is_none());
4911 assert!(!serde_json::to_string(&spec).unwrap().contains("mobile"));
4912 assert!(plain.content.contains("id=\"chartlet-"));
4913 }
4914
4915 #[test]
4916 fn html_carries_both_variants_behind_a_container_query() {
4917 let specification = with_mobile(GROUPED, r#"{"width": 360, "breakpoint": 700}"#);
4918 let html = render_json(
4919 &specification,
4920 RenderFormat::Html,
4921 &RenderOptions {
4922 id_prefix: Some("chart".to_owned()),
4923 ..RenderOptions::default()
4924 },
4925 )
4926 .unwrap()
4927 .content;
4928 assert!(html.starts_with(
4929 "<div class=\"chartlet-wrapper chartlet-responsive chartlet-bp-700\"><style>"
4930 ));
4931 assert!(html.contains("container-type:inline-size"));
4932 assert!(html.contains("@container (max-width:699px){.chartlet-bp-700 .chartlet-variant-desktop{display:none}.chartlet-bp-700 .chartlet-variant-mobile{display:block}}"));
4933 assert!(html.contains(".chartlet-variant-mobile{display:none}"));
4934 assert!(html.contains("<div class=\"chartlet-variant-desktop\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"800\" height=\"450\""));
4935 assert!(html.contains("<div class=\"chartlet-variant-mobile\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"360\" height=\"360\""));
4936 assert!(html.contains("id=\"chart\""));
4937 assert!(html.contains("id=\"chart-m\""));
4938 assert!(html.contains("aria-labelledby=\"chart-m-title chart-m-description\""));
4939 let ids = html
4941 .split(" id=\"")
4942 .skip(1)
4943 .map(|rest| rest.split('"').next().unwrap())
4944 .collect::<Vec<_>>();
4945 let unique = ids.iter().collect::<std::collections::BTreeSet<_>>();
4946 assert_eq!(ids.len(), unique.len(), "{ids:?}");
4947 for once in [
4949 "<figcaption>",
4950 "<table>",
4951 "<fieldset class=\"chartlet-filter\">",
4952 ] {
4953 assert_eq!(html.matches(once).count(), 1, "{once}");
4954 }
4955 assert!(html.contains(".chartlet-wrapper:has(.chartlet-filter input.series-1:not(:checked)) .chartlet-root [data-series=\"1\"]{display:none}"));
4957 assert_eq!(html.matches("<svg").count(), 2);
4958 }
4959
4960 #[test]
4961 fn the_svg_profile_is_unchanged_by_a_mobile_variant() {
4962 let options = RenderOptions {
4963 id_prefix: Some("chart".to_owned()),
4964 ..RenderOptions::default()
4965 };
4966 let plain = render_json(GROUPED, RenderFormat::Svg, &options).unwrap();
4967 let responsive = render_json(
4968 &with_mobile(GROUPED, r#"{"width": 360}"#),
4969 RenderFormat::Svg,
4970 &options,
4971 )
4972 .unwrap();
4973 assert_eq!(plain.content, responsive.content);
4974 }
4975
4976 #[test]
4977 fn the_mobile_variant_renders_alone_as_svg() {
4978 let output = mobile_svg(&with_mobile(GROUPED, r#"{"width": 320, "height": 400}"#))
4979 .unwrap()
4980 .content;
4981 assert!(output.starts_with("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"320\" height=\"400\" viewBox=\"0 0 320 400\" id=\"chartlet-"));
4982 assert!(output.contains("-m-title\""));
4983
4984 let error = mobile_svg(GROUPED).unwrap_err();
4985 assert_eq!(
4986 (error.code, error.path.as_str()),
4987 ("missing_mobile", "/mobile")
4988 );
4989
4990 let error = render_json(
4991 &with_mobile(GROUPED, r#"{"width": 360}"#),
4992 RenderFormat::Html,
4993 &RenderOptions {
4994 variant: Variant::Mobile,
4995 ..RenderOptions::default()
4996 },
4997 )
4998 .unwrap_err();
4999 assert_eq!(
5000 (error.code, error.path.as_str()),
5001 ("option_not_supported", "/render/variant")
5002 );
5003 }
5004
5005 #[test]
5006 fn the_print_variant_draws_the_chart_with_literal_colors() {
5007 let print = |specification: &str, format| {
5008 render_json(
5009 specification,
5010 format,
5011 &RenderOptions {
5012 id_prefix: Some("costs".to_owned()),
5013 variant: Variant::Print,
5014 ..RenderOptions::default()
5015 },
5016 )
5017 };
5018 let desktop = render_json(
5019 GROUPED,
5020 RenderFormat::Svg,
5021 &RenderOptions {
5022 id_prefix: Some("costs".to_owned()),
5023 ..RenderOptions::default()
5024 },
5025 )
5026 .unwrap()
5027 .content;
5028 let light = print(GROUPED, RenderFormat::Svg).unwrap().content;
5029 let dark = print(
5030 &GROUPED.replacen('{', r#"{"theme": "dark","#, 1),
5031 RenderFormat::Svg,
5032 )
5033 .unwrap()
5034 .content;
5035
5036 for svg in [&light, &dark] {
5037 assert!(svg.contains(" id=\"costs-p\""));
5038 assert!(!svg.contains("var("), "{svg}");
5039 assert!(!svg.contains(":has("), "{svg}");
5040 assert!(!svg.contains("currentColor"), "{svg}");
5041 assert!(!svg.contains("--chartlet"), "{svg}");
5042 assert!(svg.contains("font-family:Inter,ui-sans-serif,system-ui,sans-serif;"));
5043 }
5044 let body = |svg: &str| svg[svg.find("</style>").unwrap()..].replace("costs-p", "costs");
5046 assert_eq!(body(&light), body(&desktop));
5047 assert!(light.contains("<style>#costs-p.chartlet-root{max-width:100%;height:auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif;color:#172033}"));
5048 assert!(light.contains("#costs-p .chartlet-series-2{fill:#c2410c}"));
5049 assert!(dark.contains("#costs-p .chartlet-series-2{fill:#ff9c72}"));
5050 assert!(dark.contains("#costs-p .chartlet-background{fill:#0e131c}"));
5051
5052 let error = print(GROUPED, RenderFormat::Html).unwrap_err();
5053 assert_eq!(
5054 (error.code, error.path.as_str()),
5055 ("option_not_supported", "/render/variant")
5056 );
5057 }
5058
5059 #[test]
5060 fn the_print_variant_resolves_a_declared_variable_color_to_the_text_color() {
5061 let svg = render_json(
5062 &TIME.replacen(
5063 r#""name": "Orders""#,
5064 r#""name": "Orders", "color": "var(--brand)""#,
5065 1,
5066 ),
5067 RenderFormat::Svg,
5068 &RenderOptions {
5069 variant: Variant::Print,
5070 ..RenderOptions::default()
5071 },
5072 )
5073 .unwrap();
5074 assert!(
5075 svg.content.contains(".chartlet-style-0{stroke:#172033}"),
5076 "{}",
5077 svg.content
5078 );
5079 assert!(!svg.content.contains("var("), "{}", svg.content);
5080 assert_eq!(
5081 svg.warnings
5082 .iter()
5083 .map(|warning| (warning.code, warning.path.as_str()))
5084 .collect::<Vec<_>>(),
5085 [("color_not_resolved", "/panes/0/layers/0/color")]
5086 );
5087 }
5088
5089 #[test]
5090 fn zoom_panels_carry_both_variants_under_one_radio_group() {
5091 let specification = r#"{
5092 "schemaVersion": 1,
5093 "type": "bar",
5094 "title": "Quarterly revenue",
5095 "mobile": {"width": 360},
5096 "data": [
5097 {"label": "Q1", "value": 320},
5098 {"label": "Q2", "value": 345},
5099 {"label": "Q3", "value": 380},
5100 {"label": "Q4", "value": 410}
5101 ],
5102 "zoomSteps": [
5103 {"label": "First half", "from": 0, "to": 1},
5104 {"label": "All", "from": 0, "to": 3}
5105 ]
5106 }"#;
5107 let html = render_json(
5108 specification,
5109 RenderFormat::Html,
5110 &RenderOptions {
5111 id_prefix: Some("q".to_owned()),
5112 ..RenderOptions::default()
5113 },
5114 )
5115 .unwrap()
5116 .content;
5117 assert_eq!(
5118 html.matches("<fieldset class=\"chartlet-zoom\">").count(),
5119 1
5120 );
5121 assert_eq!(html.matches("type=\"radio\"").count(), 2);
5122 for id in ["q-z0", "q-z0-m", "q-z1", "q-z1-m"] {
5123 assert!(html.contains(&format!("id=\"{id}\"")), "{id}");
5124 }
5125 assert!(html.contains("<div class=\"chartlet-panel chartlet-panel-1\"><div class=\"chartlet-variant-desktop\"><svg"));
5126 assert_eq!(html.matches("<table>").count(), 1);
5127 }
5128
5129 #[test]
5130 fn warnings_of_the_mobile_variant_are_marked_and_deduplicated() {
5131 let specification = r#"{
5134 "schemaVersion": 1,
5135 "type": "bar",
5136 "title": "Revenue",
5137 "mobile": {"width": 280},
5138 "categories": ["An exceptionally long category label that never fits", "B"],
5139 "series": [
5140 {"name": "Direct", "values": [1, 2]},
5141 {"name": "A partner network with a long name", "values": [2, 1]}
5142 ]
5143 }"#;
5144 let desktop = render_json(specification, RenderFormat::Svg, &RenderOptions::default())
5145 .unwrap()
5146 .warnings;
5147 let html = render_json(specification, RenderFormat::Html, &RenderOptions::default())
5148 .unwrap()
5149 .warnings;
5150 assert!(html.starts_with(&desktop));
5151 let added = &html[desktop.len()..];
5152 assert_ne!(added, []);
5153 for warning in added {
5154 assert!(
5155 warning.message.starts_with("mobile variant: "),
5156 "{warning:?}"
5157 );
5158 assert!(
5159 !desktop
5160 .iter()
5161 .any(|seen| (seen.code, &seen.path) == (warning.code, &warning.path)),
5162 "{warning:?}"
5163 );
5164 }
5165 }
5166
5167 #[test]
5168 fn the_mobile_variant_is_checked_for_density_at_its_own_width() {
5169 let points = (0..400)
5170 .map(|index| {
5171 format!(
5172 "{{\"time\": {}, \"value\": {index}}}",
5173 1_772_323_200 + index * 3600
5174 )
5175 })
5176 .collect::<Vec<_>>()
5177 .join(",");
5178 let specification = format!(
5179 r#"{{"schemaVersion": 1, "type": "time", "title": "Load", "mobile": {{"width": 280}},
5180 "panes": [{{"layers": [{{"mark": "line", "points": [{points}]}}]}}]}}"#
5181 );
5182 let desktop = render_json(&specification, RenderFormat::Svg, &RenderOptions::default())
5183 .unwrap()
5184 .warnings;
5185 assert!(!desktop.iter().any(|warning| warning.code == "dense_chart"));
5186 let html = render_json(
5187 &specification,
5188 RenderFormat::Html,
5189 &RenderOptions::default(),
5190 )
5191 .unwrap()
5192 .warnings;
5193 assert!(html.iter().any(|warning| warning.code == "dense_chart"
5194 && warning.message.starts_with("mobile variant: ")));
5195 }
5196
5197 #[test]
5198 fn the_topic_picker_switches_both_variants() {
5199 let specification = with_mobile(
5200 include_str!("../examples/topicmap-sample.json"),
5201 r#"{"width": 360}"#,
5202 );
5203 let html = render_json(
5204 &specification,
5205 RenderFormat::Html,
5206 &RenderOptions::default(),
5207 )
5208 .unwrap()
5209 .content;
5210 assert_eq!(
5211 html.matches("<fieldset class=\"chartlet-topic-picker\">")
5212 .count(),
5213 1
5214 );
5215 assert_eq!(html.matches("<svg").count(), 2);
5216 let rule = ":checked) #{root} .chartlet-topic-area.chartlet-topic-0{";
5218 let desktop = html.split(" id=\"").nth(1).unwrap();
5219 let desktop = &desktop[..desktop.find('"').unwrap()];
5220 assert!(html.contains(&rule.replace("{root}", desktop)));
5221 assert!(html.contains(&rule.replace("{root}", &format!("{desktop}-m"))));
5222 assert!(html.starts_with("<div class=\"chartlet-wrapper chartlet-responsive"));
5223 }
5224
5225 const TRADING_DAYS: [i64; 5] = [
5229 1_772_668_800,
5230 1_772_755_200,
5231 1_773_014_400,
5232 1_773_100_800,
5233 1_773_187_200,
5234 ];
5235
5236 fn collapsed(days: &[i64], extra: &str, tail: &str) -> String {
5239 let points = days
5240 .iter()
5241 .enumerate()
5242 .map(|(index, day)| format!(r#"{{"time": {day}, "value": {}}}"#, index + 1))
5243 .collect::<Vec<_>>()
5244 .join(", ");
5245 format!(
5246 r#"{{
5247 "schemaVersion": 1,
5248 "type": "time",
5249 "title": "Trading days",
5250 "showValues": false,
5251 "timeAxis": {{"gaps": "collapse"}},
5252 "panes": [{{"layers": [{{"mark": "line", "points": [{points}]}}{extra}]}}]{tail}
5253 }}"#
5254 )
5255 }
5256
5257 fn point_xs(svg: &str) -> Vec<f64> {
5259 svg.split("<circle cx=\"")
5260 .skip(1)
5261 .map(|rest| rest[..rest.find('"').unwrap()].parse().unwrap())
5262 .collect()
5263 }
5264
5265 fn time_ticks(svg: &str) -> Vec<(String, f64)> {
5267 svg.split("<text x=\"")
5268 .skip(1)
5269 .filter(|rest| rest.contains("class=\"chartlet-tick\">2026"))
5270 .map(|rest| {
5271 let x = rest[..rest.find('"').unwrap()].parse().unwrap();
5272 let label = rest.split("class=\"chartlet-tick\">").nth(1).unwrap();
5273 (label[..label.find('<').unwrap()].to_owned(), x)
5274 })
5275 .collect()
5276 }
5277
5278 #[test]
5279 fn collapsed_gaps_place_observations_at_equal_distances() {
5280 let svg = render_ok(&collapsed(&TRADING_DAYS, "", "")).content;
5281 let xs = point_xs(&svg);
5282 assert_eq!(xs.len(), 5);
5283 let step = xs[1] - xs[0];
5284 assert!(step > 0.0);
5285 for pair in xs.windows(2) {
5286 assert!((pair[1] - pair[0] - step).abs() < 0.01, "{xs:?}");
5287 }
5288 assert!(svg.contains("<title>2026-03-06: 2</title>"));
5290 assert!(svg.contains("<title>2026-03-09: 3</title>"));
5291 let labels: Vec<String> = time_ticks(&svg)
5293 .into_iter()
5294 .map(|(label, _)| label)
5295 .collect();
5296 assert_eq!(
5297 labels,
5298 [
5299 "2026-03-05",
5300 "2026-03-06",
5301 "2026-03-09",
5302 "2026-03-10",
5303 "2026-03-11"
5304 ]
5305 );
5306
5307 let shown =
5309 render_ok(&collapsed(&TRADING_DAYS, "", "").replace("collapse", "show")).content;
5310 let xs = point_xs(&shown);
5311 assert!(xs[2] - xs[1] > 2.0 * (xs[1] - xs[0]));
5312 }
5313
5314 #[test]
5315 fn collapsed_ticks_sit_on_the_first_observation_after_their_boundary() {
5316 let monday: i64 = 1_772_409_600;
5318 let days: Vec<i64> = (0..33)
5319 .map(|day| monday + day * 86_400)
5320 .filter(|epoch| (epoch / 86_400 + 3).rem_euclid(7) < 5)
5321 .filter(|epoch| *epoch != monday + 14 * 86_400)
5322 .collect();
5323 let svg = render_ok(&collapsed(&days, "", "")).content;
5324 let ticks = time_ticks(&svg);
5325 let labels: Vec<&str> = ticks.iter().map(|(label, _)| label.as_str()).collect();
5326 assert_eq!(
5327 labels,
5328 [
5329 "2026-03-02",
5330 "2026-03-09",
5331 "2026-03-17",
5332 "2026-03-23",
5333 "2026-03-30"
5334 ]
5335 );
5336 let xs = point_xs(&svg);
5338 let tuesday = days
5339 .iter()
5340 .position(|epoch| *epoch == monday + 15 * 86_400)
5341 .unwrap();
5342 assert!((ticks[2].1 - xs[tuesday]).abs() < 0.01, "{ticks:?}");
5343 }
5344
5345 #[test]
5346 fn collapsed_gaps_move_annotations_onto_observations() {
5347 let extra = r#", {"mark": "annotation", "label": "Rule", "time": "2026-03-07"},
5348 {"mark": "annotation", "label": "Event", "time": "2026-03-08", "value": 2, "shape": "square"},
5349 {"mark": "band", "label": "Zone", "from": "2026-03-07", "to": "2026-03-10T12:00"}"#;
5350 let output = render_ok(&collapsed(&TRADING_DAYS, extra, ""));
5351 let svg = output.content;
5352 let xs = point_xs(&svg);
5353 let (monday, tuesday) = (xs[2], xs[3]);
5354 assert!(svg.contains(&format!(
5356 "<polyline points=\"{monday},78 {monday},414\" class=\"chartlet-rule\">"
5357 )));
5358 let marker = svg
5360 .split("class=\"chartlet-marker\"")
5361 .next()
5362 .and_then(|head| head.rsplit("<polyline points=\"").next())
5363 .unwrap();
5364 let corners: Vec<f64> = marker
5365 .split([' ', '"'])
5366 .filter_map(|pair| pair.split(',').next()?.parse().ok())
5367 .collect();
5368 let center = f64::midpoint(corners[0], corners[1]);
5369 assert!((center - monday).abs() < 0.01, "{marker}");
5370 let zone = svg
5372 .split(" class=\"chartlet-zone\"")
5373 .next()
5374 .and_then(|head| head.rsplit("<rect ").next())
5375 .expect("a zone");
5376 let attribute = |name: &str| -> f64 {
5377 zone.split(&format!("{name}=\""))
5378 .nth(1)
5379 .and_then(|rest| rest.split('"').next())
5380 .and_then(|value| value.parse().ok())
5381 .expect("a numeric attribute")
5382 };
5383 assert!((attribute("x") - monday).abs() < 0.01, "{zone}");
5384 assert!(
5385 (attribute("width") - (tuesday - monday)).abs() < 0.01,
5386 "{zone}"
5387 );
5388 assert!((attribute("y") - 78.0).abs() < 0.01, "{zone}");
5389 assert!(svg.contains("<title>Rule: 2026-03-07</title>"));
5391 assert!(svg.contains("Reference lines: Rule at 2026-03-07."));
5392 }
5393
5394 #[test]
5395 fn collapsed_gaps_refuse_times_without_an_observation() {
5396 for (extra, path) in [
5397 (
5398 r#", {"mark": "annotation", "label": "Rule", "time": "2026-03-04"}"#,
5399 "/panes/0/layers/1/time",
5400 ),
5401 (
5402 r#", {"mark": "annotation", "label": "Event", "time": "2026-03-12", "value": 2}"#,
5403 "/panes/0/layers/1/time",
5404 ),
5405 (
5406 r#", {"mark": "band", "label": "Weekend", "from": "2026-03-07", "to": "2026-03-08"}"#,
5407 "/panes/0/layers/1",
5408 ),
5409 (
5410 r#", {"mark": "band", "label": "Before", "from": "2026-03-01", "to": "2026-03-04"}"#,
5411 "/panes/0/layers/1",
5412 ),
5413 ] {
5414 assert_eq!(
5415 render_err(&collapsed(&TRADING_DAYS, extra, "")),
5416 ("time_out_of_range", path.to_owned()),
5417 "{extra}"
5418 );
5419 }
5420 let wide =
5422 r#", {"mark": "band", "label": "All", "from": "2026-03-01", "to": "2026-03-20"}"#;
5423 let svg = render_ok(&collapsed(&TRADING_DAYS, wide, "")).content;
5424 let xs = point_xs(&svg);
5425 assert!(svg.contains(&format!(
5426 "<rect x=\"{}\" y=\"78\" width=\"{}\"",
5427 xs[0],
5428 xs[4] - xs[0]
5429 )));
5430 let rule = r#", {"mark": "annotation", "label": "Rule", "time": "2026-03-04"}"#;
5432 render_ok(&collapsed(&TRADING_DAYS, rule, "").replace("collapse", "show"));
5433 }
5434
5435 #[test]
5436 fn collapsed_zoom_windows_close_their_gaps_too() {
5437 let zones = r#", {"mark": "band", "label": "Early", "from": "2026-03-05", "to": "2026-03-07T12:00"}"#;
5438 let zoom = r#", "zoomSteps": [
5439 {"label": "All", "from": "2026-03-05", "to": "2026-03-11"},
5440 {"label": "Weekend on", "from": "2026-03-07", "to": "2026-03-11"}
5441 ]"#;
5442 let html = html_ok(&collapsed(&TRADING_DAYS, zones, zoom));
5443 let panel = |index: usize| {
5444 html.split(&format!("chartlet-panel chartlet-panel-{index}"))
5445 .nth(1)
5446 .and_then(|panel| panel.split("</div>").next())
5447 .unwrap()
5448 .to_owned()
5449 };
5450 let (all, late) = (panel(0), panel(1));
5451 assert_eq!(point_xs(&all).len(), 5);
5452 let xs = point_xs(&late);
5453 assert_eq!(xs.len(), 3);
5454 assert!(((xs[2] - xs[1]) - (xs[1] - xs[0])).abs() < 0.01, "{xs:?}");
5455 assert!(all.contains("class=\"chartlet-zone\""));
5458 assert!(!late.contains("class=\"chartlet-zone\""));
5459 }
5460
5461 #[test]
5462 fn collapsed_gaps_are_named_in_the_description() {
5463 let spec = collapsed(&TRADING_DAYS, "", "");
5464 assert!(
5465 render_ok(&spec)
5466 .content
5467 .contains("Lowest: 1 (2026-03-05). Gaps in time are closed up.</desc>")
5468 );
5469 let html = html_ok(&german(&spec, "time"));
5470 assert!(
5471 html.contains("Zeiträume ohne Beobachtung sind ausgelassen."),
5472 "{html}"
5473 );
5474 let shown = render_ok(&spec.replace("collapse", "show")).content;
5475 assert!(!shown.contains("Gaps in time"));
5476 }
5477
5478 #[test]
5479 fn collapsed_small_multiples_share_their_slots() {
5480 let points = |days: &[i64]| {
5481 days.iter()
5482 .map(|day| format!(r#"{{"time": {day}, "value": 1}}"#))
5483 .collect::<Vec<_>>()
5484 .join(", ")
5485 };
5486 let spec = format!(
5487 r#"{{
5488 "schemaVersion": 1,
5489 "type": "multiples",
5490 "title": "Desks",
5491 "timeAxis": {{"gaps": "collapse"}},
5492 "panes": [
5493 {{"title": "A", "layers": [{{"mark": "line", "points": [{}]}}]}},
5494 {{"title": "B", "layers": [{{"mark": "line", "points": [{}]}}]}}
5495 ]
5496 }}"#,
5497 points(&TRADING_DAYS[..3]),
5498 points(&TRADING_DAYS[2..]),
5499 );
5500 let svg = render_ok(&spec).content;
5501 let xs = point_xs(&svg);
5502 assert_eq!(xs.len(), 6);
5503 let cell = 384.0;
5506 let step = xs[1] - xs[0];
5507 assert!((xs[3] - cell - xs[2]).abs() < 0.01, "{xs:?}");
5508 assert!((xs[5] - xs[4] - step).abs() < 0.01, "{xs:?}");
5509 }
5510}