mod atlas;
mod calendar;
mod color;
mod contour;
mod diverging;
mod error;
mod layout;
mod metrics;
mod noise;
mod ohlc;
pub mod qr;
mod rangebar;
mod render;
mod scene;
mod sha256;
mod spec;
mod stripes;
mod text;
mod time;
use std::fmt::Write as _;
pub use error::{ChartError, ChartWarning};
pub use metrics::{BuiltinMetrics, TextMetrics};
pub use sha256::sha256;
pub use spec::{
CalendarDay, CalendarLayout, CalendarSpec, CartoucheSpec, CategoryAxisSpec, ChartSpec,
ChartType, Corner, Dash, DataPoint, Gaps, LayerSpec, Mark, MobileSpec, OhlcPoint, Orientation,
PaneSpec, RangeSpec, SeriesSpec, Shape, StripesSpec, Stroke, Theme, TimeAxisSpec, TimePoint,
TopicLinkSpec, TopicMapSpec, TopicSpec, ValueAxisSpec, ValueFormat, ZoomBound, ZoomStep,
};
use spec::{Dataset, LayerRef};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenderFormat {
Svg,
Html,
}
#[derive(Debug, Clone, Default)]
pub struct RenderOptions {
pub id_prefix: Option<String>,
pub table_mode: TableMode,
pub variant: Variant,
pub manifest: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Variant {
#[default]
Desktop,
Mobile,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum TableMode {
#[default]
Details,
Visible,
}
#[derive(Debug, Clone)]
pub struct RenderOutput {
pub content: String,
pub warnings: Vec<ChartWarning>,
pub manifest: Option<Manifest>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Manifest {
pub chartlet: &'static str,
pub schema_version: u8,
pub spec_hash: String,
pub output_hash: String,
pub format: RenderFormat,
pub variant: Variant,
pub id_prefix: String,
pub warnings: Vec<ChartWarning>,
}
impl Manifest {
#[must_use]
pub fn to_json(&self) -> String {
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct Json<'a> {
chartlet: &'a str,
schema_version: u8,
spec_hash: &'a str,
output_hash: &'a str,
format: &'a str,
variant: &'a str,
id_prefix: &'a str,
warnings: Vec<Warning<'a>>,
}
#[derive(serde::Serialize)]
struct Warning<'a> {
code: &'a str,
path: &'a str,
message: &'a str,
}
let json = Json {
chartlet: self.chartlet,
schema_version: self.schema_version,
spec_hash: &self.spec_hash,
output_hash: &self.output_hash,
format: match self.format {
RenderFormat::Svg => "svg",
RenderFormat::Html => "html",
},
variant: match self.variant {
Variant::Desktop => "desktop",
Variant::Mobile => "mobile",
},
id_prefix: &self.id_prefix,
warnings: self
.warnings
.iter()
.map(|warning| Warning {
code: warning.code,
path: &warning.path,
message: &warning.message,
})
.collect(),
};
serde_json::to_string_pretty(&json).expect("a manifest always serializes")
}
}
pub fn render_json(
input: &str,
format: RenderFormat,
options: &RenderOptions,
) -> Result<RenderOutput, ChartError> {
let spec = ChartSpec::from_json(input)?;
render(&spec, format, options)
}
pub fn render(
spec: &ChartSpec,
format: RenderFormat,
options: &RenderOptions,
) -> Result<RenderOutput, ChartError> {
render_with_metrics(spec, format, options, &BuiltinMetrics)
}
pub fn render_with_metrics(
spec: &ChartSpec,
format: RenderFormat,
options: &RenderOptions,
metrics: &impl TextMetrics,
) -> Result<RenderOutput, ChartError> {
let mut output = render_content(spec, format, options, metrics)?;
if options.manifest {
let id_prefix = match &options.id_prefix {
Some(id_prefix) => id_prefix.clone(),
None => default_id_prefix(spec)?,
};
output.manifest = Some(Manifest {
chartlet: env!("CARGO_PKG_VERSION"),
schema_version: spec.schema_version,
spec_hash: format!("sha256:{}", sha256::hex(&sha256(&canonical_json(spec)?))),
output_hash: format!("sha256:{}", sha256::hex(&sha256(output.content.as_bytes()))),
format,
variant: options.variant,
id_prefix,
warnings: output.warnings.clone(),
});
}
Ok(output)
}
fn render_content(
spec: &ChartSpec,
format: RenderFormat,
options: &RenderOptions,
metrics: &impl TextMetrics,
) -> Result<RenderOutput, ChartError> {
let mut warnings = spec.validate()?;
let id_prefix = match &options.id_prefix {
Some(id_prefix) => {
validate_id_prefix(id_prefix)?;
id_prefix.clone()
}
None => default_id_prefix(spec)?,
};
let mobile = spec.mobile_variant();
let mobile_prefix = |prefix: &str| format!("{prefix}-m");
if options.variant == Variant::Mobile {
if format == RenderFormat::Html {
return Err(ChartError::new(
"option_not_supported",
"/render/variant",
"the HTML profile carries both variants; render the mobile variant alone as SVG",
));
}
let Some(mobile) = mobile else {
return Err(ChartError::new(
"missing_mobile",
"/mobile",
"the specification has no mobile variant; add \"mobile\": { \"width\": 360 }",
));
};
let mut warnings = mobile.validate_mobile_variant()?;
let content = render_panel(&mobile, &mobile_prefix(&id_prefix), metrics, &mut warnings);
return Ok(RenderOutput {
content,
warnings,
manifest: None,
});
}
let mut mobile_warnings = match (&mobile, format) {
(Some(mobile), RenderFormat::Html) => mobile.validate_mobile_variant()?,
_ => Vec::new(),
};
if format == RenderFormat::Html && spec.zoom_steps.len() > 1 {
let mut panels = Vec::new();
for (index, step) in spec.zoom_steps.iter().enumerate() {
let sliced = zoom_variant(spec, step);
let panel_prefix = format!("{id_prefix}-z{index}");
let svg = render_panel(&sliced, &panel_prefix, metrics, &mut warnings);
let mobile = mobile.as_ref().map(|mobile| {
render_panel(
&zoom_variant(mobile, step),
&mobile_prefix(&panel_prefix),
metrics,
&mut mobile_warnings,
)
});
panels.push(render::Panel {
label: step.label.clone(),
svg,
mobile,
});
}
dedupe_warnings(&mut warnings);
merge_mobile_warnings(&mut warnings, mobile_warnings);
return Ok(RenderOutput {
content: render::html_zoom(&panels, spec, options.table_mode, &id_prefix),
warnings,
manifest: None,
});
}
let svg = render_panel(spec, &id_prefix, metrics, &mut warnings);
let content = match format {
RenderFormat::Svg => svg,
RenderFormat::Html => {
let mobile = mobile.as_ref().map(|mobile| {
render_panel(
mobile,
&mobile_prefix(&id_prefix),
metrics,
&mut mobile_warnings,
)
});
merge_mobile_warnings(&mut warnings, mobile_warnings);
render::html(
render::Panel {
label: String::new(),
svg,
mobile,
},
spec,
options.table_mode,
&id_prefix,
)
}
};
Ok(RenderOutput {
content,
warnings,
manifest: None,
})
}
fn zoom_variant(spec: &ChartSpec, step: &ZoomStep) -> ChartSpec {
if spec.chart_type == ChartType::Time {
let zone = spec.time_zone().unwrap_or_default();
let bound = |bound: &ZoomBound| {
bound
.resolve(zone)
.expect("validated zoom steps resolve to timestamps")
};
spec.windowed(bound(&step.from), bound(&step.to))
} else {
let index = |bound: &ZoomBound| {
bound
.index()
.expect("validated zoom steps of a category chart are indices")
};
spec.sliced(index(&step.from), index(&step.to))
}
}
fn render_panel(
spec: &ChartSpec,
id_prefix: &str,
metrics: &impl TextMetrics,
warnings: &mut Vec<ChartWarning>,
) -> String {
let description = spec
.description
.clone()
.unwrap_or_else(|| automatic_description(spec));
let scene = layout::layout(spec, warnings, metrics);
render::svg(&scene, spec, &description, id_prefix)
}
fn dedupe_warnings(warnings: &mut Vec<ChartWarning>) {
let mut seen = std::collections::BTreeSet::new();
warnings.retain(|warning| seen.insert((warning.code, warning.path.clone())));
}
fn merge_mobile_warnings(warnings: &mut Vec<ChartWarning>, mut mobile: Vec<ChartWarning>) {
dedupe_warnings(&mut mobile);
let reported = warnings
.iter()
.map(|warning| (warning.code, warning.path.clone()))
.collect::<std::collections::BTreeSet<_>>();
warnings.extend(
mobile
.into_iter()
.filter(|warning| !reported.contains(&(warning.code, warning.path.clone())))
.map(|warning| ChartWarning {
message: format!("mobile variant: {}", warning.message),
..warning
}),
);
}
fn validate_id_prefix(value: &str) -> Result<(), ChartError> {
let mut characters = value.chars();
let valid_start = characters
.next()
.is_some_and(|character| character.is_ascii_alphabetic());
let valid_rest = characters
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'));
if !valid_start || !valid_rest || value.len() > 64 {
return Err(ChartError::new(
"invalid_id_prefix",
"/render/idPrefix",
"use 1–64 ASCII letters, digits, hyphens, or underscores, starting with a letter",
));
}
Ok(())
}
fn canonical_json(spec: &ChartSpec) -> Result<Vec<u8>, ChartError> {
serde_json::to_vec(spec).map_err(|error| {
ChartError::new(
"serialization_failed",
"/",
format!("could not canonicalize the chart specification: {error}"),
)
})
}
fn default_id_prefix(spec: &ChartSpec) -> Result<String, ChartError> {
let hash = canonical_json(spec)?
.iter()
.fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
(hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3)
});
Ok(format!("chartlet-{hash:016x}"))
}
fn automatic_description(spec: &ChartSpec) -> String {
match spec.chart_type {
ChartType::Time | ChartType::Multiples => return time_description(spec),
ChartType::Topicmap => return topicmap_description(spec),
ChartType::Atlas => return atlas_description(spec),
ChartType::Stripes => return stripes::description(spec),
ChartType::Calendar => return calendar::description(spec),
ChartType::Rangebar => return rangebar::description(spec),
ChartType::Bar | ChartType::Line => {}
}
let dataset = spec.dataset();
let locale = spec.locale;
let words = locale.words();
let line = spec.chart_type == ChartType::Line;
let show = |value| layout::format_value(value, spec.number_style());
let highest_value = dataset
.values()
.max_by(f64::total_cmp)
.expect("validated charts contain data");
let lowest_value = dataset
.values()
.min_by(f64::total_cmp)
.expect("validated charts contain data");
let categories = dataset.categories.len();
let grouped = dataset.series.len() > 1;
let equal = highest_value.total_cmp(&lowest_value).is_eq();
if !grouped && equal {
return text::equal_values(locale, line, categories, &show(highest_value));
}
let names = dataset
.series
.iter()
.filter_map(|series| series.name.clone())
.collect::<Vec<_>>()
.join(", ");
let mut description =
text::categories_opening(locale, line, categories, dataset.series.len(), &names);
if equal {
write!(
description,
" {}: {}.",
words.all_values,
show(highest_value)
)
} else {
write!(
description,
" {}: {} ({}). {}: {} ({}).",
words.highest,
show(highest_value),
labels_at_value(&dataset, highest_value, locale),
words.lowest,
show(lowest_value),
labels_at_value(&dataset, lowest_value, locale)
)
}
.expect("writing to String cannot fail");
if grouped {
let missing = dataset
.series
.iter()
.flat_map(|series| &series.values)
.filter(|value| value.is_none())
.count();
description.push_str(&text::missing_values(locale, missing));
}
description
}
fn labels_at_value(dataset: &Dataset, value: f64, locale: spec::Locale) -> String {
let grouped = dataset.series.len() > 1;
dataset
.categories
.iter()
.enumerate()
.flat_map(|(index, category)| {
dataset
.series
.iter()
.filter(move |series| {
series.values[index]
.is_some_and(|series_value| series_value.total_cmp(&value).is_eq())
})
.map(move |series| match series.name.as_deref() {
Some(name) if grouped => text::series_at(locale, name, category),
_ => category.clone(),
})
})
.collect::<Vec<_>>()
.join(", ")
}
fn time_description(spec: &ChartSpec) -> String {
if spec.chart_type == ChartType::Time
&& (spec.panes.len() > 1 || spec.layers().any(|layer| layer.mark == spec::Mark::Ohlc))
{
return stacked_description(spec);
}
let locale = spec.locale;
let words = locale.words();
let zone = spec.time_zone().unwrap_or_default();
let precision = spec.time_precision(zone);
let dataset = spec.time_dataset(zone, precision, false);
let show = |value| layout::format_value(value, spec.number_style());
let points = dataset.categories.len();
let range = format!(
"{} {} {} {}",
words.from,
dataset
.categories
.first()
.expect("validated time charts have points"),
words.to,
dataset
.categories
.last()
.expect("validated time charts have points")
);
let names = spec
.series_names()
.into_iter()
.flatten()
.map(|name| {
let modeled = spec
.data_layers()
.any(|entry| entry.layer.name.as_ref() == Some(&name) && entry.layer.modeled);
if modeled {
format!("{name} ({})", words.modeled)
} else {
name
}
})
.collect::<Vec<_>>();
let mut description = if spec.chart_type == ChartType::Multiples {
let panels = spec
.panes
.iter()
.filter_map(|pane| pane.title.clone())
.collect::<Vec<_>>();
text::multiples_opening(locale, &panels, &range, &names)
} else {
let listed = if dataset.series.len() > 1 {
names.as_slice()
} else {
&[]
};
text::time_opening(
locale,
points,
&range,
listed,
names.len() == 1 && spec.data_layers().any(|entry| entry.layer.modeled),
)
};
let highest_value = dataset
.values()
.max_by(f64::total_cmp)
.expect("validated time charts contain values");
let lowest_value = dataset
.values()
.min_by(f64::total_cmp)
.expect("validated time charts contain values");
if highest_value.total_cmp(&lowest_value).is_eq() {
write!(
description,
" {}: {}.",
words.all_values,
show(highest_value)
)
} else {
write!(
description,
" {}: {} ({}). {}: {} ({}).",
words.highest,
show(highest_value),
labels_at_value(&dataset, highest_value, locale),
words.lowest,
show(lowest_value),
labels_at_value(&dataset, lowest_value, locale)
)
}
.expect("writing to String cannot fail");
describe_additions(spec, zone, &mut description);
description
}
fn describe_additions(spec: &ChartSpec, zone: time::TimeZone, description: &mut String) {
if spec.time_axis.gaps == spec::Gaps::Collapse {
description.push_str(text::gaps_collapsed(spec.locale));
}
let missing = spec
.data_layers()
.map(|entry| entry.layer.missing_values())
.sum();
description.push_str(&text::missing_values(spec.locale, missing));
describe_areas(spec, description);
describe_bands(spec, description);
describe_rules(spec, zone, description);
describe_zones_and_markers(spec, zone, description);
}
fn stacked_description(spec: &ChartSpec) -> String {
let locale = spec.locale;
let words = locale.words();
let zone = spec.time_zone().unwrap_or_default();
let precision = spec.time_precision(zone);
let dataset = spec.time_dataset(zone, precision, false);
let range = format!(
"{} {} {} {}",
words.from,
dataset
.categories
.first()
.expect("validated time charts have points"),
words.to,
dataset
.categories
.last()
.expect("validated time charts have points")
);
let names = spec
.data_layers()
.filter_map(|entry| {
let name = entry.layer.name.clone()?;
Some(if entry.layer.modeled {
format!("{name} ({})", words.modeled)
} else {
name
})
})
.collect::<Vec<_>>();
let layered = spec.data_layers().count() > 1;
let mut description = text::time_opening(
locale,
dataset.categories.len(),
&range,
if layered { names.as_slice() } else { &[] },
!layered && spec.data_layers().any(|entry| entry.layer.modeled),
);
let labels: Vec<String> = spec
.panes
.iter()
.enumerate()
.map(|(index, pane)| {
pane.value_axis
.title
.clone()
.unwrap_or_else(|| format!("{} {}", words.pane, index + 1))
})
.collect();
if spec.panes.len() > 1 {
description.push_str(&text::panes(locale, &labels));
}
for (pane_index, label) in labels.iter().enumerate() {
let style = spec.pane_style(pane_index);
let layers: Vec<LayerRef> = spec
.data_layers()
.filter(|entry| entry.pane == pane_index)
.collect();
for entry in layers
.iter()
.filter(|entry| entry.layer.mark == spec::Mark::Ohlc)
{
description.push_str(&ohlc::describe(spec, *entry, zone, precision, style));
}
let observations: Vec<(f64, String)> = layers
.iter()
.filter(|entry| entry.layer.mark != spec::Mark::Ohlc)
.flat_map(|entry| {
entry
.layer
.resolved_points(zone)
.into_iter()
.map(move |(epoch, value)| {
let time = precision.format(epoch, zone);
let place = match &entry.layer.name {
Some(name) if layered => text::series_at(locale, name, &time),
_ => time,
};
(value, place)
})
})
.collect();
describe_extremes(
&observations,
(spec.panes.len() > 1).then_some(label.as_str()),
style,
locale,
&mut description,
);
}
describe_additions(spec, zone, &mut description);
description
}
fn describe_extremes(
observations: &[(f64, String)],
label: Option<&str>,
style: spec::NumberStyle,
locale: spec::Locale,
description: &mut String,
) {
let words = locale.words();
let (Some(highest), Some(lowest)) = (
observations
.iter()
.map(|(value, _)| *value)
.max_by(f64::total_cmp),
observations
.iter()
.map(|(value, _)| *value)
.min_by(f64::total_cmp),
) else {
return;
};
let at = |target: f64| {
observations
.iter()
.filter(|(value, _)| value.total_cmp(&target).is_eq())
.map(|(_, place)| place.as_str())
.collect::<Vec<_>>()
.join(", ")
};
let show = |value| layout::format_value(value, style);
let prefix = label.map_or_else(String::new, |label| format!(" {label} –"));
if highest.total_cmp(&lowest).is_eq() {
write!(
description,
"{prefix} {}: {}.",
words.all_values,
show(highest)
)
} else {
write!(
description,
"{prefix} {}: {} ({}). {}: {} ({}).",
words.highest,
show(highest),
at(highest),
words.lowest,
show(lowest),
at(lowest)
)
}
.expect("writing to String cannot fail");
}
fn describe_areas(spec: &ChartSpec, description: &mut String) {
let areas: Vec<LayerRef> = spec
.data_layers()
.filter(|entry| entry.layer.mark == spec::Mark::Area)
.collect();
if areas.is_empty() {
return;
}
let mut names: Vec<String> = Vec::new();
for entry in areas {
if let Some(name) = &entry.layer.name
&& !names.contains(name)
{
names.push(name.clone());
}
}
description.push_str(&text::areas(spec.locale, &names));
}
fn describe_bands(spec: &ChartSpec, description: &mut String) {
let banded = spec
.data_layers()
.filter(|entry| entry.layer.has_band())
.count();
if banded == 0 {
return;
}
let hatched = spec
.data_layers()
.any(|entry| entry.layer.has_band() && entry.layer.modeled);
description.push_str(&text::bands(spec.locale, banded, hatched));
}
fn describe_rules(spec: &ChartSpec, zone: time::TimeZone, description: &mut String) {
let words = spec.locale.words();
let rules = spec
.indexed_layers()
.filter(|entry| entry.layer.is_rule())
.filter_map(|entry| {
let layer = entry.layer;
let label = layer.label.as_deref()?;
if let Some(value) = layer.value {
return Some(format!(
"{label} {} {}",
words.at,
layout::format_value(value, spec.pane_style(entry.pane))
));
}
let epoch = layer.time.as_ref()?.resolve(zone).ok()?;
Some(format!(
"{label} {} {}",
words.at,
time::Precision::of(std::iter::once(epoch), zone).format(epoch, zone)
))
})
.collect::<Vec<_>>();
if !rules.is_empty() {
write!(
description,
" {}: {}.",
words.reference_lines,
rules.join("; ")
)
.expect("writing to String cannot fail");
}
}
fn describe_zones_and_markers(spec: &ChartSpec, zone: time::TimeZone, description: &mut String) {
let words = spec.locale.words();
let zones: Vec<String> = spec
.indexed_layers()
.filter(|entry| entry.layer.mark == spec::Mark::Band)
.filter_map(|entry| {
let label = entry.layer.label.as_deref()?;
Some(format!(
"{label}, {}",
layout::zone_extent(spec, entry.layer, zone, spec.pane_style(entry.pane))
))
})
.collect();
if !zones.is_empty() {
write!(description, " {}: {}.", words.zones, zones.join("; "))
.expect("writing to String cannot fail");
}
let markers: Vec<String> = spec
.indexed_layers()
.filter(|entry| entry.layer.is_marker())
.filter_map(|entry| {
let label = entry.layer.label.as_deref()?;
Some(format!(
"{label} {} {}",
words.at,
layout::marker_position(entry.layer, zone, spec.pane_style(entry.pane))
))
})
.collect();
if !markers.is_empty() {
write!(description, " {}: {}.", words.markers, markers.join("; "))
.expect("writing to String cannot fail");
}
}
fn atlas_description(spec: &ChartSpec) -> String {
let atlas = spec
.atlas
.as_ref()
.expect("validated atlas charts carry an atlas block");
let show = |value| layout::format_value(value, spec.number_style());
let realms = atlas.realms.len();
let regions: usize = atlas.realms.iter().map(|realm| realm.regions.len()).sum();
let places: usize = atlas
.realms
.iter()
.flat_map(|realm| &realm.regions)
.map(|region| region.places.len())
.sum();
let largest = atlas
.realms
.iter()
.flat_map(|realm| &realm.regions)
.max_by(|a, b| a.value.total_cmp(&b.value))
.expect("validated atlas charts have at least one region");
text::atlas_sentence(
spec.locale,
realms,
regions,
(&largest.label, &show(largest.value)),
places,
)
}
fn topicmap_description(spec: &ChartSpec) -> String {
let topicmap = spec
.topicmap
.as_ref()
.expect("validated topicmap charts carry a topicmap block");
let show = |value| layout::format_value(value, spec.number_style());
let areas = topicmap.topics.len();
let largest = topicmap
.topics
.iter()
.max_by(|a, b| a.value.total_cmp(&b.value))
.expect("validated topicmap charts have at least one topic");
let smallest = topicmap
.topics
.iter()
.min_by(|a, b| a.value.total_cmp(&b.value))
.expect("validated topicmap charts have at least one topic");
let mut description = text::topicmap_opening(
spec.locale,
areas,
topicmap.islands.len(),
(&largest.label, &show(largest.value)),
(&smallest.label, &show(smallest.value)),
);
let mut strongest: Vec<&spec::TopicLinkSpec> = topicmap.links.iter().collect();
strongest.sort_by(|a, b| b.weight.total_cmp(&a.weight));
let named: Vec<(&str, &str)> = strongest
.iter()
.take(3)
.map(|link| (link.from.as_str(), link.to.as_str()))
.collect();
if !named.is_empty() {
description.push_str(&text::neighbours(spec.locale, &named));
}
description
}
#[cfg(test)]
mod tests {
use super::{
BuiltinMetrics, ChartSpec, RenderFormat, RenderOptions, TextMetrics, Variant, render_json,
render_with_metrics,
};
const SPEC: &str = r#"{
"schemaVersion": 1,
"type": "bar",
"title": "Profit & loss",
"data": [
{"label": "North <East>", "value": 12},
{"label": "South", "value": -4}
]
}"#;
#[test]
fn renders_accessible_deterministic_svg() {
let first = render_json(SPEC, RenderFormat::Svg, &RenderOptions::default()).unwrap();
let second = render_json(SPEC, RenderFormat::Svg, &RenderOptions::default()).unwrap();
assert_eq!(first.content, second.content);
assert!(first.content.contains("role=\"img\""));
assert!(first.content.contains("<title id="));
assert!(first.content.contains("<desc id="));
assert!(first.content.contains("North <East>"));
assert!(first.content.contains("height=\""));
}
#[test]
fn html_contains_complete_data_table() {
let output = render_json(SPEC, RenderFormat::Html, &RenderOptions::default()).unwrap();
assert!(output.content.contains("<figure"));
assert!(output.content.contains("<details"));
assert!(output.content.contains("<table>"));
assert!(output.content.contains("<td>−4</td>"));
}
#[test]
fn deserialization_errors_use_json_pointers() {
for (input, code, path) in [
(
SPEC.replace("\"title\"", "\"colour\": 1, \"title\""),
"invalid_spec",
"/colour",
),
(
SPEC.replace("12}", "\"12\"}"),
"invalid_spec",
"/data/0/value",
),
(
SPEC.replace("\"title\": \"Profit & loss\",", ""),
"invalid_spec",
"/",
),
("{\"schemaVersion\": 1,".to_owned(), "invalid_json", "/"),
] {
let error =
render_json(&input, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
assert_eq!((error.code, error.path.as_str()), (code, path), "{input}");
}
}
#[test]
fn rejects_duplicate_labels_with_path() {
let duplicate = SPEC.replace("South", "North <East>");
let error =
render_json(&duplicate, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
assert_eq!(error.code, "duplicate_label");
assert_eq!(error.path, "/data/1/label");
}
#[test]
fn explicit_prefix_prevents_duplicate_ids() {
let first = render_json(
SPEC,
RenderFormat::Svg,
&RenderOptions {
id_prefix: Some("first".to_owned()),
..RenderOptions::default()
},
)
.unwrap();
let second = render_json(
SPEC,
RenderFormat::Svg,
&RenderOptions {
id_prefix: Some("second".to_owned()),
..RenderOptions::default()
},
)
.unwrap();
assert!(first.content.contains("first-title"));
assert!(second.content.contains("second-title"));
}
#[test]
fn preserves_small_values_in_every_text_alternative() {
let small = SPEC.replace("12}", "0.001}");
let output = render_json(&small, RenderFormat::Html, &RenderOptions::default()).unwrap();
assert!(output.content.contains("<td>0.001</td>"));
assert!(output.content.contains("0.001"));
}
#[test]
fn rejects_values_outside_the_supported_scale_range() {
let extreme = SPEC.replace("12}", "1e308}");
let error =
render_json(&extreme, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
assert_eq!(error.code, "unsupported_numeric_range");
assert_eq!(error.path, "/data/0/value");
}
#[test]
fn rejects_xml_control_characters() {
let invalid = SPEC.replace("Profit & loss", "Bad\\u0000title");
let error =
render_json(&invalid, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
assert_eq!(error.code, "invalid_xml_character");
assert_eq!(error.path, "/title");
}
#[test]
fn equal_values_do_not_invent_extrema() {
let tied = SPEC.replace("-4", "12");
let output = render_json(&tied, RenderFormat::Svg, &RenderOptions::default()).unwrap();
assert!(
output
.content
.contains("Bar chart with 2 equal values: 12 each.")
);
}
#[test]
fn visible_table_mode_omits_details_disclosure() {
let output = render_json(
SPEC,
RenderFormat::Html,
&RenderOptions {
table_mode: super::TableMode::Visible,
..RenderOptions::default()
},
)
.unwrap();
assert!(output.content.contains("<div class=\"chartlet-data\">"));
assert!(!output.content.contains("<details"));
}
#[test]
fn line_chart_preserves_missing_values_as_gaps() {
let specification = include_str!("../examples/monthly-trend.json");
let output =
render_json(specification, RenderFormat::Html, &RenderOptions::default()).unwrap();
assert_eq!(output.content.matches("<polyline").count(), 2);
assert_eq!(output.content.matches("<circle").count(), 5);
assert!(output.content.contains("<td>Missing</td>"));
}
const GROUPED: &str = r#"{
"schemaVersion": 1,
"type": "bar",
"title": "Budget and actual",
"categories": ["Jan", "Feb", "Mar"],
"series": [
{"name": "Budget", "values": [120, 150, 140]},
{"name": "Actual", "values": [130, 145, null]}
]
}"#;
#[test]
fn grouped_bars_render_legend_bars_and_series_table() {
let output = render_json(GROUPED, RenderFormat::Html, &RenderOptions::default()).unwrap();
assert_eq!(
output
.content
.matches("class=\"chartlet-bar chartlet-series-1\"")
.count(),
4
);
assert_eq!(
output
.content
.matches("class=\"chartlet-bar chartlet-series-2\"")
.count(),
3
);
assert!(
output
.content
.contains("<th scope=\"col\">Budget</th><th scope=\"col\">Actual</th>")
);
assert!(
output
.content
.contains("<th scope=\"row\">Mar</th><td>140</td><td>Missing</td>")
);
assert!(output.content.contains(
"Bar chart with 3 categories and 2 series (Budget, Actual). Highest: 150 (Budget in Feb). Lowest: 120 (Budget in Jan). 1 value is missing."
));
}
#[test]
fn grouped_bars_reject_mismatched_series_length() {
let short = GROUPED.replace("[130, 145, null]", "[130, 145]");
let error = render_json(&short, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
assert_eq!(error.code, "series_length_mismatch");
assert_eq!(error.path, "/series/1/values");
}
#[test]
fn data_and_series_are_mutually_exclusive() {
let both = GROUPED.replace(
"\"categories\"",
"\"data\": [{\"label\": \"Jan\", \"value\": 1}], \"categories\"",
);
let error = render_json(&both, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
assert_eq!(error.code, "conflicting_data_shape");
assert_eq!(error.path, "/data");
}
#[test]
fn line_charts_do_not_accept_series_yet() {
let line = GROUPED.replace("\"bar\"", "\"line\"");
let error = render_json(&line, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
assert_eq!(error.code, "option_not_supported");
assert_eq!(error.path, "/series");
}
#[test]
fn single_series_output_carries_no_series_styles() {
let output = render_json(SPEC, RenderFormat::Svg, &RenderOptions::default()).unwrap();
assert!(!output.content.contains("chartlet-series-"));
}
#[test]
fn short_negative_bar_keeps_its_label_readable() {
let specification = r#"{
"schemaVersion": 1,
"type": "bar",
"orientation": "horizontal",
"title": "Change",
"valueAxis": {"format": "percent"},
"data": [
{"label": "A", "value": 0.3},
{"label": "B", "value": -0.005}
]
}"#;
let output =
render_json(specification, RenderFormat::Svg, &RenderOptions::default()).unwrap();
assert!(
output
.content
.contains("text-anchor=\"end\" class=\"chartlet-value\">−0.5%</text>")
);
assert!(!output.content.contains("class=\"chartlet-value-inverse\""));
}
#[test]
fn bar_chart_rejects_missing_values() {
let missing = SPEC.replace("12}", "null}");
let error =
render_json(&missing, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
assert_eq!(error.code, "missing_bar_value");
assert_eq!(error.path, "/data/0/value");
}
#[test]
fn grouped_bars_render_series_filter_checkboxes() {
let output = render_json(GROUPED, RenderFormat::Html, &RenderOptions::default()).unwrap();
assert!(
output
.content
.contains("<fieldset class=\"chartlet-filter\">")
);
assert!(output.content.contains("class=\"series-0\" checked"));
assert!(output.content.contains("data-series=\"0\""));
assert!(output.content.contains("<text data-series=\"0\""));
assert!(output.content.contains("<title>Jan – Budget: 120</title>"));
}
#[test]
fn single_series_bars_carry_native_tooltips() {
let output = render_json(SPEC, RenderFormat::Svg, &RenderOptions::default()).unwrap();
assert!(
output
.content
.contains("<title>North <East>: 12</title>")
);
assert!(!output.content.contains("data-series"));
}
#[test]
fn zoom_steps_render_radio_selectable_panels() {
let specification = r#"{
"schemaVersion": 1,
"type": "bar",
"title": "Quarterly revenue",
"data": [
{"label": "Q1", "value": 320},
{"label": "Q2", "value": 345},
{"label": "Q3", "value": 380},
{"label": "Q4", "value": 410}
],
"zoomSteps": [
{"label": "First half", "from": 0, "to": 1},
{"label": "All", "from": 0, "to": 3}
]
}"#;
let output =
render_json(specification, RenderFormat::Html, &RenderOptions::default()).unwrap();
assert!(
output
.content
.contains("<fieldset class=\"chartlet-zoom\">")
);
assert!(
output
.content
.contains("class=\"chartlet-panel chartlet-panel-0\"")
);
assert!(
output
.content
.contains("class=\"chartlet-panel chartlet-panel-1\"")
);
assert!(output.content.contains("class=\"zoom-0\" checked"));
assert_eq!(output.content.matches("<title id=").count(), 2);
}
#[test]
fn a_single_zoom_step_is_rejected() {
let specification = r#"{
"schemaVersion": 1,
"type": "bar",
"title": "Quarterly revenue",
"data": [
{"label": "Q1", "value": 320},
{"label": "Q2", "value": 345}
],
"zoomSteps": [
{"label": "All", "from": 0, "to": 1}
]
}"#;
let error = render_json(specification, RenderFormat::Svg, &RenderOptions::default())
.expect_err("one zoom step cannot provide a choice");
assert_eq!(error.code, "not_enough_zoom_steps");
assert_eq!(error.path, "/zoomSteps");
}
const TIME: &str = r#"{
"schemaVersion": 1,
"type": "time",
"title": "Daily orders",
"timeAxis": {"timezone": "UTC", "title": "Day"},
"panes": [
{
"valueAxis": {"title": "Orders"},
"layers": [
{
"mark": "line",
"name": "Orders",
"points": [
{"time": "2026-03-01", "value": 10},
{"time": "2026-03-02", "value": 15},
{"time": "2026-03-03", "value": 20}
]
}
]
}
]
}"#;
fn hourly(points: usize, extra: &str) -> String {
let sample = (0..points)
.map(|index| {
let offset = i64::try_from(index).expect("test sizes fit in an i64") * 3_600;
format!(
"{{\"time\": {}, \"value\": {}}}",
1_770_000_000 + offset,
index % 5 + 1
)
})
.collect::<Vec<_>>()
.join(",");
format!(
r#"{{
"schemaVersion": 1,
"type": "time",
"title": "Hourly load",
"timeAxis": {{"timezone": "UTC"}},
"panes": [
{{
"valueAxis": {{"title": "Load"}},
"layers": [{{"mark": "line", "name": "Load"{extra}, "points": [{sample}]}}]
}}
]
}}"#
)
}
#[test]
fn german_locale_writes_description_table_and_numbers_in_german() {
let spec = TIME
.replace(
"\"type\": \"time\",",
"\"type\": \"time\", \"locale\": \"de\", \"source\": \"Shop\",",
)
.replace(
"\"valueAxis\": {\"title\": \"Orders\"}",
"\"valueAxis\": {\"title\": \"Orders\", \"decimals\": 1}",
)
.replace("\"value\": 15}", "\"value\": -1.5}");
let html = render_json(&spec, RenderFormat::Html, &RenderOptions::default()).unwrap();
let content = &html.content;
assert!(content.contains(
"Zeitreihe mit 3 Punkten von 2026-03-01 bis 2026-03-03. Höchster Wert: 20,0 (2026-03-03). Niedrigster Wert: \u{2212}1,5 (2026-03-02)."
));
assert!(content.contains("<summary>Diagrammdaten anzeigen</summary>"));
assert!(content.contains("<caption>Daten zu Daily orders</caption>"));
assert!(content.contains("<th scope=\"col\">Zeit</th>"));
assert!(content.contains("<td>\u{2212}1,5</td>"));
assert!(content.contains("<p class=\"chartlet-source\">Quelle: Shop</p>"));
}
#[test]
fn a_thin_line_is_drawn_thin_in_the_plot_and_in_the_legend() {
let spec = TIME.replace(
"\"name\": \"Orders\",",
"\"name\": \"Orders\", \"stroke\": \"thin\",",
).replace(
"]\n }\n ]",
", {\"mark\": \"line\", \"name\": \"Mean\", \"points\": [{\"time\": \"2026-03-01\", \"value\": 12}, {\"time\": \"2026-03-03\", \"value\": 18}]}]\n }\n ]",
);
let svg = render_ok(&spec).content;
assert!(svg.contains(".chartlet-line-thin{stroke-width:1}"));
assert_eq!(
svg.matches("class=\"chartlet-line chartlet-line-thin chartlet-line-series-1\"")
.count(),
2
);
assert_eq!(
svg.matches("class=\"chartlet-line chartlet-line-series-2\"")
.count(),
2
);
let rule = TIME.replace(
"\"name\": \"Orders\",",
"\"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\",",
);
assert_eq!(
render_err(&rule),
(
"option_not_supported",
"/panes/0/layers/1/stroke".to_owned()
)
);
}
fn german(spec: &str, chart_type: &str) -> String {
spec.replace(
&format!("\"type\": \"{chart_type}\","),
&format!("\"type\": \"{chart_type}\", \"locale\": \"de\","),
)
}
fn html_ok(spec: &str) -> String {
render_json(spec, RenderFormat::Html, &RenderOptions::default())
.unwrap()
.content
}
#[test]
fn german_bar_chart_writes_description_table_and_numbers_in_german() {
let html = html_ok(&german(SPEC, "bar").replace("\"value\": -4}", "\"value\": -4.5}"));
assert!(html.contains(
"Balkendiagramm mit 2 Kategorien. Höchster Wert: 12 (North <East>). Niedrigster Wert: \u{2212}4,5 (South)."
));
assert!(html.contains("<th scope=\"col\">Kategorie</th><th scope=\"col\">Wert</th>"));
assert!(html.contains("<td>\u{2212}4,5</td>"));
}
#[test]
fn german_stripes_write_description_tooltips_and_table_in_german() {
let html = html_ok(&german(STRIPES, "stripes"));
assert!(html.contains("<title>1851: \u{2212}0,5</title>"));
assert!(html.contains(
"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."
));
assert!(html.contains("<th scope=\"col\">Jahr</th>"));
assert!(html.contains("<th scope=\"row\">1852</th><td>fehlt</td>"));
}
#[test]
fn german_calendar_names_months_and_weekdays_in_german() {
let days = DAYS.replace("\"value\": -2}", "\"value\": -2.5}");
let months = html_ok(&german(&calendar("months", &days), "calendar"));
for month in ["Jan", "Mär", "Mai", "Okt", "Dez"] {
assert!(months.contains(&format!(">{month}</text>")), "{month}");
}
assert!(!months.contains(">Mar</text>"));
assert!(months.contains("<title>2024-01-01: \u{2212}2,5</title>"));
assert!(months.contains("<title>2024-12-31: kein Wert</title>"));
assert!(months.contains(
"Kalender 2024 mit einer Zeile pro Monat, auf einer divergierenden Farbskala um "
));
assert!(months.contains(
"2 von 366 Tagen haben einen Wert. Niedrigster Wert: \u{2212}2,5 (2024-01-01). Höchster Wert: 4 (2024-02-29)."
));
assert!(months.contains("<th scope=\"col\">Datum</th>"));
let weeks = render_ok(&german(&calendar("weeks", DAYS), "calendar")).content;
for weekday in ["Mo", "Mi", "Fr", "So"] {
assert!(weeks.contains(&format!(">{weekday}</text>")), "{weekday}");
}
assert!(weeks.contains(">Mär</text>"));
assert!(weeks.contains("einer Zeile pro Wochentag und einer Spalte pro Woche"));
}
#[test]
fn german_range_chart_writes_spans_legend_and_table_in_german() {
let html = html_ok(&german(RANGES, "rangebar"));
assert!(html.contains("<title>Observed: 1,05 (0,9 bis 1,2)</title>"));
assert!(html.contains("<title>Natural: \u{2212}0,1 bis 0,1, modelliert</title>"));
assert!(html.contains(">Schraffiert: modelliert<"));
assert!(html.contains(
"Spannendiagramm 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."
));
assert!(html.contains(
"<th scope=\"col\">Kategorie</th><th scope=\"col\">Unterer Wert</th><th scope=\"col\">Mittlerer Wert</th><th scope=\"col\">Oberer Wert</th>"
));
assert!(html.contains("<th scope=\"row\">Natural (modelliert)</th>"));
}
#[test]
fn german_maps_write_description_picker_and_table_in_german() {
let topicmap = html_ok(&german(TOPICMAP, "topicmap"));
assert!(topicmap.contains(
"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."
));
assert!(topicmap.contains("<legend>Gebiet</legend>"));
assert!(topicmap.contains(
"<th scope=\"col\">Thema</th><th scope=\"col\">Einträge</th><th scope=\"col\">Pfade</th><th scope=\"col\">Anteil</th>"
));
let atlas = html_ok(&german(ATLAS, "atlas"));
assert!(atlas.contains(
"Wissenslandschaft aus 2 Reichen und 4 Regionen; die größte ist Models mit 40. 1 Ort ist markiert."
));
assert!(atlas.contains(
"<th scope=\"col\">Region</th><th scope=\"col\">Einträge</th><th scope=\"col\">Orte</th>"
));
}
#[test]
fn too_many_decimals_are_refused() {
let spec = TIME.replace(
"\"valueAxis\": {\"title\": \"Orders\"}",
"\"valueAxis\": {\"title\": \"Orders\", \"decimals\": 9}",
);
assert_eq!(
render_err(&spec),
("invalid_decimals", "/panes/0/valueAxis/decimals".to_owned())
);
}
#[test]
fn time_chart_renders_a_line_marks_and_a_time_axis() {
let svg = render_json(TIME, RenderFormat::Svg, &RenderOptions::default()).unwrap();
assert!(svg.content.contains("<polyline"));
assert_eq!(svg.content.matches("class=\"chartlet-point\"").count(), 3);
assert!(
svg.content
.contains("<title>2026-03-01 – Orders: 10</title>")
);
assert!(svg.content.contains(">2026-03-01<"));
assert!(svg.content.contains(">Day<"));
assert!(svg.content.contains(">Orders<"));
assert!(svg.warnings.is_empty(), "{:?}", svg.warnings);
let html = render_json(TIME, RenderFormat::Html, &RenderOptions::default()).unwrap();
assert!(
html.content
.contains("<th scope=\"col\">Time</th><th scope=\"col\">Orders</th>")
);
assert!(
html.content
.contains("<th scope=\"row\">2026-03-03</th><td>20</td>")
);
}
#[test]
fn time_chart_without_a_description_gets_a_generated_one() {
let output = render_json(TIME, RenderFormat::Svg, &RenderOptions::default()).unwrap();
assert!(output.content.contains(
"Time chart with 3 points from 2026-03-01 to 2026-03-03. Highest: 20 (2026-03-03). Lowest: 10 (2026-03-01)."
));
}
#[test]
fn the_timezone_moves_the_wall_clock_labels() {
let bare = TIME.replace("\"timezone\": \"UTC\"", "\"timezone\": \"+02:00\"");
let bare = render_json(&bare, RenderFormat::Svg, &RenderOptions::default()).unwrap();
assert!(bare.content.contains(">2026-03-01<"));
assert!(!bare.content.contains(">2026-03-01 02:00<"));
let seconds = TIME
.replace("\"time\": \"2026-03-01\"", "\"time\": 1772323200")
.replace("\"time\": \"2026-03-02\"", "\"time\": 1772409600")
.replace("\"time\": \"2026-03-03\"", "\"time\": 1772496000");
let utc = render_json(&seconds, RenderFormat::Svg, &RenderOptions::default()).unwrap();
assert!(utc.content.contains(">2026-03-01<"));
assert!(!utc.content.contains("00:00<"));
let shifted = seconds.replace("\"timezone\": \"UTC\"", "\"timezone\": \"+02:00\"");
let shifted = render_json(&shifted, RenderFormat::Svg, &RenderOptions::default()).unwrap();
assert!(shifted.content.contains(">2026-03-02 00:00<"));
assert!(shifted.content.contains(">2026-03-01 12:00<"));
let zone = seconds.replace("\"timezone\": \"UTC\"", "\"timezone\": \"+02:00\"");
let html = render_json(&zone, RenderFormat::Html, &RenderOptions::default()).unwrap();
assert!(
html.content
.contains("<th scope=\"row\">2026-03-01 02:00</th>")
);
}
#[test]
fn timestamps_that_do_not_increase_are_rejected() {
let reversed = TIME.replace("\"value\": 10", "\"value\": 99").replace(
"\"time\": \"2026-03-01\", \"value\": 99",
"\"time\": \"2026-03-03\", \"value\": 99",
);
let error = render_json(&reversed, RenderFormat::Svg, &RenderOptions::default())
.expect_err("a backwards series has no direction");
assert_eq!(error.code, "unordered_time");
assert_eq!(error.path, "/panes/0/layers/0/points/1/time");
}
#[test]
fn fractional_and_out_of_range_timestamps_are_rejected() {
for (time, message) in [
("12.5", "Unix seconds must be a whole number"),
(
"8000000000",
"timestamps must lie between 1700-01-01 and 2200-01-01",
),
(
"\"March 1st\"",
"expected an ISO 8601 date such as 2026-03-01 or 2026-03-01T12:00:00Z, or a year such as 1850",
),
] {
let broken = TIME.replace("\"time\": \"2026-03-01\"", &format!("\"time\": {time}"));
let error = render_json(&broken, RenderFormat::Svg, &RenderOptions::default())
.expect_err("a timestamp outside the contract is refused");
assert_eq!(
(error.code, error.path.as_str()),
("invalid_time", "/panes/0/layers/0/points/0/time")
);
assert_eq!(error.message, message, "{time}");
}
}
#[test]
fn a_line_needs_two_observations() {
let error = render_json(&hourly(1, ""), RenderFormat::Svg, &RenderOptions::default())
.expect_err("a single observation is not a line");
assert_eq!(error.code, "empty_series");
assert_eq!(error.path, "/panes/0/layers/0/points");
}
#[test]
fn dense_layers_are_drawn_as_a_line_without_markers() {
let sparse = render_json(
&hourly(60, ""),
RenderFormat::Svg,
&RenderOptions::default(),
)
.expect("60 observations stay readable");
assert_eq!(
sparse.content.matches("class=\"chartlet-point\"").count(),
60
);
let dense = render_json(
&hourly(61, ""),
RenderFormat::Svg,
&RenderOptions::default(),
)
.unwrap();
assert!(dense.content.contains("<polyline"));
assert_eq!(dense.content.matches("class=\"chartlet-point\"").count(), 0);
}
#[test]
fn dense_layers_are_reported_once_they_outnumber_the_plot_pixels() {
let output = render_json(
&hourly(1_000, ""),
RenderFormat::Svg,
&RenderOptions::default(),
)
.unwrap();
assert_eq!(output.warnings.len(), 1);
assert_eq!(output.warnings[0].code, "dense_chart");
assert_eq!(output.warnings[0].path, "/panes/0/layers/0/points");
assert!(
output.warnings[0]
.message
.contains("1000 observations exceed the 704 horizontal pixels"),
"{}",
output.warnings[0].message
);
}
#[test]
fn declared_layer_colors_reach_the_stylesheet() {
let specification = include_str!("../examples/revenue-vs-forecast.json");
let svg = render_json(specification, RenderFormat::Svg, &RenderOptions::default()).unwrap();
assert!(svg.content.contains(".chartlet-style-0{stroke:#7ea6ff}"));
assert!(
svg.content
.contains(".chartlet-style-1{stroke:var(--chart-forecast,currentColor)}")
);
assert_eq!(svg.content.matches("chartlet-theme-dark").count(), 2);
assert!(svg.content.contains("class=\"chartlet-background\""));
assert!(svg.content.contains("class=\"chartlet-legend\""));
}
#[test]
fn declared_layer_colors_stay_inside_their_chart() {
let specification = include_str!("../examples/revenue-vs-forecast.json");
let svg = render_json(
specification,
RenderFormat::Svg,
&RenderOptions {
id_prefix: Some("first".to_owned()),
..RenderOptions::default()
},
)
.unwrap();
assert!(svg.content.contains("id=\"first\" role=\"img\""));
assert!(
svg.content
.contains("#first .chartlet-style-0{stroke:#7ea6ff}")
);
assert!(!svg.content.contains("}.chartlet-style-0{"));
assert!(svg.content.contains("#first .chartlet-title{"));
assert!(
svg.content
.contains(".chartlet-root.chartlet-theme-dark{--chartlet-text:")
);
assert!(
svg.content
.contains("<style>.chartlet-root{--chartlet-text:")
);
}
#[test]
fn an_invalid_color_is_reported_and_replaced_by_the_neutral_tone() {
let declared = hourly(3, ", \"color\": \"red\"");
let output = render_json(&declared, RenderFormat::Svg, &RenderOptions::default()).unwrap();
assert_eq!(output.warnings.len(), 1);
assert_eq!(output.warnings[0].code, "color_not_supported");
assert_eq!(output.warnings[0].path, "/panes/0/layers/0/color");
assert!(output.content.contains(".chartlet-style-0{stroke:#667085}"));
}
#[test]
fn fields_that_belong_to_another_mark_are_rejected_by_name() {
let annotated = TIME.replace(
"\"name\": \"Orders\"",
"\"name\": \"Orders\", \"label\": \"Peak\"",
);
let error = render_json(&annotated, RenderFormat::Svg, &RenderOptions::default())
.expect_err("a label belongs to an annotation");
assert_eq!(error.code, "option_not_supported");
assert_eq!(error.path, "/panes/0/layers/0/label");
assert!(error.message.contains("annotation"), "{}", error.message);
}
#[test]
fn time_charts_reject_the_bar_and_line_fields() {
for (field, path) in [
("\"data\": [{\"label\": \"Jan\", \"value\": 1}],", "/data"),
("\"categories\": [\"Jan\"],", "/categories"),
] {
let mixed = TIME.replace(
"\"schemaVersion\": 1,",
&format!("\"schemaVersion\": 1, {field}"),
);
let error = render_json(&mixed, RenderFormat::Svg, &RenderOptions::default())
.expect_err("a time chart is drawn from panes and layers");
assert_eq!(error.code, "option_not_supported", "{field}");
assert_eq!(error.path, path, "{field}");
}
}
#[test]
fn layer_names_are_required_and_unique_within_a_pane() {
let second = "{\"mark\": \"line\", \"name\": \"Orders\", \"points\": [{\"time\": \"2026-03-01\", \"value\": 1}, {\"time\": \"2026-03-02\", \"value\": 2}]}";
let duplicated = TIME.replace("\"layers\": [", &format!("\"layers\": [{second},"));
let error = render_json(&duplicated, RenderFormat::Svg, &RenderOptions::default())
.expect_err("two layers with one name cannot be told apart");
assert_eq!(error.code, "duplicate_series");
assert_eq!(error.path, "/panes/0/layers/1/name");
let anonymous = TIME.replace("\"name\": \"Orders\", ", "");
let error = render_json(&anonymous, RenderFormat::Svg, &RenderOptions::default()).unwrap();
assert!(error.warnings.is_empty());
}
#[test]
fn a_manifest_is_returned_only_on_request_and_hashes_the_parsed_specification() {
assert!(render_ok(SPEC).manifest.is_none());
let options = RenderOptions {
manifest: true,
variant: Variant::Desktop,
..RenderOptions::default()
};
let output = render_json(SPEC, RenderFormat::Html, &options).unwrap();
let manifest = output.manifest.expect("the manifest was asked for");
assert_eq!(manifest.chartlet, env!("CARGO_PKG_VERSION"));
assert_eq!(manifest.format, RenderFormat::Html);
assert_eq!(
manifest.output_hash,
format!(
"sha256:{}",
super::sha256::hex(&super::sha256(output.content.as_bytes()))
)
);
assert!(manifest.id_prefix.starts_with("chartlet-"));
let value: serde_json::Value = serde_json::from_str(SPEC).unwrap();
let compact = render_json(&value.to_string(), RenderFormat::Html, &options).unwrap();
assert_eq!(compact.manifest, Some(manifest.clone()));
let json = manifest.to_json();
let keys = [
"chartlet",
"schemaVersion",
"specHash",
"outputHash",
"format",
"variant",
"idPrefix",
"warnings",
];
let positions: Vec<usize> = keys
.iter()
.map(|key| json.find(&format!("\"{key}\"")).unwrap())
.collect();
assert!(positions.is_sorted(), "{json}");
assert!(json.contains("\"format\": \"html\""), "{json}");
}
fn render_ok(spec: &str) -> super::RenderOutput {
render_json(spec, RenderFormat::Svg, &RenderOptions::default())
.unwrap_or_else(|error| panic!("should render: {error}"))
}
fn render_err(spec: &str) -> (&'static str, String) {
let error = render_json(spec, RenderFormat::Svg, &RenderOptions::default())
.expect_err("should be refused");
(error.code, error.path)
}
fn projection(band: &str, extra: &str) -> String {
format!(
r#"{{
"schemaVersion": 1,
"type": "time",
"title": "Projection",
"showValues": false,
"panes": [{{
"layers": [
{{"mark": "line", "name": "Measured", "points": [
{{"time": "2000", "value": 0.4}},
{{"time": "2010", "value": 0.6}},
{{"time": "2020", "value": 1.0}}
]}},
{{"mark": "line", "name": "Scenario", "modeled": true{extra}, "points": [
{{"time": "2020", "value": 1.0{band}}},
{{"time": "2050", "value": 1.8{band}}}
]}}
]
}}]
}}"#
)
}
const BAND: &str = r#", "lower": 0.5, "upper": 2.5"#;
#[test]
fn a_modeled_band_is_hatched_dashed_and_named_modeled() {
let output = render_ok(&projection(BAND, ""));
let svg = &output.content;
assert!(output.warnings.is_empty(), "{:?}", output.warnings);
assert!(svg.contains("class=\"chartlet-band chartlet-band-series-2\""));
assert!(svg.contains("<pattern id=\""));
assert!(svg.contains("-hatch-1\" width=\"6\""));
assert!(svg.contains("fill=\"url(#chartlet-"));
assert!(svg.contains("class=\"chartlet-hatch chartlet-style-1\""));
assert!(svg.contains("chartlet-line chartlet-line-modeled chartlet-line-series-2"));
assert!(svg.contains(">Scenario (modeled)<"));
assert!(svg.contains("<title>2050 – Scenario: 1.8 (range 0.5 to 2.5)</title>"));
assert!(svg.contains(
"One line has a shaded band from its lower to its upper bound, hatched where modeled"
));
assert!(svg.contains("from 2000 to 2050"));
let html = render_json(
&projection(BAND, ""),
RenderFormat::Html,
&RenderOptions::default(),
)
.unwrap();
assert!(html.content.contains(
"<th scope=\"col\">Scenario</th><th scope=\"col\">Scenario (lower)</th><th scope=\"col\">Scenario (upper)</th>"
));
assert!(html.content.contains(
"<th scope=\"row\">2050</th><td>Missing</td><td>1.8</td><td>0.5</td><td>2.5</td>"
));
}
#[test]
fn a_band_widens_the_value_scale() {
let svg = render_ok(&projection(BAND, "")).content;
assert!(svg.contains(">2.5<"), "{svg}");
}
#[test]
fn a_band_needs_both_edges_on_every_point() {
let half = projection(r#", "lower": 0.5"#, "");
assert_eq!(
render_err(&half),
(
"incomplete_band",
"/panes/0/layers/1/points/0/upper".to_owned()
)
);
let mixed = projection(BAND, "").replacen(
r#""value": 1.8, "lower": 0.5, "upper": 2.5"#,
r#""value": 1.8"#,
1,
);
assert_eq!(
render_err(&mixed),
(
"incomplete_band",
"/panes/0/layers/1/points/1/lower".to_owned()
)
);
let inverted = projection(r#", "lower": 2.5, "upper": 0.5"#, "");
assert_eq!(
render_err(&inverted),
(
"invalid_band",
"/panes/0/layers/1/points/0/upper".to_owned()
)
);
}
#[test]
fn a_value_outside_its_band_is_reported() {
let output = render_ok(&projection(r#", "lower": 0.1, "upper": 0.2"#, ""));
assert_eq!(output.warnings.len(), 2);
assert_eq!(output.warnings[0].code, "value_outside_band");
assert_eq!(output.warnings[0].path, "/panes/0/layers/1/points/0/value");
}
#[test]
fn a_declared_color_paints_line_band_and_hatch() {
let svg = render_ok(&projection(BAND, r##", "color": "#7c3aed""##)).content;
assert!(svg.contains(".chartlet-band.chartlet-style-1{fill:#7c3aed;stroke:none}"));
assert!(svg.contains("class=\"chartlet-hatch-line chartlet-style-1\""));
}
#[test]
fn the_band_mark_points_to_the_line_band() {
let error = render_json(
&with_rules(
r#"{"mark": "band", "label": "Z", "bottom": 0, "top": 1,
"points": [{"time": "2026-03-01", "value": 1}]}"#,
),
RenderFormat::Svg,
&RenderOptions::default(),
)
.expect_err("a zone has no points");
assert_eq!(error.code, "option_not_supported");
assert_eq!(error.path, "/panes/0/layers/0/points");
assert!(
error.message.contains("lower and upper"),
"{}",
error.message
);
}
fn with_rules(rules: &str) -> String {
TIME.replace("\"layers\": [", &format!("\"layers\": [{rules},"))
}
#[test]
fn reference_lines_are_drawn_labelled_and_described() {
let spec = with_rules(
r#"{"mark": "annotation", "value": 30, "label": "Target"},
{"mark": "annotation", "time": "2026-03-02", "label": "Launch"}"#,
);
let output = render_ok(&spec);
let svg = &output.content;
assert!(output.warnings.is_empty(), "{:?}", output.warnings);
assert_eq!(svg.matches("class=\"chartlet-rule\"").count(), 2);
assert!(svg.contains(">Target</text>"));
assert!(svg.contains(">Launch</text>"));
assert!(svg.contains("<title>Target: 30</title>"));
assert!(svg.contains("<title>Launch: 2026-03-02</title>"));
assert!(svg.contains(">30<"));
assert!(svg.contains("Reference lines: Target at 30; Launch at 2026-03-02."));
assert!(!svg.contains("class=\"chartlet-legend\""));
let html = render_json(&spec, RenderFormat::Html, &RenderOptions::default()).unwrap();
assert!(
html.content
.contains("<th scope=\"col\">Time</th><th scope=\"col\">Orders</th></tr>")
);
}
#[test]
fn reference_lines_are_validated_by_name() {
for (rule, code, path) in [
(
r#"{"mark": "annotation", "value": 1}"#,
"missing_label",
"/panes/0/layers/0/label",
),
(
r#"{"mark": "annotation", "label": "Nowhere"}"#,
"missing_position",
"/panes/0/layers/0",
),
(
r#"{"mark": "annotation", "value": 1, "label": "L", "shape": "circle"}"#,
"option_not_supported",
"/panes/0/layers/0/shape",
),
(
r#"{"mark": "annotation", "value": 1, "label": "L", "name": "N"}"#,
"option_not_supported",
"/panes/0/layers/0/name",
),
(
r#"{"mark": "annotation", "time": "1600-01-01", "label": "L"}"#,
"invalid_time",
"/panes/0/layers/0/time",
),
] {
assert_eq!(
render_err(&with_rules(rule)),
(code, path.to_owned()),
"{rule}"
);
}
let only_rules = r#"{"schemaVersion": 1, "type": "time", "title": "T", "panes": [
{"layers": [{"mark": "annotation", "value": 1, "label": "L"}]}]}"#;
assert_eq!(
render_err(only_rules),
("empty_data", "/panes/0/layers".to_owned())
);
let seven = (0..7)
.map(|index| {
format!(r#"{{"mark": "annotation", "value": {index}, "label": "L{index}"}}"#)
})
.collect::<Vec<_>>()
.join(",");
assert_eq!(
render_err(&with_rules(&seven)),
("too_many_annotations", "/panes/0/layers".to_owned())
);
}
#[test]
fn zones_are_drawn_behind_the_data_labelled_and_described() {
let spec = with_rules(
r##"{"mark": "band", "label": "Quiet", "from": "2026-03-01", "to": "2026-03-02"},
{"mark": "band", "label": "Target", "bottom": 25, "top": 40, "color": "#16a34a"}"##,
);
let output = render_ok(&spec);
let svg = &output.content;
assert!(output.warnings.is_empty(), "{:?}", output.warnings);
assert_eq!(svg.matches("class=\"chartlet-zone").count(), 2);
assert!(svg.contains("class=\"chartlet-zone chartlet-style-1-swatch\""));
assert!(svg.contains(".chartlet-style-1-swatch{fill:#16a34a}"));
assert!(svg.rfind("chartlet-zone\"").unwrap() < svg.find("class=\"chartlet-line").unwrap());
assert!(svg.contains(">Quiet</text>"));
assert!(svg.contains(">Target</text>"));
assert!(svg.contains("<title>Quiet: 2026-03-01 to 2026-03-02</title>"));
assert!(svg.contains("<title>Target: values 25 to 40</title>"));
assert!(svg.contains(">40<"));
assert!(svg.contains("Zones: Quiet, 2026-03-01 to 2026-03-02; Target, values 25 to 40."));
assert!(!svg.contains("class=\"chartlet-legend\""));
let html = render_json(&spec, RenderFormat::Html, &RenderOptions::default()).unwrap();
assert!(
html.content
.contains("<th scope=\"col\">Time</th><th scope=\"col\">Orders</th></tr>")
);
}
#[test]
fn a_zone_with_one_open_end_reaches_the_plot_edge_and_says_so() {
let spec = with_rules(
r#"{"mark": "band", "label": "Later", "from": "2026-03-02", "bottom": 0, "top": 5}"#,
);
let svg = render_ok(&spec).content;
assert!(svg.contains("<title>Later: from 2026-03-02, values 0 to 5</title>"));
}
#[test]
fn zones_are_validated_by_name() {
for (zone, code, path) in [
(
r#"{"mark": "band", "bottom": 0, "top": 1}"#,
"missing_label",
"/panes/0/layers/0/label",
),
(
r#"{"mark": "band", "label": "Z", "top": 1, "from": "2026-03-01"}"#,
"missing_position",
"/panes/0/layers/0",
),
(
r#"{"mark": "band", "label": "Z", "bottom": 2, "top": 2}"#,
"invalid_band",
"/panes/0/layers/0/top",
),
(
r#"{"mark": "band", "label": "Z", "from": "2026-03-02", "to": "2026-03-01"}"#,
"invalid_band",
"/panes/0/layers/0/to",
),
(
r#"{"mark": "band", "label": "Z", "from": "1600-01-01", "to": "2026-03-01"}"#,
"invalid_time",
"/panes/0/layers/0/from",
),
(
r#"{"mark": "band", "label": "Z", "bottom": 0, "top": 1, "name": "N"}"#,
"option_not_supported",
"/panes/0/layers/0/name",
),
(
r#"{"mark": "band", "label": "Z", "bottom": 0, "top": 1, "value": 1}"#,
"option_not_supported",
"/panes/0/layers/0/value",
),
(
r#"{"mark": "band", "label": "Z", "bottom": 0, "top": 1, "shape": "circle"}"#,
"option_not_supported",
"/panes/0/layers/0/shape",
),
(
r#"{"mark": "band", "label": "Z", "bottom": 0, "top": 1, "dash": "dotted"}"#,
"option_not_supported",
"/panes/0/layers/0/dash",
),
] {
assert_eq!(
render_err(&with_rules(zone)),
(code, path.to_owned()),
"{zone}"
);
}
let seven = (0..7)
.map(|index| match index % 3 {
0 => format!(r#"{{"mark": "band", "bottom": {index}, "top": 50, "label": "Z{index}"}}"#),
1 => format!(r#"{{"mark": "annotation", "value": {index}, "label": "R{index}"}}"#),
_ => format!(
r#"{{"mark": "annotation", "time": "2026-03-02", "value": {index}, "label": "M{index}"}}"#
),
})
.collect::<Vec<_>>()
.join(",");
assert_eq!(
render_err(&with_rules(&seven)),
("too_many_annotations", "/panes/0/layers".to_owned())
);
}
#[test]
fn point_markers_are_drawn_in_their_shape_labelled_and_described() {
let spec = with_rules(
r##"{"mark": "annotation", "time": "2026-03-02", "value": 50, "label": "Launch", "shape": "triangle-up"},
{"mark": "annotation", "time": "2026-03-01", "value": 12, "label": "Start"},
{"mark": "annotation", "time": "2026-03-02", "value": 5, "label": "Dip", "shape": "diamond", "color": "#16a34a"}"##,
);
let output = render_ok(&spec);
let svg = &output.content;
assert!(output.warnings.is_empty(), "{:?}", output.warnings);
assert_eq!(svg.matches("class=\"chartlet-marker").count(), 3);
assert!(svg.contains("<circle cx="));
assert!(svg.contains("class=\"chartlet-marker\"><title>Start: 2026-03-01, 12</title>"));
assert!(svg.contains("<title>Launch: 2026-03-02, 50</title>"));
assert!(svg.contains(
"class=\"chartlet-marker chartlet-style-2\"><title>Dip: 2026-03-02, 5</title>"
));
assert!(svg.contains(
".chartlet-marker.chartlet-style-2{fill:#16a34a;stroke:var(--chartlet-background)}"
));
assert!(svg.contains(">Launch</text>"));
assert!(svg.contains(">50<"));
assert!(svg.contains(
"Markers: Launch at 2026-03-02, 50; Start at 2026-03-01, 12; Dip at 2026-03-02, 5."
));
assert!(!svg.contains("Reference lines"));
assert!(!svg.contains("class=\"chartlet-legend\""));
}
#[test]
fn a_point_marker_needs_a_label_and_only_it_takes_a_shape() {
assert_eq!(
render_err(&with_rules(
r#"{"mark": "annotation", "time": "2026-03-02", "value": 5}"#
)),
("missing_label", "/panes/0/layers/0/label".to_owned())
);
let error = render_json(
&with_rules(
r#"{"mark": "annotation", "time": "2026-03-02", "label": "L", "shape": "square"}"#,
),
RenderFormat::Svg,
&RenderOptions::default(),
)
.expect_err("a reference line has no shape");
assert_eq!(error.code, "option_not_supported");
assert_eq!(error.path, "/panes/0/layers/0/shape");
assert!(error.message.contains("point marker"), "{}", error.message);
}
#[test]
fn a_marker_label_moves_to_the_left_at_the_right_edge() {
let spec = with_rules(
r#"{"mark": "annotation", "time": "2026-03-03", "value": 20, "label": "Peak"}"#,
);
let output = render_ok(&spec);
assert!(output.warnings.is_empty(), "{:?}", output.warnings);
assert!(
output
.content
.contains("text-anchor=\"end\" class=\"chartlet-rule-label\">Peak</text>")
);
}
#[test]
fn overlapping_annotation_labels_are_reported_not_dropped() {
let spec = with_rules(
r#"{"mark": "annotation", "value": 12, "label": "First"},
{"mark": "annotation", "value": 12, "label": "Second"}"#,
);
let output = render_ok(&spec);
assert!(output.content.contains(">First</text>"));
assert!(output.content.contains(">Second</text>"));
assert_eq!(output.warnings.len(), 1, "{:?}", output.warnings);
assert_eq!(output.warnings[0].code, "label_overlap");
assert_eq!(output.warnings[0].path, "/panes/0/layers/1");
}
#[test]
fn a_reference_line_label_across_a_data_line_is_reported() {
let spec = TIME
.replace("\"value\": 10}", "\"value\": 10.4}")
.replace("\"value\": 15}", "\"value\": 10.4}")
.replace("\"value\": 20}", "\"value\": 0}")
.replace(
"\"layers\": [",
r#""layers": [{"mark": "annotation", "value": 10, "label": "Limit"},"#,
);
let output = render_ok(&spec);
assert_eq!(output.warnings.len(), 1, "{:?}", output.warnings);
assert_eq!(output.warnings[0].code, "label_overlap");
assert_eq!(output.warnings[0].path, "/panes/0/layers/0");
assert!(output.warnings[0].message.contains("data line"));
}
#[test]
fn a_label_outside_the_plot_is_reported() {
let spec = TIME
.replace("\"mark\": \"line\"", "\"mark\": \"area\"")
.replace(
"\"layers\": [",
r#""layers": [{"mark": "annotation", "value": 20, "label": "Ceiling"},"#,
);
let output = render_ok(&spec);
assert_eq!(output.warnings.len(), 1, "{:?}", output.warnings);
assert_eq!(output.warnings[0].code, "label_overlap");
assert_eq!(output.warnings[0].path, "/panes/0/layers/0");
assert!(output.warnings[0].message.contains("outside"));
}
#[test]
fn label_collisions_are_measured_with_the_injected_metrics() {
struct Wide;
impl TextMetrics for Wide {
fn width(&self, text: &str, font_size: f64) -> f64 {
BuiltinMetrics.width(text, font_size) * 4.0
}
}
let spec = ChartSpec::from_json(&with_rules(
r#"{"mark": "annotation", "value": 24, "label": "Ceiling"},
{"mark": "annotation", "time": "2026-03-01T06:00:00Z", "label": "Deploy"}"#,
))
.unwrap();
let narrow = render_ok(&serde_json::to_string(&spec).unwrap());
assert!(narrow.warnings.is_empty(), "{:?}", narrow.warnings);
let wide = render_with_metrics(&spec, RenderFormat::Svg, &RenderOptions::default(), &Wide)
.unwrap();
assert!(
wide.warnings
.iter()
.any(|warning| warning.code == "label_overlap"),
"{:?}",
wide.warnings
);
}
#[test]
fn zones_and_markers_are_described_in_german() {
let spec = with_rules(
r#"{"mark": "band", "label": "Ruhe", "from": "2026-03-01", "to": "2026-03-02"},
{"mark": "band", "label": "Ziel", "bottom": 2.5},
{"mark": "annotation", "time": "2026-03-02", "value": 12.5, "label": "Start"}"#,
)
.replace(
"\"schemaVersion\": 1,",
"\"schemaVersion\": 1, \"locale\": \"de\",",
);
assert_eq!(
render_err(&spec),
("missing_position", "/panes/0/layers/1".to_owned())
);
let spec = spec.replace(
r#""bottom": 2.5}"#,
r#""bottom": 2.5, "from": "2026-03-02", "to": "2026-03-03"}"#,
);
let svg = render_ok(&spec).content;
assert!(svg.contains(
"Bereiche: Ruhe, 2026-03-01 bis 2026-03-02; Ziel, 2026-03-02 bis 2026-03-03, Werte über 2,5."
), "{svg}");
assert!(svg.contains("Markierungen: Start bei 2026-03-02, 12,5."));
assert!(svg.contains("<title>Start: 2026-03-02, 12,5</title>"));
}
#[test]
fn a_zoom_window_cuts_zones_and_leaves_out_those_it_misses() {
let spec = with_rules(
r#"{"mark": "band", "label": "Early", "from": "2026-03-01", "to": "2026-03-02"},
{"mark": "band", "label": "Wide", "from": "2026-02-20", "to": "2026-03-10"}"#,
)
.replace(
"\"timeAxis\"",
r#""zoomSteps": [{"label": "All", "from": "2026-03-01", "to": "2026-03-03"},
{"label": "Late", "from": "2026-03-02", "to": "2026-03-03"}],
"timeAxis""#,
);
let html = render_json(&spec, RenderFormat::Html, &RenderOptions::default())
.unwrap()
.content;
assert_eq!(html.matches("class=\"chartlet-zone\"").count(), 3);
assert!(html.contains("<title>Wide: 2026-03-02 to 2026-03-03</title>"));
}
const STRIPES: &str = r#"{
"schemaVersion": 1,
"type": "stripes",
"title": "Stripes",
"stripes": {"firstYear": 1850, "values": [-1, -0.5, null, 0, 0.5, 2], "min": -1, "max": 1}
}"#;
#[test]
fn stripes_draw_one_colored_stripe_per_year() {
let output = render_ok(STRIPES);
let svg = &output.content;
assert!(output.warnings.is_empty(), "{:?}", output.warnings);
assert_eq!(svg.matches("class=\"chartlet-diverging-").count(), 5);
assert!(svg.contains("class=\"chartlet-diverging-0\"><title>1850: −1</title>"));
assert!(svg.contains("class=\"chartlet-diverging-4\"><title>1851: −0.5</title>"));
assert!(svg.contains("class=\"chartlet-diverging-8\"><title>1853: 0</title>"));
assert!(svg.contains("class=\"chartlet-diverging-16\"><title>1855: 2</title>"));
assert!(svg.contains(">1850</text>"));
assert!(svg.contains(">1855</text>"));
assert!(svg.contains("--chartlet-diverging-8:#eeeeee"));
assert!(svg.contains(
"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."
));
let html = render_json(STRIPES, RenderFormat::Html, &RenderOptions::default()).unwrap();
assert!(html.content.contains("<th scope=\"col\">Year</th>"));
assert!(
html.content
.contains("<th scope=\"row\">1852</th><td>Missing</td>")
);
}
#[test]
fn stripes_are_validated_by_name() {
for (from, to, code, path) in [
("\"min\": -1", "\"min\": 0", "invalid_scale", "/stripes/min"),
("\"max\": 1", "\"max\": -2", "invalid_scale", "/stripes/max"),
(
"\"firstYear\": 1850",
"\"firstYear\": 9998",
"invalid_year",
"/stripes/firstYear",
),
(
"[-1, -0.5, null, 0, 0.5, 2]",
"[null]",
"empty_series",
"/stripes/values",
),
(
"[-1, -0.5, null, 0, 0.5, 2]",
"[]",
"empty_data",
"/stripes/values",
),
(
"\"title\": \"Stripes\",",
"\"title\": \"Stripes\", \"panes\": [{}],",
"option_not_supported",
"/panes",
),
] {
assert_eq!(
render_err(&STRIPES.replace(from, to)),
(code, path.to_owned()),
"{to}"
);
}
let missing = STRIPES.replace(
r#""stripes": {"firstYear": 1850, "values": [-1, -0.5, null, 0, 0.5, 2], "min": -1, "max": 1}"#,
"\"width\": 800",
);
assert_eq!(
render_err(&missing),
("missing_stripes", "/stripes".to_owned())
);
}
#[test]
fn a_block_of_another_type_is_rejected() {
let foreign = SPEC.replace(
"\"title\": \"Profit & loss\",",
"\"title\": \"Profit & loss\", \"stripes\": {\"firstYear\": 2000, \"values\": [1]},",
);
assert_eq!(
render_err(&foreign),
("option_not_supported", "/stripes".to_owned())
);
let columns = TIME.replace("\"type\": \"time\",", "\"type\": \"time\", \"columns\": 2,");
assert_eq!(
render_err(&columns),
("option_not_supported", "/columns".to_owned())
);
}
fn calendar(layout: &str, days: &str) -> String {
format!(
r#"{{
"schemaVersion": 1,
"type": "calendar",
"title": "Calendar",
"calendar": {{"year": 2024, "layout": "{layout}", "days": [{days}]}}
}}"#
)
}
const DAYS: &str = r#"{"date": "2024-01-01", "value": -2}, {"date": "2024-02-29", "value": 4}, {"date": "2024-12-31", "value": null}"#;
#[test]
fn a_calendar_has_a_cell_for_every_day_and_leaves_gaps_empty() {
for layout in ["months", "weeks"] {
let output = render_ok(&calendar(layout, DAYS));
let svg = &output.content;
assert!(output.warnings.is_empty(), "{:?}", output.warnings);
assert_eq!(svg.matches("<rect").count(), 366 + 17, "{layout}");
assert_eq!(
svg.matches("class=\"chartlet-calendar-empty\"").count(),
364
);
assert!(svg.contains("class=\"chartlet-diverging-4\"><title>2024-01-01: −2</title>"));
assert!(svg.contains("class=\"chartlet-diverging-16\"><title>2024-02-29: 4</title>"));
assert!(svg.contains("<title>2024-12-31: no value</title>"));
assert!(svg.contains(
"2 of 366 days have a value. Lowest: −2 (2024-01-01). Highest: 4 (2024-02-29)."
));
}
let weeks = render_ok(&calendar("weeks", DAYS)).content;
assert!(weeks.contains(">Mon</text>"));
let months = render_ok(&calendar("months", DAYS)).content;
assert!(months.contains(">Feb</text>"));
let html = render_json(
&calendar("months", DAYS),
RenderFormat::Html,
&RenderOptions::default(),
)
.unwrap();
assert!(
html.content
.contains("<th scope=\"row\">2024-02-29</th><td>4</td>")
);
}
#[test]
fn calendar_days_are_validated_by_name() {
for (days, code, path) in [
(
r#"{"date": "2023-12-31", "value": 1}"#,
"date_outside_year",
"/calendar/days/0/date",
),
(
r#"{"date": "2024-02-30", "value": 1}"#,
"invalid_date",
"/calendar/days/0/date",
),
(
r#"{"date": "2024-03-01T12:00", "value": 1}"#,
"invalid_date",
"/calendar/days/0/date",
),
(
r#"{"date": "2024-03-01", "value": 1}, {"date": "2024-03-01", "value": 2}"#,
"duplicate_date",
"/calendar/days/1/date",
),
(
r#"{"date": "2024-03-01", "value": null}"#,
"empty_series",
"/calendar/days",
),
("", "empty_data", "/calendar/days"),
] {
assert_eq!(
render_err(&calendar("months", days)),
(code, path.to_owned()),
"{days}"
);
}
}
const RANGES: &str = r#"{
"schemaVersion": 1,
"type": "rangebar",
"orientation": "horizontal",
"title": "Contributions",
"ranges": [
{"label": "Observed", "low": 0.9, "high": 1.2, "mid": 1.05},
{"label": "Natural", "low": -0.1, "high": 0.1, "modeled": true}
]
}"#;
#[test]
fn range_bars_draw_spans_marks_and_hatching() {
for orientation in ["horizontal", "vertical"] {
let spec = RANGES.replace("\"horizontal\"", &format!("\"{orientation}\""));
let output = render_ok(&spec);
let svg = &output.content;
assert_eq!(
svg.matches("class=\"chartlet-range\"").count(),
3,
"{orientation}"
);
assert_eq!(svg.matches("class=\"chartlet-range-hatch\"").count(), 2);
assert_eq!(svg.matches("class=\"chartlet-range-mid\"").count(), 1);
assert!(svg.contains("-hatch-range\" width=\"6\""));
assert!(svg.contains("<title>Observed: 1.05 (0.9 to 1.2)</title>"));
assert!(svg.contains("<title>Natural: −0.1 to 0.1, modeled</title>"));
assert!(svg.contains(">Hatched: modeled<"));
assert!(svg.contains(".chartlet-legend{font-size:12px;fill:var(--chartlet-muted)}"));
assert!(svg.contains(
"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."
));
}
let html = render_json(RANGES, RenderFormat::Html, &RenderOptions::default()).unwrap();
assert!(html.content.contains(
"<th scope=\"col\">Low</th><th scope=\"col\">Mid</th><th scope=\"col\">High</th>"
));
assert!(html.content.contains(
"<th scope=\"row\">Natural (modeled)</th><td>−0.1</td><td>Missing</td><td>0.1</td>"
));
}
#[test]
fn ranges_are_validated_by_name() {
for (from, to, code, path) in [
(
"\"low\": 0.9, \"high\": 1.2",
"\"low\": 1.3, \"high\": 1.2",
"invalid_range",
"/ranges/0/high",
),
(
"\"mid\": 1.05",
"\"mid\": 2",
"mid_outside_range",
"/ranges/0/mid",
),
(
"\"label\": \"Natural\"",
"\"label\": \"Observed\"",
"duplicate_label",
"/ranges/1/label",
),
(
"\"orientation\": \"horizontal\",",
"\"orientation\": \"horizontal\", \"data\": [{\"label\": \"A\", \"value\": 1}],",
"option_not_supported",
"/data",
),
] {
assert_eq!(
render_err(&RANGES.replace(from, to)),
(code, path.to_owned()),
"{to}"
);
}
}
fn multiples(panes: &str) -> String {
format!(
r#"{{
"schemaVersion": 1,
"type": "multiples",
"title": "Sectors",
"valueAxis": {{"title": "Emissions"}},
"panes": [{panes}]
}}"#
)
}
fn panel(title: &str, scale: f64) -> String {
format!(
r#"{{"title": "{title}", "layers": [
{{"mark": "line", "name": "History", "points": [{{"time": "2000", "value": {}}}, {{"time": "2010", "value": {}}}]}},
{{"mark": "line", "name": "Pathway", "modeled": true, "points": [{{"time": "2010", "value": {}, "lower": 0, "upper": {}}}, {{"time": "2050", "value": 0, "lower": 0, "upper": {}}}]}},
{{"mark": "annotation", "value": 0, "label": "Net zero"}}
]}}"#,
10.0 * scale,
12.0 * scale,
12.0 * scale,
14.0 * scale,
2.0 * scale
)
}
#[test]
fn small_multiples_share_one_scale_and_one_legend() {
let spec = multiples(
&[
panel("Energy", 2.0),
panel("Industry", 1.0),
panel("Transport", 0.5),
]
.join(","),
);
let output = render_ok(&spec);
let svg = &output.content;
assert!(output.warnings.is_empty(), "{:?}", output.warnings);
assert!(svg.contains("class=\"chartlet-root chartlet-multiples\""));
for title in ["Energy", "Industry", "Transport"] {
assert!(svg.contains(&format!(">{title}</text>")));
}
assert_eq!(svg.matches(">30</text>").count(), 3);
assert_eq!(svg.matches("class=\"chartlet-legend\"").count(), 2);
assert!(svg.contains(">Pathway (modeled)<"));
assert_eq!(
svg.matches("chartlet-line chartlet-line-modeled chartlet-line-series-2")
.count(),
4
);
assert!(svg.contains("<title>2050 – Industry · Pathway: 0 (range 0 to 2)</title>"));
assert!(svg.contains(
"Small multiples of 3 panels (Energy, Industry, Transport) with a shared value axis, each from 2000 to 2050, showing History, Pathway (modeled)."
));
let html = render_json(&spec, RenderFormat::Html, &RenderOptions::default()).unwrap();
assert!(
html.content
.contains("<th scope=\"col\">Energy · History</th>")
);
assert!(
html.content
.contains("<th scope=\"col\">Transport · Pathway (upper)</th>")
);
}
#[test]
fn small_multiples_are_validated_by_name() {
let two = [panel("A", 1.0), panel("B", 1.0)].join(",");
assert_eq!(
render_err(&multiples(&panel("A", 1.0))),
("not_enough_panes", "/panes".to_owned())
);
assert_eq!(
render_err(&multiples(&two.replacen("\"title\": \"A\", ", "", 1))),
("missing_title", "/panes/0/title".to_owned())
);
assert_eq!(
render_err(&multiples(
&two.replace("\"title\": \"B\"", "\"title\": \"A\"")
)),
("duplicate_title", "/panes/1/title".to_owned())
);
assert_eq!(
render_err(&multiples(&two.replacen(
"\"title\": \"A\",",
"\"title\": \"A\", \"valueAxis\": {\"title\": \"Own\"},",
1
))),
("option_not_supported", "/panes/0/valueAxis".to_owned())
);
let columns = multiples(&two).replace(
"\"type\": \"multiples\",",
"\"type\": \"multiples\", \"columns\": 7,",
);
assert_eq!(
render_err(&columns),
("invalid_columns", "/columns".to_owned())
);
let thirteen = (0..13)
.map(|index| panel(&format!("P{index}"), 1.0))
.collect::<Vec<_>>()
.join(",");
assert_eq!(
render_err(&multiples(&thirteen)),
("too_many_panes", "/panes".to_owned())
);
let titled = TIME.replace(
"\"valueAxis\": {\"title\": \"Orders\"},",
"\"title\": \"Pane\",",
);
assert_eq!(
render_err(&titled),
("option_not_supported", "/panes/0/title".to_owned())
);
}
const ATLAS: &str = r#"{
"schemaVersion": 1,
"type": "atlas",
"title": "Knowledge landscape",
"atlas": {
"realms": [
{
"label": "Machines",
"regions": [
{
"label": "Models",
"value": 40,
"places": [{ "label": "A first look", "weight": 2 }]
},
{ "label": "Agents", "value": 12 }
]
},
{
"label": "Craft",
"regions": [
{ "label": "Tooling", "value": 30 },
{ "label": "Accessibility", "value": 8 }
]
}
],
"links": [{ "from": "Agents", "to": "Tooling", "weight": 0.4 }],
"seed": 7
}
}"#;
const TOPICMAP: &str = r#"{
"schemaVersion": 1,
"type": "topicmap",
"title": "Insights themes",
"topicmap": {
"topics": [
{"label": "AI in practice", "value": 291, "points": 41},
{"label": "Engineering", "value": 282, "points": 24},
{"label": "Cloud native", "value": 100, "points": 9},
{"label": "Web marketing", "value": 95, "points": 7},
{"label": "Management", "value": 69, "points": 5}
],
"links": [
{"from": "AI in practice", "to": "Engineering", "weight": 0.6}
],
"islands": [
{"label": "WASM", "value": 6, "points": 1}
],
"cartouche": {"heading": "Topic map", "meta": "843 entries · 87 paths · 2026"}
}
}"#;
#[test]
fn topicmap_renders_areas_labels_and_links() {
let output = render_json(TOPICMAP, RenderFormat::Svg, &RenderOptions::default()).unwrap();
assert_eq!(
output
.content
.matches("class=\"chartlet-topic-area")
.count(),
6
);
assert_eq!(
output
.content
.matches("class=\"chartlet-topic-link\"")
.count(),
1
);
assert!(output.content.contains(
"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."
));
assert_eq!(
output
.content
.matches("class=\"chartlet-topic-band\"")
.count(),
12
);
assert_eq!(
output
.content
.matches("class=\"chartlet-topic-halo")
.count(),
6
);
assert_eq!(
output
.content
.matches("class=\"chartlet-topic-point")
.count(),
41 + 24 + 9 + 7 + 5 + 1
);
assert!(!output.content.contains("class=\"chartlet-topic-area\"/>"));
let centers = output
.content
.split("class=\"chartlet-topic-area")
.skip(1)
.map(|rest| {
let attribute = |name: &str| -> f64 {
let start = rest.find(name).expect("center attribute") + name.len();
let end = start + rest[start..].find('"').expect("closing quote");
rest[start..end].parse().expect("a number")
};
(attribute("data-cx=\""), attribute("data-cy=\""))
})
.collect::<Vec<_>>();
assert_eq!(centers.len(), 6);
for (cx, cy) in centers {
assert!((0.0..=800.0).contains(&cx) && (0.0..=450.0).contains(&cy));
}
for label in [
"AI in practice",
"Engineering",
"Cloud native",
"Web marketing",
"Management",
"WASM",
] {
assert!(output.content.contains(&format!(">{label}<")), "{label}");
}
assert_eq!(texts(&output.content, "chartlet-topic-outside").len(), 6);
assert_eq!(
output
.content
.matches("class=\"chartlet-topic-leader\"")
.count(),
6
);
assert_eq!(
output
.content
.matches("class=\"chartlet-topic-label")
.count(),
0
);
assert_eq!(
output
.warnings
.iter()
.map(|warning| (warning.code, warning.path.as_str()))
.collect::<Vec<_>>(),
vec![
("label_outside_area", "/topicmap/topics/0/label"),
("label_outside_area", "/topicmap/topics/1/label"),
("label_outside_area", "/topicmap/topics/2/label"),
("label_outside_area", "/topicmap/topics/3/label"),
("label_outside_area", "/topicmap/topics/4/label"),
("label_outside_area", "/topicmap/islands/0/label"),
]
);
}
#[test]
fn topicmap_draws_its_map_furniture_in_order() {
let output = render_json(TOPICMAP, RenderFormat::Svg, &RenderOptions::default()).unwrap();
let at = |needle: &str| {
output
.content
.find(needle)
.unwrap_or_else(|| panic!("{needle} is missing"))
};
assert!(at("class=\"chartlet-sea\"") < at("class=\"chartlet-topic-band\""));
assert!(at("class=\"chartlet-graticule\"") < at("class=\"chartlet-topic-area"));
assert!(at("class=\"chartlet-topic-area") < at("class=\"chartlet-compass-ring\""));
assert!(at("class=\"chartlet-compass-ring\"") < at("class=\"chartlet-cartouche\""));
assert_eq!(
output
.content
.matches("class=\"chartlet-graticule\"")
.count(),
6
);
assert!(output.content.contains(">Topic map</text>"));
assert!(
output
.content
.contains(">843 entries · 87 paths · 2026</text>")
);
assert!(output.content.contains(">N</text>"));
}
#[test]
fn topicmap_leaves_out_the_furniture_it_was_not_asked_for() {
let bare = r#"{
"schemaVersion": 1,
"type": "topicmap",
"title": "Plain",
"topicmap": {
"graticule": false,
"compass": false,
"topics": [
{"label": "One", "value": 40},
{"label": "Two", "value": 20}
]
}
}"#;
let output = render_json(bare, RenderFormat::Svg, &RenderOptions::default()).unwrap();
assert!(!output.content.contains("class=\"chartlet-graticule\""));
assert!(!output.content.contains("class=\"chartlet-compass-ring\""));
assert!(!output.content.contains("class=\"chartlet-cartouche\""));
assert!(output.content.contains("class=\"chartlet-sea\""));
}
#[test]
fn topicmap_html_offers_a_radio_group_for_the_areas() {
let output = render_json(TOPICMAP, RenderFormat::Html, &RenderOptions::default()).unwrap();
assert!(
output
.content
.contains("<fieldset class=\"chartlet-topic-picker\"><legend>Area</legend>")
);
assert_eq!(output.content.matches("type=\"radio\"").count(), 5);
assert_eq!(
output.content.matches("class=\"topic-0\" checked").count(),
1
);
assert!(
output
.content
.contains("class=\"topic-4\"> Management</label>")
);
assert!(!output.content.contains("WASM</label>"));
assert!(output.content.contains("name=\"topic-chartlet-"));
}
#[test]
fn topicmap_selection_styles_only_apply_once_something_is_selected() {
let output = render_json(TOPICMAP, RenderFormat::Html, &RenderOptions::default()).unwrap();
for rule in output
.content
.split('}')
.filter(|rule| rule.contains(".chartlet-topic-panel"))
{
assert!(
rule.contains(":has("),
"a panel rule applies unconditionally: {rule}"
);
}
let root = output.content.split(" id=\"").nth(1).unwrap();
let root = &root[..root.find('"').unwrap()];
assert!(output.content.contains(&format!(
".chartlet-wrapper:has(.chartlet-topic-picker input.topic-2:checked) #{root} .chartlet-topic-area.chartlet-topic-2{{fill:var(--chartlet-accent)}}"
)));
let svg = render_json(TOPICMAP, RenderFormat::Svg, &RenderOptions::default()).unwrap();
assert!(!svg.content.contains("chartlet-topic-picker"));
}
#[test]
fn topicmap_table_carries_entries_paths_and_share() {
let output = render_json(TOPICMAP, RenderFormat::Html, &RenderOptions::default()).unwrap();
assert!(output.content.contains(
"<th scope=\"col\">Topic</th><th scope=\"col\">Entries</th><th scope=\"col\">Paths</th><th scope=\"col\">Share</th>"
));
assert!(output.content.contains(
"<th scope=\"row\">AI in practice</th><td>291</td><td>41</td><td>34.52%</td>"
));
assert!(
output
.content
.contains("<th scope=\"row\">WASM</th><td>6</td><td>1</td><td>0.71%</td>")
);
}
#[test]
fn topicmap_layout_is_deterministic() {
let first = render_json(TOPICMAP, RenderFormat::Svg, &RenderOptions::default()).unwrap();
let second = render_json(TOPICMAP, RenderFormat::Svg, &RenderOptions::default()).unwrap();
assert_eq!(first.content, second.content);
}
fn texts(svg: &str, class: &str) -> Vec<(f64, f64, String, String)> {
let mut found = Vec::new();
for chunk in svg.split("<text ").skip(1) {
let attributes = chunk.split('>').next().expect("a tag ends");
let classes = attributes
.split("class=\"")
.nth(1)
.and_then(|rest| rest.split('"').next())
.unwrap_or_default();
if !classes.split_whitespace().any(|name| name == class) {
continue;
}
let read = |name: &str| {
attributes
.split(&format!("{name}=\""))
.nth(1)
.and_then(|rest| rest.split('"').next())
.map(str::to_owned)
};
let content = chunk
.split_once('>')
.and_then(|(_, rest)| rest.split_once("</text>"))
.map(|(content, _)| content.to_owned())
.expect("a text element has content");
found.push((
read("x").expect("x").parse().expect("a number"),
read("font-size")
.expect("font-size")
.parse()
.expect("a number"),
read("text-anchor").expect("anchor"),
content,
));
}
found
}
#[test]
fn topicmap_labels_moved_outside_stay_on_the_canvas_and_clear_of_each_other() {
let topics = (0..24)
.map(|index| {
format!(
r#"{{"label": "Topic number {index}", "value": {}}}"#,
20 + (index * 37) % 260
)
})
.collect::<Vec<_>>()
.join(",");
let specification = format!(
r#"{{"schemaVersion": 1, "type": "topicmap", "title": "Crowded",
"topicmap": {{"topics": [{topics}]}}}}"#
);
let output =
render_json(&specification, RenderFormat::Svg, &RenderOptions::default()).unwrap();
let metrics = BuiltinMetrics;
let mut placed: Vec<(f64, f64, f64, f64)> = Vec::new();
for (x, size, anchor, content) in texts(&output.content, "chartlet-topic-outside") {
let width = metrics.width(&content, size);
let (left, right) = if anchor == "start" {
(x, x + width)
} else {
(x - width, x)
};
assert!(left >= 0.0, "{content} starts off the canvas at {left}");
assert!(right <= 800.0, "{content} ends off the canvas at {right}");
placed.push((left, right, x, size));
}
assert!(placed.len() > 12, "most labels should sit outside");
let reported = output
.warnings
.iter()
.filter(|warning| warning.code == "dense_chart")
.count();
assert_eq!(placed.len() + reported, 24);
}
#[test]
fn topicmap_caps_links_per_topic_and_drops_the_weakest() {
let specification = r#"{
"schemaVersion": 1,
"type": "topicmap",
"title": "Busy hub",
"topicmap": {
"topics": [
{"label": "Hub", "value": 200},
{"label": "A", "value": 50},
{"label": "B", "value": 50},
{"label": "C", "value": 50},
{"label": "D", "value": 50},
{"label": "E", "value": 50}
],
"links": [
{"from": "Hub", "to": "A", "weight": 0.9},
{"from": "Hub", "to": "B", "weight": 0.8},
{"from": "Hub", "to": "C", "weight": 0.7},
{"from": "Hub", "to": "D", "weight": 0.6},
{"from": "Hub", "to": "E", "weight": 0.5}
]
}
}"#;
let output =
render_json(specification, RenderFormat::Svg, &RenderOptions::default()).unwrap();
assert_eq!(
output
.content
.matches("class=\"chartlet-topic-link\"")
.count(),
3
);
assert_eq!(
output
.warnings
.iter()
.map(|warning| (warning.code, warning.path.as_str()))
.collect::<Vec<_>>(),
vec![
("link_dropped", "/topicmap/links/3"),
("link_dropped", "/topicmap/links/4"),
]
);
}
#[test]
fn atlas_renders_and_describes_its_landscape() {
let svg = render_json(ATLAS, RenderFormat::Svg, &RenderOptions::default()).unwrap();
assert!(
svg.content
.contains("Knowledge landscape of 2 realms and 4 regions")
);
assert!(svg.content.contains("largest is Models with 40"));
assert!(svg.content.contains("1 place is marked"));
}
#[test]
fn atlas_draws_its_terrain_unless_it_is_turned_off() {
let drawn = render_json(ATLAS, RenderFormat::Svg, &RenderOptions::default()).unwrap();
assert!(drawn.content.contains("class=\"chartlet-atlas-contour\""));
let flat = ATLAS.replace(r#""seed": 7"#, r#""seed": 7, "contours": false"#);
let plain = render_json(&flat, RenderFormat::Svg, &RenderOptions::default()).unwrap();
assert!(!plain.content.contains("class=\"chartlet-atlas-contour\""));
assert!(plain.content.contains("class=\"chartlet-atlas-coast\""));
}
#[test]
fn atlas_requires_an_atlas_block() {
let missing = r#"{"schemaVersion": 1, "type": "atlas", "title": "Empty"}"#;
let error = render_json(missing, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
assert_eq!(error.code, "missing_atlas");
assert_eq!(error.path, "/atlas");
}
#[test]
fn atlas_rejects_a_realm_without_regions() {
let empty = ATLAS.replace(r#"{ "label": "Tooling", "value": 30 },"#, "");
let empty = empty.replace(r#"{ "label": "Accessibility", "value": 8 }"#, "");
let error = render_json(&empty, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
assert_eq!(error.code, "empty_realm");
assert_eq!(error.path, "/atlas/realms/1/regions");
}
#[test]
fn atlas_rejects_a_region_that_repeats_a_realm_name() {
let clash = ATLAS.replace(r#""label": "Tooling""#, r#""label": "Machines""#);
let error = render_json(&clash, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
assert_eq!(error.code, "duplicate_atlas_label");
assert_eq!(error.path, "/atlas/realms/1/regions/0/label");
}
#[test]
fn atlas_rejects_links_to_unknown_regions() {
let broken = ATLAS.replace(r#""to": "Tooling""#, r#""to": "Machines""#);
let error = render_json(&broken, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
assert_eq!(error.code, "unknown_region_link");
assert_eq!(error.path, "/atlas/links/0/to");
}
#[test]
fn atlas_rejects_area_damping_outside_its_range() {
let broken = ATLAS.replace(r#""seed": 7"#, r#""seed": 7, "areaDamping": 1.5"#);
let error = render_json(&broken, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
assert_eq!(error.code, "invalid_area_damping");
assert_eq!(error.path, "/atlas/areaDamping");
}
#[test]
fn atlas_warns_when_a_region_lists_more_places_than_it_claims() {
let crowded = r#"{
"schemaVersion": 1,
"type": "atlas",
"title": "Miscounted",
"atlas": {
"realms": [
{
"label": "Machines",
"regions": [
{
"label": "Models",
"value": 1,
"places": [{ "label": "One" }, { "label": "Two" }]
}
]
}
]
}
}"#;
let rendered = render_json(crowded, RenderFormat::Svg, &RenderOptions::default()).unwrap();
assert!(
rendered
.warnings
.iter()
.any(|warning| warning.code == "more_places_than_value"),
"expected the miscount warning, got {:?}",
rendered.warnings
);
}
#[test]
fn atlas_rejects_a_place_weight_outside_its_range() {
let broken = ATLAS.replace(r#""weight": 2 }]"#, r#""weight": 9 }]"#);
let error = render_json(&broken, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
assert_eq!(error.code, "place_weight_out_of_range");
assert_eq!(error.path, "/atlas/realms/0/regions/0/places/0/weight");
}
#[test]
fn atlas_rejects_series_alongside_its_realms() {
let mixed = ATLAS.replace(
r#""title": "Knowledge landscape","#,
r#""title": "Knowledge landscape", "data": [{"label": "A", "value": 1}],"#,
);
let error = render_json(&mixed, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
assert_eq!(error.code, "option_not_supported");
assert_eq!(error.path, "/data");
assert!(
error
.message
.contains("an atlas chart is drawn from atlas.realms")
);
}
#[test]
fn topicmap_requires_a_topicmap_block() {
let missing = r#"{"schemaVersion": 1, "type": "topicmap", "title": "Empty"}"#;
let error = render_json(missing, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
assert_eq!(error.code, "missing_topicmap");
assert_eq!(error.path, "/topicmap");
}
#[test]
fn topicmap_rejects_too_many_topics() {
let topics = (0..41)
.map(|i| format!(r#"{{"label": "T{i}", "value": 10}}"#))
.collect::<Vec<_>>()
.join(",");
let spec = format!(
r#"{{"schemaVersion": 1, "type": "topicmap", "title": "Big", "topicmap": {{"topics": [{topics}]}}}}"#
);
let error = render_json(&spec, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
assert_eq!(error.code, "too_many_topics");
assert_eq!(error.path, "/topicmap/topics");
}
#[test]
fn topicmap_rejects_duplicate_labels_across_topics_and_islands() {
let duplicate = TOPICMAP.replace(r#""label": "WASM""#, r#""label": "Management""#);
let error =
render_json(&duplicate, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
assert_eq!(error.code, "duplicate_topic_label");
assert_eq!(error.path, "/topicmap/islands/0/label");
}
#[test]
fn topicmap_rejects_links_to_unknown_labels() {
let broken = TOPICMAP.replace(r#""to": "Engineering""#, r#""to": "Nonexistent""#);
let error = render_json(&broken, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
assert_eq!(error.code, "unknown_topic_link");
assert_eq!(error.path, "/topicmap/links/0/to");
}
#[test]
fn topicmap_rejects_link_weight_outside_zero_to_one() {
let broken = TOPICMAP.replace(r#""weight": 0.6"#, r#""weight": 1.5"#);
let error = render_json(&broken, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
assert_eq!(error.code, "invalid_link_weight");
assert_eq!(error.path, "/topicmap/links/0/weight");
}
#[test]
fn topicmap_rejects_a_value_below_one() {
let broken = TOPICMAP.replace(r#""value": 69"#, r#""value": 0"#);
let error = render_json(&broken, RenderFormat::Svg, &RenderOptions::default()).unwrap_err();
assert_eq!(error.code, "topic_value_out_of_range");
assert_eq!(error.path, "/topicmap/topics/4/value");
}
#[test]
fn topicmap_warns_when_a_topic_is_too_small_for_its_label() {
let small = TOPICMAP.replace(r#""value": 69, "points": 5"#, r#""value": 5, "points": 5"#);
let output = render_json(&small, RenderFormat::Svg, &RenderOptions::default()).unwrap();
assert!(
output
.warnings
.iter()
.any(|warning| warning.code == "topic_too_small_for_label"
&& warning.path == "/topicmap/topics/4/value"),
"{:?}",
output.warnings
);
}
#[test]
fn topicmap_rejects_bar_line_and_time_only_fields() {
for (field, path) in [
(r#""data": [{"label": "A", "value": 1}],"#, "/data"),
(r#""categories": ["A"],"#, "/categories"),
(r#""series": [{"name": "S", "values": [1]}],"#, "/series"),
(
r#""zoomSteps": [{"label": "A", "from": 0, "to": 0}, {"label": "B", "from": 0, "to": 1}],"#,
"/zoomSteps",
),
(r#""panes": [{}],"#, "/panes"),
] {
let mixed = TOPICMAP.replace(
"\"schemaVersion\": 1,",
&format!("\"schemaVersion\": 1, {field}"),
);
let error = render_json(&mixed, RenderFormat::Svg, &RenderOptions::default())
.expect_err("a topicmap chart is drawn from topicmap.topics");
assert_eq!(error.code, "option_not_supported", "{field}");
assert_eq!(error.path, path, "{field}");
}
}
#[test]
fn topicmap_html_lists_every_area_with_entries_and_paths() {
let output = render_json(TOPICMAP, RenderFormat::Html, &RenderOptions::default()).unwrap();
assert!(output.content.contains(
"<th scope=\"col\">Topic</th><th scope=\"col\">Entries</th><th scope=\"col\">Paths</th>"
));
assert!(
output
.content
.contains("<th scope=\"row\">WASM</th><td>6</td><td>1</td>")
);
}
fn week(layers: &str, extra: &str) -> String {
format!(
r#"{{
"schemaVersion": 1,
"type": "time",
"title": "Sensors",
"showValues": false{extra},
"panes": [{{"layers": [{layers}]}}]
}}"#
)
}
fn sensor(name: &str, fields: &str, values: [&str; 5]) -> String {
let points = values
.iter()
.enumerate()
.map(|(day, value)| format!(r#"{{"time": "2026-03-0{}", "value": {value}}}"#, day + 1))
.collect::<Vec<_>>()
.join(", ");
format!(r#"{{"mark": "line", "name": "{name}"{fields}, "points": [{points}]}}"#)
}
const GAPPED: [&str; 5] = ["10", "12", "null", "14", "15"];
#[test]
fn a_null_value_breaks_the_line_and_counts_as_missing() {
let spec = week(&sensor("Load", "", GAPPED), "");
let output = render_ok(&spec);
let svg = &output.content;
assert!(output.warnings.is_empty(), "{:?}", output.warnings);
assert_eq!(svg.matches("<polyline").count(), 2);
assert_eq!(svg.matches("class=\"chartlet-point\"").count(), 4);
assert!(svg.contains("1 value is missing."), "{svg}");
let html = html_ok(&spec);
assert!(html.contains("<th scope=\"row\">2026-03-03</th><td>Missing</td>"));
let german = html_ok(&german(&spec, "time"));
assert!(german.contains("1 Wert fehlt."));
assert!(german.contains("<th scope=\"row\">2026-03-03</th><td>fehlt</td>"));
let lone = week(&sensor("Load", "", ["10", "null", "12", "null", "15"]), "");
let svg = render_ok(&lone).content;
assert!(!svg.contains("<polyline"), "{svg}");
assert_eq!(svg.matches("class=\"chartlet-point\"").count(), 3);
assert!(svg.contains("2 values are missing."));
let gapped = panel("B", 1.0).replacen(
r#"{"time": "2010", "value": 12}"#,
r#"{"time": "2005", "value": null}, {"time": "2010", "value": 12}"#,
1,
);
let spec = multiples(&[panel("A", 1.0), gapped].join(","));
assert!(render_ok(&spec).content.contains("1 value is missing."));
}
#[test]
fn a_band_breaks_at_a_missing_value_and_a_missing_value_has_no_band() {
let banded = |gap: &str| {
let points = [
r#"{"time": "2026-03-01", "value": 1, "lower": 0, "upper": 2}"#,
r#"{"time": "2026-03-02", "value": 2, "lower": 1, "upper": 3}"#,
gap,
r#"{"time": "2026-03-04", "value": 2, "lower": 1, "upper": 3}"#,
r#"{"time": "2026-03-05", "value": 3, "lower": 2, "upper": 4}"#,
]
.join(", ");
week(
&format!(r#"{{"mark": "line", "name": "Load", "points": [{points}]}}"#),
"",
)
};
let svg = render_ok(&banded(r#"{"time": "2026-03-03", "value": null}"#)).content;
assert_eq!(
svg.matches("class=\"chartlet-band chartlet-band-series-1\"")
.count(),
2
);
assert_eq!(
render_err(&banded(
r#"{"time": "2026-03-03", "value": null, "lower": 1, "upper": 3}"#
)),
(
"invalid_band",
"/panes/0/layers/0/points/2/lower".to_owned()
)
);
}
#[test]
fn a_line_takes_its_declared_pattern_and_weight_in_plot_and_legend() {
let spec = week(
&[
sensor("Dotted", r#", "dash": "dotted""#, GAPPED),
sensor("Bold", r#", "stroke": "bold""#, GAPPED),
sensor("Dashed", r#", "dash": "dashed""#, GAPPED),
sensor(
"Solid model",
r#", "modeled": true, "dash": "solid""#,
GAPPED,
),
]
.join(", "),
"",
);
let svg = render_ok(&spec).content;
assert!(svg.contains(".chartlet-line-dotted{stroke-dasharray:0 7}"));
assert!(svg.contains(".chartlet-line-bold{stroke-width:4.5}"));
for class in [
"chartlet-line chartlet-line-dotted chartlet-line-series-1",
"chartlet-line chartlet-line-bold chartlet-line-series-2",
"chartlet-line chartlet-line-dashed chartlet-line-series-3",
"chartlet-line chartlet-line-series-4",
] {
assert_eq!(
svg.matches(&format!("class=\"{class}\"")).count(),
3,
"{class}"
);
}
assert!(svg.contains(">Solid model (modeled)<"));
assert!(!svg.contains("class=\"chartlet-line chartlet-line-modeled"));
let plain = render_ok(&week(&sensor("Load", "", GAPPED), "")).content;
assert!(!plain.contains("chartlet-line-dotted"));
let rule = week(
&format!(
r#"{}, {{"mark": "annotation", "value": 1, "label": "Limit", "dash": "dotted"}}"#,
sensor("Load", "", GAPPED)
),
"",
);
assert_eq!(
render_err(&rule),
("option_not_supported", "/panes/0/layers/1/dash".to_owned())
);
}
#[test]
fn an_area_is_filled_down_to_zero_and_described() {
let area = sensor("Load", "", ["10", "12", "null", "14", "15"])
.replace("\"mark\": \"line\"", "\"mark\": \"area\"");
let spec = week(
&[
area.clone(),
sensor("Base", "", ["8", "9", "9", "10", "11"]),
]
.join(", "),
"",
);
let output = render_ok(&spec);
let svg = &output.content;
assert!(output.warnings.is_empty(), "{:?}", output.warnings);
assert!(svg.contains(".chartlet-area{stroke:none;fill-opacity:.18}"));
assert_eq!(
svg.matches("<polyline points=\"").count(),
7
);
assert_eq!(
svg.matches("class=\"chartlet-area chartlet-band-series-1\"")
.count(),
3
);
assert!(svg.contains("class=\"chartlet-tick\">0</text>"), "{svg}");
assert!(svg.contains("The area below Load is filled down to zero."));
assert!(
html_ok(&german(&spec, "time")).contains("Die Fläche unter Load ist bis null gefüllt.")
);
let colored = week(
&area.replace(
"\"name\": \"Load\"",
"\"name\": \"Load\", \"color\": \"#0f766e\"",
),
"",
);
let svg = render_ok(&colored).content;
assert!(svg.contains(".chartlet-area.chartlet-style-0{fill:#0f766e;stroke:none}"));
assert!(svg.contains("The area below Load is filled down to zero."));
}
#[test]
fn a_time_chart_zooms_into_windows_of_time() {
let zoom = r#", "zoomSteps": [
{"label": "All", "from": "2026-03-01", "to": "2026-03-05"},
{"label": "End", "from": "2026-03-04", "to": 1772668800}
]"#;
let spec = week(&sensor("Load", "", GAPPED), zoom);
let html = html_ok(&spec);
assert!(html.contains("<fieldset class=\"chartlet-zoom\">"));
assert_eq!(html.matches("<svg").count(), 2);
let end = html
.split("chartlet-panel chartlet-panel-1")
.nth(1)
.and_then(|panel| panel.split("</div>").next())
.expect("the second window has a panel");
assert!(end.contains("Time chart with 2 points from 2026-03-04 to 2026-03-05."));
assert!(!end.contains("2026-03-01"));
assert!(html.contains("<th scope=\"row\">2026-03-01</th>"));
assert_eq!(render_ok(&spec).content.matches("<svg").count(), 1);
for (steps, code, path) in [
(
r#"[{"label": "A", "from": "2026-03-04", "to": "2026-03-04"}, {"label": "B", "from": "2026-03-01", "to": "2026-03-05"}]"#,
"invalid_zoom_step",
"/zoomSteps/0/from",
),
(
r#"[{"label": "A", "from": "2026-03-01", "to": "2026-03-05"}, {"label": "B", "from": "2026-03-02", "to": "2026-03-03"}]"#,
"zoom_out_of_range",
"/zoomSteps/1",
),
(
r#"[{"label": "A", "from": "March", "to": "2026-03-05"}, {"label": "B", "from": "2026-03-01", "to": "2026-03-05"}]"#,
"invalid_time",
"/zoomSteps/0/from",
),
(
r#"[{"label": "A", "from": "2026-03-01", "to": "2026-03-05"}]"#,
"not_enough_zoom_steps",
"/zoomSteps",
),
] {
let spec = week(
&sensor("Load", "", GAPPED),
&format!(r#", "zoomSteps": {steps}"#),
);
assert_eq!(render_err(&spec), (code, path.to_owned()), "{steps}");
}
let dated = SPEC.replace(
"\"schemaVersion\": 1,",
r#""schemaVersion": 1, "zoomSteps": [{"label": "A", "from": "2026-03-01", "to": 1}, {"label": "B", "from": 0, "to": 1}],"#,
);
assert_eq!(
render_err(&dated),
("invalid_spec", "/zoomSteps/0/from".to_owned())
);
let panels = [panel("A", 1.0), panel("B", 1.0)].join(",");
let zoomed = multiples(&panels).replace(
"\"schemaVersion\": 1,",
r#""schemaVersion": 1, "zoomSteps": [{"label": "A", "from": "2000", "to": "2010"}, {"label": "B", "from": "2000", "to": "2050"}],"#,
);
assert_eq!(
render_err(&zoomed),
("option_not_supported", "/zoomSteps".to_owned())
);
}
#[test]
fn a_pane_takes_six_layers_when_two_bring_their_own_color() {
let colored = r##", "color": "#0f766e""##;
let layers = |names: &[(&str, &str)]| {
names
.iter()
.map(|(name, fields)| sensor(name, fields, GAPPED))
.collect::<Vec<_>>()
.join(", ")
};
let six = [
("Own", colored),
("A", ""),
("B", ""),
("C", ""),
("Also own", colored),
("D", ""),
];
let output = render_ok(&week(&layers(&six), ""));
let svg = &output.content;
assert!(output.warnings.is_empty(), "{:?}", output.warnings);
assert!(svg.contains("class=\"chartlet-line chartlet-line-series-1\""));
for series in 2..=4 {
assert!(svg.contains(&format!("chartlet-line-series-{series}")));
}
assert_eq!(svg.matches("class=\"chartlet-legend\"").count(), 6);
let seven = [six.as_slice(), &[("E", colored)]].concat();
assert_eq!(
render_err(&week(&layers(&seven), "")),
("too_many_layers", "/panes/0/layers".to_owned())
);
let five_in_palette = [("A", ""), ("B", ""), ("C", ""), ("D", ""), ("E", "")];
assert_eq!(
render_err(&week(&layers(&five_in_palette), "")),
("too_many_layers", "/panes/0/layers/4/color".to_owned())
);
}
fn placed_texts(svg: &str, class: &str) -> Vec<(f64, f64, String)> {
svg.split("<text ")
.skip(1)
.filter(|chunk| chunk.contains(&format!("class=\"{class}\"")))
.map(|chunk| {
let read = |name: &str| -> f64 {
chunk
.split(&format!(" {name}=\""))
.nth(1)
.or_else(|| chunk.strip_prefix(&format!("{name}=\"")))
.and_then(|rest| rest.split('"').next())
.expect("a coordinate")
.parse()
.expect("a number")
};
let content = chunk
.split_once('>')
.and_then(|(_, rest)| rest.split_once("</text>"))
.map(|(content, _)| content.to_owned())
.expect("a text element has content");
(read("x"), read("y"), content)
})
.collect()
}
#[test]
fn a_long_legend_wraps_into_rows_without_overlap() {
let names = [
"Server hall north",
"Server hall south",
"Cooling plant",
"Office floors",
"Lighting",
"Charging",
];
let layers = names
.iter()
.enumerate()
.map(|(index, name)| {
let fields = if index < 4 {
String::new()
} else {
format!(r##", "color": "#12345{index}""##)
};
sensor(name, &fields, GAPPED)
})
.collect::<Vec<_>>()
.join(", ");
let svg = render_ok(&week(&layers, r#", "width": 480"#)).content;
let legend = placed_texts(&svg, "chartlet-legend");
let written: Vec<&str> = legend
.iter()
.map(|(_, _, content)| content.as_str())
.collect();
assert_eq!(written, names, "every name is written in full");
let rows: std::collections::BTreeSet<u64> =
legend.iter().map(|(_, y, _)| y.to_bits()).collect();
assert!(rows.len() > 1, "{legend:?}");
let width = |content: &str| BuiltinMetrics.width(content, 12.0);
for (x, y, content) in &legend {
assert!(x + width(content) <= 480.0, "{content} leaves the chart");
for (other_x, other_y, other) in &legend {
if other_y.to_bits() == y.to_bits() && other_x > x {
assert!(
x + width(content) < *other_x - 24.0,
"{content} runs into {other}"
);
}
}
}
let lowest = legend.iter().map(|(_, y, _)| *y).fold(0.0, f64::max);
let ticks = placed_texts(&svg, "chartlet-tick");
assert!(ticks.iter().all(|(_, y, _)| *y > lowest), "{ticks:?}");
}
const CANDLES: &str = r#"[
{"time": "2026-03-02", "open": 10, "high": 12, "low": 9, "close": 11},
{"time": "2026-03-03", "open": 11, "high": 11.5, "low": 8, "close": 9},
{"time": "2026-03-04", "open": 9, "high": 10, "low": 8.5, "close": 9}
]"#;
fn stacked(panes: &str, extra: &str) -> String {
format!(
r#"{{
"schemaVersion": 1,
"type": "time",
"title": "Share price",
"timeAxis": {{"title": "Day"}}{extra},
"panes": [{panes}]
}}"#
)
}
fn candle_pane(extra_layers: &str) -> String {
format!(
r#"{{"heightRatio": 3, "valueAxis": {{"title": "Price", "decimals": 2}}, "layers": [{{"mark": "ohlc", "name": "Price", "data": {CANDLES}}}{extra_layers}]}}"#
)
}
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}]}]}"#;
#[test]
fn candles_differ_in_shape_and_color_by_direction() {
let output = render_ok(&stacked(&candle_pane(""), ""));
assert!(output.warnings.is_empty(), "{:?}", output.warnings);
let svg = output.content;
assert_eq!(
svg.matches("class=\"chartlet-candle chartlet-candle-rise\"")
.count(),
3,
"two rising or level candles and the legend sample"
);
assert_eq!(
svg.matches("class=\"chartlet-candle chartlet-candle-fall\"")
.count(),
2
);
assert!(svg.contains(
".chartlet-candle-rise{fill:var(--chartlet-background);stroke:var(--chartlet-rise)}"
));
assert!(svg.contains(".chartlet-theme-dark{--chartlet-rise:"));
assert!(svg.contains("Price (hollow: rising, filled: falling)</text>"));
assert!(svg.contains(
"<title>2026-03-02 – Price: open 10.00, high 12.00, low 9.00, close 11.00</title>"
));
assert!(svg.contains(" height=\"1\" class=\"chartlet-candle chartlet-candle-rise\""));
assert!(svg.contains(
"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."
), "{svg}");
let html = html_ok(&stacked(&candle_pane(""), ""));
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>"));
assert!(html.contains("<td>11.00</td><td>11.50</td><td>8.00</td><td>9.00</td>"));
assert!(!render_ok(TIME).content.contains("chartlet-rise"));
}
#[test]
fn dense_candles_are_drawn_as_wicks_only() {
let data = (0..240)
.map(|index| {
format!(
r#"{{"time": {}, "open": 10, "high": 12, "low": 9, "close": 11}}"#,
1_770_000_000 + index * 86_400
)
})
.collect::<Vec<_>>()
.join(",");
let output = render_ok(&stacked(
&format!(r#"{{"layers": [{{"mark": "ohlc", "data": [{data}]}}]}}"#),
"",
));
assert_eq!(output.warnings.len(), 1, "{:?}", output.warnings);
assert_eq!(output.warnings[0].code, "dense_chart");
assert_eq!(output.warnings[0].path, "/panes/0/layers/0/data");
let svg = output.content;
assert_eq!(
svg.matches("class=\"chartlet-wick chartlet-wick-rise\"")
.count(),
241
);
assert_eq!(
svg.matches("class=\"chartlet-candle chartlet-candle-rise\"")
.count(),
1
);
}
#[test]
fn invalid_candles_are_refused_with_their_path() {
let refuse = |data: &str, layer: &str| {
render_err(&stacked(
&format!(r#"{{"layers": [{{"mark": "ohlc", "data": {data}{layer}}}]}}"#),
"",
))
};
let valid = CANDLES;
assert_eq!(
refuse(&valid.replace("\"low\": 8,", "\"low\": 9.5,"), ""),
("invalid_candle", "/panes/0/layers/0/data/1/low".to_owned())
);
assert_eq!(
refuse(&valid.replace("\"high\": 12,", "\"high\": 10.5,"), ""),
("invalid_candle", "/panes/0/layers/0/data/0/high".to_owned())
);
assert_eq!(
refuse(&valid.replace("2026-03-03", "2026-03-02"), ""),
("unordered_time", "/panes/0/layers/0/data/1/time".to_owned())
);
assert_eq!(
refuse(&valid.replace("\"open\": 10,", "\"open\": 1e101,"), ""),
(
"unsupported_numeric_range",
"/panes/0/layers/0/data/0/open".to_owned()
)
);
assert_eq!(
refuse(
r#"[{"time": "2026-03-02", "open": 1, "high": 2, "low": 0, "close": 1}]"#,
""
),
("empty_series", "/panes/0/layers/0/data".to_owned())
);
assert_eq!(
refuse(valid, r##", "color": "#123""##),
("option_not_supported", "/panes/0/layers/0/color".to_owned())
);
assert_eq!(
refuse(valid, r#", "points": [{"time": "2026-03-02", "value": 1}]"#),
(
"option_not_supported",
"/panes/0/layers/0/points".to_owned()
)
);
assert_eq!(
render_err(&multiples(&format!(
r#"{{"title": "A", "layers": [{{"mark": "ohlc", "data": {valid}}}]}}, {}"#,
panel("B", 1.0)
))),
("option_not_supported", "/panes/0/layers/0/mark".to_owned())
);
}
#[test]
fn panes_stack_by_their_ratio_and_share_one_time_axis_at_the_bottom() {
let svg = render_ok(&stacked(&format!("{}, {VOLUME_PANE}", candle_pane("")), "")).content;
let titles = placed_texts(&svg, "chartlet-axis-title");
let at = |name: &str| {
titles
.iter()
.find(|(_, _, content)| content == name)
.map(|(_, y, _)| *y)
.expect("the axis title is drawn")
};
assert!((at("Price") - 82.0).abs() < 1e-9);
assert!((at("Volume") - 310.0).abs() < 1e-9);
let ticks = placed_texts(&svg, "chartlet-tick");
let time_ticks: Vec<_> = ticks.iter().filter(|(x, _, _)| *x > 72.0).collect();
assert!(!time_ticks.is_empty());
assert!(
time_ticks.iter().all(|(_, y, _)| (*y - 418.0).abs() < 1e-9),
"{time_ticks:?}"
);
assert!(
ticks
.iter()
.any(|(x, _, content)| *x <= 72.0 && !content.ends_with('%'))
);
assert!(ticks.iter().any(|(_, _, content)| content.ends_with('%')));
assert!(svg.contains("y1=\"102\" x2="));
assert!(svg.contains("y1=\"330\" x2="));
assert!(svg.contains("y2=\"394\" class=\"chartlet-grid\""));
assert!(svg.contains("Volume</text>"));
assert!(svg.contains("chartlet-area chartlet-band-series-1"));
assert!(svg.contains(" 2 stacked panes on one time axis: Price, Volume."));
assert!(
svg.contains(
" Volume – Highest: 20% (Volume in 2026-03-03). Lowest: 8% (Volume in 2026-03-04)."
),
"{svg}"
);
}
#[test]
fn every_pane_writes_its_values_by_its_own_axis() {
let html = html_ok(&stacked(
&format!(
"{}, {VOLUME_PANE}",
candle_pane(
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}"#
)
),
"",
));
assert!(html.contains("<td>10.50</td>"), "{html}");
assert!(html.contains("<td>12%</td>"));
assert!(html.contains("Reference lines: Target at 11.00."));
assert!(
html.contains("chartlet-line-series-1"),
"the line takes the first palette color"
);
}
#[test]
fn pane_limits_are_refused_by_name() {
let line = |name: &str| {
format!(
r#"{{"layers": [{{"mark": "line", "name": "{name}", "points": [{{"time": "2026-03-02", "value": 1}}, {{"time": "2026-03-03", "value": 2}}]}}]}}"#
)
};
let five = (0..5)
.map(|index| line(&format!("L{index}")))
.collect::<Vec<_>>()
.join(",");
assert_eq!(
render_err(&stacked(&five, "")),
("too_many_panes", "/panes".to_owned())
);
let four = (0..4)
.map(|index| line(&format!("L{index}")))
.collect::<Vec<_>>()
.join(",");
assert!(render_ok(&stacked(&four, "")).warnings.is_empty());
let tall = format!(
"{}, {}",
line("A"),
line("B").replacen('{', r#"{"heightRatio": 11, "#, 1)
);
assert_eq!(
render_err(&stacked(&tall, "")),
("invalid_height_ratio", "/panes/1/heightRatio".to_owned())
);
assert_eq!(
render_err(&stacked(&format!("{}, {}", line("A"), line("A")), "")),
("duplicate_series", "/panes/1/layers/0/name".to_owned())
);
let anonymous = line("A").replace(r#""name": "A", "#, "");
assert_eq!(
render_err(&stacked(&format!("{anonymous}, {}", line("B")), "")),
("missing_name", "/panes/0/layers/0/name".to_owned())
);
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}]}]}"#;
assert_eq!(
render_err(&stacked(
&format!("{four}, {two}")
.replacen(&line("L3"), "", 1)
.replace(",,", ","),
""
)),
("too_many_layers", "/panes/3/layers/1/color".to_owned())
);
let late = line("Late")
.replace("2026-03-02", "2026-03-10")
.replace("2026-03-03", "2026-03-11");
let zoomed = stacked(
&format!("{}, {late}", line("Early")),
r#", "zoomSteps": [{"label": "All", "from": "2026-03-01", "to": "2026-03-12"}, {"label": "Early", "from": "2026-03-01", "to": "2026-03-04"}]"#,
);
assert_eq!(
render_err(&zoomed),
("zoom_out_of_range", "/zoomSteps/1".to_owned())
);
let squeezed = stacked(&tall.replace("11", "10"), r#", "height": 240"#);
let output = render_ok(&squeezed);
assert!(
output
.warnings
.iter()
.any(|warning| warning.code == "dense_chart"
&& warning.path == "/panes/0/heightRatio"),
"{:?}",
output.warnings
);
}
#[test]
fn german_candles_and_panes_write_german_texts() {
let spec = stacked(
&format!("{}, {VOLUME_PANE}", candle_pane("")),
r#", "locale": "de""#,
);
let html = html_ok(&spec);
assert!(html.contains("Price (hohl: steigend, gefüllt: fallend)</text>"));
assert!(html.contains(
"<title>2026-03-02 – Price: Eröffnung 10,00; Hoch 12,00; Tief 9,00; Schluss 11,00</title>"
));
assert!(
html.contains(
" 2 übereinanderliegende Teildiagramme auf einer Zeitachse: Price, Volume."
)
);
assert!(html.contains(
"Price: Eröffnung 10,00 am 2026-03-02, Schluss 9,00 am 2026-03-04, Veränderung \u{2212}1,00 (\u{2212}10,0\u{202f}%); Hoch 12,00 am 2026-03-02, Tief 8,00 am 2026-03-03."
), "{html}");
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>"));
assert!(html.contains("<td>12\u{202f}%</td>"));
}
fn with_mobile(specification: &str, mobile: &str) -> String {
specification.replacen('{', &format!("{{\"mobile\": {mobile},"), 1)
}
fn mobile_svg(specification: &str) -> Result<super::RenderOutput, super::ChartError> {
render_json(
specification,
RenderFormat::Svg,
&RenderOptions {
variant: Variant::Mobile,
..RenderOptions::default()
},
)
}
#[test]
fn mobile_sizes_are_validated_with_their_paths() {
for (mobile, path) in [
(r#"{"width": 279}"#, "/mobile/width"),
(r#"{"width": 601}"#, "/mobile/width"),
(r#"{"width": 360, "height": 239}"#, "/mobile/height"),
(r#"{"width": 360, "height": 1601}"#, "/mobile/height"),
(r#"{"width": 360, "breakpoint": 319}"#, "/mobile/breakpoint"),
(
r#"{"width": 360, "breakpoint": 1601}"#,
"/mobile/breakpoint",
),
] {
let error = render_json(
&with_mobile(GROUPED, mobile),
RenderFormat::Html,
&RenderOptions::default(),
)
.unwrap_err();
assert_eq!(
(error.code, error.path.as_str()),
("invalid_dimension", path)
);
}
let missing = render_json(
&with_mobile(GROUPED, r#"{"height": 360}"#),
RenderFormat::Html,
&RenderOptions::default(),
)
.unwrap_err();
assert_eq!(
(missing.code, missing.path.as_str()),
("invalid_spec", "/mobile")
);
}
#[test]
fn a_missing_mobile_field_keeps_the_default_id() {
let plain = render_json(GROUPED, RenderFormat::Svg, &RenderOptions::default()).unwrap();
let spec = ChartSpec::from_json(GROUPED).unwrap();
assert!(spec.mobile.is_none());
assert!(!serde_json::to_string(&spec).unwrap().contains("mobile"));
assert!(plain.content.contains("id=\"chartlet-"));
}
#[test]
fn html_carries_both_variants_behind_a_container_query() {
let specification = with_mobile(GROUPED, r#"{"width": 360, "breakpoint": 700}"#);
let html = render_json(
&specification,
RenderFormat::Html,
&RenderOptions {
id_prefix: Some("chart".to_owned()),
..RenderOptions::default()
},
)
.unwrap()
.content;
assert!(html.starts_with(
"<div class=\"chartlet-wrapper chartlet-responsive chartlet-bp-700\"><style>"
));
assert!(html.contains("container-type:inline-size"));
assert!(html.contains("@container (max-width:699px){.chartlet-bp-700 .chartlet-variant-desktop{display:none}.chartlet-bp-700 .chartlet-variant-mobile{display:block}}"));
assert!(html.contains(".chartlet-variant-mobile{display:none}"));
assert!(html.contains("<div class=\"chartlet-variant-desktop\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"800\" height=\"450\""));
assert!(html.contains("<div class=\"chartlet-variant-mobile\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"360\" height=\"360\""));
assert!(html.contains("id=\"chart\""));
assert!(html.contains("id=\"chart-m\""));
assert!(html.contains("aria-labelledby=\"chart-m-title chart-m-description\""));
let ids = html
.split(" id=\"")
.skip(1)
.map(|rest| rest.split('"').next().unwrap())
.collect::<Vec<_>>();
let unique = ids.iter().collect::<std::collections::BTreeSet<_>>();
assert_eq!(ids.len(), unique.len(), "{ids:?}");
for once in [
"<figcaption>",
"<table>",
"<fieldset class=\"chartlet-filter\">",
] {
assert_eq!(html.matches(once).count(), 1, "{once}");
}
assert!(html.contains(".chartlet-wrapper:has(.chartlet-filter input.series-1:not(:checked)) .chartlet-root [data-series=\"1\"]{display:none}"));
assert_eq!(html.matches("<svg").count(), 2);
}
#[test]
fn the_svg_profile_is_unchanged_by_a_mobile_variant() {
let options = RenderOptions {
id_prefix: Some("chart".to_owned()),
..RenderOptions::default()
};
let plain = render_json(GROUPED, RenderFormat::Svg, &options).unwrap();
let responsive = render_json(
&with_mobile(GROUPED, r#"{"width": 360}"#),
RenderFormat::Svg,
&options,
)
.unwrap();
assert_eq!(plain.content, responsive.content);
}
#[test]
fn the_mobile_variant_renders_alone_as_svg() {
let output = mobile_svg(&with_mobile(GROUPED, r#"{"width": 320, "height": 400}"#))
.unwrap()
.content;
assert!(output.starts_with("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"320\" height=\"400\" viewBox=\"0 0 320 400\" id=\"chartlet-"));
assert!(output.contains("-m-title\""));
let error = mobile_svg(GROUPED).unwrap_err();
assert_eq!(
(error.code, error.path.as_str()),
("missing_mobile", "/mobile")
);
let error = render_json(
&with_mobile(GROUPED, r#"{"width": 360}"#),
RenderFormat::Html,
&RenderOptions {
variant: Variant::Mobile,
..RenderOptions::default()
},
)
.unwrap_err();
assert_eq!(
(error.code, error.path.as_str()),
("option_not_supported", "/render/variant")
);
}
#[test]
fn zoom_panels_carry_both_variants_under_one_radio_group() {
let specification = r#"{
"schemaVersion": 1,
"type": "bar",
"title": "Quarterly revenue",
"mobile": {"width": 360},
"data": [
{"label": "Q1", "value": 320},
{"label": "Q2", "value": 345},
{"label": "Q3", "value": 380},
{"label": "Q4", "value": 410}
],
"zoomSteps": [
{"label": "First half", "from": 0, "to": 1},
{"label": "All", "from": 0, "to": 3}
]
}"#;
let html = render_json(
specification,
RenderFormat::Html,
&RenderOptions {
id_prefix: Some("q".to_owned()),
..RenderOptions::default()
},
)
.unwrap()
.content;
assert_eq!(
html.matches("<fieldset class=\"chartlet-zoom\">").count(),
1
);
assert_eq!(html.matches("type=\"radio\"").count(), 2);
for id in ["q-z0", "q-z0-m", "q-z1", "q-z1-m"] {
assert!(html.contains(&format!("id=\"{id}\"")), "{id}");
}
assert!(html.contains("<div class=\"chartlet-panel chartlet-panel-1\"><div class=\"chartlet-variant-desktop\"><svg"));
assert_eq!(html.matches("<table>").count(), 1);
}
#[test]
fn warnings_of_the_mobile_variant_are_marked_and_deduplicated() {
let specification = r#"{
"schemaVersion": 1,
"type": "bar",
"title": "Revenue",
"mobile": {"width": 280},
"categories": ["An exceptionally long category label that never fits", "B"],
"series": [
{"name": "Direct", "values": [1, 2]},
{"name": "A partner network with a long name", "values": [2, 1]}
]
}"#;
let desktop = render_json(specification, RenderFormat::Svg, &RenderOptions::default())
.unwrap()
.warnings;
let html = render_json(specification, RenderFormat::Html, &RenderOptions::default())
.unwrap()
.warnings;
assert!(html.starts_with(&desktop));
let added = &html[desktop.len()..];
assert!(!added.is_empty());
for warning in added {
assert!(
warning.message.starts_with("mobile variant: "),
"{warning:?}"
);
assert!(
!desktop
.iter()
.any(|seen| (seen.code, &seen.path) == (warning.code, &warning.path)),
"{warning:?}"
);
}
}
#[test]
fn the_mobile_variant_is_checked_for_density_at_its_own_width() {
let points = (0..400)
.map(|index| {
format!(
"{{\"time\": {}, \"value\": {index}}}",
1_772_323_200 + index * 3600
)
})
.collect::<Vec<_>>()
.join(",");
let specification = format!(
r#"{{"schemaVersion": 1, "type": "time", "title": "Load", "mobile": {{"width": 280}},
"panes": [{{"layers": [{{"mark": "line", "points": [{points}]}}]}}]}}"#
);
let desktop = render_json(&specification, RenderFormat::Svg, &RenderOptions::default())
.unwrap()
.warnings;
assert!(!desktop.iter().any(|warning| warning.code == "dense_chart"));
let html = render_json(
&specification,
RenderFormat::Html,
&RenderOptions::default(),
)
.unwrap()
.warnings;
assert!(html.iter().any(|warning| warning.code == "dense_chart"
&& warning.message.starts_with("mobile variant: ")));
}
#[test]
fn the_topic_picker_switches_both_variants() {
let specification = with_mobile(
include_str!("../examples/topicmap-sample.json"),
r#"{"width": 360}"#,
);
let html = render_json(
&specification,
RenderFormat::Html,
&RenderOptions::default(),
)
.unwrap()
.content;
assert_eq!(
html.matches("<fieldset class=\"chartlet-topic-picker\">")
.count(),
1
);
assert_eq!(html.matches("<svg").count(), 2);
let rule = ":checked) #{root} .chartlet-topic-area.chartlet-topic-0{";
let desktop = html.split(" id=\"").nth(1).unwrap();
let desktop = &desktop[..desktop.find('"').unwrap()];
assert!(html.contains(&rule.replace("{root}", desktop)));
assert!(html.contains(&rule.replace("{root}", &format!("{desktop}-m"))));
assert!(html.starts_with("<div class=\"chartlet-wrapper chartlet-responsive"));
}
const TRADING_DAYS: [i64; 5] = [
1_772_668_800,
1_772_755_200,
1_773_014_400,
1_773_100_800,
1_773_187_200,
];
fn collapsed(days: &[i64], extra: &str, tail: &str) -> String {
let points = days
.iter()
.enumerate()
.map(|(index, day)| format!(r#"{{"time": {day}, "value": {}}}"#, index + 1))
.collect::<Vec<_>>()
.join(", ");
format!(
r#"{{
"schemaVersion": 1,
"type": "time",
"title": "Trading days",
"showValues": false,
"timeAxis": {{"gaps": "collapse"}},
"panes": [{{"layers": [{{"mark": "line", "points": [{points}]}}{extra}]}}]{tail}
}}"#
)
}
fn point_xs(svg: &str) -> Vec<f64> {
svg.split("<circle cx=\"")
.skip(1)
.map(|rest| rest[..rest.find('"').unwrap()].parse().unwrap())
.collect()
}
fn time_ticks(svg: &str) -> Vec<(String, f64)> {
svg.split("<text x=\"")
.skip(1)
.filter(|rest| rest.contains("class=\"chartlet-tick\">2026"))
.map(|rest| {
let x = rest[..rest.find('"').unwrap()].parse().unwrap();
let label = rest.split("class=\"chartlet-tick\">").nth(1).unwrap();
(label[..label.find('<').unwrap()].to_owned(), x)
})
.collect()
}
#[test]
fn collapsed_gaps_place_observations_at_equal_distances() {
let svg = render_ok(&collapsed(&TRADING_DAYS, "", "")).content;
let xs = point_xs(&svg);
assert_eq!(xs.len(), 5);
let step = xs[1] - xs[0];
assert!(step > 0.0);
for pair in xs.windows(2) {
assert!((pair[1] - pair[0] - step).abs() < 0.01, "{xs:?}");
}
assert!(svg.contains("<title>2026-03-06: 2</title>"));
assert!(svg.contains("<title>2026-03-09: 3</title>"));
let labels: Vec<String> = time_ticks(&svg)
.into_iter()
.map(|(label, _)| label)
.collect();
assert_eq!(
labels,
[
"2026-03-05",
"2026-03-06",
"2026-03-09",
"2026-03-10",
"2026-03-11"
]
);
let shown =
render_ok(&collapsed(&TRADING_DAYS, "", "").replace("collapse", "show")).content;
let xs = point_xs(&shown);
assert!(xs[2] - xs[1] > 2.0 * (xs[1] - xs[0]));
}
#[test]
fn collapsed_ticks_sit_on_the_first_observation_after_their_boundary() {
let monday: i64 = 1_772_409_600;
let days: Vec<i64> = (0..33)
.map(|day| monday + day * 86_400)
.filter(|epoch| (epoch / 86_400 + 3).rem_euclid(7) < 5)
.filter(|epoch| *epoch != monday + 14 * 86_400)
.collect();
let svg = render_ok(&collapsed(&days, "", "")).content;
let ticks = time_ticks(&svg);
let labels: Vec<&str> = ticks.iter().map(|(label, _)| label.as_str()).collect();
assert_eq!(
labels,
[
"2026-03-02",
"2026-03-09",
"2026-03-17",
"2026-03-23",
"2026-03-30"
]
);
let xs = point_xs(&svg);
let tuesday = days
.iter()
.position(|epoch| *epoch == monday + 15 * 86_400)
.unwrap();
assert!((ticks[2].1 - xs[tuesday]).abs() < 0.01, "{ticks:?}");
}
#[test]
fn collapsed_gaps_move_annotations_onto_observations() {
let extra = r#", {"mark": "annotation", "label": "Rule", "time": "2026-03-07"},
{"mark": "annotation", "label": "Event", "time": "2026-03-08", "value": 2, "shape": "square"},
{"mark": "band", "label": "Zone", "from": "2026-03-07", "to": "2026-03-10T12:00"}"#;
let output = render_ok(&collapsed(&TRADING_DAYS, extra, ""));
let svg = output.content;
let xs = point_xs(&svg);
let (monday, tuesday) = (xs[2], xs[3]);
assert!(svg.contains(&format!(
"<polyline points=\"{monday},78 {monday},414\" class=\"chartlet-rule\">"
)));
let marker = svg
.split("class=\"chartlet-marker\"")
.next()
.and_then(|head| head.rsplit("<polyline points=\"").next())
.unwrap();
let corners: Vec<f64> = marker
.split([' ', '"'])
.filter_map(|pair| pair.split(',').next()?.parse().ok())
.collect();
let center = f64::midpoint(corners[0], corners[1]);
assert!((center - monday).abs() < 0.01, "{marker}");
assert!(svg.contains(&format!(
"<rect x=\"{monday}\" y=\"78\" width=\"{}\" height=\"336\" class=\"chartlet-zone\">",
tuesday - monday
)));
assert!(svg.contains("<title>Rule: 2026-03-07</title>"));
assert!(svg.contains("Reference lines: Rule at 2026-03-07."));
}
#[test]
fn collapsed_gaps_refuse_times_without_an_observation() {
for (extra, path) in [
(
r#", {"mark": "annotation", "label": "Rule", "time": "2026-03-04"}"#,
"/panes/0/layers/1/time",
),
(
r#", {"mark": "annotation", "label": "Event", "time": "2026-03-12", "value": 2}"#,
"/panes/0/layers/1/time",
),
(
r#", {"mark": "band", "label": "Weekend", "from": "2026-03-07", "to": "2026-03-08"}"#,
"/panes/0/layers/1",
),
(
r#", {"mark": "band", "label": "Before", "from": "2026-03-01", "to": "2026-03-04"}"#,
"/panes/0/layers/1",
),
] {
assert_eq!(
render_err(&collapsed(&TRADING_DAYS, extra, "")),
("time_out_of_range", path.to_owned()),
"{extra}"
);
}
let wide =
r#", {"mark": "band", "label": "All", "from": "2026-03-01", "to": "2026-03-20"}"#;
let svg = render_ok(&collapsed(&TRADING_DAYS, wide, "")).content;
let xs = point_xs(&svg);
assert!(svg.contains(&format!(
"<rect x=\"{}\" y=\"78\" width=\"{}\"",
xs[0],
xs[4] - xs[0]
)));
let rule = r#", {"mark": "annotation", "label": "Rule", "time": "2026-03-04"}"#;
render_ok(&collapsed(&TRADING_DAYS, rule, "").replace("collapse", "show"));
}
#[test]
fn collapsed_zoom_windows_close_their_gaps_too() {
let zones = r#", {"mark": "band", "label": "Early", "from": "2026-03-05", "to": "2026-03-07T12:00"}"#;
let zoom = r#", "zoomSteps": [
{"label": "All", "from": "2026-03-05", "to": "2026-03-11"},
{"label": "Weekend on", "from": "2026-03-07", "to": "2026-03-11"}
]"#;
let html = html_ok(&collapsed(&TRADING_DAYS, zones, zoom));
let panel = |index: usize| {
html.split(&format!("chartlet-panel chartlet-panel-{index}"))
.nth(1)
.and_then(|panel| panel.split("</div>").next())
.unwrap()
.to_owned()
};
let (all, late) = (panel(0), panel(1));
assert_eq!(point_xs(&all).len(), 5);
let xs = point_xs(&late);
assert_eq!(xs.len(), 3);
assert!(((xs[2] - xs[1]) - (xs[1] - xs[0])).abs() < 0.01, "{xs:?}");
assert!(all.contains("class=\"chartlet-zone\""));
assert!(!late.contains("class=\"chartlet-zone\""));
}
#[test]
fn collapsed_gaps_are_named_in_the_description() {
let spec = collapsed(&TRADING_DAYS, "", "");
assert!(
render_ok(&spec)
.content
.contains("Lowest: 1 (2026-03-05). Gaps in time are closed up.</desc>")
);
let html = html_ok(&german(&spec, "time"));
assert!(
html.contains("Zeiträume ohne Beobachtung sind ausgelassen."),
"{html}"
);
let shown = render_ok(&spec.replace("collapse", "show")).content;
assert!(!shown.contains("Gaps in time"));
}
#[test]
fn collapsed_small_multiples_share_their_slots() {
let points = |days: &[i64]| {
days.iter()
.map(|day| format!(r#"{{"time": {day}, "value": 1}}"#))
.collect::<Vec<_>>()
.join(", ")
};
let spec = format!(
r#"{{
"schemaVersion": 1,
"type": "multiples",
"title": "Desks",
"timeAxis": {{"gaps": "collapse"}},
"panes": [
{{"title": "A", "layers": [{{"mark": "line", "points": [{}]}}]}},
{{"title": "B", "layers": [{{"mark": "line", "points": [{}]}}]}}
]
}}"#,
points(&TRADING_DAYS[..3]),
points(&TRADING_DAYS[2..]),
);
let svg = render_ok(&spec).content;
let xs = point_xs(&svg);
assert_eq!(xs.len(), 6);
let cell = 384.0;
let step = xs[1] - xs[0];
assert!((xs[3] - cell - xs[2]).abs() < 0.01, "{xs:?}");
assert!((xs[5] - xs[4] - step).abs() < 0.01, "{xs:?}");
}
}