use crate::ir::ChartSpec;
use crate::num::fmt_num;
const MAX_WORDCLOUD_WORDS: usize = 500;
const MAX_WORDCLOUD_WORD_BYTES: usize = 200;
pub const MAX_SANKEY_LINKS: usize = 10_000;
pub const MAX_SANKEY_NODES: usize = 2_000;
pub const DEFAULT_MAX_TOTAL_DATA_POINTS: usize = 1_000_000;
pub const DEFAULT_MAX_SERIES: usize = 1_000;
pub const DEFAULT_MAX_CATEGORIES: usize = 100_000;
pub const DEFAULT_MAX_CATEGORICAL_PRIMITIVES: usize = 1_000_000;
pub const DEFAULT_MAX_LABEL_BYTES: usize = 4_096;
pub const DEFAULT_MAX_TREE_DEPTH: usize = 50;
pub const DEFAULT_MAX_DIMENSION_PX: f64 = 32_768.0;
pub const DEFAULT_MIN_DIMENSION_PX: f64 = 1.0;
#[derive(Debug, Clone)]
pub struct InputLimits {
pub max_total_data_points: usize,
pub max_series: usize,
pub max_categories: usize,
pub max_categorical_primitives: usize,
pub max_label_bytes: usize,
pub max_dimension_px: f64,
pub min_dimension_px: f64,
}
impl Default for InputLimits {
fn default() -> Self {
Self {
max_total_data_points: DEFAULT_MAX_TOTAL_DATA_POINTS,
max_series: DEFAULT_MAX_SERIES,
max_categories: DEFAULT_MAX_CATEGORIES,
max_categorical_primitives: DEFAULT_MAX_CATEGORICAL_PRIMITIVES,
max_label_bytes: DEFAULT_MAX_LABEL_BYTES,
max_dimension_px: DEFAULT_MAX_DIMENSION_PX,
min_dimension_px: DEFAULT_MIN_DIMENSION_PX,
}
}
}
fn validate_tree(
nodes: &[crate::ir::TreeNode],
depth: usize,
limits: &InputLimits,
) -> Result<usize, String> {
let mut count = 0usize;
for n in nodes {
if depth > DEFAULT_MAX_TREE_DEPTH {
return Err(format!(
"treemap のツリー深さが上限 {} を超えています",
DEFAULT_MAX_TREE_DEPTH,
));
}
if n.label.len() > limits.max_label_bytes {
return Err(format!(
"treemap ラベルの長さ {} バイトが上限 {} を超えています",
n.label.len(),
limits.max_label_bytes,
));
}
count += 1 + validate_tree(&n.children, depth + 1, limits)?;
}
Ok(count)
}
pub fn validate_spec(spec: &ChartSpec, limits: &InputLimits) -> Result<(), String> {
if !spec.width.is_finite()
|| spec.width < limits.min_dimension_px
|| spec.width > limits.max_dimension_px
{
return Err(format!(
"width {:.0} は有効範囲 [{:.0}–{:.0}] を外れています",
spec.width, limits.min_dimension_px, limits.max_dimension_px,
));
}
if !spec.height.is_finite()
|| spec.height < limits.min_dimension_px
|| spec.height > limits.max_dimension_px
{
return Err(format!(
"height {:.0} は有効範囲 [{:.0}–{:.0}] を外れています",
spec.height, limits.min_dimension_px, limits.max_dimension_px,
));
}
if spec.series.len() > limits.max_series {
return Err(format!(
"系列数 {} が上限 {} を超えています",
spec.series.len(),
limits.max_series,
));
}
if spec.categories.len() > limits.max_categories {
return Err(format!(
"カテゴリ数 {} が上限 {} を超えています",
spec.categories.len(),
limits.max_categories,
));
}
let categorical_primitives = spec.series.len().saturating_mul(spec.categories.len());
if categorical_primitives > limits.max_categorical_primitives {
return Err(format!(
"系列数 {} × カテゴリ数 {} = {} が上限 {} を超えています",
spec.series.len(),
spec.categories.len(),
categorical_primitives,
limits.max_categorical_primitives,
));
}
if matches!(spec.kind, crate::ir::ChartKind::Progress) {
const PRIMS_PER_BAR: usize = 4; let bars = spec.series.first().map(|s| s.values.len()).unwrap_or(0);
let progress_primitives = bars.saturating_mul(PRIMS_PER_BAR);
if progress_primitives > limits.max_categorical_primitives {
return Err(format!(
"progress バー数 {} (プリミティブ {}) が上限 {} を超えています",
bars, progress_primitives, limits.max_categorical_primitives,
));
}
}
if matches!(spec.kind, crate::ir::ChartKind::BoxPlot) {
const PRIMS_PER_BOX: usize = 10;
let boxes = spec
.series
.iter()
.flat_map(|s| s.box_points.iter())
.filter(|p| {
p.min.is_finite()
&& p.q1.is_finite()
&& p.median.is_finite()
&& p.q3.is_finite()
&& p.max.is_finite()
})
.count();
let boxplot_primitives = boxes.saturating_mul(PRIMS_PER_BOX);
if boxplot_primitives > limits.max_categorical_primitives {
return Err(format!(
"boxplot ボックス数 {} (プリミティブ {}) が上限 {} を超えています",
boxes, boxplot_primitives, limits.max_categorical_primitives,
));
}
}
if let crate::ir::ChartKind::VegaRect {
x_labels,
y_labels,
cells,
} = &spec.kind
{
for label in x_labels.iter().chain(y_labels.iter()) {
if label.len() > limits.max_label_bytes {
return Err(format!(
"VegaRect のラベル長 {} バイトが上限 {} を超えています",
label.len(),
limits.max_label_bytes,
));
}
}
if x_labels.len() > limits.max_categories {
return Err(format!(
"VegaRect の x_labels 数 {} が max_categories 上限 {} を超えています",
x_labels.len(),
limits.max_categories,
));
}
if y_labels.len() > limits.max_categories {
return Err(format!(
"VegaRect の y_labels 数 {} が max_categories 上限 {} を超えています",
y_labels.len(),
limits.max_categories,
));
}
if cells.len() != y_labels.len() {
return Err(format!(
"VegaRect cells 行数 {} と y_labels 数 {} が不一致",
cells.len(),
y_labels.len(),
));
}
for (row_i, row) in cells.iter().enumerate() {
if row.len() != x_labels.len() {
return Err(format!(
"VegaRect cells 行 {} の列数 {} と x_labels 数 {} が不一致",
row_i,
row.len(),
x_labels.len(),
));
}
}
let grid = x_labels.len().saturating_mul(y_labels.len());
if grid > limits.max_categorical_primitives {
return Err(format!(
"VegaRect の grid セル数 {} (x_labels {} × y_labels {}) が上限 {} を超えています",
grid,
x_labels.len(),
y_labels.len(),
limits.max_categorical_primitives,
));
}
}
if let crate::ir::ChartKind::OutlabeledPie { ref outlabel, .. } = spec.kind {
if matches!(
spec.legend,
crate::ir::LegendPos::Left | crate::ir::LegendPos::Right
) {
return Err("outlabeledPie では Left/Right 凡例はサポートされていません".to_string());
}
if outlabel.text.len() > limits.max_label_bytes {
return Err(format!(
"outlabel.text の長さ {} バイトが上限 {} を超えています",
outlabel.text.len(),
limits.max_label_bytes,
));
}
let n_lines = outlabel.text.split('\n').count().max(1);
let prims_per_slice = 3 + n_lines;
let slices = spec
.series
.first()
.map(|s| {
s.values
.iter()
.filter(|v| v.is_finite() && **v > 0.0)
.count()
})
.unwrap_or(0);
let outlabeled_primitives = slices.saturating_mul(prims_per_slice);
if outlabeled_primitives > limits.max_categorical_primitives {
return Err(format!(
"outlabeledPie: スライス数 {} × {} プリミティブ = {} が上限 {} を超えます",
slices, prims_per_slice, outlabeled_primitives, limits.max_categorical_primitives,
));
}
let expanded_text_bytes = estimate_outlabel_expanded_bytes(spec, &outlabel.text);
if expanded_text_bytes > limits.max_categorical_primitives {
return Err(format!(
"outlabeledPie: 展開後ラベル文字列 {} バイトが上限 {} を超えます",
expanded_text_bytes, limits.max_categorical_primitives,
));
}
}
let mut tree_points = 0usize;
for s in &spec.series {
tree_points += validate_tree(&s.tree, 0, limits)?;
}
let link_points: usize = spec.series.iter().map(|s| s.links.len()).sum();
let total_points: usize = spec
.series
.iter()
.map(|s| s.values.len().max(s.points.len()).max(s.box_points.len()))
.sum::<usize>()
+ tree_points
+ link_points;
if total_points > limits.max_total_data_points {
return Err(format!(
"全系列のデータ点数合計 {} が上限 {} を超えています",
total_points, limits.max_total_data_points,
));
}
if let Some(title) = &spec.title
&& title.len() > limits.max_label_bytes
{
return Err(format!(
"タイトルの長さ {} バイトが上限 {} を超えています",
title.len(),
limits.max_label_bytes,
));
}
for cat in &spec.categories {
if cat.len() > limits.max_label_bytes {
return Err(format!(
"カテゴリラベルの長さ {} バイトが上限 {} を超えています",
cat.len(),
limits.max_label_bytes,
));
}
}
for ser in &spec.series {
if ser.name.len() > limits.max_label_bytes {
return Err(format!(
"系列名の長さ {} バイトが上限 {} を超えています",
ser.name.len(),
limits.max_label_bytes,
));
}
}
if let crate::ir::ChartKind::WordCloud {
entries,
min_rotation,
max_rotation,
padding,
..
} = &spec.kind
{
if entries.len() > MAX_WORDCLOUD_WORDS {
return Err(format!(
"wordcloud の単語数 {} が上限 {} を超えています",
entries.len(),
MAX_WORDCLOUD_WORDS,
));
}
if !padding.is_finite() || *padding < 0.0 {
return Err(format!(
"wordcloud: padding は 0 以上の有限値でなければなりません: {padding}"
));
}
if !min_rotation.is_finite() || !max_rotation.is_finite() {
return Err("wordcloud: 回転角度は有限値でなければなりません".to_string());
}
for e in entries {
if e.text.len() > MAX_WORDCLOUD_WORD_BYTES {
return Err(format!(
"wordcloud: 単語が長すぎます ({}バイト > 上限 {}バイト)",
e.text.len(),
MAX_WORDCLOUD_WORD_BYTES,
));
}
if !e.size.is_finite() || e.size <= 0.0 {
return Err(format!(
"wordcloud: 単語サイズは正の有限値でなければなりません: {}",
e.size
));
}
}
}
if let crate::ir::ChartKind::Sankey {
labels, columns, ..
} = &spec.kind
{
let links = spec.series.first().map(|s| s.links.len()).unwrap_or(0);
if links > MAX_SANKEY_LINKS {
return Err(format!(
"sankey link count {} exceeds limit {}",
links, MAX_SANKEY_LINKS,
));
}
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut total_flow = 0.0_f64;
if let Some(s) = spec.series.first() {
for link in &s.links {
for key in [link.from.as_str(), link.to.as_str()] {
if seen.insert(key) && key.len() > limits.max_label_bytes {
return Err(format!(
"sankey node label length {} bytes exceeds limit {}",
key.len(),
limits.max_label_bytes,
));
}
}
total_flow += link.flow;
}
}
if seen.len() > MAX_SANKEY_NODES {
return Err(format!(
"sankey node count {} exceeds limit {}",
seen.len(),
MAX_SANKEY_NODES,
));
}
if !total_flow.is_finite() {
return Err(format!(
"sankey total flow is non-finite (values too large): {total_flow}"
));
}
for (key, label) in labels {
if label.len() > limits.max_label_bytes {
return Err(format!(
"sankey label override ({}) length {} bytes exceeds limit {}",
key,
label.len(),
limits.max_label_bytes,
));
}
}
for (key, &col) in columns {
if seen.contains(key.as_str()) && col >= MAX_SANKEY_NODES {
return Err(format!(
"sankey manual column ({})={} must be below limit {}",
key, col, MAX_SANKEY_NODES,
));
}
}
}
Ok(())
}
fn estimate_outlabel_expanded_bytes(spec: &ChartSpec, template: &str) -> usize {
let Some(series) = spec.series.first() else {
return 0;
};
let mut num_l = 0usize;
let mut num_v = 0usize;
let mut num_p = 0usize;
let mut other_len = 0usize;
let mut chars = template.chars().peekable();
while let Some(c) = chars.next() {
if c == '%' {
match chars.peek() {
Some(&'l') => {
chars.next();
num_l = num_l.saturating_add(1);
}
Some(&'v') => {
chars.next();
num_v = num_v.saturating_add(1);
}
Some(&'p') => {
chars.next();
num_p = num_p.saturating_add(1);
}
_ => {
other_len = other_len.saturating_add(c.len_utf8());
}
}
} else {
other_len = other_len.saturating_add(c.len_utf8());
}
}
series
.values
.iter()
.enumerate()
.filter(|(_, v)| v.is_finite() && **v > 0.0)
.map(|(idx, value)| {
let label_len = spec.categories.get(idx).map(|s| s.len()).unwrap_or(0);
let value_len = fmt_num(*value).len();
const PCT_LEN_BOUND: usize = 3;
other_len
.saturating_add(num_l.saturating_mul(label_len))
.saturating_add(num_v.saturating_mul(value_len))
.saturating_add(num_p.saturating_mul(PCT_LEN_BOUND))
})
.fold(0usize, usize::saturating_add)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::frontend::chartjs;
fn base_spec() -> ChartSpec {
chartjs::parse(
r#"{"type":"bar","data":{"labels":["a"],"datasets":[{"data":[1]}]}}"#,
false,
)
.unwrap()
}
fn default_limits() -> InputLimits {
InputLimits::default()
}
#[test]
fn valid_spec_passes() {
assert!(validate_spec(&base_spec(), &default_limits()).is_ok());
}
#[test]
fn zero_width_is_rejected() {
let mut s = base_spec();
s.width = 0.0;
assert!(validate_spec(&s, &default_limits()).is_err());
}
#[test]
fn negative_height_is_rejected() {
let mut s = base_spec();
s.height = -1.0;
assert!(validate_spec(&s, &default_limits()).is_err());
}
#[test]
fn max_dimension_is_accepted() {
let mut s = base_spec();
s.width = DEFAULT_MAX_DIMENSION_PX;
s.height = DEFAULT_MAX_DIMENSION_PX;
assert!(validate_spec(&s, &default_limits()).is_ok());
}
#[test]
fn over_max_dimension_is_rejected() {
let mut s = base_spec();
s.width = DEFAULT_MAX_DIMENSION_PX + 1.0;
assert!(validate_spec(&s, &default_limits()).is_err());
}
#[test]
fn nan_dimension_is_rejected() {
let mut s = base_spec();
s.width = f64::NAN;
assert!(validate_spec(&s, &default_limits()).is_err());
}
#[test]
fn too_many_series_is_rejected() {
use crate::ir::{Color, Series, SeriesType};
let mut s = base_spec();
let dummy = Series {
name: String::new(),
values: vec![1.0],
points: vec![],
fill: vec![Color {
r: 0,
g: 0,
b: 0,
a: 1.0,
}],
stroke: vec![],
stroke_width: 1.0,
area: false,
tension: 0.0,
series_type: SeriesType::Bar,
point_radius: None,
box_points: vec![],
tree: vec![],
links: vec![],
};
s.series = vec![dummy; DEFAULT_MAX_SERIES + 1];
assert!(validate_spec(&s, &default_limits()).is_err());
}
#[test]
fn too_many_categories_is_rejected() {
let mut s = base_spec();
s.categories = vec!["x".to_string(); DEFAULT_MAX_CATEGORIES + 1];
assert!(validate_spec(&s, &default_limits()).is_err());
}
#[test]
fn too_many_categorical_primitives_is_rejected() {
use crate::ir::{Color, Series, SeriesType};
let mut s = base_spec();
let dummy = Series {
name: String::new(),
values: vec![1.0],
points: vec![],
fill: vec![Color {
r: 0,
g: 0,
b: 0,
a: 1.0,
}],
stroke: vec![],
stroke_width: 1.0,
area: false,
tension: 0.0,
series_type: SeriesType::Bar,
point_radius: None,
box_points: vec![],
tree: vec![],
links: vec![],
};
s.series = vec![dummy; 100];
s.categories = vec!["x".to_string(); 10_001];
assert!(validate_spec(&s, &default_limits()).is_err());
}
#[test]
fn categorical_primitives_at_limit_is_accepted() {
use crate::ir::{Color, Series, SeriesType};
let mut s = base_spec();
let dummy = Series {
name: String::new(),
values: vec![1.0],
points: vec![],
fill: vec![Color {
r: 0,
g: 0,
b: 0,
a: 1.0,
}],
stroke: vec![],
stroke_width: 1.0,
area: false,
tension: 0.0,
series_type: SeriesType::Bar,
point_radius: None,
box_points: vec![],
tree: vec![],
links: vec![],
};
s.series = vec![dummy; 100];
s.categories = vec!["x".to_string(); 10_000];
assert!(validate_spec(&s, &default_limits()).is_ok());
}
#[test]
fn too_many_total_data_points_is_rejected() {
use crate::ir::{Color, Series, SeriesType};
let mut s = base_spec();
let big_series = Series {
name: String::new(),
values: vec![1.0; DEFAULT_MAX_TOTAL_DATA_POINTS + 1],
points: vec![],
fill: vec![Color {
r: 0,
g: 0,
b: 0,
a: 1.0,
}],
stroke: vec![],
stroke_width: 1.0,
area: false,
tension: 0.0,
series_type: SeriesType::Bar,
point_radius: None,
box_points: vec![],
tree: vec![],
links: vec![],
};
s.series = vec![big_series];
assert!(validate_spec(&s, &default_limits()).is_err());
}
#[test]
fn too_many_progress_bars_is_rejected() {
let mut s = chartjs::parse(
r#"{"type":"progress","data":{"datasets":[{"data":[1]}]}}"#,
false,
)
.unwrap();
s.series[0].values = vec![1.0; DEFAULT_MAX_CATEGORICAL_PRIMITIVES / 4 + 1];
assert!(validate_spec(&s, &default_limits()).is_err());
}
#[test]
fn progress_bars_within_limit_is_accepted() {
let mut s = chartjs::parse(
r#"{"type":"progress","data":{"datasets":[{"data":[1]}]}}"#,
false,
)
.unwrap();
s.series[0].values = vec![1.0; DEFAULT_MAX_CATEGORICAL_PRIMITIVES / 4];
assert!(validate_spec(&s, &default_limits()).is_ok());
}
#[test]
fn long_label_is_rejected() {
let mut s = base_spec();
s.categories = vec!["x".repeat(DEFAULT_MAX_LABEL_BYTES + 1)];
assert!(validate_spec(&s, &default_limits()).is_err());
}
#[test]
fn long_series_name_is_rejected() {
let mut s = base_spec();
s.series[0].name = "x".repeat(DEFAULT_MAX_LABEL_BYTES + 1);
assert!(validate_spec(&s, &default_limits()).is_err());
}
#[test]
fn long_title_is_rejected() {
let mut s = base_spec();
s.title = Some("x".repeat(DEFAULT_MAX_LABEL_BYTES + 1));
assert!(validate_spec(&s, &default_limits()).is_err());
}
#[test]
fn outlabeled_pie_primitive_guard_rejects_too_many_slices() {
use crate::ir::OutlabelConfig;
let mut spec = chartjs::parse(
r#"{"type":"outlabeledPie","data":{"labels":["A"],"datasets":[{"data":[1]}]}}"#,
false,
)
.unwrap();
spec.kind = crate::ir::ChartKind::OutlabeledPie {
donut_ratio: 0.0,
outlabel: OutlabelConfig::default(),
};
spec.series[0].values = vec![1.0; 200_001];
let limits = default_limits();
let result = validate_spec(&spec, &limits);
assert!(result.is_err(), "must reject too many outlabel primitives");
}
#[test]
fn outlabeled_pie_rejects_amplified_outlabel_text() {
use crate::ir::OutlabelConfig;
let mut spec = chartjs::parse(
r#"{"type":"outlabeledPie","data":{"labels":["A"],"datasets":[{"data":[1]}]}}"#,
false,
)
.unwrap();
let outlabel = OutlabelConfig {
text: "x".repeat(DEFAULT_MAX_LABEL_BYTES),
..OutlabelConfig::default()
};
spec.kind = crate::ir::ChartKind::OutlabeledPie {
donut_ratio: 0.0,
outlabel,
};
spec.categories = vec!["A".to_string(); 1_000];
spec.series[0].values = vec![1.0; 1_000];
let result = validate_spec(&spec, &default_limits());
assert!(
result.is_err(),
"must reject aggregate outlabel text amplification"
);
}
#[test]
fn outlabeled_pie_allows_default_template_many_slices() {
let mut spec = chartjs::parse(
r#"{"type":"outlabeledPie","data":{"labels":["A"],"datasets":[{"data":[1]}]}}"#,
false,
)
.unwrap();
spec.kind = crate::ir::ChartKind::OutlabeledPie {
donut_ratio: 0.0,
outlabel: crate::ir::OutlabelConfig::default(),
};
spec.categories = vec!["A".to_string(); 100_000];
spec.series[0].values = vec![1.0; 100_000];
assert!(validate_spec(&spec, &default_limits()).is_ok());
}
#[test]
fn outlabeled_pie_allows_small_template_many_slices() {
use crate::ir::OutlabelConfig;
let mut spec = chartjs::parse(
r#"{"type":"outlabeledPie","data":{"labels":["A"],"datasets":[{"data":[1]}]}}"#,
false,
)
.unwrap();
let outlabel = OutlabelConfig {
text: "%l".to_string(),
..OutlabelConfig::default()
};
spec.kind = crate::ir::ChartKind::OutlabeledPie {
donut_ratio: 0.0,
outlabel,
};
spec.categories = vec!["A".to_string(); 100_000];
spec.series[0].values = vec![1.0; 100_000];
assert!(validate_spec(&spec, &default_limits()).is_ok());
}
#[test]
fn custom_limits_override_defaults() {
let mut s = base_spec();
let strict = InputLimits {
max_series: 0,
..InputLimits::default()
};
assert!(validate_spec(&s, &strict).is_err());
s.width = 50_000.0;
let relaxed = InputLimits {
max_dimension_px: 100_000.0,
..InputLimits::default()
};
assert!(validate_spec(&s, &relaxed).is_ok());
}
#[test]
fn treemap_tree_nodes_count_toward_total_points() {
let spec = crate::frontend::chartjs::parse(
r#"{"type":"treemap","data":{"datasets":[{"tree":[1,2,3,4,5]}]}}"#,
false,
)
.unwrap();
let mut limits = default_limits();
limits.max_total_data_points = 4; assert!(validate_spec(&spec, &limits).is_err());
limits.max_total_data_points = 5;
assert!(validate_spec(&spec, &limits).is_ok());
}
#[test]
fn treemap_deep_tree_is_rejected() {
use crate::ir::TreeNode;
let mut node = TreeNode {
label: "leaf".into(),
value: 1.0,
children: vec![],
};
for _ in 0..(DEFAULT_MAX_TREE_DEPTH + 2) {
node = TreeNode {
label: "g".into(),
value: 1.0,
children: vec![node],
};
}
let mut spec = crate::frontend::chartjs::parse(
r#"{"type":"treemap","data":{"datasets":[{"tree":[1]}]}}"#,
false,
)
.unwrap();
spec.series[0].tree = vec![node];
assert!(validate_spec(&spec, &default_limits()).is_err());
}
#[test]
fn treemap_tree_at_max_depth_is_accepted() {
use crate::ir::TreeNode;
let mut node = TreeNode {
label: "leaf".into(),
value: 1.0,
children: vec![],
};
for _ in 0..DEFAULT_MAX_TREE_DEPTH {
node = TreeNode {
label: "g".into(),
value: 1.0,
children: vec![node],
};
}
let mut spec = crate::frontend::chartjs::parse(
r#"{"type":"treemap","data":{"datasets":[{"tree":[1]}]}}"#,
false,
)
.unwrap();
spec.series[0].tree = vec![node];
assert!(validate_spec(&spec, &default_limits()).is_ok());
}
#[test]
fn treemap_oversized_label_is_rejected() {
use crate::ir::TreeNode;
let mut spec = crate::frontend::chartjs::parse(
r#"{"type":"treemap","data":{"datasets":[{"tree":[1]}]}}"#,
false,
)
.unwrap();
spec.series[0].tree = vec![TreeNode {
label: "x".repeat(DEFAULT_MAX_LABEL_BYTES + 1),
value: 1.0,
children: vec![],
}];
assert!(validate_spec(&spec, &default_limits()).is_err());
}
#[test]
fn wordcloud_too_many_words() {
use crate::ir::{ChartKind, WordEntry};
let entries: Vec<WordEntry> = (0..=500)
.map(|i| WordEntry {
text: format!("word{i}"),
size: 12.0,
color: None,
})
.collect();
let s = ChartSpec {
kind: ChartKind::WordCloud {
entries,
min_rotation: -90.0,
max_rotation: 0.0,
rotation_steps: 2,
padding: 2.0,
},
series: vec![],
categories: vec![],
..base_spec()
};
assert!(validate_spec(&s, &default_limits()).is_err());
}
#[test]
fn wordcloud_label_too_long() {
use crate::ir::{ChartKind, WordEntry};
let s = ChartSpec {
kind: ChartKind::WordCloud {
entries: vec![WordEntry {
text: "a".repeat(201),
size: 12.0,
color: None,
}],
min_rotation: -90.0,
max_rotation: 0.0,
rotation_steps: 2,
padding: 2.0,
},
series: vec![],
categories: vec![],
..base_spec()
};
assert!(validate_spec(&s, &default_limits()).is_err());
}
#[test]
fn wordcloud_valid_passes_guard() {
use crate::ir::{ChartKind, WordEntry};
let s = ChartSpec {
kind: ChartKind::WordCloud {
entries: vec![
WordEntry {
text: "Rust".to_string(),
size: 80.0,
color: None,
},
WordEntry {
text: "SVG".to_string(),
size: 60.0,
color: None,
},
],
min_rotation: -90.0,
max_rotation: 0.0,
rotation_steps: 2,
padding: 2.0,
},
series: vec![],
categories: vec![],
..base_spec()
};
assert!(validate_spec(&s, &default_limits()).is_ok());
}
#[test]
fn wordcloud_negative_padding_rejected() {
use crate::ir::{ChartKind, WordEntry};
let s = ChartSpec {
kind: ChartKind::WordCloud {
entries: vec![WordEntry {
text: "A".to_string(),
size: 12.0,
color: None,
}],
min_rotation: -90.0,
max_rotation: 0.0,
rotation_steps: 2,
padding: -1.0,
},
series: vec![],
categories: vec![],
..base_spec()
};
assert!(validate_spec(&s, &default_limits()).is_err());
}
#[test]
fn wordcloud_nonfinite_padding_rejected() {
use crate::ir::{ChartKind, WordEntry};
let s = ChartSpec {
kind: ChartKind::WordCloud {
entries: vec![WordEntry {
text: "A".to_string(),
size: 12.0,
color: None,
}],
min_rotation: -90.0,
max_rotation: 0.0,
rotation_steps: 2,
padding: f64::NAN,
},
series: vec![],
categories: vec![],
..base_spec()
};
assert!(validate_spec(&s, &default_limits()).is_err());
}
#[test]
fn wordcloud_nonfinite_rotation_rejected() {
use crate::ir::{ChartKind, WordEntry};
let s = ChartSpec {
kind: ChartKind::WordCloud {
entries: vec![WordEntry {
text: "A".to_string(),
size: 12.0,
color: None,
}],
min_rotation: f64::INFINITY,
max_rotation: 0.0,
rotation_steps: 2,
padding: 2.0,
},
series: vec![],
categories: vec![],
..base_spec()
};
assert!(validate_spec(&s, &default_limits()).is_err());
}
#[test]
fn wordcloud_zero_size_rejected() {
use crate::ir::{ChartKind, WordEntry};
let s = ChartSpec {
kind: ChartKind::WordCloud {
entries: vec![WordEntry {
text: "A".to_string(),
size: 0.0,
color: None,
}],
min_rotation: -90.0,
max_rotation: 0.0,
rotation_steps: 2,
padding: 2.0,
},
series: vec![],
categories: vec![],
..base_spec()
};
assert!(validate_spec(&s, &default_limits()).is_err());
}
fn sankey_spec_with_links(links: Vec<crate::ir::SankeyLink>) -> ChartSpec {
let mut s = chartjs::parse(
r#"{"type":"sankey","data":{"datasets":[{"data":[{"from":"A","to":"B","flow":1}]}]}}"#,
false,
)
.unwrap();
s.series[0].links = links;
s
}
fn link(from: &str, to: &str) -> crate::ir::SankeyLink {
crate::ir::SankeyLink {
from: from.to_string(),
to: to.to_string(),
flow: 1.0,
color_from: None,
color_to: None,
}
}
#[test]
fn sankey_link_count_over_limit_rejected() {
let links = vec![link("A", "B"); MAX_SANKEY_LINKS + 1];
let spec = sankey_spec_with_links(links);
assert!(validate_spec(&spec, &default_limits()).is_err());
}
#[test]
fn sankey_node_count_over_limit_rejected() {
let links: Vec<_> = (0..=MAX_SANKEY_NODES)
.map(|i| {
let key = format!("n{i}");
link(&key, &key)
})
.collect();
let spec = sankey_spec_with_links(links);
assert!(validate_spec(&spec, &default_limits()).is_err());
}
#[test]
fn sankey_within_limits_ok() {
let links = vec![
link("A", "B"),
link("A", "C"),
link("B", "D"),
link("C", "D"),
];
let spec = sankey_spec_with_links(links);
assert!(validate_spec(&spec, &default_limits()).is_ok());
}
#[test]
fn sankey_node_label_too_long_rejected() {
let long = "x".repeat(DEFAULT_MAX_LABEL_BYTES + 1);
let spec = sankey_spec_with_links(vec![link(&long, "B")]);
assert!(validate_spec(&spec, &default_limits()).is_err());
}
#[test]
fn sankey_links_count_toward_total_points() {
let spec = sankey_spec_with_links(vec![link("A", "B"), link("B", "C"), link("C", "D")]);
let mut limits = default_limits();
limits.max_total_data_points = 2; assert!(validate_spec(&spec, &limits).is_err());
limits.max_total_data_points = 3;
assert!(validate_spec(&spec, &limits).is_ok());
}
#[test]
fn sankey_huge_manual_column_rejected() {
let mut spec = sankey_spec_with_links(vec![link("A", "B")]);
if let crate::ir::ChartKind::Sankey { columns, .. } = &mut spec.kind {
columns.insert("B".to_string(), MAX_SANKEY_NODES);
}
assert!(validate_spec(&spec, &default_limits()).is_err());
let mut ok = sankey_spec_with_links(vec![link("A", "B")]);
if let crate::ir::ChartKind::Sankey { columns, .. } = &mut ok.kind {
columns.insert("B".to_string(), MAX_SANKEY_NODES - 1);
}
assert!(validate_spec(&ok, &default_limits()).is_ok());
}
#[test]
fn sankey_oversized_label_override_rejected() {
let long = "x".repeat(DEFAULT_MAX_LABEL_BYTES + 1);
let mut spec = sankey_spec_with_links(vec![link("A", "B")]);
if let crate::ir::ChartKind::Sankey { labels, .. } = &mut spec.kind {
labels.insert("A".to_string(), long);
}
assert!(validate_spec(&spec, &default_limits()).is_err());
}
fn flow_link(from: &str, to: &str, flow: f64) -> crate::ir::SankeyLink {
crate::ir::SankeyLink {
from: from.into(),
to: to.into(),
flow,
color_from: None,
color_to: None,
}
}
#[test]
fn sankey_non_finite_flow_total_rejected() {
let spec =
sankey_spec_with_links(vec![flow_link("A", "B", 1e308), flow_link("A", "C", 1e308)]);
assert!(validate_spec(&spec, &default_limits()).is_err());
}
#[test]
fn sankey_aggregate_flow_overflow_rejected() {
let spec =
sankey_spec_with_links(vec![flow_link("A", "B", 1e308), flow_link("C", "D", 1e308)]);
assert!(validate_spec(&spec, &default_limits()).is_err());
let ok = sankey_spec_with_links(vec![flow_link("A", "B", 1e308)]);
assert!(validate_spec(&ok, &default_limits()).is_ok());
}
fn rect_spec_2x2() -> ChartSpec {
crate::frontend::vegalite::parse(
r#"{
"mark": "rect",
"data": {"values": [
{"x":"A","y":"X","v":1},
{"x":"B","y":"X","v":2},
{"x":"A","y":"Y","v":3},
{"x":"B","y":"Y","v":4}
]},
"encoding": {
"x": {"field":"x"},
"y": {"field":"y"},
"color": {"field":"v","type":"quantitative"}
}
}"#,
false,
)
.unwrap()
}
#[test]
fn vega_rect_oversized_label_is_rejected() {
let mut spec = rect_spec_2x2();
if let crate::ir::ChartKind::VegaRect { x_labels, .. } = &mut spec.kind {
x_labels[0] = "x".repeat(DEFAULT_MAX_LABEL_BYTES + 1);
}
assert!(validate_spec(&spec, &default_limits()).is_err());
}
#[test]
fn vega_rect_grid_over_primitive_cap_is_rejected() {
let mut spec = rect_spec_2x2();
if let crate::ir::ChartKind::VegaRect {
x_labels,
y_labels,
cells,
} = &mut spec.kind
{
*x_labels = (0..1001).map(|i| format!("x{i}")).collect();
*y_labels = (0..1001).map(|i| format!("y{i}")).collect();
*cells = vec![vec![None; x_labels.len()]; y_labels.len()];
}
assert!(validate_spec(&spec, &default_limits()).is_err());
}
#[test]
fn vega_rect_within_grid_cap_is_accepted() {
let spec = rect_spec_2x2();
assert!(validate_spec(&spec, &default_limits()).is_ok());
}
#[test]
fn vega_rect_asymmetric_x_over_max_categories_is_rejected() {
let limits = default_limits();
let n = limits.max_categories + 1;
let mut spec = rect_spec_2x2();
if let crate::ir::ChartKind::VegaRect {
x_labels,
y_labels,
cells,
} = &mut spec.kind
{
*x_labels = (0..n).map(|i| format!("x{i}")).collect();
*y_labels = vec!["y".to_string()];
*cells = vec![vec![None; n]];
}
let err = validate_spec(&spec, &limits).unwrap_err();
assert!(
err.contains("x_labels"),
"expected x_labels error, got: {err}"
);
}
#[test]
fn vega_rect_asymmetric_y_over_max_categories_is_rejected() {
let limits = default_limits();
let n = limits.max_categories + 1;
let mut spec = rect_spec_2x2();
if let crate::ir::ChartKind::VegaRect {
x_labels,
y_labels,
cells,
} = &mut spec.kind
{
*x_labels = vec!["x".to_string()];
*y_labels = (0..n).map(|i| format!("y{i}")).collect();
*cells = vec![vec![None]; n];
}
let err = validate_spec(&spec, &limits).unwrap_err();
assert!(
err.contains("y_labels"),
"expected y_labels error, got: {err}"
);
}
#[test]
fn vega_rect_cells_shape_mismatch_is_rejected() {
let mut spec = rect_spec_2x2();
if let crate::ir::ChartKind::VegaRect {
x_labels,
y_labels,
cells,
} = &mut spec.kind
{
*x_labels = vec!["A".to_string(), "B".to_string()];
*y_labels = vec!["X".to_string()];
*cells = vec![vec![None]];
}
let err = validate_spec(&spec, &default_limits()).unwrap_err();
assert!(
err.contains("cells") || err.contains("不一致"),
"expected shape mismatch error, got: {err}"
);
}
#[test]
fn wordcloud_negative_size_rejected() {
use crate::ir::{ChartKind, WordEntry};
let s = ChartSpec {
kind: ChartKind::WordCloud {
entries: vec![WordEntry {
text: "A".to_string(),
size: -5.0,
color: None,
}],
min_rotation: -90.0,
max_rotation: 0.0,
rotation_steps: 2,
padding: 2.0,
},
series: vec![],
categories: vec![],
..base_spec()
};
assert!(validate_spec(&s, &default_limits()).is_err());
}
}