use fulgur_chart::font::DEFAULT_FONT;
use fulgur_chart::frontend::chartjs;
use fulgur_chart::layout::line;
use fulgur_chart::raster_direct::render_chart_to_png;
use fulgur_chart::render::render_chart;
use fulgur_chart::scene::Prim;
use fulgur_chart::text::TextMeasurer;
fn render(json: &str) -> String {
render_chart(&chartjs::parse(json, false).unwrap())
}
fn render_png(json: &str) -> Vec<u8> {
render_chart_to_png(&chartjs::parse(json, false).unwrap(), 1.0, DEFAULT_FONT).unwrap()
}
#[test]
fn line_has_polyline_and_markers() {
let svg = render(
r#"{"type":"line","data":{"labels":["A","B","C"],"datasets":[{"label":"s","data":[1,3,2]}]}}"#,
);
assert!(svg.contains("<polyline"));
assert!(svg.matches("<circle").count() >= 3); assert!(!svg.contains("NaN") && !svg.contains("inf"));
assert!(svg.starts_with("<svg") && svg.trim_end().ends_with("</svg>"));
}
#[test]
fn area_emits_filled_path_with_opacity() {
let svg = render(
r#"{"type":"line","data":{"labels":["A","B"],"datasets":[{"data":[1,2],"fill":true}]}}"#,
);
assert!(svg.contains("<path"));
assert!(svg.contains("fill-opacity=")); assert!(svg.contains("Z\"")); }
#[test]
fn tension_uses_bezier_path() {
let svg = render(
r#"{"type":"line","data":{"labels":["A","B","C"],"datasets":[{"data":[1,3,2],"tension":0.4}]}}"#,
);
assert!(svg.contains("<path")); assert!(svg.contains(" C ")); }
#[test]
fn line_deterministic() {
let j = r#"{"type":"line","data":{"labels":["A","B"],"datasets":[{"data":[1,2]}]}}"#;
assert_eq!(render(j), render(j));
}
#[test]
fn line_snapshot() {
let svg = render(
r#"{"type":"line","data":{"labels":["1月","2月","3月"],"datasets":[{"label":"売上","data":[120,200,150]}]},"options":{"plugins":{"title":{"display":true,"text":"推移"}}}}"#,
);
insta::assert_snapshot!(svg);
}
#[test]
fn area_snapshot() {
let svg = render(
r#"{"type":"line","data":{"labels":["Q1","Q2","Q3"],"datasets":[{"label":"累計","data":[30,75,130],"fill":true}]}}"#,
);
insta::assert_snapshot!(svg);
}
#[test]
fn tension_snapshot() {
let svg = render(
r#"{"type":"line","data":{"labels":["A","B","C","D"],"datasets":[{"label":"曲線","data":[1,3,2,4],"tension":0.4}]}}"#,
);
insta::assert_snapshot!(svg);
}
#[test]
fn offset_snapshot() {
let svg = render(
r#"{"type":"line","data":{"labels":["1月","2月","3月"],"datasets":[{"label":"売上","data":[120,200,150]}]},"options":{"scales":{"x":{"offset":true}}}}"#,
);
insta::assert_snapshot!(svg);
}
fn polyline_pts(json: &str) -> usize {
let spec = chartjs::parse(json, false).unwrap();
let m = TextMeasurer::new(DEFAULT_FONT).unwrap();
let scene = line::build(&spec, &m);
scene
.items
.iter()
.filter_map(|p| match p {
Prim::Polyline { points, .. } => Some(points.len()),
_ => None,
})
.sum()
}
#[test]
fn large_line_is_decimated_vs_disabled() {
let n = 8000;
let labels: Vec<String> = (0..n).map(|i| format!("\"{i}\"")).collect();
let data: Vec<String> = (0..n).map(|i| format!("{}", (i * 37) % 101)).collect();
let body = format!(
"\"labels\":[{}],\"datasets\":[{{\"data\":[{}]}}]",
labels.join(","),
data.join(",")
);
let on = format!(r#"{{"type":"line","data":{{{body}}}}}"#);
let off = format!(
r#"{{"type":"line","data":{{{body}}},"options":{{"plugins":{{"decimation":{{"enabled":false}}}}}}}}"#
);
let on_pts = polyline_pts(&on);
let off_pts = polyline_pts(&off);
assert_eq!(off_pts, n, "disabled must keep all points (single segment)");
assert!(
on_pts > 0 && on_pts < off_pts,
"default must decimate: {on_pts} vs {off_pts}"
);
}
#[test]
fn small_line_polyline_unchanged() {
let pts = polyline_pts(
r#"{"type":"line","data":{"labels":["a","b","c"],"datasets":[{"data":[1,2,3]}]}}"#,
);
assert_eq!(pts, 3);
}
fn circle_count(json: &str) -> usize {
let spec = chartjs::parse(json, false).unwrap();
let m = TextMeasurer::new(DEFAULT_FONT).unwrap();
let scene = line::build(&spec, &m);
scene
.items
.iter()
.filter(|p| matches!(p, Prim::Circle { .. }))
.count()
}
#[test]
fn large_line_suppresses_markers_by_default() {
let n = 5000;
let labels: Vec<String> = (0..n).map(|i| format!("\"{i}\"")).collect();
let data: Vec<String> = (0..n).map(|i| format!("{}", i % 50)).collect();
let json = format!(
r#"{{"type":"line","data":{{"labels":[{}],"datasets":[{{"data":[{}]}}]}}}}"#,
labels.join(","),
data.join(",")
);
assert_eq!(
circle_count(&json),
0,
"large line should suppress markers by default"
);
}
#[test]
fn large_line_keeps_markers_when_pointradius_set() {
let n = 5000;
let labels: Vec<String> = (0..n).map(|i| format!("\"{i}\"")).collect();
let data: Vec<String> = (0..n).map(|i| format!("{}", i % 50)).collect();
let json = format!(
r#"{{"type":"line","data":{{"labels":[{}],"datasets":[{{"data":[{}],"pointRadius":2}}]}}}}"#,
labels.join(","),
data.join(",")
);
assert!(
circle_count(&json) > 0,
"explicit pointRadius should keep markers"
);
}
#[test]
fn small_line_markers_unchanged() {
assert_eq!(
circle_count(
r#"{"type":"line","data":{"labels":["a","b","c"],"datasets":[{"data":[1,2,3]}]}}"#
),
3
);
}
fn polylines(spec: &fulgur_chart::ir::ChartSpec) -> Vec<Vec<(f64, f64)>> {
let m = TextMeasurer::new(DEFAULT_FONT).unwrap();
let scene = line::build(spec, &m);
scene
.items
.iter()
.filter_map(|p| match p {
Prim::Polyline { points, .. } => Some(points.clone()),
_ => None,
})
.collect()
}
#[test]
fn gapped_large_line_keeps_segments_and_decimates() {
let n = 8000;
let labels: Vec<String> = (0..n).map(|i| format!("\"{i}\"")).collect();
let data: Vec<String> = (0..n).map(|i| format!("{}", (i * 37) % 101)).collect();
let json = format!(
r#"{{"type":"line","data":{{"labels":[{}],"datasets":[{{"data":[{}]}}]}}}}"#,
labels.join(","),
data.join(",")
);
let mut spec = chartjs::parse(&json, false).unwrap();
spec.series[0].values[n / 2] = f64::NAN;
let polys = polylines(&spec);
let counts: Vec<usize> = polys.iter().map(|p| p.len()).collect();
assert!(
counts.len() >= 2,
"gap must yield >=2 polylines, got {}: {counts:?}",
counts.len()
);
assert!(
counts.iter().all(|&c| c >= 2),
"every polyline must have >=2 points: {counts:?}"
);
let total: usize = counts.iter().sum();
assert!(
total < n / 2,
"decimation must substantially reduce total points: {total} vs n={n}"
);
assert!(
polys
.iter()
.flatten()
.all(|&(x, y)| x.is_finite() && y.is_finite()),
"all emitted polyline points must be finite"
);
}
#[test]
fn gapped_large_line_lttb_prorates_segment_budget() {
let n = 8000;
let labels: Vec<String> = (0..n).map(|i| format!("\"{i}\"")).collect();
let data: Vec<String> = (0..n).map(|i| format!("{}", (i * 37) % 101)).collect();
let json = format!(
r#"{{"type":"line","data":{{"labels":[{}],"datasets":[{{"data":[{}]}}]}},"options":{{"plugins":{{"decimation":{{"enabled":true,"algorithm":"lttb","samples":100,"threshold":500}}}}}}}}"#,
labels.join(","),
data.join(",")
);
let mut spec = chartjs::parse(&json, false).unwrap();
for p in [n / 4, n / 2, 3 * n / 4] {
spec.series[0].values[p] = f64::NAN;
}
let polys = polylines(&spec);
let counts: Vec<usize> = polys.iter().map(|p| p.len()).collect();
let num_seg = counts.len();
let total: usize = counts.iter().sum();
assert!(
num_seg >= 2,
"gaps must yield >=2 polylines, got {counts:?}"
);
assert!(
counts.iter().all(|&c| c >= 2),
"no segment collapse: {counts:?}"
);
assert!(
total <= 100 + 3 * num_seg,
"budget must be prorated across segments: total={total}, num_seg={num_seg}"
);
assert!(
total < 100 * num_seg,
"must beat naive per-segment budget: total={total}"
);
}
fn circle_count_spec(spec: &fulgur_chart::ir::ChartSpec) -> usize {
let m = TextMeasurer::new(DEFAULT_FONT).unwrap();
let scene = line::build(spec, &m);
scene
.items
.iter()
.filter(|p| matches!(p, Prim::Circle { .. }))
.count()
}
#[test]
fn all_singleton_gaps_keep_markers_when_decimated() {
let n = 12000;
let labels: Vec<String> = (0..n).map(|i| format!("\"{i}\"")).collect();
let data: Vec<String> = (0..n).map(|i| format!("{}", (i * 37) % 101)).collect();
let json = format!(
r#"{{"type":"line","data":{{"labels":[{}],"datasets":[{{"data":[{}]}}]}}}}"#,
labels.join(","),
data.join(",")
);
let mut spec = chartjs::parse(&json, false).unwrap();
for i in (1..n).step_by(2) {
spec.series[0].values[i] = f64::NAN;
}
assert!(polylines(&spec).is_empty(), "all singletons → no polyline");
assert!(
circle_count_spec(&spec) > 0,
"singleton (undrawable-as-line) points must keep markers when decimated"
);
}
#[test]
fn disabled_decimation_keeps_all_points_sanity() {
let n = 3000;
let labels: Vec<String> = (0..n).map(|i| format!("\"{i}\"")).collect();
let data: Vec<String> = (0..n).map(|i| format!("{}", (i * 13) % 50)).collect();
let off = format!(
r#"{{"type":"line","data":{{"labels":[{}],"datasets":[{{"data":[{}]}}]}},"options":{{"plugins":{{"decimation":{{"enabled":false}}}}}}}}"#,
labels.join(","),
data.join(",")
);
assert_eq!(polyline_pts(&off), n);
}
fn big_decimated_line_json() -> String {
let n = 5000;
let labels: Vec<String> = (0..n).map(|i| format!("\"{i}\"")).collect();
let data: Vec<String> = (0..n).map(|i| format!("{}", (i * 29) % 83)).collect();
format!(
r#"{{"type":"line","data":{{"labels":[{}],"datasets":[{{"data":[{}]}}]}}}}"#,
labels.join(","),
data.join(",")
)
}
#[test]
fn decimated_line_is_deterministic() {
let json = big_decimated_line_json();
assert_eq!(render(&json), render(&json), "SVG must be byte-identical");
assert_eq!(
render_png(&json),
render_png(&json),
"PNG must be byte-identical"
);
}
#[test]
fn decimated_line_renders_svg_and_png_consistently() {
let json = big_decimated_line_json();
let svg = render(&json);
assert!(svg.starts_with("<svg") && svg.trim_end().ends_with("</svg>"));
assert!(!svg.contains("NaN") && !svg.contains("inf"));
assert!(svg.contains("<polyline"));
let png = render_png(&json);
assert!(png.len() > 8, "PNG must be non-empty");
let pix = tiny_skia::Pixmap::decode_png(&png).expect("decimated PNG must decode");
assert!(pix.width() > 0 && pix.height() > 0);
}