use chartlet::{RenderFormat, RenderOptions, render_json};
fn examples() -> Vec<(String, String)> {
let mut examples: Vec<(String, String)> = std::fs::read_dir("examples")
.expect("the examples directory should exist")
.map(|entry| entry.expect("readable entry").path())
.filter(|path| {
path.extension()
.is_some_and(|extension| extension == "json")
})
.map(|path| {
(
path.file_stem()
.expect("a file name")
.to_string_lossy()
.into_owned(),
std::fs::read_to_string(&path).expect("readable example"),
)
})
.collect();
examples.sort();
examples
}
fn render(specification: &str, format: RenderFormat, hooks: bool) -> String {
render_json(
specification,
format,
&RenderOptions {
hooks,
..RenderOptions::default()
},
)
.expect("every example renders")
.content
}
fn remove_attribute(content: &str, name: &str) -> String {
let needle = format!(" {name}=\"");
let mut output = String::new();
let mut rest = content;
while let Some(start) = rest.find(&needle) {
output.push_str(&rest[..start]);
let value = &rest[start + needle.len()..];
rest = &value[value.find('"').expect("a closed attribute") + 1..];
}
output.push_str(rest);
output
}
fn remove_between(content: &str, open: &str, close: &str) -> String {
let mut output = String::new();
let mut rest = content;
while let Some(start) = rest.find(open) {
output.push_str(&rest[..start]);
let after = &rest[start..];
rest = &after[after.find(close).expect("a closed element") + close.len()..];
}
output.push_str(rest);
output
}
fn without_hooks(content: &str) -> String {
let mut content = content.to_owned();
while let Some(start) = content.find(" data-pane=\"") {
let series = content[..start]
.rfind(" data-series=\"")
.filter(|series| !content[*series..start].contains('>'));
let end = start + content[start + 12..].find('"').expect("closed") + 13;
content.replace_range(series.unwrap_or(start)..end, "");
}
for name in [
"data-chartlet-type",
"data-chartlet-id",
"data-chartlet-locale",
"data-chartlet-table",
"data-part",
"data-stacked",
"data-x",
"data-value",
"data-name",
"data-annotation",
] {
content = remove_attribute(&content, name);
}
content = remove_between(&content, "<script type=\"application/json\"", "</script>");
content = remove_between(&content, "<g data-chartlet-plot", "/>");
content.replace("<g>", "").replace("</g>", "")
}
#[test]
fn hooks_only_add_to_every_example() {
for (name, specification) in examples() {
for format in [RenderFormat::Svg, RenderFormat::Html] {
let plain = render(&specification, format, false);
let hooked = render(&specification, format, true);
assert!(
!plain.contains("data-chartlet-"),
"{name}: hooks without the option"
);
assert!(hooked.contains("data-chartlet-type"), "{name}: no hooks");
assert_eq!(without_hooks(&hooked), plain, "{name} ({format:?})");
}
}
}
fn data_block(svg: &str) -> serde_json::Value {
let start = svg.find("data-chartlet-data=\"\">").expect("a data block") + 22;
let end = start + svg[start..].find("</script>").expect("a closed block");
let json = svg[start..end]
.replace("<", "<")
.replace(">", ">")
.replace("&", "&");
serde_json::from_str(&json).expect("the data block is JSON")
}
fn numbers(text: &str) -> Vec<f64> {
text.split_whitespace()
.map(|number| number.parse().expect("a number"))
.collect()
}
fn attribute<'a>(tag: &'a str, name: &str) -> &'a str {
let start = tag.find(&format!(" {name}=\"")).expect("the attribute") + name.len() + 3;
&tag[start..start + tag[start..].find('"').expect("closed")]
}
#[test]
fn the_svg_profile_carries_the_table_as_a_data_block() {
let specification = include_str!("../examples/sensor-readings.json");
let block = data_block(&render(specification, RenderFormat::Svg, true));
let html = render(specification, RenderFormat::Html, true);
let columns = block["columns"].as_array().expect("columns");
let rows = block["rows"].as_array().expect("rows");
assert_eq!(rows.len(), html.matches("<tr data-x=").count());
assert_eq!(columns[1]["name"], "Servers");
assert_eq!(columns[1]["series"], 1);
assert_eq!(columns[1]["part"], "value");
assert!(html.contains(
"<th scope=\"col\" data-series=\"1\" data-pane=\"0\" data-part=\"value\">Servers</th>"
));
assert_eq!(rows[0]["label"], "2026-03-01");
assert_eq!(rows[0]["value"][1], 22.0);
assert_eq!(rows[0]["text"][1], "22.0");
assert!(!html.contains("<script"));
}
#[test]
fn a_plot_hook_maps_an_observation_onto_its_drawn_point() {
let svg = render(
include_str!("../examples/sensor-readings.json"),
RenderFormat::Svg,
true,
);
let block = data_block(&svg);
let plot = &svg[svg.find("<g data-chartlet-plot").expect("a plot hook")..];
let plot = &plot[..plot.find("/>").expect("closed")];
let (x_domain, x_range) = (
numbers(attribute(plot, "data-x-domain")),
numbers(attribute(plot, "data-x-range")),
);
let (y_domain, y_range) = (
numbers(attribute(plot, "data-y-domain")),
numbers(attribute(plot, "data-y-range")),
);
let linear = |domain: &[f64], range: &[f64], value: f64| {
range[0] + (value - domain[0]) / (domain[1] - domain[0]) * (range[1] - range[0])
};
let row = &block["rows"][0];
let x = linear(&x_domain, &x_range, row["x"].as_f64().expect("x"));
let y = linear(
&y_domain,
&y_range,
row["value"][1].as_f64().expect("a value"),
);
let group = &svg[svg.find("<g data-series=\"1\"").expect("the Servers layer")..];
let points = attribute(
&group[group.find("<polyline").expect("its line")..],
"points",
);
let first: Vec<f64> = points
.split_whitespace()
.next()
.expect("a point")
.split(',')
.map(|number| number.parse().expect("a number"))
.collect();
assert!((first[0] - x).abs() < 0.01, "{first:?} vs {x}");
assert!((first[1] - y).abs() < 0.01, "{first:?} vs {y}");
}
#[test]
fn collapsed_gaps_list_every_slot() {
let svg = render(
include_str!("../examples/share-price.json"),
RenderFormat::Svg,
true,
);
let plots: Vec<&str> = svg.split("<g data-chartlet-plot").skip(1).collect();
assert_eq!(plots.len(), 2, "one plot per pane");
let rows = data_block(&svg)["rows"].as_array().expect("rows").len();
assert_eq!(numbers(attribute(plots[0], "data-x-domain")).len(), rows);
assert_eq!(attribute(plots[1], "data-pane"), "1");
}
#[test]
fn the_cli_writes_hooks_with_the_flag() {
let output = std::process::Command::new(env!("CARGO_BIN_EXE_chartlet"))
.args([
"render",
"examples/monthly-trend.json",
"--format",
"svg",
"--hooks",
])
.output()
.expect("the CLI runs");
assert!(output.status.success(), "{output:?}");
let svg = String::from_utf8(output.stdout).expect("UTF-8");
assert!(svg.contains("data-chartlet-type=\"line\""));
assert!(svg.contains("data-x-domain=\"0 5\""));
}