Skip to main content

chartlet/
lib.rs

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    /// Also returns a [`Manifest`] of the render in [`RenderOutput::manifest`].
51    pub manifest: bool,
52    /// Where the chart's styles live. The print variant always carries its own.
53    pub styles: Styles,
54    /// Adds the `data-*` hooks that the optional interactive module of the npm package reads:
55    /// chart type and ID on the root, the plot geometry of every pane, a group around what each
56    /// layer draws, and the unformatted values on the data table, or in a JSON data block in
57    /// the SVG profile. The print variant carries none.
58    pub hooks: bool,
59}
60
61/// Where a chart's styles live.
62#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
63pub enum Styles {
64    /// Every chart carries its whole stylesheet, scoped to its root: an SVG file stands alone.
65    #[default]
66    Inline,
67    /// The page loads the shared [`stylesheet`] once; a chart carries only what is its own, such
68    /// as declared layer colors, and often no `<style>` at all.
69    External,
70}
71
72/// The stylesheet every chart rendered with [`Styles::External`] relies on. It is the same for
73/// every chart, so a site serves it once as a cacheable file.
74#[must_use]
75pub fn stylesheet() -> String {
76    render::shared_stylesheet(&ChartType::ALL, true)
77}
78
79/// The shared stylesheet with only the rules of `chart_types`: enough for a site that renders no
80/// other types. It is [`stylesheet_common`] followed by [`stylesheet_types`], and the part of
81/// each type comes in the order of `chart_types`.
82#[must_use]
83pub fn stylesheet_for(chart_types: &[ChartType]) -> String {
84    render::shared_stylesheet(chart_types, true)
85}
86
87/// The part of the shared stylesheet that every chart relies on, whatever its type: colors,
88/// text, grid and axes. A page that loads type parts separately loads this part first.
89#[must_use]
90pub fn stylesheet_common() -> String {
91    render::shared_stylesheet(&[], true)
92}
93
94/// The parts of the shared stylesheet that only charts of `chart_types` use, without the common
95/// part. Each type's rules style only charts of that type, so the parts of separate calls can be
96/// loaded as separate files, in any order, after [`stylesheet_common`].
97#[must_use]
98pub fn stylesheet_types(chart_types: &[ChartType]) -> String {
99    render::shared_stylesheet(chart_types, false)
100}
101
102/// Which layout the SVG profile renders. The HTML profile always carries the chart and, when the
103/// specification has one, its mobile variant.
104#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
105pub enum Variant {
106    /// The chart at `width` × `height`.
107    #[default]
108    Desktop,
109    /// The chart at the size in `mobile`; its IDs end in `-m`.
110    Mobile,
111    /// The chart at `width` × `height` for print and PDF pipelines and renderers outside the
112    /// browser: its stylesheet carries the theme's colors as literal values instead of CSS
113    /// custom properties, and no rule that needs a browser. Its IDs end in `-p`.
114    Print,
115    /// The chart on a 1200 × 630 canvas for link previews such as Open Graph images: the title
116    /// drawn large, the source below, no tooltips, and the resolved stylesheet of
117    /// [`Variant::Print`]. Its IDs end in `-s`.
118    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    /// The provenance of `content`, when [`RenderOptions::manifest`] asks for it.
133    pub manifest: Option<Manifest>,
134}
135
136/// The provenance of one render: which version of chartlet produced which output from which
137/// specification, with which options and warnings. It carries no timestamp, so the same render
138/// always yields the same manifest.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct Manifest {
141    /// The version of the chartlet crate that rendered.
142    pub chartlet: &'static str,
143    pub schema_version: u8,
144    /// `sha256:` and the hexadecimal SHA-256 of the canonical specification: the parsed
145    /// specification serialized again, so key order and whitespace of the input do not matter.
146    pub spec_hash: String,
147    /// `sha256:` and the hexadecimal SHA-256 of [`RenderOutput::content`] as UTF-8.
148    pub output_hash: String,
149    pub format: RenderFormat,
150    pub variant: Variant,
151    /// The ID prefix the chart was rendered with: the one passed in, or the one derived from the
152    /// specification. The mobile variant appends `-m` to it, the print variant `-p`, the social
153    /// variant `-s`.
154    pub id_prefix: String,
155    pub warnings: Vec<ChartWarning>,
156}
157
158impl Manifest {
159    /// The manifest as a pretty-printed JSON object, its keys in a fixed order:
160    /// `chartlet`, `schemaVersion`, `specHash`, `outputHash`, `format`, `variant`, `idPrefix`,
161    /// `warnings` (each with `code`, `path`, `message`).
162    ///
163    /// # Panics
164    ///
165    /// Never: every field is a string, a number or a list of strings.
166    #[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/// What a chart says without its graphic: the description its SVG carries and the data table of
217/// its HTML profile, as text, for hosts that build their own accessible wrapper around the SVG.
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct TextAlternative {
220    pub description: String,
221    pub table: DataTable,
222}
223
224/// A chart's data table as text, values written as the chart writes them.
225#[derive(Debug, Clone, PartialEq, Eq)]
226pub struct DataTable {
227    pub caption: String,
228    /// The column heads; the first names the categories.
229    pub columns: Vec<String>,
230    /// One row per category, starting with the category.
231    pub rows: Vec<Vec<String>>,
232}
233
234/// Validates a chart specification and returns its text alternative.
235///
236/// # Errors
237///
238/// Returns a structured error when the specification is invalid.
239pub 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
250/// Parses and renders a chart specification.
251///
252/// # Errors
253///
254/// Returns a structured error when parsing, validation, or rendering fails.
255pub 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
264/// Validates and renders an already parsed chart specification.
265///
266/// # Errors
267///
268/// Returns a structured error when the specification or render context is invalid.
269pub 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
277/// Renders with caller-provided deterministic text metrics.
278///
279/// # Errors
280///
281/// Returns a structured error when the specification or render context is invalid.
282pub 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    // The HTML profile lays the chart out a second time at the mobile size; its warnings are
355    // reported where they add to those of the chart itself.
356    let mut mobile_warnings = match (&mobile, format) {
357        (Some(mobile), RenderFormat::Html) => mobile.validate_mobile_variant()?,
358        _ => Vec::new(),
359    };
360
361    // Zoom steps render as pre-computed variants switched by radio buttons, which only the
362    // HTML profile can carry. The pure SVG profile stays a single static chart.
363    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
425/// The chart one zoom step shows: a window of time on a time chart, a range of categories on
426/// any other.
427fn 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
446/// The mobile variant alone, as SVG, with its IDs under `prefix`.
447fn 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
486/// The print variant, as SVG, with its IDs ending in `-p`.
487fn 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
518/// The social variant, as SVG, with its IDs ending in `-s`. The chart is laid out without its
519/// title at the size the canvas leaves it, and validated at that size like a mobile variant.
520fn 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
561/// A variant with a resolved stylesheet cannot see the page that would define a `var()` color, so
562/// such a layer is drawn in the text color; several of them would become indistinguishable.
563fn 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/// Options of [`render_png`].
580#[cfg(feature = "png")]
581#[derive(Debug, Clone, Copy, PartialEq)]
582pub struct PngOptions {
583    /// [`Variant::Print`] rasterizes the chart at `width` × `height`, [`Variant::Social`] the
584    /// 1200 × 630 canvas, [`Variant::Mobile`] the mobile layout. [`Variant::Desktop`] is taken as
585    /// [`Variant::Print`]: a PNG needs the resolved stylesheet.
586    pub variant: Variant,
587    /// Image pixels per SVG pixel, 0.25–4; 2 for a high-density screen.
588    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/// A rendered PNG and the warnings of the SVG it was rasterized from.
602#[cfg(feature = "png")]
603#[derive(Debug, Clone)]
604pub struct PngOutput {
605    pub png: Vec<u8>,
606    pub warnings: Vec<ChartWarning>,
607}
608
609/// Validates a chart specification and renders it as PNG: the print or the social variant,
610/// rasterized with the bundled Inter font and no system fonts. The same specification, options
611/// and chartlet version yield the same bytes.
612///
613/// # Errors
614///
615/// Returns a structured error when the specification is invalid, the scale is out of range, or
616/// the mobile variant is asked for and the specification has none.
617#[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    // The mobile variant is rasterized like the chart itself: its layout with the resolved
627    // stylesheet of the print variant.
628    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
658/// Lays out and serializes one chart, using its explicit description or a generated one. The HTML
659/// profile captions the chart, so its SVGs leave the drawn title out (`format`); the title stays
660/// their accessible name either way. `print` draws it for print (see [`Variant::Print`]).
661fn 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
686/// Which hooks a chart's SVGs carry: the HTML profile has its data table to read the values
687/// from, the SVG profile carries them itself.
688const 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
696/// Zoom panels repeat the same data, so identical warnings would otherwise appear once per panel.
697fn 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
702/// Adds the warnings of the mobile variant that the chart itself does not already report, marked
703/// as coming from the mobile variant.
704fn 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
738/// The canonical form of a specification: the parsed specification serialized again, so that two
739/// inputs that differ only in key order or whitespace share it.
740fn 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 &lt;East&gt;"));
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 &amp; loss")
804        );
805        assert!(!html.content.contains("class=\"chartlet-title\""));
806        assert!(
807            html.content
808                .contains("<figcaption>Profit &amp; loss</figcaption>")
809        );
810        assert!(html.content.contains("-title\">Profit &amp; 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 &amp; 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        // Every bar plus one legend swatch per series.
994        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        // Only the weekly mean is a line; the readings are dots, one in the legend as well.
1132        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        // Seven gridlines from 10 to 10,000,000, but only every other tick labelled.
1156        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        // Monthly values beside yearly means placed mid-year: the means say only the year.
1178        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        // On a numeric axis, a finely spaced helper line does not add decimals to the points.
1194        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        // Two observations, two tooltips and two rows; the line runs on to the end of 2025.
1263        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        // The plot starts after the compact gutter of 56 pixels.
1292        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        // The long title takes two lines; the plots of both panels start below the same head.
1318        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        // Ticks every year would run into each other: only the labels that fit are written.
1373        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        // The axis end is no observation: the description still starts in 1991.
1401        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        // No title, legend or value axis title: the plot starts 14 pixels from the top.
1430        assert!(svg.contains(" y1=\"14\""), "{svg}");
1431        // Short value labels on a compact chart: a gutter narrower than the compact 56 pixels.
1432        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        // No title above the plot: the plot starts near the top.
1454        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        // Three groups: a span in each color, and a swatch per group in the legend.
1467        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        // The scale reaches the total of the highest stack, 2,920 TWh in 2016.
1505        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        // A tooltip tells the layer's own value, not the top of the stack.
1517        assert!(
1518            output.content.contains("Wind and solar: 760"),
1519            "{}",
1520            output.content
1521        );
1522
1523        // A stacked pane takes eight areas; beyond the palette they bring their own colors.
1524        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        // One visible dot where the line ends; every observation keeps an invisible tooltip.
1597        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        // Both end at 3, so the names move apart.
1626        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            // The time axis writes years; the value ticks are the short ones.
1650            .filter(|tick| tick.chars().count() <= 2)
1651            .collect();
1652        // The ends stay at −2.5 and 3.5; the ticks sit on multiples of the step, zero included.
1653        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        // Three observations and a corner before each of the two jumps.
1739        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        // Reversed: the first position, 0, sits at the right end of the plot.
1762        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        // One column: both panel titles start at the same x.
1796        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        // SPEC has a negative value.
1831        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 &lt;East&gt;: 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        // Every panel gets its own non-colliding accessibility IDs.
1949        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    /// A time chart with `points` hourly observations in one layer, for the density rules.
1996    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        // The plotted line and its legend sample.
2060        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    /// Adds `"locale": "de"` to a specification of the given type.
2084    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 &lt;East&gt;). 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        // A bare date is wall-clock midnight in the configured zone, so the offset does not move
2286        // it: +02:00 keeps the date labels.
2287        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        // Absolute seconds are read in the zone, so midnight in UTC is two in the morning
2293        // in +02:00 and the labels carry a time of day.
2294        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        // The data table names the observation in the zone as well.
2308        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        // One more observation and the markers would sit closer than eleven pixels apart.
2377        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        // The default width is 800, of which 704 pixels are plot.
2396        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        // A bare var() would drop the series on a page that never defines the variable.
2439        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        // Inline SVG stylesheets apply to the whole page; an unscoped rule would recolor the
2461        // layers of every other chart on it.
2462        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        // Every other rule is scoped too, so a later chart's `.chartlet-line` cannot restyle
2469        // this one; the dark palette outranks a later light chart's defaults.
2470        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    // --- Uncertainty bands, reference lines, stripes, calendars, range bars, small multiples ---
2536
2537    #[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        // Whitespace and key order of the input do not change the specification hash.
2560        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    /// A time chart with one measured and one modeled layer; `band` is spliced into every point
2595    /// of the modeled layer and `extra` into its layer object.
2596    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        // The band, its hatched copy, and the pattern it refers to by the chart's own prefix.
2628        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        // Annual data reads as years, not as January 1st.
2640        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        // The upper edge of 2.5 lies above every value, so the axis has to reach it.
2660        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        // 30 lies above every observation, so the axis reaches it.
2749        assert!(svg.contains(">30<"));
2750        assert!(svg.contains("Reference lines: Target at 30; Launch at 2026-03-02."));
2751        // Reference lines are neither series in the legend nor columns in the table.
2752        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        // Behind the data: every zone comes before the line.
2826        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        // 40 lies above every observation, so the axis reaches it.
2832        assert!(svg.contains(">40<"));
2833        assert!(svg.contains("Zones: Quiet, 2026-03-01 to 2026-03-02; Target, values 25 to 40."));
2834        // Zones are neither series in the legend nor columns in the table.
2835        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        // Zones, reference lines and point markers share one limit.
2908        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        // Shapes other than the circle are closed outlines; the default is a circle.
2935        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        // 50 lies above every observation, so the axis reaches it.
2947        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        // An area starts the axis at zero without padding, so 20 is the top edge of the plot and
3024        // the label of a reference line there sits above it.
3025        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        // Wide metrics stretch the label of the horizontal line into that of the vertical one.
3041        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        // A zone with one edge covers everything above it, up to the edge of the plot.
3077        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        // Both zones in the first window, only the wide one in the second.
3112        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        // Five values; the missing year stays empty.
3129        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        // Beyond max takes the outermost step.
3134        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            // 2024 is a leap year: 366 cells, of which two carry a value; the key adds 17.
3232            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            // The legend text needs its own color; without it a dark chart draws it black.
3327            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        // One shared scale: the top tick of the largest panel appears in every panel.
3422        assert_eq!(svg.matches(">30</text>").count(), 3);
3423        // One legend entry per name, not per panel; the same name keeps its color everywhere.
3424        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            // Three panels and the legend sample.
3430            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        // Two depth lines around each of the six areas, a halo under every coastline, and one
3571        // point per path through a topic.
3572        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        // A coastline is a closed ring, not a circle.
3594        assert!(!output.content.contains("class=\"chartlet-topic-area\"/>"));
3595        // Every area names the center it was placed around, inside the viewBox.
3596        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        // At this size no area carries a name at the type size the design brief asks for, so
3615        // every label sits beside the map with a leader line to its area. No name is lost.
3616        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        // The sea and its grid go underneath everything, the compass rose and the cartouche on
3668        // top of it: furniture must never hide an area, and an area must never hide the legend.
3669        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        // The stylesheet always carries every topic map rule, so this looks for drawn elements.
3705        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        // The sea stays: it is the surface the map is drawn on, not an optional ornament.
3710        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        // One radio per area, all in one group, the first one selected. Islands are not offered:
3722        // the picker is about what the map is of.
3723        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        // The radio group is named after the chart, so two maps on one page stay independent.
3735        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        // Every selection rule hangs off a checked radio. A browser without :has() drops them
3742        // all, which leaves the map in its plain state rather than in a half-applied one — and
3743        // panels the host page provides stay visible instead of being hidden with no way back.
3744        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        // The rule names the map's root ID: the chart's own rules are scoped to it and would
3755        // outrank a rule that selects by class alone.
3756        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        // The plain SVG profile carries no selection at all.
3762        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        // 291 of 843 entries, as a percentage of the whole map, islands included.
3773        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    /// Every `<text>` in the SVG as (x, size, anchor, content).
3791    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            // An element can carry more than one class, so this compares whole names: looking for
3796            // "chartlet-topic-outside" must not find "chartlet-topic-outside-value".
3797            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        // Twenty-four areas: nearly every label has to go beside the map, which is where labels
3833        // start running off the canvas or landing on top of one another.
3834        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        // No label may be dropped silently: whatever is not drawn is reported.
3866        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        // The class is always in the stylesheet; what the switch decides is the elements.
3935        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        // The land itself is untouched by the switch.
3940        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    // --- Gaps, line patterns, areas, time zoom and more layers ---
4147
4148    /// A time chart over five days whose pane holds `layers`; `extra` goes into the top level.
4149    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    /// A line layer over the five days of [`week`]; `fields` goes into the layer object and
4162    /// `values` are the five values, `null` included.
4163    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        // Two pieces of line around the gap, and a marker for each of the four values.
4182        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        // A lone value between two gaps keeps its marker but draws no line.
4198        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        // Small multiples break their lines the same way.
4209        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        // Two pieces of line around the gap, and the legend sample.
4271        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        // An explicit pattern replaces the modeled dash, but the legend still says modeled.
4284        assert!(svg.contains(">Solid model (modeled)<"));
4285        assert!(!svg.contains("class=\"chartlet-line chartlet-line-modeled"));
4286
4287        // Without the new options a chart carries none of their rules.
4288        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        // One filled outline on either side of the gap, and a swatch under the legend sample.
4321        assert_eq!(
4322            svg.matches("<polyline points=\"").count(),
4323            // Two area outlines, two line pieces, one line for the second layer, two samples.
4324            7
4325        );
4326        assert_eq!(
4327            svg.matches("class=\"chartlet-area chartlet-band-series-1\"")
4328                .count(),
4329            3
4330        );
4331        // The value axis reaches zero although every value lies well above it.
4332        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        // The table keeps every observation.
4368        assert!(html.contains("<th scope=\"row\">2026-03-01</th>"));
4369        // The SVG profile stays one static chart.
4370        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        // A category chart still counts categories, and a timestamp is no category.
4402        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        // The fifth data layer takes the first palette color no layer before it holds.
4443        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    /// Position and content of every text of `class`, in document order.
4462    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        // The plot starts below the last legend row.
4533        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    // --- Candlesticks and stacked panes ---
4539
4540    /// Three candles: rising, falling, and one that opens and closes alike.
4541    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    /// A time chart whose panes are given as JSON; `extra` goes into the top level.
4548    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        // A rising candle is hollow, a falling one filled; the legend sample shows both.
4574        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        // A level candle keeps a body one pixel high.
4594        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        // Charts without candles carry none of their styles.
4602        assert!(!render_ok(TIME).content.contains("chartlet-rise"));
4603    }
4604
4605    #[test]
4606    fn dense_candles_are_drawn_as_wicks_only() {
4607        // 704 plot pixels at the default width leave room for 234 candles.
4608        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        // Only the legend sample keeps its bodies.
4631        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        // 450 high: the plot runs from 102 (title and one legend row) to 394 (time axis title).
4697        // Less the gap of 36, the ratio 3:1 splits 256 pixels into 192 and 64.
4698        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        // Time tick labels sit only below the bottom pane; value ticks at the left of both.
4709        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        // Vertical grid lines run through both panes, each within its own.
4723        assert!(svg.contains("y1=\"102\" x2="));
4724        assert!(svg.contains("y1=\"330\" x2="));
4725        assert!(svg.contains("y2=\"394\" class=\"chartlet-grid\""));
4726        // The panes share one legend, and the area of the second pane takes the next palette
4727        // color, since candles take none.
4728        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    /// The specification with a `mobile` field added at the top level.
4855    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        // Every ID is unique across both variants.
4940        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        // Caption, source, filter and data table appear once.
4948        for once in [
4949            "<figcaption>",
4950            "<table>",
4951            "<fieldset class=\"chartlet-filter\">",
4952        ] {
4953            assert_eq!(html.matches(once).count(), 1, "{once}");
4954        }
4955        // The series filter reaches the bars of both variants.
4956        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        // The same scene as the chart on screen, only the stylesheet differs.
5045        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        // A label that is shortened at both sizes is reported once; one that only the mobile
5132        // variant shortens is reported as coming from it.
5133        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        // The picker's rules name both variants' root IDs.
5217        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    // --- Collapsed gaps ---
5226
5227    /// Thursday 2026-03-05 to Wednesday 2026-03-11 without the weekend, as Unix seconds.
5228    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    /// A time chart with gaps collapsed: one line over `days`, then `extra` layers, then `tail`
5237    /// at the top level.
5238    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    /// The horizontal centers of the line's point markers, in order.
5258    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    /// The labels and positions of the time ticks, in order.
5266    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        // The weekend takes no space: Friday and Monday are neighbours.
5289        assert!(svg.contains("<title>2026-03-06: 2</title>"));
5290        assert!(svg.contains("<title>2026-03-09: 3</title>"));
5291        // Daily ticks: the weekend's boundaries fall on Monday, which is labelled once.
5292        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        // The same data with gaps shown leaves room for the weekend.
5308        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        // Five weeks of weekdays from Monday 2026-03-02, without Monday 2026-03-16.
5317        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        // The tick of the week without its Monday sits on the Tuesday's observation.
5337        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        // A reference line on Saturday stands on Monday, the next observation.
5355        assert!(svg.contains(&format!(
5356            "<polyline points=\"{monday},78 {monday},414\" class=\"chartlet-rule\">"
5357        )));
5358        // So does a point marker on Sunday.
5359        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        // A zone covers the observations between its edges: from Monday to Tuesday.
5371        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        // Tooltips and description keep the real times.
5390        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        // A zone reaching beyond the observations is cut at the first and the last of them.
5421        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        // With the distances kept, the same times are fine.
5431        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        // The zone keeps Thursday and Friday in the full window; the late window starts on the
5456        // Saturday, so its share of the zone holds no observation and is left out.
5457        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        // Panel B, one grid cell of (800 - 2 × 16) / 2 pixels to the right, starts on the third
5504        // of the five shared slots, where panel A ends, and keeps the same distances.
5505        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}