Files
vectorcraft/crates/engine/src/cmd/graph/tests.rs
T

4201 lines
241 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use serde_json::json;
use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::{CharStyle, ColumnDesign, ColumnType, Fractions, GraphKind, GraphSpec, NodeKind};
use crate::{NodeId, Session};
#[test]
fn fmt_value_does_not_saturate_beyond_i64() {
assert_eq!(super::fmt_value(1e19), "10000000000000000000");
assert_eq!(super::fmt_value(2e19), "20000000000000000000");
assert_eq!(super::fmt_value(-2e19), "-20000000000000000000");
assert_eq!(super::fmt_value(42.0), "42");
assert_eq!(super::fmt_value(-0.0), "0");
assert_eq!(super::fmt_value(2.5), "2.5");
}
fn graph(s: &mut Session, ty: &str) -> NodeId {
let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6";
NodeId(
s.execute("graph.create", &json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"].as_u64().unwrap(),
)
}
fn group<'a>(n: &'a vectorcraft_doc::Node, name: &str) -> &'a vectorcraft_doc::Node {
n.children().unwrap().iter().find(|c| c.name.as_deref() == Some(name)).unwrap_or_else(|| panic!("no {name}"))
}
/// The generated group stored for series `index`, not a sibling that happens to share its name.
fn series(n: &vectorcraft_doc::Node, index: u32) -> &vectorcraft_doc::Node {
n.children().unwrap().iter().find(|c| c.extras().series_index == Some(index)).unwrap_or_else(|| panic!("no series {index}"))
}
#[test]
fn every_graph_type_generates_series_groups() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
for ty in ["column", "stackedColumn", "bar", "stackedBar", "line", "area", "scatter", "pie", "radar"] {
let id = graph(&mut s, ty);
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
assert!(n.extras().graph.is_some(), "{ty}");
if ty != "scatter" {
assert!(!group(&n, "2024").children().unwrap().is_empty(), "{ty}");
assert!(!group(&n, "2025").children().unwrap().is_empty(), "{ty}");
}
assert!(n.geometric_bounds().unwrap().width() > 100.0, "{ty}");
}
}
#[test]
fn column_heights_follow_data_and_data_edits_regenerate_in_place() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let heights = |s: &Session| -> Vec<f64> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
// Bars only (the legend swatch is the 8 × 8 square at the end).
let g = group(&n, "2025");
let ch = g.children().unwrap();
ch[..ch.len() - 1].iter().map(|c| c.geometric_bounds().unwrap().height()).collect()
};
let h = heights(&s);
assert!((h[2] / h[0] - 3.0).abs() < 1e-6, "{h:?}");
// Move the graph, then change the data: it stays where it was moved.
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("object.move", &json!({"dx": 50, "dy": 0})).unwrap();
let x0 = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0;
let v = s.execute("graph.setData", &json!({})).unwrap();
assert!(v["csv"].as_str().unwrap().contains("Q2,5,4"));
s.execute("graph.setData", &json!({"csv": ",2024,2025\nQ1,3,2\nQ2,5,8\nQ3,-1,6"})).unwrap();
let h2 = heights(&s);
assert!((h2[1] / h2[0] - 4.0).abs() < 1e-6, "{h2:?}");
assert!((s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0 - x0).abs() < 1.0);
// Type change keeps the data; undo restores the columns.
s.execute("graph.setType", &json!({"type": "pie"})).unwrap();
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().kind, vectorcraft_doc::GraphKind::Pie);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["type"], json!("column"));
// Selecting a bar inside still targets the graph.
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let bar = group(&n, "2024").children().unwrap()[0].id;
s.execute("select.set", &json!({"ids": [bar.0]})).unwrap();
assert!(s.execute("graph.setData", &json!({})).is_ok());
assert!(matches!(n.kind, NodeKind::Group { .. }));
}
/// The x of every marker of series `index`, left to right.
fn marker_xs(s: &Session, id: NodeId, index: u32) -> Vec<f64> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let mut xs: Vec<f64> = series(&n, index)
.children()
.unwrap()
.iter()
.map(|c| c.geometric_bounds().unwrap())
.filter(|b| (b.width() - 5.0).abs() < 1e-6)
.map(|b| b.center().x)
.collect();
xs.sort_by(f64::total_cmp);
xs
}
#[test]
fn edge_to_edge_lines_off_puts_line_points_at_category_centres() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
// 300 pt wide, three categories: centres at 150, 250, 350 (off by default); edges at 100 and 400.
let id = graph(&mut s, "line");
assert_eq!(marker_xs(&s, id, 0), vec![150.0, 250.0, 350.0]);
assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["edgeToEdge"], json!(false));
s.execute("graph.setType", &json!({"edgeToEdge": true})).unwrap();
assert_eq!(marker_xs(&s, id, 0), vec![100.0, 250.0, 400.0]);
// A graph saved before the option existed keeps its edge-to-edge lines.
let old: GraphSpec = serde_json::from_value(json!({"kind": "line"})).unwrap();
assert!(old.edge_to_edge);
}
#[test]
fn radar_graphs_follow_mark_and_connect_data_points() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "radar");
let parts = |s: &Session| series(&s.doc().unwrap().doc.node(id).unwrap().clone(), 0).children().unwrap().len();
// The ring, three markers and the legend swatch.
assert_eq!(parts(&s), 5);
s.execute("graph.setType", &json!({"markPoints": false})).unwrap();
assert_eq!(parts(&s), 2);
s.execute("graph.setType", &json!({"markPoints": true, "connectPoints": false})).unwrap();
assert_eq!(parts(&s), 4);
}
#[test]
fn an_overridden_value_axis_is_used_exactly() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let r = s.execute("graph.create", &json!({"type": "column", "x": 20, "y": 20, "width": 170, "height": 74, "series": ["a"], "categories": ["x", "y"], "rows": [[4839], [10488]]})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
let tallest = |s: &Session| -> f64 {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let bars = series(&n, 0).children().unwrap();
bars.iter().map(|c| c.geometric_bounds().unwrap().height()).fold(0.0, f64::max)
};
// Calculated: a nice axis (0 to 12500 here), so the 10488 column falls short of 10488 / 12000 of the plot.
assert!((tallest(&s) - 74.0 * 10488.0 / 12000.0).abs() > 1.0);
s.execute("graph.setType", &json!({"axisMin": 0, "axisMax": 12000})).unwrap();
assert!((tallest(&s) - 74.0 * 10488.0 / 12000.0).abs() < 1e-6, "{}", tallest(&s));
let o = s.execute("graph.setType", &json!({})).unwrap();
assert_eq!((o["axisMin"].as_f64(), o["axisMax"].as_f64()), (Some(0.0), Some(12000.0)));
// One bound only keeps the calculated (nice) axis around it.
s.execute("graph.setType", &json!({"axisMax": null})).unwrap();
assert!((tallest(&s) - 74.0 * 10488.0 / 12000.0).abs() > 1.0);
}
#[test]
fn a_scatter_x_axis_that_overflows_does_not_hang() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
// Finite x values whose rounded axis end is infinite: this used to add ticks forever. Now the
// command returns: points that far off the plot can't be drawn, which is an error.
let csv = ",y1,x1\nP,1,1.7e308\nQ,2,1.5e308";
let refused = |r: crate::Result<serde_json::Value>| assert!(r.unwrap_err().to_string().contains("exceed the canvas"));
refused(s.execute("graph.create", &json!({"type": "scatter", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})));
// The same data through Graph Data, on an ordinary scatter graph: it stays as it was.
let axes = |s: &Session, id: NodeId| group(s.doc().unwrap().doc.node(id).unwrap(), "Axes").children().unwrap().len();
let id = make(&mut s, "scatter", ",y1,x1\nP,1,10\nQ,2,20");
let before = axes(&s, id);
refused(s.execute("graph.setData", &json!({"id": id.0, "csv": csv})));
assert_eq!(axes(&s, id), before);
}
#[test]
fn tick_values_end_on_the_maximum_and_never_run_away() {
// Counted, not accumulated: an offset range keeps its last tick.
let t = super::tick_values(1e10, 1e10 + 1.0, 0.01);
assert_eq!(t.len(), 101);
assert!((t[100] - (1e10 + 1.0)).abs() < 1e-3);
// A range too wide for f64, or a step too small to move, gives no ticks or a capped list.
assert!(super::tick_values(-1.7e308, 1.7e308, f64::INFINITY).is_empty());
assert!(super::tick_values(1e20, 1e20 + 16384.0, 163.84).len() <= 1001);
assert!(super::tick_values(0.0, 1.0, 0.0).is_empty());
// An override whose span overflows falls back to the calculated axis instead of hanging.
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
graph(&mut s, "column");
s.execute("graph.setType", &json!({"axisMin": -1.7e308, "axisMax": 1.7e308})).unwrap();
}
#[test]
fn group_selection_paint_becomes_the_series_colour_and_survives_regeneration() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
for ty in GraphKind::ALL {
let id = graph(&mut s, ty.id());
let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id;
s.execute("paint.setFill", &json!({"ids": [series_id.0], "color": "#ff0000"})).unwrap();
let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone();
let restored: GraphSpec = serde_json::from_value(serde_json::to_value(&spec).unwrap()).unwrap();
assert_eq!(spec, restored);
let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0));
assert_eq!(spec.series_paints.first().and_then(|p| p.fill.clone()), Some(red.clone()), "{ty:?}");
let selected_group = group(s.doc().unwrap().doc.node(id).unwrap(), "2024");
assert!(selected_group.children().unwrap().iter().all(|n| n.appearance.fill_paint() == red), "{ty:?}");
s.execute("graph.setData", &json!({"rows": [[2, 3], [4, 5]]})).unwrap();
s.execute("graph.setType", &json!({"type": "line"})).unwrap();
let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap();
assert_eq!(spec.series_paints.first().and_then(|p| p.fill.clone()), Some(red.clone()), "{ty:?}");
let series = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap();
assert!(series.iter().any(|n| n.appearance.fill_paint() == red), "{ty:?}");
}
}
#[test]
fn painting_a_series_is_one_undoable_edit() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id;
s.execute("paint.setFill", &json!({"ids": [series_id.0], "color": "#e8573f"})).unwrap();
assert_eq!(
s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0]
.fill
.as_ref()
.and_then(Paint::color)
.unwrap()
.to_hex(),
"#e8573f"
);
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints.is_empty());
assert_eq!(
group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0].appearance.fill_paint().color(),
Some(super::default_series_color(0))
);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(
s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0]
.fill
.as_ref()
.and_then(Paint::color)
.unwrap()
.to_hex(),
"#e8573f"
);
}
/// A fill that a series' marks or a value axis number have already is one undo step when the graph
/// keeps no fill for them (as after a command that paints only the art): the art stays as it was
/// and the graph keeps the fill. Given again, the fill records no undo step.
#[test]
fn a_fill_the_art_has_already_is_kept_by_the_graph_in_one_step() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id;
let label = text_id(&s, id, "Axes", "4");
for target in [series_id, label] {
let fill = json!({"ids": [target.0], "color": "#e8573f"});
s.execute("paint.setFill", &fill).unwrap();
// The graph keeps nothing, as after a command that paints only the art.
s.edit("Forget", |d, _| {
let g = d.node_mut(id).unwrap().extras_if_any().unwrap().graph.as_deref_mut().unwrap();
g.series_paints.clear();
g.label_styles = vectorcraft_doc::LabelStyles::default();
Ok(())
})
.unwrap();
let art = s.doc().unwrap().doc.node(target).unwrap().clone();
let steps = s.doc().unwrap().history.undo.len();
s.execute("paint.setFill", &fill).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "{target:?}");
assert_eq!(s.doc().unwrap().doc.node(target).unwrap(), &art, "the art is as it was");
let g = spec(&s, id);
let kept = if target == series_id { g.series_paints[0].fill.clone() } else { g.label_styles.values.map(|st| st.fill) };
assert_eq!(kept.and_then(|p| p.color()).map(|c| c.to_hex()), Some("#e8573f".into()), "{target:?}");
s.execute("paint.setFill", &fill).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "nothing left to keep: {target:?}");
}
}
#[test]
fn painting_one_bar_sets_the_colour_used_by_the_whole_series_on_rebuild() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let bar = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0].id;
s.execute("paint.setFill", &json!({"ids": [bar.0], "color": "#12ab34"})).unwrap();
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
let node = s.doc().unwrap().doc.node(id).unwrap();
let green = vectorcraft_color::Color::rgb(18.0 / 255.0, 171.0 / 255.0, 52.0 / 255.0);
assert_eq!(node.extras().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color), Some(green));
assert!(group(node, "2024").children().unwrap().iter().all(|n| n.appearance.fill_paint().color() == Some(green)));
}
/// An opacity set on the fill or stroke item of one series mark is kept by the graph: every path of that series has
/// it after the graph is drawn again, the other series stay opaque, 100% forgets it, and undo takes it back.
#[test]
fn an_item_opacity_on_a_series_mark_survives_regeneration() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let opacities = |s: &Session, index: u32| -> Vec<(f32, f32)> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let marks = series(&n, index).children().unwrap().to_vec();
marks.iter().map(|m| (m.appearance.fill().unwrap().opacity, m.appearance.stroke().unwrap().opacity)).collect()
};
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "opacity": 40})).unwrap();
s.execute("transparency.set", &json!({"ids": [bar.0], "item": 1, "opacity": 60})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill_opacity, kept.stroke_opacity), (Some(0.4), Some(0.6)));
let restored: GraphSpec = serde_json::from_value(serde_json::to_value(spec(&s, id)).unwrap()).unwrap();
assert_eq!(restored, spec(&s, id));
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
assert!(opacities(&s, 0).iter().all(|o| *o == (0.4, 0.6)), "{:?}", opacities(&s, 0));
assert!(opacities(&s, 1).iter().all(|o| *o == (1.0, 1.0)), "{:?}", opacities(&s, 1));
// Back to 100%: the graph keeps no opacity for the fill.
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "opacity": 100})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None);
s.execute("graph.setType", &json!({"type": "bar"})).unwrap();
assert!(opacities(&s, 0).iter().all(|o| *o == (1.0, 0.6)), "{:?}", opacities(&s, 0));
// Undo goes back through the type change, the 100% and the data edit, then takes the stroke opacity away with its
// edit.
for _ in 0..4 {
s.execute("edit.undo", &json!({})).unwrap();
}
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill_opacity, kept.stroke_opacity), (Some(0.4), None));
}
/// Clear Appearance and Reduce to Basic Appearance on a series mark take its fill and stroke opacity away, and the
/// graph keeps none, so the series stays opaque when it is drawn again.
#[test]
fn clearing_a_series_marks_appearance_forgets_its_kept_opacity() {
for cmd in ["appearance.clear", "appearance.reduceToBasic"] {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "opacity": 40})).unwrap();
s.execute("transparency.set", &json!({"ids": [bar.0], "item": 1, "opacity": 60})).unwrap();
s.execute(cmd, &json!({"ids": [bar.0]})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill_opacity, kept.stroke_opacity), (None, None), "{cmd}");
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
assert!(series(&n, 0).children().unwrap().iter().all(|m| m.appearance.fill().unwrap().opacity == 1.0), "{cmd}");
}
}
/// Removing the fill whose opacity the graph keeps takes the kept opacity away; an edit that leaves a mark opaque
/// and Normal (an opacity of 100% alone) keeps nothing for its series.
#[test]
fn removing_a_series_marks_fill_forgets_its_kept_opacity() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "opacity": 100})).unwrap();
assert!(spec(&s, id).series_paints.is_empty());
s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "opacity": 40})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.4));
s.execute("appearance.removeItem", &json!({"ids": [bar.0], "index": 0})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None);
// An opaque fill added over the kept one takes it away too.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("appearance.addFill", &json!({"ids": [bar.0]})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None);
}
/// An opacity set on a row added to a graph design's copy inside a series is not kept for the series: the graph draws
/// the design again from the design itself.
#[test]
fn an_opacity_on_a_design_copy_in_a_series_is_not_kept() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
save_design(&mut s, "Square");
s.execute("graph.column", &json!({"id": id.0, "design": "Square"})).unwrap();
let copy = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone();
assert!(matches!(copy.kind, NodeKind::Group { .. }));
s.execute("appearance.addFill", &json!({"ids": [copy.id.0]})).unwrap();
s.execute("transparency.set", &json!({"ids": [copy.id.0], "item": 0, "opacity": 40})).unwrap();
assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill_opacity.is_none()));
}
/// A series' kept opacity goes on its generated marks only: a column design made of one path keeps its own opacity.
#[test]
fn a_column_design_keeps_its_own_opacity_under_a_series_opacity() {
let mut d = vectorcraft_doc::Document::new(800.0, 800.0);
let mut look = vectorcraft_doc::Appearance::basic(Paint::solid(vectorcraft_color::Color::BLACK), Paint::None, 0.0);
look.fill_mut().unwrap().opacity = 0.5;
let rect = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0));
let art = vectorcraft_doc::Node::path(d.alloc_id(), rect, look);
d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Square".into(), art: std::sync::Arc::new(art) });
let options = ColumnDesign { design: "Square".into(), column_type: ColumnType::Vertical, ..ColumnDesign::default() };
let paints = vec![vectorcraft_doc::SeriesPaint { fill_opacity: Some(0.2), ..Default::default() }];
let g = GraphSpec {
rows: vec![vec![35.0]],
axis_min: Some(0.0),
axis_max: Some(50.0),
series_columns: vec![Some(options)],
series_paints: paints,
..GraphSpec::default()
};
let out = super::generate(&mut d, &g);
let group = out.iter().find(|n| n.extras().series_index == Some(0)).unwrap();
let mut fills = vec![];
group.walk(&mut |n| {
if matches!(n.kind, NodeKind::Path { .. }) {
fills.push(n.appearance.fill().unwrap().opacity);
}
});
assert!(!fills.is_empty() && fills.iter().all(|o| *o == 0.5), "{fills:?}");
}
/// An opacity set on an item that the graph does not draw again (a second fill added to a mark) is not kept.
#[test]
fn an_opacity_on_an_added_item_of_a_series_mark_is_not_kept() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
s.execute("appearance.addFill", &json!({"ids": [bar.0]})).unwrap();
let items = s.doc().unwrap().doc.node(bar).unwrap().appearance.items.clone();
let lower = items.iter().position(vectorcraft_doc::AppearanceItem::is_fill).unwrap();
assert!(items.iter().rposition(vectorcraft_doc::AppearanceItem::is_fill).unwrap() != lower);
s.execute("transparency.set", &json!({"ids": [bar.0], "item": lower, "opacity": 40})).unwrap();
assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill_opacity.is_none()));
}
/// The fill positions (lowest, topmost) in a mark's appearance stack.
fn fill_rows(s: &Session, mark: NodeId) -> (usize, usize) {
let items = s.doc().unwrap().doc.node(mark).unwrap().appearance.items.clone();
let lower = items.iter().position(vectorcraft_doc::AppearanceItem::is_fill).unwrap();
let upper = items.iter().rposition(vectorcraft_doc::AppearanceItem::is_fill).unwrap();
assert!(lower < upper);
(lower, upper)
}
/// Moving a translucent fill to the top of a series mark's stack, or placing a copy of it there, makes the graph keep
/// its opacity.
#[test]
fn moving_or_duplicating_a_series_marks_fill_to_the_top_keeps_its_opacity() {
for cmd in ["appearance.moveItem", "appearance.duplicateItem"] {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
s.execute("appearance.addFill", &json!({"ids": [bar.0]})).unwrap();
let (lower, upper) = fill_rows(&s, bar);
s.execute("transparency.set", &json!({"ids": [bar.0], "item": lower, "opacity": 40})).unwrap();
assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill_opacity.is_none()), "{cmd}");
let p = if cmd == "appearance.moveItem" {
json!({"ids": [bar.0], "from": lower, "to": upper})
} else {
json!({"ids": [bar.0], "index": lower, "to": upper + 1})
};
s.execute(cmd, &p).unwrap();
let top = fill_rows(&s, bar).1;
assert_eq!(s.doc().unwrap().doc.node(bar).unwrap().appearance.items[top].opacity(), 0.4, "{cmd}");
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.4), "{cmd}");
}
}
/// Moving or copying a translucent mark's appearance onto a mark of another series (the Layers panel's target
/// circle, or the Eyedropper) makes the graph keep the opacity for that series; a move leaves the source series with
/// none.
#[test]
fn moving_or_copying_an_appearance_onto_a_series_mark_keeps_its_opacity() {
for (cmd, source_keeps) in [("move", None), ("copy", Some(0.4)), ("eyedropper", Some(0.4))] {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let (from, to) = (series(&n, 0).children().unwrap()[0].id, series(&n, 1).children().unwrap()[0].id);
s.execute("transparency.set", &json!({"ids": [from.0], "item": 0, "opacity": 40})).unwrap();
match cmd {
"eyedropper" => s.execute("appearance.copyFrom", &json!({"source": from.0, "ids": [to.0]})).unwrap(),
_ => s.execute("appearance.transfer", &json!({"source": from.0, "target": to.0, "copy": cmd == "copy"})).unwrap(),
};
let paints = spec(&s, id).series_paints;
assert_eq!(paints.get(1).and_then(|p| p.fill_opacity), Some(0.4), "{cmd}");
assert_eq!(paints[0].fill_opacity, source_keeps, "{cmd}");
}
}
/// A line graph keeps a stroke opacity set on a series' line, and puts it on the line drawn again.
#[test]
fn a_stroke_opacity_on_a_series_line_survives_regeneration() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "line");
let lines = |s: &Session| -> Vec<vectorcraft_doc::Node> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let marks = series(&n, 0).children().unwrap().to_vec();
marks.iter().filter(|m| m.appearance.stroke().is_some_and(|st| st.width > 0.0)).map(|m| (**m).clone()).collect()
};
let line = lines(&s).first().unwrap().clone();
let row = line.appearance.items.iter().rposition(|it| !it.is_fill()).unwrap();
s.execute("transparency.set", &json!({"ids": [line.id.0], "item": row, "opacity": 60})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].stroke_opacity, Some(0.6));
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4], [2, 5]]})).unwrap();
let after = lines(&s);
assert!(!after.is_empty() && after.iter().all(|m| m.appearance.stroke().unwrap().opacity == 0.6));
}
/// Each mark of series `index`: its object opacity and blend mode, and the blend modes of its top fill and stroke.
fn transparency_of(s: &Session, id: NodeId, index: u32) -> Vec<(f32, BlendMode, BlendMode, BlendMode)> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let marks = series(&n, index).children().unwrap().to_vec();
marks.iter().map(|m| (m.opacity, m.blend, m.appearance.fill().unwrap().blend, m.appearance.stroke().unwrap().blend)).collect()
}
/// An object opacity and blend mode set on one series mark with no fill or stroke row picked are kept by the graph:
/// every mark of that series has them after the graph is drawn again, through save and open, the other series stay
/// opaque and Normal, 100% and Normal forget them, and undo takes them back.
#[test]
fn an_object_opacity_and_blend_mode_on_a_series_mark_survive_regeneration() {
let (mut s, id, _, marks) = column_marks();
s.execute("transparency.set", &json!({"ids": [marks[0].0], "opacity": 40, "blend": "multiply"})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.opacity, kept.blend), (Some(0.4), Some(BlendMode::Multiply)));
let restored: GraphSpec = serde_json::from_value(serde_json::to_value(spec(&s, id)).unwrap()).unwrap();
assert_eq!(restored, spec(&s, id));
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
let normal = BlendMode::Normal;
assert!(transparency_of(&s, id, 0).iter().all(|t| *t == (0.4, BlendMode::Multiply, normal, normal)), "{:?}", transparency_of(&s, id, 0));
assert!(transparency_of(&s, id, 1).iter().all(|t| *t == (1.0, normal, normal, normal)), "{:?}", transparency_of(&s, id, 1));
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
s.execute("transparency.set", &json!({"ids": [bar.0], "opacity": 100, "blend": "normal"})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.opacity, kept.blend), (None, None));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(spec(&s, id).series_paints.is_empty(), "undo takes back the edit and what the graph stored");
}
/// `object.setProps` with the opacity a mark already shows replaces the opacity another mark of the series left on the
/// graph, in one undo step, and an opacity on a graph design's copy in a series is not kept.
#[test]
fn an_object_opacity_a_series_mark_already_shows_replaces_the_kept_opacity() {
let (mut s, id, _, marks) = column_marks();
s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 50})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.5));
s.execute("object.setProps", &json!({"id": marks[1].0, "opacity": 100})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].opacity, None);
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
assert!(transparency_of(&s, id, 0).iter().all(|t| t.0 == 1.0), "{:?}", transparency_of(&s, id, 0));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.5));
// Setting it again where the graph already stores it leaves the document and its history as they are.
let undo = s.doc().unwrap().history.undo.len();
s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 50})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
save_design(&mut s, "Square");
s.execute("graph.column", &json!({"id": id.0, "design": "Square"})).unwrap();
let copy = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone();
assert!(matches!(copy.kind, NodeKind::Group { .. }));
s.execute("object.setProps", &json!({"id": copy.id.0, "opacity": 50})).unwrap();
assert!(spec(&s, id).series_paints.iter().all(|p| p.opacity.is_none()));
}
/// A blend mode set on the top fill or stroke row of one series mark is kept by the graph and drawn on every mark of
/// the series again; Normal forgets it.
#[test]
fn a_row_blend_mode_on_a_series_mark_survives_regeneration() {
let (mut s, id, _, marks) = column_marks();
let (fill, stroke) = (top_row(&s, marks[0], true), top_row(&s, marks[0], false));
s.execute("transparency.set", &json!({"ids": [marks[0].0], "item": fill, "blend": "screen"})).unwrap();
s.execute("transparency.set", &json!({"ids": [marks[1].0], "item": stroke, "blend": "darken"})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill_blend, kept.stroke_blend), (Some(BlendMode::Screen), Some(BlendMode::Darken)));
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
let normal = BlendMode::Normal;
assert!(transparency_of(&s, id, 0).iter().all(|t| *t == (1.0, normal, BlendMode::Screen, BlendMode::Darken)), "{:?}", transparency_of(&s, id, 0));
assert!(transparency_of(&s, id, 1).iter().all(|t| *t == (1.0, normal, normal, normal)), "{:?}", transparency_of(&s, id, 1));
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
s.execute("transparency.set", &json!({"ids": [bar.0], "item": top_row(&s, bar, true), "blend": "normal"})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill_blend, None);
}
/// The graph stores an object's or a row's opacity and blend mode as one pair from the mark just edited: setting only
/// one half on another mark replaces both, either way round, and the redraw and undo follow the stored pair.
#[test]
fn setting_half_of_a_series_marks_transparency_stores_that_marks_whole_pair() {
let normal = BlendMode::Normal;
let (mut s, id, _, marks) = column_marks();
s.execute("transparency.set", &json!({"ids": [marks[0].0], "blend": "multiply"})).unwrap();
s.execute("object.setProps", &json!({"id": marks[1].0, "opacity": 50})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.opacity, kept.blend), (Some(0.5), None));
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
assert!(transparency_of(&s, id, 0).iter().all(|t| (t.0, t.1) == (0.5, normal)), "{:?}", transparency_of(&s, id, 0));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.opacity, kept.blend), (None, Some(BlendMode::Multiply)));
let (mut s, id, _, marks) = column_marks();
s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 30})).unwrap();
s.execute("transparency.set", &json!({"ids": [marks[1].0], "blend": "screen"})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.opacity, kept.blend), (None, Some(BlendMode::Screen)));
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
assert!(transparency_of(&s, id, 0).iter().all(|t| (t.0, t.1) == (1.0, BlendMode::Screen)), "{:?}", transparency_of(&s, id, 0));
// A fill row: a blend alone on one mark replaces the opacity another mark's row left, and the other way round.
let (mut s, id, _, marks) = column_marks();
let fill = top_row(&s, marks[0], true);
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "opacity": 40})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": fill, "blend": "darken"})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill_opacity, kept.fill_blend), (None, Some(BlendMode::Darken)));
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "opacity": 60})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill_opacity, kept.fill_blend), (Some(0.6), None));
s.execute("edit.undo", &json!({})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill_opacity, kept.fill_blend), (None, Some(BlendMode::Darken)));
}
/// Clear Appearance on a series mark sets its object opacity back to 100% and Normal, and the graph keeps neither.
#[test]
fn clearing_a_series_marks_appearance_forgets_its_kept_object_opacity() {
let (mut s, id, _, marks) = column_marks();
s.execute("transparency.set", &json!({"ids": [marks[0].0], "opacity": 40, "blend": "multiply"})).unwrap();
s.execute("appearance.clear", &json!({"ids": [marks[1].0]})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.opacity, kept.blend), (None, None));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.4));
}
/// An appearance moved or copied onto a mark of another series takes the source's object opacity and blend mode, and
/// the graph keeps them for the target's series; a move leaves the source's series opaque and Normal.
#[test]
fn moving_an_appearance_onto_a_series_mark_keeps_its_object_opacity() {
for copy in [false, true] {
let (mut s, id, _, marks) = column_marks();
let to = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
s.execute("transparency.set", &json!({"ids": [marks[0].0], "opacity": 40, "blend": "multiply"})).unwrap();
s.execute("appearance.transfer", &json!({"source": marks[0].0, "target": to.0, "copy": copy})).unwrap();
let paints = spec(&s, id).series_paints;
assert_eq!((paints[1].opacity, paints[1].blend), (Some(0.4), Some(BlendMode::Multiply)), "copy {copy}");
let source = if copy { (Some(0.4), Some(BlendMode::Multiply)) } else { (None, None) };
assert_eq!((paints[0].opacity, paints[0].blend), source, "copy {copy}");
}
}
/// An Eyedropper pick of the object's transparency onto a series mark makes the graph keep the picked opacity and blend
/// mode for that series, even an opaque Normal pick onto a mark that already shows it.
#[test]
fn an_eyedropper_pick_of_object_transparency_onto_a_series_mark_is_kept() {
let (mut s, id, _, marks) = column_marks();
let to = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
s.execute("transparency.set", &json!({"ids": [marks[0].0], "opacity": 40, "blend": "multiply"})).unwrap();
s.execute("appearance.copyFrom", &json!({"source": marks[0].0, "ids": [to.0]})).unwrap();
let kept = spec(&s, id).series_paints[1].clone();
assert_eq!((kept.opacity, kept.blend), (Some(0.4), Some(BlendMode::Multiply)));
// An opaque Normal source picked onto another opaque mark of series 0 replaces what series 0 stores.
let opaque = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[1].id;
s.execute("appearance.copyFrom", &json!({"source": opaque.0, "ids": [marks[1].0]})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.opacity, kept.blend), (None, None));
}
/// A graphic style made from an object at 50% Multiply, applied to a series mark, makes the graph keep the style's object
/// opacity and blend mode; redefining the style updates them for a mark still linked to it.
#[test]
fn a_graphic_styles_object_opacity_on_a_series_mark_is_kept() {
let (mut s, id, _, marks) = column_marks();
let (rect, _) = blue_style(&mut s, "Blue", true);
s.execute("object.setProps", &json!({"id": rect.0, "opacity": 50, "blend": "multiply"})).unwrap();
s.execute("graphicStyle.new", &json!({"id": rect.0, "name": "Faded"})).unwrap();
s.execute("graphicStyle.apply", &json!({"name": "Faded", "ids": [marks[0].0]})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.opacity, kept.blend), (Some(0.5), Some(BlendMode::Multiply)));
s.execute("object.setProps", &json!({"id": rect.0, "opacity": 100, "blend": "normal"})).unwrap();
s.execute("graphicStyle.redefine", &json!({"name": "Faded", "id": rect.0})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.opacity, kept.blend), (None, None));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.5), "one undo takes back the redefinition and what the graph stored");
// An opaque Normal style applied to an opaque mark replaces the opacity another mark of the series left.
let (mut s, id, _, marks) = column_marks();
s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 40})).unwrap();
blue_style(&mut s, "Blue", true);
s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[1].0]})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].opacity, None);
}
/// The colors of series `index`'s marks: each mark's top fill and stroke paint and its stroke weight.
fn marks_of(s: &Session, id: NodeId, index: u32) -> Vec<(Paint, Paint, f64)> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let marks = series(&n, index).children().unwrap().to_vec();
marks.iter().map(|m| (m.appearance.fill_paint(), m.appearance.stroke_paint(), m.appearance.stroke_width())).collect()
}
/// Set the top fill of series 0's first mark to the color `hex` with `appearance.setItem`, and return that paint.
fn hex_paint(s: &mut Session, id: NodeId, hex: &str) -> Paint {
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
let row = s.doc().unwrap().doc.node(bar).unwrap().appearance.items.iter().rposition(vectorcraft_doc::AppearanceItem::is_fill).unwrap();
s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": row, "color": hex})).unwrap();
s.doc().unwrap().doc.node(bar).unwrap().appearance.fill_paint()
}
/// A color or weight set with `appearance.setItem` on the topmost fill or stroke of one series mark is kept by the
/// graph: every mark of that series has it after the graph is drawn again, and undo takes it back with its edit. A row
/// under the topmost one is not kept, and a change to one kind of row keeps nothing for the other.
#[test]
fn an_appearance_item_paint_on_a_series_mark_survives_regeneration() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let blue = hex_paint(&mut s, id, "#0000ff");
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill, kept.stroke, kept.stroke_width), (Some(blue.clone()), None, None));
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
let row = s.doc().unwrap().doc.node(bar).unwrap().appearance.items.iter().rposition(|it| !it.is_fill()).unwrap();
s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": row, "color": "#ff0000", "weight": 3})).unwrap();
let red = s.doc().unwrap().doc.node(bar).unwrap().appearance.stroke_paint();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.stroke, kept.stroke_width), (Some(red.clone()), Some(3.0)));
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
assert!(marks_of(&s, id, 0).iter().all(|m| *m == (blue.clone(), red.clone(), 3.0)), "{:?}", marks_of(&s, id, 0));
assert!(marks_of(&s, id, 1).iter().all(|m| m.0 != blue));
// Undo goes back through the data edit, then takes the stroke paint away with its edit.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill, kept.stroke, kept.stroke_width), (Some(blue.clone()), None, None));
// The graph does not keep a color set on a fill under the topmost one.
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
s.execute("appearance.addFill", &json!({"ids": [bar.0]})).unwrap();
let (lower, _) = fill_rows(&s, bar);
s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": lower, "color": "#00ff00"})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill, Some(blue));
}
/// A weight set on a stroke row, with no color, is kept by the graph with the row's paint.
#[test]
fn an_appearance_item_weight_on_a_series_mark_survives_regeneration() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone();
let row = bar.appearance.items.iter().rposition(|it| !it.is_fill()).unwrap();
s.execute("appearance.setItem", &json!({"ids": [bar.id.0], "index": row, "color": "#ff0000"})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [bar.id.0], "index": row, "weight": 4})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(4.0));
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
assert!(marks_of(&s, id, 0).iter().all(|m| m.2 == 4.0), "{:?}", marks_of(&s, id, 0));
}
/// A column graph with series 0 kept blue at 40% opacity: the graph, the paint, the first marks of series 0 and 1.
fn blue_source() -> (Session, NodeId, Paint, NodeId, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let blue = hex_paint(&mut s, id, "#0000ff");
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let (from, to) = (series(&n, 0).children().unwrap()[0].id, series(&n, 1).children().unwrap()[0].id);
s.execute("transparency.set", &json!({"ids": [from.0], "item": 0, "opacity": 40})).unwrap();
(s, id, blue, from, to)
}
/// After the graph is drawn again, every mark of series 1 is `blue` at 40%; undo goes back through the data edit and the
/// command before it, which restores the paints the graph kept in `was`.
fn series_1_is_blue_at_40_then_undo(s: &mut Session, id: NodeId, blue: &Paint, was: &[vectorcraft_doc::SeriesPaint]) {
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
for m in series(&n, 1).children().unwrap() {
let f = m.appearance.fill().unwrap();
assert_eq!((f.paint.clone(), f.opacity), (blue.clone(), 0.4));
}
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(s, id).series_paints, was);
}
/// The topmost fill (`true`) or stroke (`false`) row of `mark`.
fn top_row(s: &Session, mark: NodeId, fill: bool) -> usize {
s.doc().unwrap().doc.node(mark).unwrap().appearance.items.iter().rposition(|it| it.is_fill() == fill).unwrap()
}
/// A new column graph, the fill of series 0's marks and those marks' ids.
fn column_marks() -> (Session, NodeId, Paint, Vec<NodeId>) {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let marks = series(&n, 0).children().unwrap().to_vec();
(s, id, marks[0].appearance.fill_paint(), marks.iter().map(|m| m.id).collect())
}
/// An appearance moved onto a mark of another series makes the graph keep its paint with its opacity for that series,
/// and leaves the source series without paint.
#[test]
fn moving_an_appearance_onto_a_series_mark_keeps_its_paint() {
let (mut s, id, blue, from, to) = blue_source();
let was = spec(&s, id).series_paints;
s.execute("appearance.transfer", &json!({"source": from.0, "target": to.0})).unwrap();
let paints = spec(&s, id).series_paints;
assert_eq!((paints[0].fill.clone(), paints[1].fill.clone()), (Some(Paint::None), Some(blue.clone())));
series_1_is_blue_at_40_then_undo(&mut s, id, &blue, &was);
}
/// An appearance copied onto a mark of another series makes the graph keep its paint with its opacity for that series.
#[test]
fn copying_an_appearance_onto_a_series_mark_keeps_its_paint() {
let (mut s, id, blue, from, to) = blue_source();
let was = spec(&s, id).series_paints;
s.execute("appearance.transfer", &json!({"source": from.0, "target": to.0, "copy": true})).unwrap();
assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue.clone()));
series_1_is_blue_at_40_then_undo(&mut s, id, &blue, &was);
}
/// An Eyedropper pick onto a mark of another series makes the graph keep its paint with its opacity for that series.
#[test]
fn an_eyedropper_pick_onto_a_series_mark_keeps_its_paint() {
let (mut s, id, blue, from, to) = blue_source();
let was = spec(&s, id).series_paints;
s.execute("appearance.copyFrom", &json!({"source": from.0, "ids": [to.0]})).unwrap();
assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue.clone()));
series_1_is_blue_at_40_then_undo(&mut s, id, &blue, &was);
}
/// A reversed Eyedropper (Alt-click) copies onto the clicked mark, and the graph keeps the paint for the clicked series.
#[test]
fn a_reversed_eyedropper_pick_keeps_its_paint_on_the_clicked_series() {
let (mut s, id, blue, from, to) = blue_source();
let was = spec(&s, id).series_paints;
s.execute("appearance.copyFrom", &json!({"source": to.0, "ids": [from.0], "reverse": true})).unwrap();
assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue.clone()));
series_1_is_blue_at_40_then_undo(&mut s, id, &blue, &was);
}
/// A reversed Eyedropper with nothing selected copies what it picked up last onto the clicked mark.
#[test]
fn a_reversed_eyedropper_pick_of_the_last_sample_keeps_its_paint() {
let (mut s, id, blue, from, to) = blue_source();
s.execute("appearance.copyFrom", &json!({"source": from.0, "ids": []})).unwrap();
s.execute("select.set", &json!({"ids": []})).unwrap();
s.execute("appearance.copyFrom", &json!({"source": to.0, "reverse": true})).unwrap();
assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue));
}
/// Clear Appearance on a series mark leaves it with no fill and no stroke, and the series stays that way when the graph
/// is drawn again. Undo brings the kept paint back.
#[test]
fn clearing_a_series_marks_appearance_updates_its_kept_paint() {
let (mut s, id, _, marks) = column_marks();
let row = top_row(&s, marks[0], false);
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": row, "color": "#ff0000", "weight": 2})).unwrap();
let was = spec(&s, id).series_paints;
s.execute("appearance.clear", &json!({"ids": [marks[0].0]})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill, kept.stroke, kept.stroke_width), (Some(Paint::None), Some(Paint::None), Some(0.0)));
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
assert!(marks_of(&s, id, 0).iter().all(|m| *m == (Paint::None, Paint::None, 0.0)), "{:?}", marks_of(&s, id, 0));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).series_paints, was);
}
/// A mark with `blue` under a second fill: the graph, the paint, the mark and its (lower, upper) fill rows, with the
/// upper row green and kept.
fn two_fills() -> (Session, NodeId, Paint, NodeId, usize, usize) {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let blue = hex_paint(&mut s, id, "#0000ff");
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
s.execute("appearance.addFill", &json!({"ids": [bar.0]})).unwrap();
let (lower, upper) = fill_rows(&s, bar);
s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": upper, "color": "#00ff00"})).unwrap();
assert_ne!(spec(&s, id).series_paints[0].fill, Some(blue.clone()));
(s, id, blue, bar, lower, upper)
}
/// `cmd` makes the graph keep `blue`, and undo brings back the paints it kept before.
fn top_row_command_keeps_blue(s: &mut Session, id: NodeId, blue: &Paint, cmd: &str, p: &serde_json::Value) {
let was = spec(s, id).series_paints;
s.execute(cmd, p).unwrap();
assert_eq!(spec(s, id).series_paints[0].fill.as_ref(), Some(blue), "{cmd}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(s, id).series_paints, was, "{cmd}");
}
/// Reduce to Basic Appearance with the lower fill as the active row leaves that fill, and the graph keeps its paint.
#[test]
fn reducing_a_series_marks_appearance_keeps_the_row_it_leaves() {
let (mut s, id, blue, bar, lower, _) = two_fills();
s.execute("select.set", &json!({"ids": [bar.0]})).unwrap();
s.execute("appearance.setActiveItem", &json!({"index": lower})).unwrap();
top_row_command_keeps_blue(&mut s, id, &blue, "appearance.reduceToBasic", &json!({}));
}
/// Removing a mark's top row makes the graph keep the paint now on top.
#[test]
fn removing_a_series_marks_top_row_keeps_the_paint_now_on_top() {
let (mut s, id, blue, bar, _, upper) = two_fills();
top_row_command_keeps_blue(&mut s, id, &blue, "appearance.removeItem", &json!({"ids": [bar.0], "index": upper}));
}
/// Moving a lower row above the others makes the graph keep its paint.
#[test]
fn moving_a_row_to_a_series_marks_top_keeps_its_paint() {
let (mut s, id, blue, bar, lower, upper) = two_fills();
top_row_command_keeps_blue(&mut s, id, &blue, "appearance.moveItem", &json!({"ids": [bar.0], "from": lower, "to": upper}));
}
/// Moving a block of rows above the others makes the graph keep the paint now on top.
#[test]
fn moving_a_block_of_rows_to_a_series_marks_top_keeps_its_paint() {
let (mut s, id, blue, bar, lower, upper) = two_fills();
top_row_command_keeps_blue(&mut s, id, &blue, "appearance.moveItem", &json!({"ids": [bar.0], "indices": [lower], "to": upper}));
}
/// A copy of a lower row placed on top makes the graph keep its paint.
#[test]
fn a_copy_placed_on_a_series_marks_top_keeps_its_paint() {
let (mut s, id, blue, bar, lower, upper) = two_fills();
top_row_command_keeps_blue(&mut s, id, &blue, "appearance.duplicateItem", &json!({"ids": [bar.0], "index": lower, "to": upper + 1}));
}
/// The mark's only stroke row removed: the series draws without a stroke; a new row comes back as its paint.
#[test]
fn removing_and_adding_a_series_marks_only_stroke_updates_its_kept_paint() {
let (mut s, id, _, marks) = column_marks();
let bar = marks[0];
let row = top_row(&s, bar, false);
s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": row, "color": "#ff0000", "weight": 2})).unwrap();
s.execute("appearance.removeItem", &json!({"ids": [bar.0], "index": row})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill, kept.stroke, kept.stroke_width), (None, Some(Paint::None), Some(0.0)));
let was = spec(&s, id).series_paints;
s.execute("appearance.addStroke", &json!({"ids": [bar.0]})).unwrap();
let stroke = s.doc().unwrap().doc.node(bar).unwrap().appearance.stroke_paint();
assert!(!stroke.is_none());
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(stroke));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).series_paints, was);
}
/// Hiding the top row makes the graph keep the visible row under it, and its opacity; hiding the only one keeps no paint;
/// Show All Hidden Attributes brings the top row back. A hidden row on top of a moved appearance is not kept.
#[test]
fn a_hidden_top_row_is_not_what_the_graph_keeps() {
let (mut s, id, blue, bar, lower, upper) = two_fills();
let green = s.doc().unwrap().doc.node(bar).unwrap().appearance.fill_paint();
s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": lower, "opacity": 50})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None, "the row under the top one");
s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": upper, "visible": false})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill, kept.fill_opacity), (Some(blue.clone()), Some(0.5)));
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == blue), "{:?}", marks_of(&s, id, 0));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("appearance.showAllHidden", &json!({"ids": [bar.0]})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill, kept.fill_opacity), (Some(green), None));
// A transfer of [blue, hidden green] onto another series keeps the blue the source shows.
let (mut s, id, blue, bar, _, upper) = two_fills();
s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": upper, "visible": false})).unwrap();
let to = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
s.execute("appearance.transfer", &json!({"source": bar.0, "target": to.0, "copy": true})).unwrap();
assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue));
let (mut s, id, _, marks) = column_marks();
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "visible": false})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill, Some(Paint::None));
}
/// Hiding a top row and Show All Hidden Attributes each undo in one step, with what the graph stores.
#[test]
fn hiding_and_showing_a_series_marks_top_row_undo_with_the_stored_paint() {
let (mut s, id, _, bar, _, upper) = two_fills();
let was = spec(&s, id).series_paints;
s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": upper, "visible": false})).unwrap();
let hidden = spec(&s, id).series_paints;
assert_ne!(hidden, was);
s.execute("appearance.showAllHidden", &json!({"ids": [bar.0]})).unwrap();
assert_eq!(spec(&s, id).series_paints, was);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).series_paints, hidden);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).series_paints, was);
}
/// An opaque Eyedropper pick onto a mark replaces the opacity another mark of the series stored, even though the mark
/// itself stays opaque: the series is drawn with the picked color at 100%.
#[test]
fn an_opaque_pick_replaces_the_opacity_the_series_stores() {
let (mut s, id, _, marks) = column_marks();
let fill = top_row(&s, marks[0], true);
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "opacity": 40})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.4));
let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
s.execute("appearance.setItem", &json!({"ids": [source.0], "index": top_row(&s, source, true), "color": "#0000ff"})).unwrap();
let was = spec(&s, id).series_paints;
s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0]})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None);
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
assert!(series(&n, 0).children().unwrap().iter().all(|m| m.appearance.fill().unwrap().opacity == 1.0));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).series_paints, was);
// A pick of the opacity alone replaces it too, and a pick that leaves opacity out keeps it.
let (mut s, id, _, marks) = column_marks();
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "opacity": 40})).unwrap();
let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
let colors_only = json!({"appearance": {"fill": {"color": true, "transparency": false}, "stroke": {"color": false}}});
s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": colors_only})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.4));
let opacity_only = json!({"appearance": {"fill": {"color": false, "transparency": true}, "stroke": {"color": false}}});
s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": opacity_only})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None);
}
/// An appearance moved between two marks of one series leaves the series with the target's paint, not the source's
/// empty one, and the series still shows when the graph is drawn again.
#[test]
fn moving_an_appearance_within_a_series_keeps_the_targets_paint() {
let (mut s, id, shown, marks) = column_marks();
s.execute("appearance.transfer", &json!({"source": marks[0].0, "target": marks[1].0})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill, Some(shown.clone()));
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == shown), "{:?}", marks_of(&s, id, 0));
}
/// A row shown again with the color the mark already shows replaces the paint another mark of the series stored.
#[test]
fn showing_a_row_with_the_color_a_mark_shows_replaces_the_kept_paint() {
let (mut s, id, shown, marks) = column_marks();
s.execute("appearance.addFill", &json!({"ids": [marks[0].0]})).unwrap();
let (_, upper) = fill_rows(&s, marks[0]);
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "visible": false})).unwrap();
let other = top_row(&s, marks[1], true);
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": other, "color": "#ff0000"})).unwrap();
assert_ne!(spec(&s, id).series_paints[0].fill, Some(shown.clone()));
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "visible": true, "color": "#000000"})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill, Some(s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint()));
}
/// A color or weight set again to what a mark already shows and an Eyedropper pick that leaves the mark as it was replace
/// the paint another mark of the series stored, in an undo step of their own. Clear Appearance on a clear mark leaves the
/// stored paint and the undo history as they are.
#[test]
fn writing_the_paint_a_series_mark_already_shows_replaces_the_kept_paint() {
let (mut s, id, shown, marks) = column_marks();
let fill = top_row(&s, marks[0], true);
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap();
assert_ne!(spec(&s, id).series_paints[0].fill, Some(shown.clone()));
let was = spec(&s, id).series_paints;
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": fill, "color": "#000000"})).unwrap();
let again = s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.fill_paint();
assert_eq!(spec(&s, id).series_paints[0].fill, Some(again));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).series_paints, was);
let (mut s, id, _, marks) = column_marks();
let stroke = top_row(&s, marks[0], false);
for (mark, weight) in [(marks[1], 2), (marks[0], 5), (marks[1], 2)] {
s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": stroke, "color": "#ff0000", "weight": weight})).unwrap();
}
assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(2.0));
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "weight": 5})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "weight": 2})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(2.0));
let (mut s, id, shown, marks) = column_marks();
let fill = top_row(&s, marks[0], true);
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap();
s.execute("appearance.copyFrom", &json!({"source": marks[2].0, "ids": [marks[1].0]})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill, Some(shown));
let (mut s, id, _, marks) = column_marks();
let fill = top_row(&s, marks[0], true);
s.execute("appearance.clear", &json!({"ids": [marks[1].0]})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap();
let was = spec(&s, id).series_paints;
let shown = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint();
s.execute("appearance.clear", &json!({"ids": [marks[1].0]})).unwrap();
assert_eq!(spec(&s, id).series_paints, was);
s.execute("edit.undo", &json!({})).unwrap();
assert_ne!(s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint(), shown, "the undo took back the color, not a clear");
}
/// A line graph with a marker: the graph, the first thin line, a marker.
fn line_and_marker() -> (Session, NodeId, NodeId, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "line");
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let marks = series(&n, 0).children().unwrap().to_vec();
let line = marks.iter().find(|m| m.appearance.stroke_width() > 0.0).unwrap().id;
let marker = marks.iter().find(|m| m.appearance.stroke_width() == 0.0 && m.appearance.stroke().is_some()).unwrap().id;
(s, id, line, marker)
}
/// The first thin line of series 0, and every other path of the series (its markers and legend swatch).
fn line_parts(s: &Session, id: NodeId) -> (vectorcraft_doc::Node, Vec<vectorcraft_doc::Node>) {
let parts = series_parts(s, id, 0);
let (lines, others): (Vec<_>, Vec<_>) =
parts.into_iter().partition(|m| m.appearance.fill_paint().is_none() && m.appearance.stroke_width() == 1.0);
(lines[0].clone(), others)
}
/// On a line graph, a stroke set on the thin line goes on the line after a redraw; a fill set on the thin line goes on
/// the markers and the legend swatch, and the line stays without a fill.
#[test]
fn a_line_series_draws_its_kept_stroke_on_the_line_and_its_kept_fill_on_the_markers() {
let (mut s, id, line, _) = line_and_marker();
s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, false), "color": "#ff0000"})).unwrap();
let red = s.doc().unwrap().doc.node(line).unwrap().appearance.stroke_paint();
s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, true), "color": "#0000ff"})).unwrap();
let blue = s.doc().unwrap().doc.node(line).unwrap().appearance.fill_paint();
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
let (drawn, others) = line_parts(&s, id);
assert_eq!((drawn.appearance.fill_paint(), drawn.appearance.stroke_paint()), (Paint::None, red));
assert!(others.iter().all(|m| m.appearance.fill_paint() == blue), "{others:?}");
}
/// Clear Appearance on a thin line leaves the fill the graph stores for the markers; on a marker that shows no stroke, it
/// leaves the stroke the graph stores for the line. An Eyedropper pick of a stroke-only source onto the line and a copy
/// of one line onto another leave the stored fill too.
#[test]
fn a_row_left_without_paint_leaves_the_paint_the_series_stores() {
let (mut s, id, line, marker) = line_and_marker();
s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": top_row(&s, marker, true), "color": "#0000ff"})).unwrap();
let blue = spec(&s, id).series_paints[0].fill.clone().unwrap();
s.execute("appearance.clear", &json!({"ids": [line.0]})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill.as_ref(), kept.stroke.as_ref()), (Some(&blue), Some(&Paint::None)));
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
let parts = series_parts(&s, id, 0);
assert!(parts.iter().filter(|m| !m.appearance.fill_paint().is_none()).all(|m| m.appearance.fill_paint() == blue));
assert!(parts.iter().any(|m| m.appearance.fill_paint() == blue), "the markers keep their fill");
let (mut s, id, line, marker) = line_and_marker();
s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, false), "color": "#ff0000"})).unwrap();
let red = s.doc().unwrap().doc.node(line).unwrap().appearance.stroke_paint();
s.execute("appearance.clear", &json!({"ids": [marker.0]})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(red.clone()), "the marker showed no stroke");
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
assert_eq!(line_parts(&s, id).0.appearance.stroke_paint(), red);
let (mut s, id, line, marker) = line_and_marker();
s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": top_row(&s, marker, true), "color": "#0000ff"})).unwrap();
let blue = spec(&s, id).series_paints[0].fill.clone().unwrap();
let other = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
s.execute("appearance.copyFrom", &json!({"source": other.0, "ids": [line.0]})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill, Some(blue.clone()), "the source's line has no fill");
s.execute("appearance.transfer", &json!({"source": other.0, "target": line.0, "copy": true})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill, Some(blue));
}
/// A line graph with Filled Lines on: the session, the graph and series 0's band.
fn band_graph() -> (Session, NodeId, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "line", ",a\nQ1,2\nQ2,3\nQ3,2");
s.execute("graph.setType", &json!({"id": id.0, "filledLines": true})).unwrap();
let band = series_parts(&s, id, 0)[0].id;
(s, id, band)
}
/// The band's fill, outline paint and outline weight after the graph is drawn again.
fn redrawn_band(s: &mut Session, id: NodeId) -> (Paint, Paint, f64) {
s.execute("graph.setData", &json!({"id": id.0, "rows": [[2], [3], [4]]})).unwrap();
let band = series_parts(s, id, 0)[0].clone();
(band.appearance.fill_paint(), band.appearance.stroke_paint(), band.appearance.stroke_width())
}
/// A fill set on a Filled Lines band is kept, and the band keeps its thin black outline.
#[test]
fn a_filled_line_band_keeps_a_fill_set_on_it() {
let (mut s, id, band) = band_graph();
s.execute("appearance.setItem", &json!({"ids": [band.0], "index": top_row(&s, band, true), "color": "#0000ff"})).unwrap();
let blue = s.doc().unwrap().doc.node(band).unwrap().appearance.fill_paint();
assert_eq!(redrawn_band(&mut s, id), (blue, Paint::solid(vectorcraft_color::Color::BLACK), 0.25));
}
/// A stroke set on a Filled Lines band is not kept: the band keeps its color and its thin black outline.
#[test]
fn a_stroke_set_on_a_filled_line_band_does_not_repaint_it() {
let (mut s, id, band) = band_graph();
let was = series_parts(&s, id, 0)[0].appearance.fill_paint();
let row = top_row(&s, band, false);
s.execute("appearance.setItem", &json!({"ids": [band.0], "index": row, "color": "#ff0000", "weight": 3})).unwrap();
assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.stroke.is_none()));
assert_eq!(redrawn_band(&mut s, id), (was, Paint::solid(vectorcraft_color::Color::BLACK), 0.25));
}
/// An opacity and blend mode set on a Filled Lines band's stroke row are kept and drawn on its outline, which keeps its
/// black paint and 0.25 pt weight.
#[test]
fn a_filled_line_bands_outline_keeps_a_stroke_opacity_and_blend_mode() {
let (mut s, id, band) = band_graph();
let row = top_row(&s, band, false);
s.execute("appearance.setItem", &json!({"ids": [band.0], "index": row, "opacity": 50, "blend": "multiply"})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.stroke, kept.stroke_opacity, kept.stroke_blend), (None, Some(0.5), Some(BlendMode::Multiply)));
let (_, outline, width) = redrawn_band(&mut s, id);
assert_eq!((outline, width), (Paint::solid(vectorcraft_color::Color::BLACK), 0.25));
let band = series_parts(&s, id, 0)[0].clone();
let st = band.appearance.stroke().unwrap();
assert_eq!((st.opacity, st.blend), (0.5, BlendMode::Multiply));
}
/// A graphic style without a stroke applied to a marker whose stroke had paint replaces the stroke opacity and blend mode
/// the series' Filled Lines band left: the band's outline is drawn opaque and Normal again. A marker that had no stroke
/// before the style leaves them as they are.
#[test]
fn a_style_that_removes_a_marks_stroke_replaces_the_kept_stroke_opacity() {
for stroked in [true, false] {
let (mut s, id, band) = band_graph();
let marker = series_parts(&s, id, 0)[1].id;
if stroked {
let row = top_row(&s, marker, false);
s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": row, "color": "#ff0000"})).unwrap();
}
let row = top_row(&s, band, false);
s.execute("appearance.setItem", &json!({"ids": [band.0], "index": row, "opacity": 40, "blend": "multiply"})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].stroke_opacity, Some(0.4));
blue_style(&mut s, "Blue", false);
s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marker.0]})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
let want = if stroked { (None, None) } else { (Some(0.4), Some(BlendMode::Multiply)) };
assert_eq!((kept.stroke_opacity, kept.stroke_blend), want, "stroked {stroked}");
redrawn_band(&mut s, id);
let st = series_parts(&s, id, 0)[0].appearance.stroke().unwrap().clone();
assert_eq!((Some(st.opacity).filter(|o| *o < 1.0), Some(st.blend).filter(|b| *b != BlendMode::Normal)), want, "stroked {stroked}");
}
}
/// A Filled Lines band's stroke row removed with the Appearance panel or set to None with `paint.setStroke` leaves the
/// band its color: an empty stored stroke is not a color for the band.
#[test]
fn removing_a_filled_line_bands_stroke_leaves_its_color() {
let (mut s, id, band) = band_graph();
let was = series_parts(&s, id, 0)[0].appearance.fill_paint();
s.execute("appearance.removeItem", &json!({"ids": [band.0], "index": top_row(&s, band, false)})).unwrap();
assert_eq!(redrawn_band(&mut s, id).0, was);
let (mut s, id, band) = band_graph();
let was = series_parts(&s, id, 0)[0].appearance.fill_paint();
s.execute("paint.setStroke", &json!({"ids": [band.0], "none": true})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(Paint::None));
assert_eq!(redrawn_band(&mut s, id).0, was);
}
/// With Connect Data Points off a Filled Lines graph draws no bands, so a stroke set on a marker is kept.
#[test]
fn a_stroke_on_a_marker_is_kept_when_filled_lines_draws_no_bands() {
let (mut s, id, _) = band_graph();
s.execute("graph.setType", &json!({"id": id.0, "connectPoints": false})).unwrap();
let marker = series_parts(&s, id, 0)[0].id;
s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": top_row(&s, marker, false), "color": "#ff0000"})).unwrap();
assert!(spec(&s, id).series_paints[0].stroke.as_ref().is_some_and(|p| !p.is_none()));
}
/// A Filled Lines graph of `ty` and `csv` draws no bands, so a stroke set on its first mark is stored and stays on it after
/// the graph is drawn again.
fn stroke_is_kept_without_bands(ty: &str, csv: &str) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, ty, csv);
s.execute("graph.setType", &json!({"id": id.0, "filledLines": true})).unwrap();
let mark = series_parts(&s, id, 0)[0].id;
s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, false), "color": "#ff0000", "weight": 2})).unwrap();
let red = s.doc().unwrap().doc.node(mark).unwrap().appearance.stroke_paint();
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(red.clone()));
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
let drawn = &series_parts(&s, id, 0)[0];
assert_eq!((drawn.appearance.stroke_paint(), drawn.appearance.stroke_width()), (red, 2.0));
}
#[test]
fn a_stroke_is_kept_on_a_one_row_line_series() {
stroke_is_kept_without_bands("line", ",a\nQ1,2");
}
#[test]
fn a_stroke_is_kept_on_a_radar_series_of_zeros() {
stroke_is_kept_without_bands("radar", ",a\nQ1,0\nQ2,0\nQ3,0");
}
#[test]
fn a_stroke_is_kept_on_a_scatter_series_of_one_repeated_point() {
stroke_is_kept_without_bands("scatter", "a,b\n1,2\n1,2");
}
/// A radar series whose values all fall below `axisMin` is plotted at the center and draws no band, so a stroke set on its
/// marks is kept. A radar series with a value above `axisMin` draws a band and stores no stroke.
#[test]
fn a_radar_series_draws_a_band_only_for_values_away_from_the_center() {
for (csv, kept) in [(",a\nQ1,1\nQ2,2\nQ3,3", true), (",a\nQ1,1\nQ2,15\nQ3,3", false)] {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "radar", csv);
s.execute("graph.setType", &json!({"id": id.0, "filledLines": true, "axisMin": 10, "axisMax": 20})).unwrap();
let mark = series_parts(&s, id, 0)[0].id;
s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, false), "color": "#ff0000", "weight": 2})).unwrap();
assert_eq!(spec(&s, id).series_paints.first().is_some_and(|p| p.stroke.is_some()), kept, "{csv}");
}
}
/// Past the Filled Lines budget the graph draws thin lines, and a stroke set on one is kept.
#[test]
fn a_stroke_on_a_thin_line_past_the_filled_lines_budget_is_kept() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let rows: Vec<Vec<f64>> = (0..8_000).map(|i| vec![(i % 7) as f64, 1.0, 2.0]).collect();
let id = NodeId(
s.execute("graph.create", &json!({"type": "line", "x": 0, "y": 0, "width": 300, "height": 200, "rows": rows})).unwrap()["id"]
.as_u64()
.unwrap(),
);
s.execute("graph.setType", &json!({"id": id.0, "filledLines": true, "markPoints": false})).unwrap();
let line = series_parts(&s, id, 0)[0].id;
s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, false), "color": "#ff0000"})).unwrap();
assert!(spec(&s, id).series_paints[0].stroke.as_ref().is_some_and(|p| !p.is_none()));
}
/// A weight of 0 set again on a painted stroke replaces the weight another mark of the series left on the graph.
#[test]
fn a_zero_weight_on_a_painted_stroke_replaces_the_kept_weight() {
let (mut s, id, _, marks) = column_marks();
let stroke = top_row(&s, marks[0], false);
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 0})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#ff0000", "weight": 5})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(5.0));
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "weight": 0})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(0.0));
}
/// An Eyedropper `append` of a source with no stroke leaves the stroke the series keeps.
#[test]
fn appending_a_fill_only_appearance_leaves_the_kept_stroke() {
let (mut s, id, _, marks) = column_marks();
let stroke = top_row(&s, marks[0], false);
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#ff0000", "weight": 5})).unwrap();
let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
s.execute("appearance.removeItem", &json!({"ids": [source.0], "index": top_row(&s, source, false)})).unwrap();
s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "append": true})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(5.0));
// A pick of the weight alone from that source leaves the mark and what the series keeps as they were.
let pick = json!({"appearance": {"fill": {"color": false}, "stroke": {"color": false, "weight": true}}});
s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": pick})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(5.0));
}
/// An Eyedropper `append` of a source with only a hidden fill leaves the fill the series keeps.
#[test]
fn appending_a_hidden_fill_leaves_the_kept_fill() {
let (mut s, id, _, marks) = column_marks();
let fill = top_row(&s, marks[0], true);
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap();
let kept = spec(&s, id).series_paints[0].fill.clone();
let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
s.execute("appearance.setItem", &json!({"ids": [source.0], "index": top_row(&s, source, true), "visible": false})).unwrap();
s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "append": true})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill, kept);
}
/// An Eyedropper pick of the weight alone reads a hidden stroke too, and that weight replaces the one the series keeps.
#[test]
fn picking_the_weight_of_a_hidden_stroke_replaces_the_kept_weight() {
let (mut s, id, _, marks) = column_marks();
let stroke = top_row(&s, marks[0], false);
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#ff0000", "weight": 5})).unwrap();
let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
let row = top_row(&s, source, false);
s.execute("appearance.setItem", &json!({"ids": [source.0], "index": row, "color": "#00ff00", "weight": 2, "visible": false})).unwrap();
let pick = json!({"appearance": {"fill": {"color": false}, "stroke": {"color": false, "weight": true}}});
s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": pick})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(2.0));
}
/// An Eyedropper pick of some attributes edits the mark's topmost stroke. When that row is hidden, the series keeps what
/// it stores instead of taking the visible row under it.
#[test]
fn a_partial_pick_onto_a_hidden_top_row_leaves_the_kept_stroke() {
let (mut s, id, _, marks) = column_marks();
let stroke = top_row(&s, marks[0], false);
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap();
s.execute("appearance.addStroke", &json!({"ids": [marks[1].0]})).unwrap();
let hidden = top_row(&s, marks[1], false);
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": hidden, "visible": false})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#0000ff", "weight": 5})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
let row = top_row(&s, source, false);
s.execute("appearance.setItem", &json!({"ids": [source.0], "index": row, "color": "#00ff00", "weight": 3})).unwrap();
let pick = json!({"appearance": {"fill": {"color": false}, "stroke": {"color": true, "weight": true}}});
s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": pick})).unwrap();
assert_eq!(spec(&s, id).series_paints[0], kept);
}
/// An Eyedropper `append` writes a visible row under the source's hidden top row, and the series stores that row's paint
/// even when the mark already shows the same paint.
#[test]
fn appending_a_visible_row_under_a_hidden_one_replaces_the_kept_stroke() {
let (mut s, id, _, marks) = column_marks();
let stroke = top_row(&s, marks[0], false);
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#0000ff", "weight": 5})).unwrap();
let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
let row = top_row(&s, source, false);
s.execute("appearance.setItem", &json!({"ids": [source.0], "index": row, "color": "#ff0000", "weight": 2})).unwrap();
s.execute("appearance.addStroke", &json!({"ids": [source.0]})).unwrap();
let hidden = top_row(&s, source, false);
s.execute("appearance.setItem", &json!({"ids": [source.0], "index": hidden, "visible": false})).unwrap();
s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "append": true})).unwrap();
let kept = &spec(&s, id).series_paints[0];
assert_eq!(kept.stroke, Some(Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0))));
assert_eq!(kept.stroke_width, Some(2.0));
}
/// A stroke added on top of a mark replaces the stroke another mark of the series left on the graph, even when the new
/// row shows the paint the mark already showed.
#[test]
fn adding_a_stroke_replaces_the_kept_stroke_with_the_marks_own() {
let (mut s, id, _, marks) = column_marks();
let stroke = top_row(&s, marks[0], false);
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#0000ff", "weight": 5})).unwrap();
s.execute("appearance.addStroke", &json!({"ids": [marks[1].0]})).unwrap();
let kept = &spec(&s, id).series_paints[0];
assert_eq!(kept.stroke, Some(Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0))));
assert_eq!(kept.stroke_width, Some(2.0));
}
/// A fill added right above a mark's active top fill replaces the fill another mark of the series left on the graph.
#[test]
fn adding_a_fill_above_the_active_top_fill_replaces_the_kept_fill() {
let (mut s, id, was, marks) = column_marks();
let fill = top_row(&s, marks[0], true);
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#0000ff"})).unwrap();
s.execute("select.set", &json!({"ids": [marks[1].0]})).unwrap();
s.execute("appearance.setActiveItem", &json!({"index": fill})).unwrap();
s.execute("appearance.addFill", &json!({})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill, Some(was));
}
/// A copy of a mark's top stroke replaces the stroke another mark of the series left on the graph.
#[test]
fn duplicating_a_marks_top_stroke_replaces_the_kept_stroke() {
let (mut s, id, _, marks) = column_marks();
let stroke = top_row(&s, marks[0], false);
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#0000ff", "weight": 5})).unwrap();
for to in [false, true] {
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#0000ff", "weight": 5})).unwrap();
let top = top_row(&s, marks[1], false);
let p = if to { json!({"ids": [marks[1].0], "index": top, "to": top + 1}) } else { json!({"ids": [marks[1].0], "index": top}) };
s.execute("appearance.duplicateItem", &p).unwrap();
let kept = &spec(&s, id).series_paints[0];
assert_eq!(kept.stroke, Some(Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0))), "{p}");
assert_eq!(kept.stroke_width, Some(2.0), "{p}");
}
}
/// A weight set on a stroke without paint (a line graph's marker) leaves the paint the series keeps for its line.
#[test]
fn a_weight_on_a_stroke_without_paint_leaves_the_kept_stroke() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "line");
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let marks = series(&n, 0).children().unwrap().to_vec();
let line = marks.iter().find(|m| m.appearance.stroke_width() > 0.0).unwrap().id;
let marker = marks.iter().find(|m| m.appearance.stroke_width() == 0.0 && m.appearance.stroke().is_some()).unwrap().id;
s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, false), "color": "#ff0000"})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert!(kept.stroke.as_ref().is_some_and(|p| !p.is_none()));
s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": top_row(&s, marker, false), "weight": 4})).unwrap();
assert_eq!(spec(&s, id).series_paints[0], kept);
}
/// The graph does not keep a paint set on a row of a graph design's copy inside a series. A stroke edit on a line keeps
/// no fill, and a blend mode alone leaves what the graph keeps as it was.
#[test]
fn a_paint_on_a_design_copy_or_the_other_kind_of_row_is_not_kept() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
save_design(&mut s, "Square");
s.execute("graph.column", &json!({"id": id.0, "design": "Square"})).unwrap();
let copy = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone();
assert!(matches!(copy.kind, NodeKind::Group { .. }));
s.execute("appearance.addFill", &json!({"ids": [copy.id.0]})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [copy.id.0], "index": 0, "color": "#0000ff"})).unwrap();
assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill.is_none()));
// A path inside the design's copy is a member of the series too, and its paint is not kept either.
let copy = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone();
let mut paths = vec![];
copy.walk(&mut |n| {
if matches!(n.kind, NodeKind::Path { .. }) {
paths.push(n.id);
}
});
let art = paths[0];
s.execute("appearance.setItem", &json!({"ids": [art.0], "index": top_row(&s, art, true), "color": "#00ff00"})).unwrap();
assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill.is_none()));
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "line");
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let line = series(&n, 0).children().unwrap().iter().find(|m| m.appearance.stroke().is_some_and(|st| st.width > 0.0)).unwrap().clone();
let row = line.appearance.items.iter().rposition(|it| !it.is_fill()).unwrap();
s.execute("appearance.setItem", &json!({"ids": [line.id.0], "index": row, "color": "#0000ff"})).unwrap();
assert!(spec(&s, id).series_paints[0].fill.is_none(), "a stroke edit on a line keeps no fill");
// A blend mode on another mark of the series leaves the stored paint as that mark's own paint differs from it, and
// stores only the blend mode.
let (mut s, id, _, marks) = column_marks();
let fill = top_row(&s, marks[0], true);
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap();
let mut kept = spec(&s, id).series_paints;
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": fill, "blend": "multiply"})).unwrap();
kept[0].fill_blend = Some(vectorcraft_color::BlendMode::Multiply);
assert_eq!(spec(&s, id).series_paints, kept);
}
/// A graphic style `name` made from a new rectangle with a blue fill at 50% opacity, and without a stroke row unless
/// `stroke`: the rectangle and the blue paint.
fn blue_style(s: &mut Session, name: &str, stroke: bool) -> (NodeId, Paint) {
let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap());
s.execute("appearance.setItem", &json!({"ids": [r.0], "index": top_row(s, r, true), "color": "#0000ff", "opacity": 50})).unwrap();
if !stroke {
s.execute("appearance.removeItem", &json!({"ids": [r.0], "index": top_row(s, r, false)})).unwrap();
}
s.execute("graphicStyle.new", &json!({"id": r.0, "name": name})).unwrap();
let blue = s.doc().unwrap().doc.node(r).unwrap().appearance.fill_paint();
(r, blue)
}
/// Series 0's marks with a red 2 pt stroke stored from `marks[1]` and a 40% fill opacity stored from `marks[2]`.
fn stored_red_stroke() -> (Session, NodeId, Vec<NodeId>) {
let (mut s, id, _, marks) = column_marks();
let stroke = top_row(&s, marks[1], false);
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[2].0], "index": top_row(&s, marks[2], true), "opacity": 40})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.stroke_width, kept.fill_opacity), (Some(2.0), Some(0.4)));
(s, id, marks)
}
/// After the graph is drawn again, every mark of series 0 has `fill` at `opacity` and `stroke` at `weight`; two undos (the
/// data edit, then the command) bring back the paints the graph stored in `was`.
fn series_0_is(s: &mut Session, id: NodeId, (fill, opacity, stroke, weight): (&Paint, f32, &Paint, f64), was: &[vectorcraft_doc::SeriesPaint]) {
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
for m in series(&n, 0).children().unwrap() {
let f = m.appearance.fill().unwrap();
assert_eq!((&f.paint, f.opacity), (fill, opacity));
assert_eq!((&m.appearance.stroke_paint(), m.appearance.stroke_width()), (stroke, weight));
}
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(s, id).series_paints, was);
}
/// A graphic style applied to a series mark is stored on the graph, its fill opacity included. A style without a stroke,
/// applied to a mark that shows one, leaves the series without a stroke.
#[test]
fn a_graphic_style_applied_to_a_series_mark_is_kept() {
let (mut s, id, marks) = stored_red_stroke();
let (_, blue) = blue_style(&mut s, "Blue", false);
let was = spec(&s, id).series_paints;
s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[1].0]})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill, kept.fill_opacity, kept.stroke), (Some(blue.clone()), Some(0.5), Some(Paint::None)));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).series_paints, was, "one undo takes back the style and what the graph stored");
s.execute("edit.redo", &json!({})).unwrap();
series_0_is(&mut s, id, (&blue, 0.5, &Paint::None, 0.0), &was);
}
/// A graphic style added on top of a series mark's appearance (Alt-click) stores the kinds of row the style shows: a style
/// with only a fill leaves the stroke the series stores.
#[test]
fn a_graphic_style_added_to_a_series_mark_keeps_only_its_own_kinds() {
let (mut s, id, marks) = stored_red_stroke();
let stroke = top_row(&s, marks[0], false);
let red = spec(&s, id).series_paints[0].stroke.clone().unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap();
let (_, blue) = blue_style(&mut s, "Blue", false);
let was = spec(&s, id).series_paints;
s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[0].0], "add": true})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill, kept.fill_opacity), (Some(blue.clone()), Some(0.5)));
assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(red.clone()), Some(2.0)));
series_0_is(&mut s, id, (&blue, 0.5, &red, 2.0), &was);
}
/// A graphic style whose stroke row is hidden, added on top of a series mark (Alt-click), stores its fill and leaves the
/// stroke the series stores, although the mark shows a stroke of its own.
#[test]
fn a_graphic_style_with_a_hidden_stroke_added_to_a_series_mark_keeps_the_stored_stroke() {
let (mut s, id, marks) = stored_red_stroke();
let stroke = top_row(&s, marks[0], false);
let red = spec(&s, id).series_paints[0].stroke.clone().unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap();
let (rect, blue) = blue_style(&mut s, "Blue", true);
s.execute("appearance.setItem", &json!({"ids": [rect.0], "index": top_row(&s, rect, false), "visible": false})).unwrap();
s.execute("graphicStyle.new", &json!({"id": rect.0, "name": "Hidden stroke"})).unwrap();
let was = spec(&s, id).series_paints;
s.execute("graphicStyle.apply", &json!({"name": "Hidden stroke", "ids": [marks[0].0], "add": true})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill, kept.fill_opacity), (Some(blue.clone()), Some(0.5)));
assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(red.clone()), Some(2.0)));
series_0_is(&mut s, id, (&blue, 0.5, &red, 2.0), &was);
}
/// Redefine Graphic Style restyles the series marks still linked to the style, and the graph stores the new look.
#[test]
fn redefining_a_graphic_style_updates_a_linked_series_mark() {
let (mut s, id, _, marks) = column_marks();
let (rect, _) = blue_style(&mut s, "Blue", true);
s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[0].0]})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [rect.0], "index": top_row(&s, rect, true), "color": "#00ff00", "opacity": 100})).unwrap();
let green = s.doc().unwrap().doc.node(rect).unwrap().appearance.fill_paint();
let was = spec(&s, id).series_paints;
s.execute("graphicStyle.redefine", &json!({"name": "Blue", "id": rect.0})).unwrap();
assert_eq!(s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint(), green, "the mark follows the style");
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill, kept.fill_opacity), (Some(green.clone()), None));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).series_paints, was, "one undo takes back the redefinition and what the graph stored");
s.execute("edit.redo", &json!({})).unwrap();
let (stroke, weight) = {
let r = s.doc().unwrap().doc.node(rect).unwrap().clone();
(r.appearance.stroke_paint(), r.appearance.stroke_width())
};
series_0_is(&mut s, id, (&green, 1.0, &stroke, weight), &was);
}
/// Redefine Graphic Style made from a series mark does not edit that mark, so the graph keeps the paint another mark of the
/// series stored after it: the graph drawn again shows the same marks with or without the redefinition, and undo brings
/// back the stored paint.
#[test]
fn redefining_a_graphic_style_from_a_series_mark_keeps_the_paint_another_mark_stored() {
let (mut s, id, _, marks) = column_marks();
blue_style(&mut s, "Blue", true);
s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[1].0]})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#00ff00"})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#ff0000"})).unwrap();
let red = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint();
let was = spec(&s, id).series_paints;
assert_eq!(was[0].fill, Some(red.clone()));
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
let without = marks_of(&s, id, 0);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("graphicStyle.redefine", &json!({"name": "Blue", "id": marks[1].0})).unwrap();
assert_eq!(spec(&s, id).series_paints, was, "the source mark was not edited");
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
assert_eq!(marks_of(&s, id, 0), without);
assert!(without.iter().all(|m| m.0 == red));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).series_paints, was);
}
/// Default Fill and Stroke on a series mark stores a white fill and a 1 pt black stroke, both opaque, on the graph.
#[test]
fn default_fill_and_stroke_on_a_series_mark_is_kept() {
let (mut s, id, marks) = stored_red_stroke();
let was = spec(&s, id).series_paints;
s.execute("paint.default", &json!({"ids": [marks[0].0]})).unwrap();
let (white, black) = (Paint::solid(vectorcraft_color::Color::WHITE), Paint::solid(vectorcraft_color::Color::BLACK));
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill.clone(), kept.fill_opacity), (Some(white.clone()), None));
assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(black.clone()), Some(1.0)));
series_0_is(&mut s, id, (&white, 1.0, &black, 1.0), &was);
}
/// Swap Fill and Stroke on a series mark stores the swapped paints and the stroke weight, and leaves the opacity the series
/// stores.
#[test]
fn swapping_a_series_marks_fill_and_stroke_is_kept() {
let (mut s, id, marks) = stored_red_stroke();
let stroke = top_row(&s, marks[0], false);
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3})).unwrap();
let mark = s.doc().unwrap().doc.node(marks[0]).unwrap().clone();
let (gray, green) = (mark.appearance.fill_paint(), mark.appearance.stroke_paint());
let was = spec(&s, id).series_paints;
s.execute("paint.swap", &json!({"ids": [marks[0].0]})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill.clone(), kept.fill_opacity), (Some(green.clone()), Some(0.4)));
assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(gray.clone()), Some(3.0)));
series_0_is(&mut s, id, (&green, 0.4, &gray, 3.0), &was);
}
/// Swap Fill and Stroke writes the topmost fill even when it is hidden: the visible fill under it does not replace the
/// fill the series stores.
#[test]
fn swapping_onto_a_hidden_top_fill_leaves_the_stored_fill() {
let (mut s, id, _, marks) = column_marks();
s.execute("appearance.addFill", &json!({"ids": [marks[0].0]})).unwrap();
let (_, upper) = fill_rows(&s, marks[0]);
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "visible": false})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#ff0000"})).unwrap();
let red = spec(&s, id).series_paints[0].fill.clone();
assert_eq!(red, Some(s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.fill_paint()));
let stroke = top_row(&s, marks[0], false);
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3})).unwrap();
s.execute("paint.swap", &json!({"ids": [marks[0].0]})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill, red);
}
/// A kept opacity read from a file outside 0..1 is held to that range, and one that is not a number is left out.
#[test]
fn a_kept_series_opacity_from_a_file_is_held_to_its_range() {
let mut d = vectorcraft_doc::Document::new(800.0, 800.0);
let paints = vec![vectorcraft_doc::SeriesPaint { fill_opacity: Some(-1.0), stroke_opacity: Some(f32::NAN), ..Default::default() }];
let paints = [paints, vec![vectorcraft_doc::SeriesPaint { fill_opacity: Some(40.0), ..Default::default() }]].concat();
let g = GraphSpec { rows: vec![vec![35.0, 20.0]], axis_min: Some(0.0), axis_max: Some(50.0), series_paints: paints, ..GraphSpec::default() };
let out = super::generate(&mut d, &g);
for (index, want) in [(0, 0.0), (1, 1.0)] {
let group = out.iter().find(|n| n.extras().series_index == Some(index)).unwrap();
let marks: Vec<_> = group.children().unwrap().iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).collect();
assert!(!marks.is_empty());
for m in marks {
assert_eq!(m.appearance.fill().unwrap().opacity, want);
assert!(m.appearance.stroke().is_none_or(|st| st.opacity == 1.0));
}
}
}
/// A series index read from a file past the graph's series is not a series: painting its art
/// paints the art alone and stores nothing (no allocation sized by the file).
#[test]
fn a_series_index_past_the_graphs_series_is_ignored() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id;
s.edit("Junk", |d, _| {
d.node_mut(series_id).unwrap().extras_mut().series_index = Some(u32::MAX);
Ok(())
})
.unwrap();
let bar = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0].id;
s.execute("paint.setFill", &json!({"ids": [bar.0, series_id.0], "color": "#12ab34"})).unwrap();
let node = s.doc().unwrap().doc.node(id).unwrap();
assert!(node.extras().graph.as_ref().unwrap().series_paints.is_empty());
assert_eq!(group(node, "2024").children().unwrap()[0].appearance.fill_paint().color().map(|c| c.to_hex()), Some("#12ab34".into()));
}
#[test]
fn painting_a_line_series_stroke_sets_its_persistent_colour() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "line");
let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id;
s.execute("paint.setStroke", &json!({"ids": [series_id.0], "color": "#3751c8"})).unwrap();
let blue = vectorcraft_color::Color::rgb(55.0 / 255.0, 81.0 / 255.0, 200.0 / 255.0);
assert_eq!(
s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0].stroke.as_ref().and_then(Paint::color),
Some(blue)
);
assert!(s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0].fill.is_none());
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
assert!(
group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap().iter().any(|n| n.appearance.stroke_paint().color() == Some(blue))
);
}
#[test]
fn series_colours_survive_native_save_open_and_export() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let first = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id;
let second = group(s.doc().unwrap().doc.node(id).unwrap(), "2025").id;
s.execute("paint.setFill", &json!({"ids": [first.0], "color": "#f00"})).unwrap();
s.execute("paint.setFill", &json!({"ids": [second.0], "color": "#008080"})).unwrap();
let dir = vectorcraft_testkit::temp_dir("graph-palette");
let path = dir.join("palette.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
let paints = &s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints;
assert_eq!(paints.iter().map(|p| p.fill.as_ref().and_then(Paint::color).unwrap().to_hex()).collect::<Vec<_>>(), vec!["#ff0000", "#008080"]);
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "rows": [[3, 4, 5]]})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap();
let swatch = group(n, "2025").children().unwrap().last().unwrap();
assert_eq!(swatch.appearance.fill_paint().color().unwrap().to_hex(), "#008080");
let third = group(n, "Series 3").children().unwrap().first().unwrap();
assert_eq!(third.appearance.fill_paint().color().unwrap().to_hex(), "#cccccc");
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
let svg = svg["text"].as_str().unwrap();
assert!(svg.contains("#ff0000") && svg.contains("#008080"));
let png = dir.join("palette.png");
s.execute("document.export", &json!({"format": "png", "path": png})).unwrap();
let pixels = image::open(&png).unwrap().to_rgba8();
assert!(pixels.pixels().any(|p| p.0 == [255, 0, 0, 255]));
assert!(pixels.pixels().any(|p| p.0 == [0, 128, 128, 255]));
let old: GraphSpec = serde_json::from_value(json!({"rows": [[1.0]]})).unwrap();
assert!(old.series_paints.is_empty(), "older files keep the greyscale default");
}
#[test]
fn a_column_stroke_stays_a_stroke_and_a_marker_fill_does_not_recolour_its_line() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id;
s.execute("paint.setStroke", &json!({"ids": [series_id.0], "color": "#0000ff"})).unwrap();
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone();
let blue = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0));
assert_eq!(spec.series_paints[0].stroke, Some(blue.clone()));
assert!(spec.series_paints[0].fill.is_none());
let bar = &group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0];
assert_eq!(bar.appearance.fill_paint().color(), Some(super::default_series_color(0)));
assert_eq!(bar.appearance.stroke_paint(), blue);
let id = graph(&mut s, "line");
let marker = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[1].id;
s.execute("paint.setFill", &json!({"ids": [marker.0], "color": "#ff0000"})).unwrap();
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone();
assert!(spec.series_paints[0].stroke.is_none());
let series = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap();
assert_eq!(series[0].appearance.stroke_paint().color(), Some(super::default_series_color(0)));
assert_eq!(series[1].appearance.fill_paint().color(), Some(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)));
}
#[test]
fn a_gradient_fill_survives_regeneration() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id;
s.execute(
"paint.setFill",
&json!({"ids": [series_id.0], "gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}}),
)
.unwrap();
s.execute("graph.setData", &json!({"rows": [[2, 3], [4, 5]]})).unwrap();
let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone();
assert!(matches!(spec.series_paints[0].fill, Some(Paint::Gradient(_))));
let bar = &group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0];
assert!(matches!(bar.appearance.fill_paint(), Paint::Gradient(_)));
}
#[test]
fn a_series_named_legend_keeps_its_own_index_and_an_empty_series_is_omitted() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let csv = ",Legend,Axes\nQ1,3,2\nQ2,5,4";
let id = NodeId(s.execute("graph.create", &json!({"type": "column", "x": 0, "y": 0, "csv": csv})).unwrap()["id"].as_u64().unwrap());
let legend_id = {
let n = s.doc().unwrap().doc.node(id).unwrap();
let legend = series(n, 0);
assert_eq!(legend.name.as_deref(), Some("Legend"));
assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Legend") && c.extras().series_index.is_none()));
assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Axes") && c.extras().series_index.is_none()));
legend.id
};
s.execute("paint.setFill", &json!({"ids": [legend_id.0], "color": "#ff0000"})).unwrap();
s.execute("graph.setData", &json!({"csv": csv})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap();
assert_eq!(n.extras().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), "#ff0000");
assert!(n.extras().graph.as_ref().unwrap().series_paints.get(1).is_none_or(|p| p.fill.is_none()));
assert_eq!(series(n, 0).children().unwrap()[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff0000");
assert_eq!(series(n, 1).name.as_deref(), Some("Axes"));
assert_ne!(series(n, 1).children().unwrap()[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff0000");
let id = graph(&mut s, "pie");
s.execute("graph.setType", &json!({"id": id.0, "legend": false})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": ",Keep,Drop\nQ1,4,0\nQ2,2,0"})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap();
assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Keep")));
assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop")));
let keep = group(n, "Keep").id;
s.execute("paint.setFill", &json!({"ids": [keep.0], "color": "#00ff00"})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "rows": [[8, 0], [1, 0]]})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap();
assert_eq!(group(n, "Keep").extras().series_index, Some(0));
assert_eq!(n.extras().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), "#00ff00");
assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop")));
}
#[test]
fn csv_parsing() {
let (s, c, r) = super::parse_csv("\t2024\t2025\nQ1\t1\t2\nQ2\t3\t4\n");
assert_eq!(s, vec!["2024", "2025"]);
assert_eq!(c, vec!["Q1", "Q2"]);
assert_eq!(r, vec![vec![Some(1.0), Some(2.0)], vec![Some(3.0), Some(4.0)]]);
let (s, c, r) = super::parse_csv("1,2\n3,4");
assert!(s.is_empty() && c.is_empty());
assert_eq!(r.len(), 2);
assert_eq!(super::nice_axis(0.0, 7.0, 0), (0.0, 8.0, 2.0));
}
#[test]
fn numeric_csv_cells_round_trip_without_rounding_or_saturation() {
let mut g = GraphSpec::default();
g.set_cells(vec![vec![Some(1.2345), Some(-1.2345)], vec![Some(1e-6), Some(-1e-6)], vec![Some(1e20), Some(-1e20)], vec![Some(0.0), None]]);
let csv = super::to_csv(&g);
let (_, _, cells) = super::parse_csv(&csv);
assert_eq!(cells, g.cells(), "{csv}");
}
#[test]
fn labels_with_commas_quotes_and_newlines_round_trip_through_csv() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let series = json!(["Revenue, USD", "Say \"hi\"", "a\tb"]);
let categories = json!(["Doe, Jane", "2024", " padded ", "two\nlines"]);
let rows = json!([[1, 2, 3], [4, null, 6], [7, 8, 9], [10, 11, 12]]);
let r = s
.execute(
"graph.create",
&json!({"type": "column", "x": 20, "y": 20, "width": 300, "height": 200, "series": series, "categories": categories, "rows": rows}),
)
.unwrap();
let id = r["id"].as_u64().unwrap();
let before = s.execute("graph.setData", &json!({"id": id})).unwrap();
assert_eq!(before["series"], series);
assert_eq!(before["categories"], categories);
s.execute("graph.setData", &json!({"id": id, "csv": before["csv"]})).unwrap();
let after = s.execute("graph.setData", &json!({"id": id})).unwrap();
assert_eq!(after["series"], before["series"], "{}", before["csv"]);
assert_eq!(after["categories"], before["categories"], "{}", before["csv"]);
assert_eq!(after["rows"], before["rows"], "{}", before["csv"]);
}
#[test]
fn empty_cells_are_blank_and_quoted_numbers_are_labels() {
// Quoted years label the rows; empty and non-numeric cells are blank values, not zeros.
let (s, c, r) = super::parse_csv(",a,b\n\"2023\",1,\n\"2024\",,x\n\"2025\",3,4");
assert_eq!(s, vec!["a", "b"]);
assert_eq!(c, vec!["2023", "2024", "2025"]);
assert_eq!(r, vec![vec![Some(1.0), None], vec![None, None], vec![Some(3.0), Some(4.0)]]);
// Back to CSV: blanks stay empty and numeric labels keep their quotes, so it reads back the same.
let mut g = GraphSpec { series: s, categories: c, ..GraphSpec::default() };
g.set_cells(r);
assert_eq!(g.blanks, vec![[0, 1], [1, 0], [1, 1]]);
let csv = super::to_csv(&g);
assert!(csv.contains("\"2024\",,"), "{csv}");
let (s2, c2, r2) = super::parse_csv(&csv);
assert_eq!((s2, c2, r2), (g.series.clone(), g.categories.clone(), g.cells()));
}
#[test]
fn a_row_with_an_empty_label_keeps_its_values_in_place() {
// The label column is the first column of every row once any row has a label.
let (_, c, r) = super::parse_csv(",a,b\nQ1,1,2\n,3,4");
assert_eq!(c, vec!["Q1", ""]);
assert_eq!(r, vec![vec![Some(1.0), Some(2.0)], vec![Some(3.0), Some(4.0)]]);
// And a data window round trip keeps it (the empty label is written as "").
let mut g = GraphSpec { series: vec!["a".into(), "b".into()], categories: c, ..GraphSpec::default() };
g.set_cells(r);
assert_eq!(super::parse_csv(&super::to_csv(&g)).2, g.cells());
// Without series or labels, a first row starting with a blank is still data after a round trip.
let mut g = GraphSpec::default();
g.set_cells(vec![vec![None, Some(2.0)], vec![Some(3.0), Some(4.0)]]);
let (s, c, r) = super::parse_csv(&super::to_csv(&g));
assert!(s.is_empty() && c.is_empty());
assert_eq!(r, g.cells());
// A label with spaces around a number stays a label, spaces and all.
let mut g = GraphSpec { categories: vec!["2024 ".into()], ..GraphSpec::default() };
g.set_cells(vec![vec![Some(1.0)]]);
assert_eq!(super::parse_csv(&super::to_csv(&g)).1, vec!["2024 "]);
}
#[test]
fn blanks_read_back_as_null_and_year_header_rows_still_read_as_headers() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let r = s.execute("graph.create", &json!({"type": "line", "x": 0, "y": 0, "width": 300, "height": 200, "rows": [[1, null], [2, 3]]})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
assert_eq!(s.execute("graph.setData", &json!({"id": id.0})).unwrap()["rows"], json!([[1.0, null], [2.0, 3.0]]));
// A label followed by empty cells is a header with no series names, as before blanks existed.
let (s, c, r) = super::parse_csv("Year,,\n2023,1,2");
assert!(s.is_empty());
assert_eq!(c, Vec::<String>::new());
assert_eq!(r, vec![vec![Some(2023.0), Some(1.0), Some(2.0)]]);
}
#[test]
fn a_huge_ragged_grid_is_capped() {
let g = GraphSpec { rows: vec![vec![0.0; vectorcraft_doc::MAX_GRAPH_SERIES + 10]; 3], ..GraphSpec::default() };
assert!(g.cells().iter().all(|r| r.len() == vectorcraft_doc::MAX_GRAPH_SERIES));
}
#[test]
fn a_label_only_row_stays_with_blank_values() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let r = s.execute("graph.create", &json!({"type": "column", "x": 0, "y": 0, "width": 300, "height": 200, "csv": ",a\nQ1,1\nQ2\nQ3,3"})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.clone().unwrap();
assert_eq!(spec.categories, vec!["Q1", "Q2", "Q3"]);
assert_eq!(spec.cells(), vec![vec![Some(1.0)], vec![None], vec![Some(3.0)]]);
}
#[test]
fn blank_cells_leave_out_their_columns_and_break_lines() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let rows = json!([[3, 1], [null, 2], [5, null], [4, 4]]);
let r = s
.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 400, "height": 200, "series": ["a", "b"], "rows": rows}))
.unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
let count = |s: &Session, index: u32| series(&s.doc().unwrap().doc.node(id).unwrap().clone(), index).children().unwrap().len();
// Three columns each (one blank), plus the legend swatch.
assert_eq!((count(&s, 0), count(&s, 1)), (4, 4));
s.execute("graph.setType", &json!({"type": "stackedColumn"})).unwrap();
assert_eq!((count(&s, 0), count(&s, 1)), (4, 4));
// Line: series a is 3, blank, 5, 4. The lone first point has nothing to connect to, so one line (5 to 4), three
// markers and the legend swatch.
s.execute("graph.setType", &json!({"type": "line"})).unwrap();
assert_eq!(count(&s, 0), 1 + 3 + 1);
// Series b is 1, 2, blank, 4: one line (1 to 2), three markers and the swatch.
assert_eq!(count(&s, 1), 1 + 3 + 1);
// A data window round trip keeps the blanks.
let csv = s.execute("graph.setData", &json!({})).unwrap()["csv"].as_str().unwrap().to_string();
assert_eq!(csv.lines().nth(2), Some(",2"), "{csv}");
let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.clone().unwrap();
assert_eq!(spec.cells()[1], vec![None, Some(2.0)]);
// In the file a blank is a 0 placeholder plus its place in `blanks`, so a version without blanks reads
// numbers (as zeros); a graph without blanks writes no `blanks` at all.
let v = serde_json::to_value(&spec).unwrap();
assert_eq!(v["rows"][1], json!([0.0, 2.0]));
assert_eq!(v["blanks"], json!([[1, 0], [2, 1]]));
assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("blanks").is_none());
}
fn spec(s: &Session, id: NodeId) -> GraphSpec {
s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone()
}
/// Bounds of a series' marks, without its legend swatch (the last child).
fn marks(s: &Session, id: NodeId, index: u32) -> Vec<vectorcraft_geom::Rect> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let ch = series(&n, index).children().unwrap().to_vec();
ch[..ch.len() - 1].iter().map(|c| c.geometric_bounds().unwrap()).collect()
}
#[test]
fn a_series_given_its_own_type_is_drawn_as_that_type_in_front_of_the_columns() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let one_column = marks(&s, id, 0)[0].width();
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap();
let g = spec(&s, id);
assert_eq!((g.kind, g.series_kind(0), g.series_kind(1)), (GraphKind::Column, GraphKind::Column, GraphKind::Line));
// The columns left take the whole cluster; the line runs through the category centres, one marker a value.
let cols = marks(&s, id, 0);
assert_eq!(cols.len(), 3);
assert!((cols[0].width() - 2.0 * one_column).abs() < 1e-6, "{} vs {one_column}", cols[0].width());
let line = marks(&s, id, 1);
assert_eq!(line.len(), 4, "a line and three markers");
assert!((line[1].center().x - cols[0].center().x).abs() < 1e-6);
// The line series is drawn after (in front of) the column series.
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let order: Vec<u32> = n.children().unwrap().iter().filter_map(|c| c.extras().series_index).collect();
assert_eq!(order, [0, 1]);
s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "line"})).unwrap();
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "column"})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let order: Vec<u32> = n.children().unwrap().iter().filter_map(|c| c.extras().series_index).collect();
assert_eq!(order, [1, 0]);
// Back to the graph's own type: nothing is stored for that series.
assert_eq!(spec(&s, id).series_kinds, [Some(GraphKind::Line)]);
// One undo per change.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).series_kinds, [Some(GraphKind::Line), Some(GraphKind::Line)]);
}
#[test]
fn group_selecting_a_series_retypes_only_that_series() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let grp = series(&n, 1).id;
s.execute("select.set", &json!({"ids": [grp.0]})).unwrap();
// The dialog opens on the series' type, and its OK (every option sent back) keeps it.
assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["type"], "column");
s.execute("graph.setType", &json!({"type": "area"})).unwrap();
let g = spec(&s, id);
assert_eq!((g.kind, g.series_kind(1)), (GraphKind::Column, GraphKind::Area));
let mut all = s.execute("graph.setType", &json!({})).unwrap();
assert_eq!(all["type"], "area");
all["ticks"] = json!(4);
s.execute("graph.setType", &all).unwrap();
assert_eq!(spec(&s, id).series_kind(1), GraphKind::Area);
// The series stays selected through each change.
let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id;
assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [grp]);
// A series and something outside it selected, or an explicit `id`: the whole graph.
s.execute("select.set", &json!({"ids": [grp.0, id.0]})).unwrap();
assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["type"], "column");
s.execute("select.set", &json!({"ids": [grp.0]})).unwrap();
assert_eq!(s.execute("graph.setType", &json!({"id": id.0})).unwrap()["type"], "column");
// The whole graph selected: the graph's type changes, the series keeps its own.
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setType", &json!({"type": "line"})).unwrap();
let g = spec(&s, id);
assert_eq!((g.kind, g.series_kind(0), g.series_kind(1)), (GraphKind::Line, GraphKind::Line, GraphKind::Area));
}
#[test]
fn types_that_do_not_share_a_value_axis_are_refused_or_ignored() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
for ty in ["pie", "radar", "scatter", "bar"] {
assert!(s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": ty})).is_err(), "{ty}");
}
for bad in [json!([2]), json!([]), json!([-1]), json!(["x"]), json!([1e30]), json!({}), json!(1)] {
assert!(s.execute("graph.setType", &json!({"seriesIndexes": bad, "type": "line"})).is_err(), "{bad}");
}
assert!(s.execute("graph.setType", &json!({"seriesIndexes": [1]})).is_err(), "no type");
assert!(spec(&s, id).series_kinds.is_empty());
// A series type the graph's new type can't take is drawn as the graph's type, and kept for when it can.
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap();
s.execute("graph.setType", &json!({"type": "pie"})).unwrap();
let g = spec(&s, id);
assert_eq!(g.series_kind(1), GraphKind::Pie);
s.execute("graph.setType", &json!({"type": "column"})).unwrap();
assert_eq!(spec(&s, id).series_kind(1), GraphKind::Line);
}
#[test]
fn stacked_columns_take_one_slot_and_the_value_axis_covers_their_total() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let csv = ",a,b,c\nQ1,10,20,30\nQ2,10,20,30";
let id = NodeId(
s.execute("graph.create", &json!({"type": "stackedColumn", "x": 0, "y": 0, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
.as_u64()
.unwrap(),
);
s.execute("graph.setType", &json!({"seriesIndexes": [2], "type": "line"})).unwrap();
let (a, b) = (marks(&s, id, 0), marks(&s, id, 1));
assert!((a[0].x0 - b[0].x0).abs() < 1e-6 && (a[0].y0 - b[0].y1).abs() < 1e-6, "b sits on a");
// a + b stack to 30, the line's highest value is 30: the axis ends at 30 and the stack reaches the top.
assert!((b[0].y0 - 0.0).abs() < 1e-6, "{:?}", b[0]);
// With a plain column too, the stack is the cluster's last slot, beside it.
s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "column"})).unwrap();
let (a, b) = (marks(&s, id, 0), marks(&s, id, 1));
assert!((a[0].width() - b[0].width()).abs() < 1e-6 && b[0].x0 > a[0].x1, "{:?} {:?}", a[0], b[0]);
}
#[test]
fn areas_mixed_with_columns_put_their_points_at_the_category_centres() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "area"})).unwrap();
let (area, cols) = (marks(&s, id, 0)[0], marks(&s, id, 1));
assert!((area.x0 - cols[0].center().x).abs() < 1e-6 && (area.x1 - cols[2].center().x).abs() < 1e-6, "{area:?}");
// The area is drawn behind the columns.
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let order: Vec<u32> = n.children().unwrap().iter().filter_map(|c| c.extras().series_index).collect();
assert_eq!(order, [0, 1]);
// An area graph alone still spans the plot.
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "area"})).unwrap();
assert!((marks(&s, id, 0)[0].x0 - 100.0).abs() < 1e-6);
}
#[test]
fn bar_graphs_take_stacked_bar_series() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let csv = ",a,b,c\nQ1,10,20,-5\nQ2,10,20,-5";
let id = NodeId(
s.execute("graph.create", &json!({"type": "bar", "x": 0, "y": 0, "width": 300, "height": 200, "csv": csv})).unwrap()["id"].as_u64().unwrap(),
);
assert!(s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "column"})).is_err());
s.execute("graph.setType", &json!({"seriesIndexes": [1, 2], "type": "stackedBar"})).unwrap();
let (a, b, c) = (marks(&s, id, 0), marks(&s, id, 1), marks(&s, id, 2));
// b and c stack along x in the cluster's second slot, below a's bar; c, negative, runs left of the zero line.
assert!((a[0].height() - b[0].height()).abs() < 1e-6 && b[0].y0 > a[0].y1, "{:?} {:?}", a[0], b[0]);
assert!((b[0].y0 - c[0].y0).abs() < 1e-6 && (c[0].x1 - b[0].x0).abs() < 1e-6, "{:?} {:?}", b[0], c[0]);
}
#[test]
fn series_types_survive_save_and_open_and_older_files_have_none() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap();
let path = vectorcraft_testkit::temp_dir("graph-kinds").join("kinds.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
assert_eq!(spec(&s, id).series_kinds, [None, Some(GraphKind::Line)]);
let g: GraphSpec = serde_json::from_value(json!({"kind": "line", "rows": [[1.0, 2.0]]})).unwrap();
assert!(g.series_kinds.is_empty());
assert!(serde_json::to_value(&g).unwrap().get("seriesKinds").is_none());
// A stored type the graph can't take, or past its series, is drawn as the graph's type.
let g: GraphSpec = serde_json::from_value(json!({"kind": "pie", "seriesKinds": ["line", null, "column"], "rows": [[1.0]]})).unwrap();
assert_eq!((g.series_kind(0), g.series_kind(5)), (GraphKind::Pie, GraphKind::Pie));
}
/// The value axis labels: text and bounds of the texts in the Axes group that read as numbers.
fn value_labels(s: &Session, id: NodeId) -> Vec<(String, vectorcraft_geom::Rect)> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
group(&n, "Axes")
.children()
.unwrap()
.iter()
.filter_map(|c| match &c.kind {
NodeKind::Text(t) => Some((t.plain_text(), c.geometric_bounds().unwrap())),
_ => None,
})
.filter(|(t, _)| t.parse::<f64>().is_ok())
.collect()
}
fn two_scale_graph(s: &mut Session) -> NodeId {
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let csv = ",a,b\nQ1,10,1000\nQ2,5,500";
NodeId(
s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
.as_u64()
.unwrap(),
)
}
#[test]
fn the_value_axis_goes_on_the_left_the_right_or_both_sides() {
let mut s = Session::new();
let id = two_scale_graph(&mut s);
let left: Vec<String> = value_labels(&s, id).into_iter().inspect(|(_, b)| assert!(b.x1 < 100.0, "{b:?}")).map(|(t, _)| t).collect();
assert_eq!(left, ["0", "200", "400", "600", "800", "1000"]);
s.execute("graph.setType", &json!({"valueAxis": "right"})).unwrap();
let right = value_labels(&s, id);
assert!(right.iter().all(|(_, b)| b.x0 > 400.0), "{right:?}");
assert_eq!(right.iter().map(|(t, _)| t.as_str()).collect::<Vec<_>>(), left);
// Both sides, one scale: the same labels on each side, and the legend moves past the right ones.
s.execute("graph.setType", &json!({"valueAxis": "both"})).unwrap();
let both = value_labels(&s, id);
assert_eq!(both.len(), 12);
let edge = both.iter().map(|(_, b)| b.x1).fold(0.0, f64::max);
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let swatch = series(&n, 0).children().unwrap().last().unwrap().geometric_bounds().unwrap();
assert!(swatch.x0 > edge, "{swatch:?} vs {edge}");
assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["valueAxis"], "both");
assert!(s.execute("graph.setType", &json!({"valueAxis": "top"})).is_err());
}
#[test]
fn separate_scales_give_the_right_axis_series_a_scale_of_their_own() {
let mut s = Session::new();
let id = two_scale_graph(&mut s);
s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap();
// Assigned, but drawn on one scale until separate scales are on with both sides.
assert_eq!(spec(&s, id).right_series, [1]);
s.execute("graph.setType", &json!({"separateScales": true})).unwrap();
assert!((marks(&s, id, 0)[0].height() - 2.0).abs() < 1e-6, "a's 10 of 1000");
s.execute("graph.setType", &json!({"valueAxis": "both"})).unwrap();
// a's 10 and b's 1000 both reach the top: 0..10 on the left, 0..1000 on the right.
let (a, b) = (marks(&s, id, 0), marks(&s, id, 1));
assert!((a[0].y0 - 100.0).abs() < 1e-6 && (b[0].y0 - 100.0).abs() < 1e-6, "{:?} {:?}", a[0], b[0]);
let labels = value_labels(&s, id);
let side = |right: bool| labels.iter().filter(|(_, b)| (b.x0 > 400.0) == right).map(|(t, _)| t.clone()).collect::<Vec<_>>();
assert_eq!(side(false), ["0", "2", "4", "6", "8", "10"]);
assert_eq!(side(true), ["0", "200", "400", "600", "800", "1000"]);
// The right axis' own tick values.
s.execute("graph.setType", &json!({"rightAxisMin": 0, "rightAxisMax": 2000, "rightTicks": 4})).unwrap();
assert!((marks(&s, id, 1)[0].y0 - 200.0).abs() < 1e-6, "1000 of 2000");
assert!((marks(&s, id, 0)[0].y0 - 100.0).abs() < 1e-6, "the left axis is unchanged");
let v = s.execute("graph.setType", &json!({})).unwrap();
assert_eq!((v["separateScales"].clone(), v["rightTicks"].clone(), v["rightAxisMax"].clone()), (json!(true), json!(4), json!(2000.0)));
// Stacked series stack on their own axis, each axis' stack in a slot of its own.
s.execute("graph.setType", &json!({"type": "stackedColumn"})).unwrap();
let (a, b) = (marks(&s, id, 0), marks(&s, id, 1));
assert!((a[0].y1 - 300.0).abs() < 1e-6 && (b[0].y1 - 300.0).abs() < 1e-6, "{:?} {:?}", a[0], b[0]);
assert!(b[0].x0 >= a[0].x1 - 1e-6 && (a[0].width() - b[0].width()).abs() < 1e-6, "side by side: {:?} {:?}", a[0], b[0]);
// A line on the right axis follows the right scale.
s.execute("graph.setType", &json!({"type": "column"})).unwrap();
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap();
let line = marks(&s, id, 1);
assert!((line[1].center().y - 200.0).abs() < 1e-6 && (line[2].center().y - 250.0).abs() < 1e-6, "1000 and 500 of 2000: {line:?}");
}
#[test]
fn group_selected_series_go_on_the_chosen_axis() {
let mut s = Session::new();
let id = two_scale_graph(&mut s);
let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id;
s.execute("select.set", &json!({"ids": [grp.0]})).unwrap();
assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["valueAxis"], "left");
assert!(s.execute("graph.setType", &json!({"valueAxis": "both"})).is_err(), "a series takes one axis");
s.execute("graph.setType", &json!({"valueAxis": "right"})).unwrap();
let g = spec(&s, id);
assert_eq!((g.right_series.clone(), g.value_axis), (vec![1], vectorcraft_doc::ValueAxisSide::Left));
assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["valueAxis"], "right");
s.execute("graph.setType", &json!({"valueAxis": "left"})).unwrap();
assert!(spec(&s, id).right_series.is_empty());
}
#[test]
fn bar_graphs_keep_their_value_axis_along_the_bottom() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "bar");
let before = (value_labels(&s, id), marks(&s, id, 0));
s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true, "seriesIndexes": [1]})).unwrap_err();
s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true})).unwrap();
s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap();
assert_eq!((value_labels(&s, id), marks(&s, id, 0)), before);
}
#[test]
fn value_axis_options_survive_save_and_open_and_are_left_out_when_unset() {
let mut s = Session::new();
let id = two_scale_graph(&mut s);
s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true, "rightAxisMin": 0, "rightAxisMax": 2000})).unwrap();
s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap();
let path = vectorcraft_testkit::temp_dir("graph-axes").join("axes.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
let g = spec(&s, id);
assert_eq!(
(g.value_axis, g.separate_scales, g.right_series, g.right_axis_max),
(vectorcraft_doc::ValueAxisSide::Both, true, vec![1], Some(2000.0))
);
let v = serde_json::to_value(GraphSpec::default()).unwrap();
for k in ["valueAxis", "separateScales", "rightSeries", "rightTicks", "rightAxisMin", "rightAxisMax"] {
assert!(v.get(k).is_none(), "{k}");
}
}
#[test]
fn the_dialog_round_trip_keeps_series_on_both_axes_where_they_are() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let csv = ",a,b,c\nQ1,10,1000,3\nQ2,5,500,4";
let id = NodeId(
s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
.as_u64()
.unwrap(),
);
s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap();
for side in ["both", "left"] {
s.execute("graph.setType", &json!({"valueAxis": side, "separateScales": true})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
s.execute("select.set", &json!({"ids": [series(&n, 0).id.0, series(&n, 1).id.0]})).unwrap();
let fields = s.execute("graph.setType", &json!({})).unwrap();
assert!(fields["valueAxis"].is_null(), "{side}: {fields}");
s.execute("graph.setType", &fields).unwrap();
let g = spec(&s, id);
assert_eq!((g.right_series, g.value_axis.id()), (vec![1], side), "{side}");
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
}
}
#[test]
fn an_axis_with_no_series_shows_the_other_scale() {
let mut s = Session::new();
let id = two_scale_graph(&mut s);
s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true})).unwrap();
let labels = |s: &Session| {
let l = value_labels(s, id);
let side = |right: bool| l.iter().filter(|(_, b)| (b.x0 > 400.0) == right).map(|(t, _)| t.clone()).collect::<Vec<_>>();
(side(false), side(true))
};
let (left, right) = labels(&s);
assert_eq!(left, right);
assert_eq!(right.last().unwrap(), "1000");
// Every series on the right: the left axis shows the right scale.
s.execute("graph.setType", &json!({"seriesIndexes": [0, 1], "valueAxis": "right", "rightAxisMin": 0, "rightAxisMax": 2000})).unwrap();
let (left, right) = labels(&s);
assert_eq!(left, right);
assert_eq!(right.last().unwrap(), "2000");
}
#[test]
fn right_axis_columns_start_at_the_right_scales_zero() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let csv = ",a,b\nQ1,10,-500\nQ2,5,500";
let id = NodeId(
s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
.as_u64()
.unwrap(),
);
s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true, "seriesIndexes": [1]})).unwrap_err();
s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true})).unwrap();
s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap();
// Right scale -600..600 (zero at y 200), left 0..10 (zero at y 300): b's columns meet at the right zero.
let b = marks(&s, id, 1);
assert!((b[0].y0 - 200.0).abs() < 1e-6 && (b[1].y1 - 200.0).abs() < 1e-6, "{b:?}");
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let at_200 = group(&n, "Axes")
.children()
.unwrap()
.iter()
.filter(|c| matches!(c.kind, NodeKind::Path { .. }))
.filter_map(|c| c.geometric_bounds())
.any(|bb| bb.height() < 1e-6 && (bb.y0 - 200.0).abs() < 1e-6 && (bb.width() - 300.0).abs() < 1e-6);
assert!(at_200, "a zero line for the right scale");
}
#[test]
fn data_without_a_right_axis_series_drops_its_assignment() {
let mut s = Session::new();
let id = two_scale_graph(&mut s);
s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap();
s.execute("graph.setData", &json!({"csv": ",a\nQ1,10\nQ2,5"})).unwrap();
assert!(spec(&s, id).right_series.is_empty());
s.execute("graph.setData", &json!({"csv": ",a,b\nQ1,10,1000\nQ2,5,500"})).unwrap();
assert!(spec(&s, id).right_series.is_empty(), "a new series starts on the left");
}
/// Bounds of the straight lines in the Axes group.
fn axis_lines(s: &Session, id: NodeId) -> Vec<vectorcraft_geom::Rect> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
group(&n, "Axes").children().unwrap().iter().filter(|c| matches!(c.kind, NodeKind::Path { .. })).filter_map(|c| c.geometric_bounds()).collect()
}
fn axis_texts(s: &Session, id: NodeId) -> Vec<String> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
group(&n, "Axes")
.children()
.unwrap()
.iter()
.filter_map(|c| match &c.kind {
NodeKind::Text(t) => Some(t.plain_text()),
_ => None,
})
.collect()
}
#[test]
fn value_axis_tick_marks_take_a_length_and_a_count_per_division() {
let mut s = Session::new();
let id = two_scale_graph(&mut s);
let short = |s: &Session, x0: f64| axis_lines(s, id).iter().filter(|b| (b.x0 - x0).abs() < 1e-6 && (b.width() - 4.0).abs() < 1e-6).count();
let across = |s: &Session| axis_lines(s, id).iter().filter(|b| b.height() < 1e-6 && (b.width() - 300.0).abs() < 1e-6).count();
// Short (the default): one at each of the six labels; the zero line runs across.
assert_eq!((short(&s, 96.0), across(&s)), (6, 1));
s.execute("graph.setType", &json!({"tickLength": "full"})).unwrap();
// Across the plot at 200…1000; the one at 0 would be the zero line again.
assert_eq!((short(&s, 96.0), across(&s)), (0, 6));
s.execute("graph.setType", &json!({"tickLength": "none"})).unwrap();
assert_eq!((short(&s, 96.0), across(&s)), (0, 1));
// Two per division: one more halfway between labels, none past the last label.
s.execute("graph.setType", &json!({"tickLength": "short", "tickMarks": 2})).unwrap();
assert_eq!(short(&s, 96.0), 11);
assert!(axis_lines(&s, id).iter().any(|b| (b.x0 - 96.0).abs() < 1e-6 && (b.y0 - 280.0).abs() < 1e-6), "100 of 0..1000 sits at y 280");
assert_eq!(axis_texts(&s, id).iter().filter(|t| t.parse::<f64>().is_ok()).count(), 6, "labels only at divisions");
// The right axis has its own.
s.execute("graph.setType", &json!({"valueAxis": "both", "rightTickLength": "none"})).unwrap();
assert_eq!((short(&s, 96.0), short(&s, 400.0)), (11, 0));
s.execute("graph.setType", &json!({"rightTickLength": "short", "rightTickMarks": 4})).unwrap();
assert_eq!(short(&s, 400.0), 21);
let v = s.execute("graph.setType", &json!({})).unwrap();
assert_eq!((v["tickLength"].clone(), v["tickMarks"].clone(), v["rightTickMarks"].clone()), (json!("short"), json!(2), json!(4)));
assert!(s.execute("graph.setType", &json!({"tickLength": "long"})).is_err());
s.execute("graph.setType", &json!({"tickMarks": 1_000_000})).unwrap();
assert_eq!(spec(&s, id).tick_marks, 20);
}
#[test]
fn category_tick_marks_go_at_or_between_the_labels() {
let mut s = Session::new();
let id = two_scale_graph(&mut s);
let below = |s: &Session| {
let mut xs: Vec<f64> = axis_lines(s, id)
.iter()
.filter(|b| b.width() < 1e-6 && (b.y0 - 300.0).abs() < 1e-6 && (b.height() - 4.0).abs() < 1e-6)
.map(|b| b.x0)
.collect();
xs.sort_by(f64::total_cmp);
xs
};
// None by default, as before.
assert!(below(&s).is_empty());
s.execute("graph.setType", &json!({"categoryTickLength": "short"})).unwrap();
assert_eq!(below(&s), [175.0, 325.0]);
s.execute("graph.setType", &json!({"ticksBetweenLabels": true})).unwrap();
assert_eq!(below(&s), [100.0, 250.0, 400.0]);
s.execute("graph.setType", &json!({"categoryTickMarks": 2})).unwrap();
assert_eq!(below(&s), [100.0, 175.0, 250.0, 325.0, 400.0]);
s.execute("graph.setType", &json!({"categoryTickLength": "full"})).unwrap();
let full = axis_lines(&s, id).iter().filter(|b| b.width() < 1e-6 && (b.height() - 200.0).abs() < 1e-6 && b.x0 > 100.0 + 1e-6).count();
assert_eq!(full, 4, "inside the plot: 175, 250, 325 and 400");
// Bar graphs: the category axis is the left one.
let bar = graph(&mut s, "bar");
s.execute("graph.setType", &json!({"id": bar.0, "categoryTickLength": "short", "ticksBetweenLabels": true})).unwrap();
let left = axis_lines(&s, bar).iter().filter(|b| b.height() < 1e-6 && (b.x0 - 96.0).abs() < 1e-6 && (b.width() - 4.0).abs() < 1e-6).count();
assert_eq!(left, 4, "three categories, four edges");
}
#[test]
fn value_axis_numbers_take_a_prefix_and_a_suffix() {
let mut s = Session::new();
let id = two_scale_graph(&mut s);
s.execute("graph.setType", &json!({"prefix": "$", "suffix": " M", "valueAxis": "both", "separateScales": true})).unwrap();
s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right", "rightSuffix": "%"})).unwrap();
let texts = axis_texts(&s, id);
assert!(texts.contains(&"$10 M".to_string()) && texts.contains(&"$0 M".to_string()), "{texts:?}");
assert!(texts.contains(&"1000%".to_string()) && !texts.contains(&"$1000 M".to_string()), "{texts:?}");
// One short line: control characters dropped, the length capped.
s.execute("graph.setType", &json!({"prefix": format!("a\nb{}", "x".repeat(500))})).unwrap();
let p = spec(&s, id).prefix;
assert!(p.starts_with("ab") && p.chars().count() == 64, "{p}");
let v = s.execute("graph.setType", &json!({})).unwrap();
assert_eq!((v["suffix"].clone(), v["rightSuffix"].clone()), (json!(" M"), json!("%")));
}
#[test]
fn tick_and_label_options_survive_save_and_open_and_are_left_out_when_unset() {
let mut s = Session::new();
let id = two_scale_graph(&mut s);
s.execute(
"graph.setType",
&json!({"tickLength": "full", "tickMarks": 3, "categoryTickLength": "short", "ticksBetweenLabels": true, "suffix": "%"}),
)
.unwrap();
let path = vectorcraft_testkit::temp_dir("graph-ticks").join("ticks.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
let g = spec(&s, id);
assert_eq!(
(g.tick_length, g.tick_marks, g.category_tick_length, g.ticks_between_labels, g.suffix.as_str()),
(vectorcraft_doc::TickLength::Full, 3, vectorcraft_doc::TickLength::Short, true, "%")
);
let v = serde_json::to_value(GraphSpec::default()).unwrap();
for k in [
"tickLength",
"tickMarks",
"rightTickLength",
"rightTickMarks",
"categoryTickLength",
"categoryTickMarks",
"ticksBetweenLabels",
"prefix",
"suffix",
"rightPrefix",
"rightSuffix",
] {
assert!(v.get(k).is_none(), "{k}");
}
// A file's huge values draw within the caps.
let g: GraphSpec = serde_json::from_value(json!({"rows": [[1.0]], "tickMarks": 1_000_000_000u64, "prefix": "y".repeat(10_000)})).unwrap();
let mut d = vectorcraft_doc::Document::new(800.0, 800.0);
let art = super::generate(&mut d, &g);
let axes = art.iter().find(|n| n.name.as_deref() == Some("Axes")).unwrap().children().unwrap().to_vec();
assert!(axes.len() < 200, "{} axis parts", axes.len());
for t in axes.iter().filter_map(|n| match &n.kind {
NodeKind::Text(t) => Some(t.plain_text()),
_ => None,
}) {
assert!(t.chars().count() < 80, "{} characters", t.chars().count());
}
}
#[test]
fn between_labels_ticks_on_an_edge_to_edge_graph_fall_halfway_between_its_labels() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "area");
s.execute("graph.setType", &json!({"categoryTickLength": "short", "ticksBetweenLabels": true})).unwrap();
// Labels at 100, 250 and 400 (the plot's edges and middle): ticks at 175 and 325.
let mut xs: Vec<f64> = axis_lines(&s, id)
.iter()
.filter(|b| b.width() < 1e-6 && (b.y0 - 300.0).abs() < 1e-6 && (b.height() - 4.0).abs() < 1e-6)
.map(|b| b.x0)
.collect();
xs.sort_by(f64::total_cmp);
assert_eq!(xs, [175.0, 325.0]);
}
#[test]
fn bar_graph_value_ticks_run_up_the_plot_and_long_labels_push_the_legend() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "bar");
let legend_x = |s: &Session| {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
series(&n, 0).children().unwrap().last().unwrap().geometric_bounds().unwrap().x0
};
let before = legend_x(&s);
assert!((before - 414.0).abs() < 1e-6, "a short last label leaves the legend where it was: {before}");
s.execute("graph.setType", &json!({"tickLength": "full", "tickMarks": 2})).unwrap();
let up: Vec<_> = axis_lines(&s, id).into_iter().filter(|b| b.width() < 1e-6 && (b.height() - 200.0).abs() < 1e-6).collect();
// -2..6 in 2s: five labels and four halves; the one at 0 is the zero line already, the left end the axis.
assert_eq!(up.len(), 9, "{up:?}");
s.execute("graph.setType", &json!({"suffix": " million tonnes"})).unwrap();
let last = value_label_edge(&s, id);
assert!(legend_x(&s) >= last + 6.0 - 1e-6, "{} vs {last}", legend_x(&s));
}
/// The right end of the Axes group's texts.
fn value_label_edge(s: &Session, id: NodeId) -> f64 {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
group(&n, "Axes")
.children()
.unwrap()
.iter()
.filter(|c| matches!(c.kind, NodeKind::Text(_)))
.filter_map(|c| c.geometric_bounds())
.map(|b| b.x1)
.fold(f64::MIN, f64::max)
}
#[test]
fn counts_come_back_from_the_dialog_as_floats_and_are_capped() {
let mut s = Session::new();
let id = two_scale_graph(&mut s);
s.execute("graph.setType", &json!({"tickMarks": 3.0, "ticks": 4.0, "categoryTickMarks": 2.6})).unwrap();
let g = spec(&s, id);
assert_eq!((g.tick_marks, g.ticks, g.category_tick_marks), (3, 4, 3));
s.execute("graph.setType", &json!({"categoryTickMarks": 0, "rightTickMarks": 1e300, "rightTicks": -2.0})).unwrap();
let g = spec(&s, id);
assert_eq!((g.category_tick_marks, g.right_tick_marks, g.right_ticks), (1, 20, 0));
// A file's values come back as drawn.
let mut fields = s.execute("graph.setType", &json!({})).unwrap();
assert_eq!(fields["rightTickMarks"], 20);
fields["tickLength"] = json!("full");
s.execute("graph.setType", &fields).unwrap();
assert_eq!(spec(&s, id).tick_length, vectorcraft_doc::TickLength::Full);
}
#[test]
fn a_long_category_axis_is_ticked_all_the_way_within_the_cap() {
let rows: Vec<Vec<f64>> = (0..5000).map(|i| vec![i as f64]).collect();
let g = GraphSpec { rows, category_tick_length: vectorcraft_doc::TickLength::Short, category_tick_marks: 20, ..GraphSpec::default() };
let mut d = vectorcraft_doc::Document::new(800.0, 800.0);
let art = super::generate(&mut d, &g);
let axes = art.iter().find(|n| n.name.as_deref() == Some("Axes")).unwrap();
let ticks: Vec<f64> = axes
.children()
.unwrap()
.iter()
.filter_map(|c| c.geometric_bounds())
.filter(|b| b.width() < 1e-9 && (b.height() - 4.0).abs() < 1e-9)
.map(|b| b.x0)
.collect();
assert!(ticks.len() <= 10_000 && ticks.len() >= 5000, "{}", ticks.len());
assert!(ticks.iter().copied().fold(f64::MIN, f64::max) > 199.0, "the last category is ticked");
}
/// A graph design: a 10 × 10 rectangle at the back and a 20-wide ellipse over it, saved as `name`.
fn save_design(s: &mut Session, name: &str) {
let r = s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
let e = s.execute("shape.ellipse", &json!({"x": 595, "y": 600, "width": 20, "height": 10})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [r, e]})).unwrap();
s.execute("graph.design", &json!({"new": name})).unwrap();
}
/// The marks of series `index` (legend swatch last) and their bounds.
fn series_parts(s: &Session, id: NodeId, index: u32) -> Vec<vectorcraft_doc::Node> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
series(&n, index).children().unwrap().iter().map(|c| (**c).clone()).collect()
}
#[test]
fn a_marker_design_draws_each_data_point_sized_by_its_backmost_object() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
save_design(&mut s, "Pill");
assert_eq!(s.execute("graph.design", &json!({})).unwrap()["designs"], json!(["Pill"]));
let id = graph(&mut s, "line");
let before = series_parts(&s, id, 1);
let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id;
s.execute("select.set", &json!({"ids": [grp.0]})).unwrap();
s.execute("graph.marker", &json!({"design": "Pill"})).unwrap();
let after = series_parts(&s, id, 1);
assert_eq!(after.len(), before.len(), "a line, three markers and the swatch");
for (old, new) in before.iter().zip(&after).skip(1) {
let (o, n) = (old.geometric_bounds().unwrap(), new.geometric_bounds().unwrap());
assert!(matches!(new.kind, NodeKind::Group { .. }), "the design's art");
// The 10-point rectangle fills the marker's square, the 20-wide ellipse twice as wide.
assert!((n.center() - o.center()).hypot() < 1e-6, "{o:?} {n:?}");
assert!((n.width() - 2.0 * o.width()).abs() < 1e-6 && (n.height() - o.height()).abs() < 1e-6, "{o:?} {n:?}");
}
// The series stays selected; the other series keeps its squares; the query names the design.
let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id;
assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [grp]);
assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
assert_eq!(s.execute("graph.marker", &json!({})).unwrap()["design"], "Pill");
assert_eq!(spec(&s, id).series_markers, [None, Some("Pill".to_string())]);
// Back to the default square.
s.execute("graph.marker", &json!({"design": null})).unwrap();
assert!(spec(&s, id).series_markers.is_empty());
assert!(series_parts(&s, id, 1).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
assert!(s.execute("graph.marker", &json!({"design": "Nope"})).is_err());
assert!(s.execute("graph.marker", &json!({"design": 3})).is_err());
}
#[test]
fn marker_designs_apply_to_markers_not_to_columns() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
save_design(&mut s, "Pill");
let id = graph(&mut s, "column");
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap();
// Every series (none picked): the line series' markers and swatch; the columns and their swatch stay.
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.marker", &json!({"design": "Pill"})).unwrap();
assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
let line = series_parts(&s, id, 1);
assert!(line.iter().skip(1).all(|n| matches!(n.kind, NodeKind::Group { .. })), "markers and swatch");
let swatch = line.last().unwrap().geometric_bounds().unwrap();
assert!((swatch.width() - 16.0).abs() < 1e-6 && (swatch.height() - 8.0).abs() < 1e-6, "{swatch:?}");
// Scatter and radar graphs draw them too.
for ty in ["scatter", "radar"] {
let g = graph(&mut s, ty);
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
s.execute("graph.marker", &json!({"design": "Pill"})).unwrap();
assert!(series_parts(&s, g, 0).iter().any(|n| matches!(n.kind, NodeKind::Group { .. })), "{ty}");
}
}
#[test]
fn graph_designs_are_saved_pasted_and_deleted() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
assert!(s.execute("graph.design", &json!({"new": "Pill"})).is_err(), "nothing selected");
save_design(&mut s, "Pill");
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
assert!(s.execute("graph.design", &json!({"new": "Pill"})).is_err(), "the name is taken");
assert!(s.execute("graph.design", &json!({"new": " "})).is_err(), "no name");
// Paste Design: a copy of the art, selected, with ids of its own.
let pasted = NodeId(s.execute("graph.design", &json!({"paste": "Pill"})).unwrap()["id"].as_u64().unwrap());
assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [pasted]);
let art = s.doc().unwrap().doc.graph_designs[0].art.clone();
let copy = s.doc().unwrap().doc.node(pasted).unwrap().clone();
assert_ne!(copy.id, art.id);
assert_eq!(copy.geometric_bounds(), art.geometric_bounds());
assert!(s.execute("graph.design", &json!({"paste": "Nope"})).is_err());
// Deleting a design draws its graphs' default markers again; undo brings both back.
let id = graph(&mut s, "line");
s.execute("graph.marker", &json!({"design": "Pill"})).unwrap();
s.execute("graph.design", &json!({"delete": "Pill"})).unwrap();
assert!(s.doc().unwrap().doc.graph_designs.is_empty());
assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
s.execute("edit.undo", &json!({})).unwrap();
assert!(series_parts(&s, id, 0).iter().skip(1).all(|n| matches!(n.kind, NodeKind::Group { .. })));
assert!(s.execute("graph.design", &json!({"delete": "Nope"})).is_err());
// A name listed many times deletes once (its graphs regenerate once).
s.execute("graph.design", &json!({"delete": vec!["Pill"; 1000]})).unwrap();
assert!(s.doc().unwrap().doc.graph_designs.is_empty());
assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
}
#[test]
fn designs_and_markers_survive_save_and_open() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
save_design(&mut s, "Pill");
let id = graph(&mut s, "line");
s.execute("graph.marker", &json!({"seriesIndexes": [0], "design": "Pill"})).unwrap();
let path = vectorcraft_testkit::temp_dir("graph-designs").join("designs.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
assert_eq!(s.execute("graph.design", &json!({})).unwrap()["designs"], json!(["Pill"]));
assert_eq!(spec(&s, id).series_markers, [Some("Pill".to_string())]);
assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("seriesMarkers").is_none());
// A design whose art has no area draws the default square.
let g = GraphSpec { kind: GraphKind::Line, series_markers: vec![Some("Dot".into())], ..GraphSpec::default() };
let mut d = vectorcraft_doc::Document::new(800.0, 800.0);
let dot = vectorcraft_doc::Node::group(d.alloc_id(), vec![]);
d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Dot".into(), art: std::sync::Arc::new(dot) });
let art = super::generate(&mut d, &g);
let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap();
assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
}
#[test]
fn a_graph_saved_as_a_design_is_plain_art_even_as_its_own_marker() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
save_design(&mut s, "Pill");
let id = graph(&mut s, "line");
s.execute("graph.marker", &json!({"design": "Pill"})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.design", &json!({"new": "Chart"})).unwrap();
let art = s.doc().unwrap().doc.graph_designs[1].art.clone();
let mut graphs = 0;
art.walk(&mut |n| graphs += usize::from(n.extras().graph.is_some() || n.extras().series_index.is_some()));
assert_eq!(graphs, 0, "no graph inside a design");
// The graph drawn with itself as a marker, then the design it used deleted: still one graph to edit.
s.execute("graph.marker", &json!({"id": id.0, "seriesIndexes": [1], "design": "Chart"})).unwrap();
s.execute("graph.design", &json!({"delete": "Pill"})).unwrap();
let mut found = 0;
s.doc().unwrap().doc.walk(|n| found += usize::from(n.extras().graph.is_some()));
assert_eq!(found, 1);
// A deleted design reads as none in the query, so the dialog opens on the default marker.
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.marker", &json!({"seriesIndexes": [0], "design": null})).unwrap();
let v = s.execute("graph.marker", &json!({"seriesIndexes": [1, 0]})).unwrap();
assert!(v["design"].is_null(), "{v}");
}
#[test]
fn designs_too_big_for_a_graph_are_refused_and_drawn_as_squares() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
// 1,500 objects at each of 200 points is past the budget.
let ids: Vec<u64> = (0..1500)
.map(|i| s.execute("shape.rectangle", &json!({"x": i % 40, "y": i / 40, "width": 1, "height": 1})).unwrap()["id"].as_u64().unwrap())
.collect();
s.execute("select.set", &json!({"ids": ids})).unwrap();
s.execute("graph.design", &json!({"new": "Big"})).unwrap();
let rows: Vec<Vec<f64>> = (0..200).map(|i| vec![i as f64]).collect();
let id = NodeId(
s.execute("graph.create", &json!({"type": "line", "x": 0, "y": 0, "width": 300, "height": 200, "rows": rows})).unwrap()["id"]
.as_u64()
.unwrap(),
);
assert!(s.execute("graph.marker", &json!({"id": id.0, "design": "Big"})).is_err());
assert!(spec(&s, id).series_markers.is_empty());
// A file asking for it anyway draws the default squares.
let mut g = spec(&s, id);
g.series_markers = vec![Some("Big".into())];
let mut d = (*s.doc().unwrap().doc).clone();
let art = super::generate(&mut d, &g);
let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap();
assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
// Too many objects for one design.
let ids: Vec<u64> = (0..2001)
.map(|i| s.execute("shape.rectangle", &json!({"x": i % 40, "y": i / 40, "width": 1, "height": 1})).unwrap()["id"].as_u64().unwrap())
.collect();
s.execute("select.set", &json!({"ids": ids})).unwrap();
assert!(s.execute("graph.design", &json!({"new": "Huge"})).is_err());
}
#[test]
fn a_files_designs_are_tidied() {
let mut d = vectorcraft_doc::Document::new(100.0, 100.0);
let art = |d: &mut vectorcraft_doc::Document| std::sync::Arc::new(vectorcraft_doc::Node::group(d.alloc_id(), vec![]));
let mut graphy = vectorcraft_doc::Node::group(d.alloc_id(), vec![]);
graphy.extras_mut().graph = Some(Box::new(GraphSpec::default()));
for name in [" A\u{7} ", "A", "", " ", &"x".repeat(100)] {
let a = art(&mut d);
d.graph_designs.push(vectorcraft_doc::GraphDesign { name: name.into(), art: a });
}
d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "G".into(), art: std::sync::Arc::new(graphy) });
d.tidy_graph_designs();
let names: Vec<_> = d.graph_designs.iter().map(|x| x.name.clone()).collect();
assert_eq!(names, ["A".to_string(), "x".repeat(64), "G".to_string()]);
assert!(d.graph_designs[2].art.extras().graph.is_none());
}
#[test]
fn painting_a_series_drawn_with_a_design_leaves_the_design_alone() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
save_design(&mut s, "Pill");
let id = graph(&mut s, "line");
s.execute("graph.marker", &json!({"design": "Pill"})).unwrap();
let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id;
s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#f00"})).unwrap();
// The design's paths keep the design's paint, before and after the graph is drawn again.
let design_fill = |s: &Session| {
let parts = series_parts(s, id, 0);
let mut fills = vec![];
parts[1].walk(&mut |n| {
if let NodeKind::Path { .. } = n.kind {
fills.push(n.appearance.fill_paint().color().map(|c| c.to_hex()));
}
});
fills
};
let painted = design_fill(&s);
assert!(!painted.contains(&Some("#ff0000".into())), "{painted:?}");
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
assert_eq!(design_fill(&s), painted);
// A design of one path too.
let r = s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setFill", &json!({"ids": [r], "color": "#00f"})).unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
s.execute("graph.design", &json!({"new": "Square"})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.marker", &json!({"design": "Square"})).unwrap();
let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id;
s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#f00"})).unwrap();
assert_eq!(design_fill(&s), [Some("#0000ff".to_string())]);
}
#[test]
fn graph_design_params_are_names() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
for p in [json!({"new": 5}), json!({"paste": null}), json!({"delete": ["a"]})] {
assert!(s.execute("graph.design", &p).is_err(), "{p}");
}
}
#[test]
fn document_wide_passes_see_design_art() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setFill", &json!({"ids": [r], "color": "#123456"})).unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
s.execute("graph.design", &json!({"new": "Blue"})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
let mut seen = false;
s.doc().unwrap().doc.visit_paints(&mut |p| seen |= p.color().is_some_and(|c| c.to_hex() == "#123456"));
assert!(seen, "the colour is used by the design");
}
#[test]
fn new_design_rename_delete_and_select_unused() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
// New Design names it "New Design", numbered when that's taken.
assert_eq!(s.execute("graph.design", &json!({"new": true})).unwrap()["name"], "New Design");
assert_eq!(s.execute("graph.design", &json!({"new": true})).unwrap()["name"], "New Design 2");
let id = graph(&mut s, "line");
s.execute("graph.marker", &json!({"id": id.0, "design": "New Design"})).unwrap();
let v = s.execute("graph.design", &json!({"selectUnused": true})).unwrap();
assert_eq!(v["unused"], json!(["New Design 2"]));
// Renaming a design the graph draws with: the graph follows, still drawing it.
s.execute("graph.design", &json!({"rename": "New Design", "to": "Dot"})).unwrap();
assert_eq!(spec(&s, id).series_markers, [Some("Dot".to_string()), Some("Dot".to_string())]);
assert!(s.execute("graph.design", &json!({"rename": "Dot", "to": "New Design 2"})).is_err(), "taken");
assert!(s.execute("graph.design", &json!({"rename": "Nope", "to": "X"})).is_err());
// Delete several at once.
s.execute("graph.design", &json!({"delete": ["Dot", "New Design 2"]})).unwrap();
assert_eq!(s.execute("graph.design", &json!({})).unwrap()["designs"], json!([]));
assert!(s.execute("graph.design", &json!({"delete": ["Nope"]})).is_err());
}
/// A column design: a `w` × `h` rectangle at the back, a `w`-wide ellipse over its top `top` points and a rectangle
/// over the rest, with a horizontal guide line between them when `guide`, saved as `name`.
fn save_column_design(s: &mut Session, name: &str, (w, h, top): (f64, f64, f64), guide: bool) {
let shape = |s: &mut Session, kind: &str, x: f64, y: f64, w: f64, h: f64| {
s.execute(kind, &json!({"x": 600.0 + x, "y": 600.0 + y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap()
};
let mut ids = vec![shape(s, "shape.rectangle", 0.0, 0.0, w, h), shape(s, "shape.ellipse", 0.0, 0.0, w, top)];
ids.push(shape(s, "shape.rectangle", w / 4.0, top, w / 2.0, h - top));
if guide {
let line =
s.execute("shape.line", &json!({"x1": 600, "y1": 600.0 + top, "x2": 600.0 + w, "y2": 600.0 + top})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [line]})).unwrap();
s.execute("view.guides.make", &json!({})).unwrap();
ids.push(line);
}
s.execute("select.set", &json!({"ids": ids})).unwrap();
s.execute("graph.design", &json!({"new": name})).unwrap();
}
/// A column graph of one series with `values`, its value axis 0 to 50 over 200 points (4 points a unit), the zero
/// line at y = 300.
fn one_series(s: &mut Session, ty: &str, values: &[f64]) -> NodeId {
let rows: Vec<Vec<f64>> = values.iter().map(|v| vec![*v]).collect();
let p = json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "series": ["A"], "rows": rows});
let id = NodeId(s.execute("graph.create", &p).unwrap()["id"].as_u64().unwrap());
s.execute("graph.setType", &json!({"id": id.0, "axisMin": 0, "axisMax": 50})).unwrap();
id
}
/// The bounds of the backmost object (the frame) of each copy of a design inside `n`, a column drawn with it.
fn frames(n: &vectorcraft_doc::Node) -> Vec<vectorcraft_geom::Rect> {
n.children().unwrap().iter().filter_map(|c| c.children()?.first()?.geometric_bounds()).collect()
}
fn near(a: vectorcraft_geom::Rect, b: [f64; 4]) -> bool {
[a.x0 - b[0], a.y0 - b[1], a.x1 - b[2], a.y1 - b[3]].iter().all(|d| d.abs() < 1e-6)
}
#[test]
fn a_repeating_column_design_stacks_a_copy_per_ten_and_chops_or_scales_the_last() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false);
let id = one_series(&mut s, "column", &[35.0]);
let plain = series_parts(&s, id, 0)[0].geometric_bounds().unwrap();
assert!(near(plain, [plain.x0, 160.0, plain.x1, 300.0]), "{plain:?}");
s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 10})).unwrap();
// 35 at 10 a copy: three copies of 40 points up from the zero line, and a fourth cut off at the value.
let column = series_parts(&s, id, 0)[0].clone();
let parts = column.children().unwrap();
assert_eq!(parts.len(), 4, "3.5 designs");
let at = |y0: f64, y1: f64| [plain.x0, y0, plain.x1, y1];
let copies = frames(&column);
assert!(near(copies[0], at(260.0, 300.0)) && near(copies[1], at(220.0, 260.0)) && near(copies[2], at(180.0, 220.0)), "{copies:?}");
let NodeKind::Group { clip: true, children } = &parts[3].kind else { panic!("the last copy is clipped: {:?}", parts[3].kind) };
assert!(near(frames(&parts[3])[0], at(140.0, 180.0)), "a whole copy");
let (clip, copy) = (children[0].geometric_bounds().unwrap(), vectorcraft_render::painted_bounds(&s.doc().unwrap().doc, &children[1]).unwrap());
assert_eq!(clip.y0, 160.0, "cut off at the value");
// Its sides and its foot, strokes included, are not cut.
assert!(clip.x0 <= copy.x0 && clip.x1 >= copy.x1 && clip.y1 >= copy.y1 && copy.x0 < plain.x0, "{clip:?} {copy:?}");
// Nor is the brush art of a brushed path inside a group of the design.
let mut d = vectorcraft_doc::Document::new(800.0, 800.0);
let square = |x0: f64, x1: f64| vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x0, 0.0, x1, 10.0));
let frame = vectorcraft_doc::Node::path(d.alloc_id(), square(0.0, 10.0), vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 0.0));
let mut look = vectorcraft_doc::Appearance::basic(Paint::None, Paint::solid(vectorcraft_color::Color::BLACK), 1.0);
for item in &mut look.items {
if let vectorcraft_doc::AppearanceItem::Stroke(st) = item {
st.brush = Some("Wide".into());
}
}
let brushed = vectorcraft_doc::Node::path(d.alloc_id(), square(4.0, 6.0), look);
let inner = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(brushed)]);
let art = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(frame), std::sync::Arc::new(inner)]);
d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Brushed".into(), art: std::sync::Arc::new(art) });
let options = ColumnDesign { design: "Brushed".into(), column_type: ColumnType::Repeating, ..ColumnDesign::default() };
let g =
GraphSpec { rows: vec![vec![35.0]], axis_min: Some(0.0), axis_max: Some(50.0), series_columns: vec![Some(options)], ..GraphSpec::default() };
let out = super::generate(&mut d, &g);
let column = out.iter().find(|n| n.extras().series_index == Some(0)).unwrap().children().unwrap()[0].clone();
let last = column.children().unwrap().last().unwrap().clone();
let NodeKind::Group { clip: true, children } = &last.kind else { panic!("the last copy is clipped: {:?}", last.kind) };
let clip = children[0].geometric_bounds().unwrap();
let mut reach = None;
children[1].walk(&mut |n| {
if vectorcraft_brush::has_brush(n) {
reach = vectorcraft_render::painted_bounds(&d, n);
}
});
let reach = reach.unwrap();
assert!(clip.x0 <= reach.x0 && clip.x1 >= reach.x1 && clip.y1 >= reach.y1, "{clip:?} {reach:?}");
// Scale: the last copy half as tall instead.
s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "fractions": "scale"})).unwrap();
let column = series_parts(&s, id, 0)[0].clone();
let copies = frames(&column);
assert_eq!(copies.len(), 4);
assert!(near(copies[3], at(160.0, 180.0)), "{copies:?}");
let c = &spec(&s, id).series_columns[0];
assert_eq!((c.as_ref().map(|c| (c.column_type, c.each, c.fractions))), Some((ColumnType::Repeating, 10.0, Fractions::Scale)));
// A bar graph stacks them along its bars, and a negative value stacks them the other way from the zero line.
let bars = one_series(&mut s, "bar", &[-15.0]);
s.execute("graph.setType", &json!({"id": bars.0, "axisMin": -50, "axisMax": 50})).unwrap();
s.execute("graph.column", &json!({"id": bars.0, "design": "Coin", "columnType": "repeating", "each": 10, "fractions": "scale"})).unwrap();
let bar = series_parts(&s, bars, 0)[0].clone();
let copies = frames(&bar);
// The value axis runs from -50 to 50 over 300 points: 3 points a unit, the zero line at x = 250.
let (zero, unit) = (250.0, 3.0);
assert!((copies[0].x1 - zero).abs() < 1e-6 && (copies[0].width() - 10.0 * unit).abs() < 1e-6, "{copies:?}");
assert!((copies[1].x1 - copies[0].x0).abs() < 1e-6 && (copies[1].width() - 5.0 * unit).abs() < 1e-6, "{copies:?}");
}
#[test]
fn a_sliding_column_design_keeps_the_part_above_its_guide_and_stretches_the_rest() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
// 10 wide and 20 tall, the guide 5 points from the top.
save_column_design(&mut s, "Pencil", (10.0, 20.0, 5.0), true);
let id = one_series(&mut s, "column", &[35.0, 20.0]);
s.execute("graph.setType", &json!({"id": id.0, "columnWidth": 20})).unwrap();
s.execute("graph.column", &json!({"id": id.0, "design": "Pencil", "columnType": "sliding"})).unwrap();
let parts = series_parts(&s, id, 0);
for (column, value_end) in parts.iter().zip([160.0, 220.0]) {
// The columns are 24 points wide (2.4 times the design): two copies, each clipped at the guide, which lands
// 12 points (2.4 times 5) below the value end.
let halves = column.children().unwrap();
assert_eq!(halves.len(), 2, "{halves:?}");
let half = |i: usize| {
let NodeKind::Group { clip: true, children } = &halves[i].kind else { panic!("a clip group: {:?}", halves[i].kind) };
(children[0].geometric_bounds().unwrap(), children[1].children().unwrap().to_vec())
};
let ((above, kept), (below, stretched)) = (half(0), half(1));
let at = value_end + 12.0;
assert!((above.y1 - at).abs() < 1e-6 && (below.y0 - at).abs() < 1e-6, "{above:?} {below:?}");
assert_eq!(kept.len(), 3, "the guide is not drawn");
// Above the guide the design keeps its proportions: the 10 × 5 ellipse is 24 × 12 at the value end.
let top = kept[1].geometric_bounds().unwrap();
assert!((top.y0 - value_end).abs() < 1e-6 && (top.width() - 24.0).abs() < 1e-6 && (top.height() - 12.0).abs() < 1e-6, "{top:?}");
// Below it the design is stretched down to the zero line.
let (frame, body) = (stretched[0].geometric_bounds().unwrap(), stretched[2].geometric_bounds().unwrap());
assert!((frame.width() - 24.0).abs() < 1e-6 && (frame.y1 - 300.0).abs() < 1e-6, "{frame:?}");
assert!((body.y0 - at).abs() < 1e-6 && (body.y1 - 300.0).abs() < 1e-6, "{body:?}");
}
// Vertically Scaled stretches the whole design, the top part included.
s.execute("graph.column", &json!({"id": id.0, "design": "Pencil", "columnType": "vertical"})).unwrap();
let art = series_parts(&s, id, 0)[0].children().unwrap()[0].children().unwrap().to_vec();
assert!((art[1].geometric_bounds().unwrap().height() - 140.0 / 4.0).abs() < 1e-6);
// Without a guide a sliding design is scaled vertically too.
save_column_design(&mut s, "Plain", (10.0, 20.0, 5.0), false);
s.execute("graph.column", &json!({"id": id.0, "design": "Plain", "columnType": "sliding"})).unwrap();
let art = series_parts(&s, id, 0)[0].children().unwrap()[0].children().unwrap().to_vec();
assert!((art[1].geometric_bounds().unwrap().height() - 140.0 / 4.0).abs() < 1e-6);
// Guides that are not a horizontal line, a 2 × 2 square and a vertical line 0.1 points long, made guides before
// the line, are passed over.
let id_of = |v: serde_json::Value| v["id"].as_u64().unwrap();
let mut ids = vec![
id_of(s.execute("shape.rectangle", &json!({"x": 700, "y": 600, "width": 10, "height": 20})).unwrap()),
id_of(s.execute("shape.ellipse", &json!({"x": 700, "y": 600, "width": 10, "height": 5})).unwrap()),
];
let square = id_of(s.execute("shape.rectangle", &json!({"x": 704, "y": 611, "width": 2, "height": 2})).unwrap());
let tick = id_of(s.execute("shape.line", &json!({"x1": 702, "y1": 615, "x2": 702, "y2": 615.1})).unwrap());
let line = id_of(s.execute("shape.line", &json!({"x1": 700, "y1": 605, "x2": 710, "y2": 605})).unwrap());
s.execute("select.set", &json!({"ids": [square, tick, line]})).unwrap();
s.execute("view.guides.make", &json!({})).unwrap();
ids.extend([square, tick, line]);
s.execute("select.set", &json!({"ids": ids})).unwrap();
s.execute("graph.design", &json!({"new": "Boxed"})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.column", &json!({"id": id.0, "design": "Boxed", "columnType": "sliding"})).unwrap();
let halves = series_parts(&s, id, 0)[0].children().unwrap().to_vec();
let above = halves[0].children().unwrap()[0].geometric_bounds().unwrap();
assert!((above.y1 - (160.0 + 12.0)).abs() < 1e-6, "cut at the line, not at the square or the short vertical line: {above:?}");
}
#[test]
fn uniformly_scaled_column_designs_keep_their_proportions_and_cast_no_shadow() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false);
let id = one_series(&mut s, "column", &[35.0, 20.0]);
s.execute("graph.setType", &json!({"id": id.0, "dropShadow": true})).unwrap();
let plain = series_parts(&s, id, 0)[0].geometric_bounds().unwrap();
s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "columnType": "uniform"})).unwrap();
for (column, length) in series_parts(&s, id, 0).iter().zip([140.0, 80.0]) {
let frame = frames(column)[0];
assert!((frame.width() - length).abs() < 1e-6 && (frame.height() - length).abs() < 1e-6, "{frame:?}");
assert!((frame.center().x - column.geometric_bounds().unwrap().center().x).abs() < 1e-6);
}
assert!((frames(&series_parts(&s, id, 0)[0])[0].center().x - plain.center().x).abs() < 1e-6, "centered on the column");
// A design twice as wide as it is tall reaches past the plot, and the legend starts right of it.
save_column_design(&mut s, "Strip", (20.0, 10.0, 4.0), false);
let wide = one_series(&mut s, "column", &[20.0, 35.0]);
s.execute("graph.column", &json!({"id": wide.0, "design": "Strip", "columnType": "uniform"})).unwrap();
let parts = series_parts(&s, wide, 0);
let (reach, legend) = (frames(&parts[1])[0], parts.last().unwrap().geometric_bounds().unwrap());
assert!(reach.x1 > 400.0 && legend.x0 > reach.x1, "{reach:?} {legend:?}");
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop Shadow")), "no shadow for designed columns");
// The legend swatch shows the design, its frame filling the 8-point square.
let swatch = series_parts(&s, id, 0).last().unwrap().clone();
let frame = swatch.children().unwrap()[0].geometric_bounds().unwrap();
assert!((frame.width() - 8.0).abs() < 1e-6 && (frame.height() - 8.0).abs() < 1e-6, "{frame:?}");
// Painting the series leaves the design's paint alone.
let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id;
s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#f00"})).unwrap();
let mut red = false;
s.doc().unwrap().doc.node(id).unwrap().walk(&mut |n| red |= n.appearance.fill_paint().color().is_some_and(|c| c.to_hex() == "#ff0000"));
assert!(!red);
}
#[test]
fn column_designs_survive_save_and_open_and_follow_their_design() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false);
let id = graph(&mut s, "stackedColumn");
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap();
// Every series takes the design; only the stacked column series draws it.
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let p = json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 2.5, "fractions": "scale"});
s.execute("graph.column", &p).unwrap();
assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Group { .. })), "columns and swatch");
assert!(series_parts(&s, id, 1).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })), "the line series");
let v = s.execute("graph.column", &json!({"id": id.0, "seriesIndexes": [0]})).unwrap();
assert_eq!(v, json!({"designs": ["Coin"], "design": "Coin", "columnType": "repeating", "each": 2.5, "fractions": "scale"}));
let path = vectorcraft_testkit::temp_dir("graph-column-designs").join("columns.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
let options = ColumnDesign { design: "Coin".into(), column_type: ColumnType::Repeating, each: 2.5, fractions: Fractions::Scale };
assert_eq!(spec(&s, id).series_columns, [Some(options.clone()), Some(options)]);
assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("seriesColumns").is_none());
// Renamed, the graph draws it still; in use, it isn't unused; deleted, the columns are plain again.
s.execute("graph.design", &json!({"rename": "Coin", "to": "Token"})).unwrap();
assert!(spec(&s, id).series_columns.iter().flatten().all(|c| c.design == "Token"));
assert_eq!(s.execute("graph.design", &json!({"selectUnused": true})).unwrap()["unused"], json!([]));
s.execute("graph.design", &json!({"delete": "Token"})).unwrap();
assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
assert!(s.execute("graph.column", &json!({"id": id.0})).unwrap()["design"].is_null());
s.execute("edit.undo", &json!({})).unwrap();
assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Group { .. })));
// Null goes back to plain columns.
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.column", &json!({"id": id.0, "design": null})).unwrap();
assert!(spec(&s, id).series_columns.is_empty());
}
#[test]
fn graph_column_params_are_checked_and_designs_too_big_are_drawn_plain() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false);
let id = one_series(&mut s, "column", &[35.0]);
for p in [
json!({"design": "Nope"}),
json!({"design": 3}),
json!({"design": "Coin", "columnType": "diagonal"}),
json!({"design": "Coin", "each": 0}),
json!({"design": "Coin", "each": -1}),
json!({"design": "Coin", "each": "10"}),
json!({"design": "Coin", "fractions": "round"}),
] {
let mut p = p;
p["id"] = json!(id.0);
assert!(s.execute("graph.column", &p).is_err(), "{p}");
}
assert!(spec(&s, id).series_columns.is_empty());
// 350,000 copies of a three-object design are past the budget: refused, and a file asking for it draws plain columns.
let p = json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 0.0001});
assert!(s.execute("graph.column", &p).is_err());
let mut g = spec(&s, id);
let options = ColumnDesign { design: "Coin".into(), column_type: ColumnType::Repeating, each: 0.0001, fractions: Fractions::Chop };
g.series_columns = vec![Some(options.clone())];
let mut d = (*s.doc().unwrap().doc).clone();
let art = super::generate(&mut d, &g);
let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap();
assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
// Columns of 0 on a value axis from 10,000 to 10,001 run 2,000,000 points from the clamped zero line: a copy each
// 0.1 is past the budget too.
let far = one_series(&mut s, "column", &[0.0, 0.0]);
s.execute("graph.setType", &json!({"id": far.0, "axisMin": 10_000, "axisMax": 10_001})).unwrap();
assert!(s.execute("graph.column", &json!({"id": far.0, "design": "Coin", "columnType": "repeating", "each": 0.1})).is_err());
assert!(spec(&s, far).series_columns.is_empty());
// A file's unusable value per copy draws the design vertically scaled, and so does a copy longer than the canvas:
// 35 at 1,000,000,000 a copy, 4,000,000,000 points a copy.
for (each, fractions) in [(-1.0, Fractions::Chop), (1e9, Fractions::Scale), (1e9, Fractions::Chop)] {
g.series_columns = vec![Some(ColumnDesign { each, fractions, ..options.clone() })];
let art = super::generate(&mut d, &g);
let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap();
let column = s0.children().unwrap()[0].clone();
assert_eq!(frames(&column).len(), 1, "{each} {fractions:?}");
assert!((frames(&column)[0].height() - 140.0).abs() < 1e-6, "{each} {fractions:?}");
assert!(!matches!(column.children().unwrap()[0].kind, NodeKind::Group { clip: true, .. }), "{each} {fractions:?}: not clipped");
}
}
/// A column design with totals: a 10 × 10 rectangle at the back, type "%31" set from 2 points above its top, and a
/// group of type "Votes" inside it and type "%02 and %00" set from 4 points below it, with a horizontal guide line 5
/// points from the top when `guide`, saved as `name`. Returns the points the two totals are set from.
fn save_totals_design(s: &mut Session, name: &str, guide: bool) -> [vectorcraft_geom::Point; 2] {
let id_of = |v: serde_json::Value| v["id"].as_u64().unwrap();
let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap());
let above = id_of(s.execute("text.create", &json!({"x": 600, "y": 598, "text": "%31", "size": 4})).unwrap());
let votes = id_of(s.execute("text.create", &json!({"x": 601, "y": 606, "text": "Votes", "size": 3})).unwrap());
let below = id_of(s.execute("text.create", &json!({"x": 600, "y": 614, "text": "%02 and %00", "size": 4})).unwrap());
let origin = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.xf * vectorcraft_geom::Point::ZERO,
k => panic!("type: {k:?}"),
};
let points = [origin(s, above), origin(s, below)];
s.execute("select.set", &json!({"ids": [votes, below]})).unwrap();
let inner = id_of(s.execute("object.group", &json!({})).unwrap());
let mut ids = vec![frame, above, inner];
if guide {
let line = id_of(s.execute("shape.line", &json!({"x1": 600, "y1": 605, "x2": 610, "y2": 605})).unwrap());
s.execute("select.set", &json!({"ids": [line]})).unwrap();
s.execute("view.guides.make", &json!({})).unwrap();
ids.push(line);
}
s.execute("select.set", &json!({"ids": ids})).unwrap();
s.execute("graph.design", &json!({"new": name})).unwrap();
points
}
/// The text of each piece of type inside `n`, in paint order.
fn texts_in(n: &vectorcraft_doc::Node) -> Vec<String> {
let mut out = vec![];
n.walk(&mut |c| {
if let NodeKind::Text(t) = &c.kind {
out.push(t.plain_text());
}
});
out
}
/// The type among `nodes`, each as its text and the point it is set from; each must be type at its own size.
fn totals_of(nodes: &[std::sync::Arc<vectorcraft_doc::Node>]) -> Vec<(String, vectorcraft_geom::Point)> {
nodes
.iter()
.map(|n| match &n.kind {
NodeKind::Text(t) => {
let [a, b, c, d, x, y] = t.xf.as_coeffs();
assert_eq!([a, b, c, d], [1.0, 0.0, 0.0, 1.0], "a total keeps its size: {:?}", t.xf);
(t.plain_text(), vectorcraft_geom::Point::new(x, y))
}
k => panic!("a total: {k:?}"),
})
.collect()
}
fn close(a: vectorcraft_geom::Point, b: vectorcraft_geom::Point) -> bool {
(a - b).hypot() < 1e-6
}
#[test]
fn column_design_totals_show_each_columns_value_at_their_own_size() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let [above, below] = save_totals_design(&mut s, "Ballot", false);
let id = one_series(&mut s, "column", &[35.0, 7.256]);
s.execute("graph.column", &json!({"id": id.0, "design": "Ballot"})).unwrap();
let check = |s: &Session, want: [[&str; 2]; 2]| {
let parts = series_parts(s, id, 0);
for (column, want) in parts.iter().zip(want) {
let children = column.children().unwrap();
// The design's copy keeps the type without a total, stretched with the design, and leaves the totals out.
assert_eq!(texts_in(&children[0]), ["Votes"]);
let frame = frames(column)[0];
let (sx, sy) = (frame.width() / 10.0, frame.height() / 10.0);
let at = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(frame.x0 + (p.x - 600.0) * sx, frame.y0 + (p.y - 600.0) * sy);
let totals = totals_of(&children[1..]);
assert_eq!(totals.iter().map(|(t, _)| t.as_str()).collect::<Vec<_>>(), want);
// Each set from where the design's copy puts the point it was set from.
assert!(close(totals[0].1, at(above)) && close(totals[1].1, at(below)), "{totals:?} {frame:?}");
}
// The legend swatch keeps the type without a total and leaves the totals out.
assert_eq!(texts_in(parts.last().unwrap()), ["Votes"]);
};
check(&s, [["035.0", "35.00 and 35"], ["007.3", "7.26 and 7"]]);
// New data draws the new values.
s.execute("graph.setData", &json!({"id": id.0, "rows": [[20], [0.004]]})).unwrap();
check(&s, [["020.0", "20.00 and 20"], ["000.0", "0.00 and 0"]]);
// The design in the Graph Design list is left as it was.
assert_eq!(texts_in(&s.doc().unwrap().doc.graph_designs[0].art), ["%31", "Votes", "%02 and %00"]);
// A bar below zero shows its minus sign, its totals moved with the design along the bar.
let bars = one_series(&mut s, "bar", &[-15.0]);
s.execute("graph.setType", &json!({"id": bars.0, "axisMin": -50, "axisMax": 50})).unwrap();
s.execute("graph.column", &json!({"id": bars.0, "design": "Ballot"})).unwrap();
let bar = series_parts(&s, bars, 0)[0].clone();
let frame = frames(&bar)[0];
let (sx, sy) = (frame.width() / 10.0, frame.height() / 10.0);
let at = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(frame.x0 + (p.x - 600.0) * sx, frame.y0 + (p.y - 600.0) * sy);
let totals = totals_of(&bar.children().unwrap()[1..]);
assert_eq!(totals.iter().map(|(t, _)| t.as_str()).collect::<Vec<_>>(), ["-015.0", "-15.00 and -15"]);
assert!((frame.x1 - 250.0).abs() < 1e-6 && close(totals[0].1, at(above)) && close(totals[1].1, at(below)), "{totals:?} {frame:?}");
// A design that is a single piece of type with a total shows the value alone, and its legend swatch is the
// default square.
let one = s.execute("text.create", &json!({"x": 600, "y": 700, "text": "%00", "size": 4})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [one]})).unwrap();
s.execute("graph.design", &json!({"new": "Count"})).unwrap();
let count = one_series(&mut s, "column", &[35.0]);
s.execute("graph.column", &json!({"id": count.0, "design": "Count"})).unwrap();
let parts = series_parts(&s, count, 0);
assert_eq!(texts_in(&parts[0]), ["35"]);
assert_eq!(totals_of(&parts[0].children().unwrap()[1..]).len(), 1);
assert!(matches!(parts.last().unwrap().kind, NodeKind::Path { .. }), "the default swatch: {:?}", parts.last().unwrap().kind);
// Repeating, with a last copy to chop, it still shows the value.
s.execute("graph.column", &json!({"id": count.0, "design": "Count", "columnType": "repeating", "each": 10})).unwrap();
let column = series_parts(&s, count, 0)[0].clone();
assert!(matches!(column.kind, NodeKind::Group { .. }) && texts_in(&column) == ["35"], "{:?}", column.kind);
}
#[test]
fn a_repeating_design_shows_its_totals_once_at_the_value_end_and_a_sliding_one_beside_its_part() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let [above, below] = save_totals_design(&mut s, "Ballot", true);
let id = one_series(&mut s, "column", &[35.0]);
let plain = series_parts(&s, id, 0)[0].geometric_bounds().unwrap();
s.execute("graph.column", &json!({"id": id.0, "design": "Ballot", "columnType": "repeating", "each": 10})).unwrap();
let column = series_parts(&s, id, 0)[0].clone();
let children = column.children().unwrap();
// Three whole copies and the one cut off at the value, none with a total; then the two totals, not cut.
assert_eq!(children.len(), 6, "{children:?}");
assert!(matches!(children[3].kind, NodeKind::Group { clip: true, .. }), "the last copy is clipped");
assert!(children[..4].iter().all(|c| texts_in(c) == ["Votes"]));
// Set as in a whole copy that ends at the value: 40 points tall from y 160, as wide as the column.
let sx = plain.width() / 10.0;
let at = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(plain.x0 + (p.x - 600.0) * sx, 160.0 + (p.y - 600.0) * 4.0);
let totals = totals_of(&children[4..]);
assert_eq!(totals.iter().map(|(t, _)| t.as_str()).collect::<Vec<_>>(), ["035.0", "35.00 and 35"]);
assert!(close(totals[0].1, at(above)) && close(totals[1].1, at(below)), "{totals:?}");
// Sliding: the total above the guide moves with the part kept in proportion at the value end, the one below the
// design with the part stretched down to the zero line.
s.execute("graph.column", &json!({"id": id.0, "design": "Ballot", "columnType": "sliding"})).unwrap();
let column = series_parts(&s, id, 0)[0].clone();
let children = column.children().unwrap();
assert_eq!(children.len(), 4, "two halves and two totals: {children:?}");
let k = (140.0 - 5.0 * sx) / 5.0;
let totals = totals_of(&children[2..]);
let kept = vectorcraft_geom::Point::new(plain.x0 + (above.x - 600.0) * sx, 160.0 + (above.y - 600.0) * sx);
let stretched = vectorcraft_geom::Point::new(plain.x0 + (below.x - 600.0) * sx, 300.0 + (below.y - 610.0) * k);
assert!(close(totals[0].1, kept) && close(totals[1].1, stretched), "{totals:?} {kept:?} {stretched:?}");
// A total's pattern fill moves with it.
let mut d = vectorcraft_doc::Document::new(800.0, 800.0);
let square = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0));
let frame = vectorcraft_doc::Node::path(d.alloc_id(), square, vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 0.0));
let origin = vectorcraft_geom::Point::new(0.0, -2.0);
let t = vectorcraft_doc::TextObject::point(origin, "%00", CharStyle::default());
let mut total = vectorcraft_doc::Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
let dots = Paint::Pattern { pattern: "Dots".into(), xf: vectorcraft_geom::Affine::IDENTITY };
total.appearance = vectorcraft_doc::Appearance::basic(dots, Paint::None, 0.0).into();
let art = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(frame), std::sync::Arc::new(total)]);
d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Dotted".into(), art: std::sync::Arc::new(art) });
let options = ColumnDesign { design: "Dotted".into(), ..ColumnDesign::default() };
let g =
GraphSpec { rows: vec![vec![35.0]], axis_min: Some(0.0), axis_max: Some(50.0), series_columns: vec![Some(options)], ..GraphSpec::default() };
let out = super::generate(&mut d, &g);
let column = out.iter().find(|n| n.extras().series_index == Some(0)).unwrap().children().unwrap()[0].clone();
let (text, at) = totals_of(&column.children().unwrap()[1..]).remove(0);
assert_eq!(text, "35");
let Paint::Pattern { xf, .. } = column.children().unwrap()[1].appearance.fill_paint() else { panic!("a pattern fill") };
assert!((xf * origin - at).hypot() < 1e-6 && (at - origin).hypot() > 1.0, "{xf:?} {at:?}");
}
#[test]
fn totals_inside_a_group_with_a_look_of_its_own_show_the_value_where_they_are() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id_of = |v: serde_json::Value| v["id"].as_u64().unwrap();
let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap());
let total = id_of(s.execute("text.create", &json!({"x": 600, "y": 605, "text": "%00", "size": 4})).unwrap());
s.execute("select.set", &json!({"ids": [total]})).unwrap();
let faded = id_of(s.execute("object.group", &json!({})).unwrap());
s.execute("select.set", &json!({"ids": [faded]})).unwrap();
s.execute("transparency.set", &json!({"opacity": 50})).unwrap();
s.execute("select.set", &json!({"ids": [frame, faded]})).unwrap();
s.execute("graph.design", &json!({"new": "Faded"})).unwrap();
let id = one_series(&mut s, "column", &[35.0]);
s.execute("graph.column", &json!({"id": id.0, "design": "Faded", "columnType": "repeating", "each": 10})).unwrap();
// The total stays in its half-opaque group: each of the four copies shows the value there.
let parts = series_parts(&s, id, 0);
let copies = parts[0].children().unwrap();
assert_eq!(copies.len(), 4, "{copies:?}");
let mut faded_groups = 0;
for c in copies {
assert_eq!(texts_in(c), ["35"]);
c.walk(&mut |n| faded_groups += usize::from(matches!(n.kind, NodeKind::Group { .. }) && (n.opacity - 0.5).abs() < 1e-6));
}
assert_eq!(faded_groups, 4);
// The legend swatch shows the design without it.
let swatch = parts.last().unwrap();
assert!(matches!(swatch.kind, NodeKind::Group { .. }) && texts_in(swatch).is_empty(), "{:?}", swatch.kind);
// Type with a total that clips other art clips it with the value in each copy, and the legend swatch leaves out
// what it clips.
let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 650, "width": 10, "height": 10})).unwrap());
let inside = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 650, "width": 10, "height": 10})).unwrap());
let mask = id_of(s.execute("text.create", &json!({"x": 600, "y": 658, "text": "%00", "size": 8})).unwrap());
s.execute("select.set", &json!({"ids": [inside, mask]})).unwrap();
let clipped = id_of(s.execute("object.clippingMask.make", &json!({})).unwrap());
s.execute("select.set", &json!({"ids": [frame, clipped]})).unwrap();
s.execute("graph.design", &json!({"new": "Masked"})).unwrap();
let masked = one_series(&mut s, "column", &[35.0]);
s.execute("graph.column", &json!({"id": masked.0, "design": "Masked"})).unwrap();
let parts = series_parts(&s, masked, 0);
let clip_groups = |n: &vectorcraft_doc::Node| {
let mut out = vec![];
n.walk(&mut |c| {
if let NodeKind::Group { clip: true, children } = &c.kind {
out.push(children.first().map(|m| texts_in(m)).unwrap_or_default());
}
});
out
};
assert_eq!(clip_groups(&parts[0]), [["35"]], "the copy's clipping type shows the value");
let swatch = parts.last().unwrap();
let mut drawn = 0;
swatch.walk(&mut |n| drawn += usize::from(!matches!(n.kind, NodeKind::Group { .. })));
assert!(clip_groups(swatch).is_empty() && drawn == 1, "only the frame: {swatch:?}");
// Totals that are a blend's key objects stay in the blend and show the value there, and the legend swatch leaves
// them out.
let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 680, "width": 10, "height": 10})).unwrap());
let first = id_of(s.execute("text.create", &json!({"x": 600, "y": 685, "text": "%00", "size": 4})).unwrap());
let second = id_of(s.execute("text.create", &json!({"x": 606, "y": 685, "text": "%00", "size": 4})).unwrap());
s.execute("select.set", &json!({"ids": [first, second]})).unwrap();
let blend = id_of(s.execute("object.blend.make", &json!({})).unwrap());
s.execute("select.set", &json!({"ids": [frame, blend]})).unwrap();
s.execute("graph.design", &json!({"new": "Blended"})).unwrap();
let blended = one_series(&mut s, "column", &[35.0]);
s.execute("graph.column", &json!({"id": blended.0, "design": "Blended"})).unwrap();
let parts = series_parts(&s, blended, 0);
let mut blends = vec![];
parts[0].walk(&mut |n| {
if matches!(n.kind, NodeKind::Blend { .. }) {
blends.push(texts_in(n));
}
});
assert_eq!(blends, [["35", "35"]], "{:?}", parts[0]);
assert!(texts_in(parts.last().unwrap()).is_empty(), "{:?}", parts.last().unwrap());
// A design that is that blend alone gets the default legend swatch.
s.execute("select.set", &json!({"ids": [blend]})).unwrap();
s.execute("graph.design", &json!({"new": "Keys"})).unwrap();
let keys = one_series(&mut s, "column", &[35.0]);
s.execute("graph.column", &json!({"id": keys.0, "design": "Keys"})).unwrap();
let parts = series_parts(&s, keys, 0);
assert_eq!(texts_in(&parts[0]), ["35", "35"]);
assert!(matches!(parts.last().unwrap().kind, NodeKind::Path { .. }), "the default swatch: {:?}", parts.last().unwrap().kind);
// A design of a total inside two plain groups shows the value, and its legend swatch is the default square.
let total = id_of(s.execute("text.create", &json!({"x": 700, "y": 700, "text": "%00", "size": 4})).unwrap());
s.execute("select.set", &json!({"ids": [total]})).unwrap();
let inner = id_of(s.execute("object.group", &json!({})).unwrap());
s.execute("select.set", &json!({"ids": [inner]})).unwrap();
let outer = id_of(s.execute("object.group", &json!({})).unwrap());
s.execute("select.set", &json!({"ids": [outer]})).unwrap();
s.execute("graph.design", &json!({"new": "Nested"})).unwrap();
let nested = one_series(&mut s, "column", &[35.0]);
s.execute("graph.column", &json!({"id": nested.0, "design": "Nested"})).unwrap();
let parts = series_parts(&s, nested, 0);
assert_eq!(texts_in(&parts[0]), ["35"]);
assert!(matches!(parts.last().unwrap().kind, NodeKind::Path { .. }), "the default swatch: {:?}", parts.last().unwrap().kind);
}
#[test]
fn totals_take_their_places_from_the_two_digits_after_the_percent_sign() {
for (v, before, after, want) in [
(122.0, 3, 0, "122"),
(122.0, 0, 0, "122"),
(5.0, 3, 1, "005.0"),
(1234.0, 2, 0, "1234"),
(2.5, 0, 0, "3"),
(-2.5, 0, 0, "-3"),
(0.25, 0, 0, "0"),
(0.25, 0, 2, "0.25"),
(1.005, 0, 2, "1.01"),
(0.1 + 0.2, 0, 2, "0.30"),
(9.995, 0, 2, "10.00"),
(99.96, 0, 1, "100.0"),
(-0.004, 0, 2, "0.00"),
(-7.25, 2, 1, "-07.3"),
(7.0, 0, 9, "7.000000000"),
(1e21, 0, 0, "1000000000000000000000"),
] {
assert_eq!(super::total_text(v, before, after), want, "{v} %{before}{after}");
}
// Each `%` and two digits, left to right; any other `%` stays.
let style = CharStyle::default;
let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "%31 and %%12, %1x, 50%", style());
assert!(super::fill_totals(&mut t, 7.25));
assert_eq!(t.plain_text(), "007.3 and %7.25, %1x, 50%");
// A total split between two runs takes the style of its `%`.
let (small, big) = (CharStyle { size: 10.0, ..style() }, CharStyle { size: 20.0, ..style() });
t.runs = vec![vectorcraft_doc::TextRun::new("a %3", small), vectorcraft_doc::TextRun::new("1 b", big)];
assert!(super::fill_totals(&mut t, 7.25));
assert_eq!(t.runs.iter().map(|r| (r.text.as_str(), r.style.size)).collect::<Vec<_>>(), [("a 007.3", 10.0), (" b", 20.0)]);
// Type without a total is left as it is.
let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Votes: 50%", style());
assert!(!super::fill_totals(&mut t, 7.25));
assert_eq!(t.plain_text(), "Votes: 50%");
}
fn make(s: &mut Session, ty: &str, csv: &str) -> NodeId {
NodeId(
s.execute("graph.create", &json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"].as_u64().unwrap(),
)
}
/// Where each wedge of series `index` starts: its angle from 12 o'clock, clockwise, in degrees (the legend
/// swatch, last, left out).
fn wedge_starts(s: &Session, id: NodeId, index: u32) -> Vec<f64> {
let parts = series_parts(s, id, index);
parts[..parts.len() - 1]
.iter()
.filter_map(|n| match &n.kind {
NodeKind::Path { path, .. } => {
let bp = path.to_bezpath();
let pts: Vec<_> = bp.elements().iter().filter_map(|e| e.end_point()).collect();
let (c, p) = (pts.first()?, pts.get(1)?);
let deg = (p.x - c.x).atan2(-(p.y - c.y)).to_degrees();
Some(if deg < -1e-6 { deg + 360.0 } else { deg })
}
_ => None,
})
.collect()
}
#[test]
fn a_bar_in_a_label_breaks_the_line() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "column", ",Total|Sales|2023,b\nQ1|first,3,2\nQ2,5,4");
let texts = axis_texts(&s, id);
assert!(texts.contains(&"Q1\nfirst".to_string()), "{texts:?}");
// The legend: a label of three lines, and the next row below all three.
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let legend: Vec<_> = group(&n, "Legend").children().unwrap().iter().map(|c| (**c).clone()).collect();
let NodeKind::Text(t) = &legend[0].kind else { panic!() };
assert_eq!(t.plain_text(), "Total\nSales\n2023");
let (a, b) =
(series_parts(&s, id, 0).last().unwrap().geometric_bounds().unwrap(), series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap());
assert!((b.y0 - a.y0 - 3.0 * 9.0 * 1.8).abs() < 1e-6, "{a:?} {b:?}");
}
#[test]
fn transpose_swaps_rows_and_columns_and_switch_xy_swaps_scatter_pairs() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "column", ",a,b\nQ1,1,2\nQ2,3,");
let before = spec(&s, id);
s.execute("graph.setData", &json!({"transpose": true})).unwrap();
let g = spec(&s, id);
assert_eq!((g.series.clone(), g.categories.clone()), (vec!["Q1".to_string(), "Q2".into()], vec!["a".to_string(), "b".into()]));
assert_eq!(g.cells(), [[Some(1.0), Some(3.0)], [Some(2.0), None]]);
s.execute("graph.setData", &json!({"transpose": true})).unwrap();
assert_eq!(spec(&s, id).cells(), before.cells());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).cells(), g.cells());
assert!(s.execute("graph.setData", &json!({"switchXY": true})).is_err(), "scatter graphs only");
let sc = make(&mut s, "scatter", ",y1,x1,y2,x2\nP,1,10,2,20");
s.execute("graph.setData", &json!({"switchXY": true})).unwrap();
let g = spec(&s, sc);
assert_eq!(g.cells(), [[Some(10.0), Some(1.0), Some(20.0), Some(2.0)]]);
assert_eq!(g.series, ["x1", "y1", "x2", "y2"]);
}
#[test]
fn pie_wedges_sort_largest_first_in_each_pie_or_in_the_first_pies_order() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "pie", ",a,b,c\nP1,1,3,2\nP2,3,1,2");
// None (data order): a starts at 12 o'clock in both pies.
assert_eq!(wedge_starts(&s, id, 0).iter().map(|d| d.round()).collect::<Vec<_>>(), [0.0, 0.0]);
s.execute("graph.setType", &json!({"pieSort": "all"})).unwrap();
// P1: b (3) first; P2: a (3) first.
let (a, b) = (wedge_starts(&s, id, 0), wedge_starts(&s, id, 1));
assert!(b[0].abs() < 1e-6 && a[1].abs() < 1e-6, "{a:?} {b:?}");
s.execute("graph.setType", &json!({"pieSort": "first"})).unwrap();
// P1's order (b, c, a) in both: b first in P2 too.
let b = wedge_starts(&s, id, 1);
assert!(b.iter().all(|d| d.abs() < 1e-6), "{b:?}");
assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["pieSort"], "first");
assert!(s.execute("graph.setType", &json!({"pieSort": "random"})).is_err());
}
#[test]
fn pies_sized_by_their_totals_side_by_side_or_stacked() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "pie", ",a,b\nSmall,5,5\nBig,20,20");
let pie = |s: &Session, c: usize| {
let wedges = |i: u32| {
let parts = series_parts(s, id, i);
parts[..parts.len() - 1].to_vec()
};
let rects: Vec<_> = (0..2).flat_map(wedges).filter_map(|n| n.geometric_bounds()).collect();
rects.into_iter().skip(c).step_by(2).reduce(|a, b| a.union(b)).unwrap()
};
let even = (pie(&s, 0), pie(&s, 1));
assert!((even.0.width() - even.1.width()).abs() < 1e-6);
s.execute("graph.setType", &json!({"piePosition": "ratio"})).unwrap();
let (small, big) = (pie(&s, 0), pie(&s, 1));
// Areas in proportion to the totals: 10 and 40, so radii 1 : 2.
assert!((big.width() / small.width() - 2.0).abs() < 1e-6, "{small:?} {big:?}");
assert!((big.width() - even.1.width()).abs() < 1e-6, "the biggest keeps the full size");
s.execute("graph.setType", &json!({"piePosition": "stacked"})).unwrap();
// Drawn largest first, so the big pie's wedges come first in each series.
let (big, small) = (pie(&s, 0), pie(&s, 1));
assert!((small.center() - big.center()).hypot() < 1e-6 && (big.width() / small.width() - 2.0).abs() < 1e-6);
// The big pie is drawn first, under the small one.
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let wedges: Vec<f64> = series(&n, 0).children().unwrap().iter().filter_map(|c| c.geometric_bounds()).map(|b| b.width()).collect();
assert!(wedges[0] > wedges[1], "{wedges:?}");
}
#[test]
fn legends_in_wedges_put_the_series_labels_inside_the_pie() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "pie", ",North,South|East\nP,1,1");
s.execute("graph.setType", &json!({"pieLegend": "wedges"})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let pie =
series(&n, 0).children().unwrap()[0].geometric_bounds().unwrap().union(series(&n, 1).children().unwrap()[0].geometric_bounds().unwrap());
let labels: Vec<_> = group(&n, "Legend").children().unwrap().iter().map(|c| (**c).clone()).collect();
assert_eq!(labels.len(), 2);
for l in &labels {
let b = l.geometric_bounds().unwrap();
assert!(pie.contains(b.center()), "{b:?} inside {pie:?}");
}
// No swatches beside the pie: each series group holds only its wedge.
assert_eq!((series(&n, 0).children().unwrap().len(), series(&n, 1).children().unwrap().len()), (1, 1));
let v = s.execute("graph.setType", &json!({})).unwrap();
assert_eq!((v["pieLegend"].clone(), v["piePosition"].clone()), (json!("wedges"), json!("even")));
assert!(v.get("legend").is_none(), "a pie's Legend stands for the checkbox");
// The labels are drawn over the wedges, and on the black first wedge in white.
let order: Vec<_> = n.children().unwrap().iter().map(|c| c.name.clone().unwrap_or_default()).collect();
assert_eq!(order.last().map(String::as_str), Some("Legend"), "{order:?}");
let colour = |l: &vectorcraft_doc::Node| match &l.kind {
NodeKind::Text(t) => t.runs[0].style.fill.color().unwrap().to_hex(),
_ => panic!(),
};
assert_eq!((colour(&labels[0]), colour(&labels[1])), ("#ffffff".to_string(), "#000000".to_string()));
// No Legend wins over a legend checkbox sent along (as the dialog does), and Standard comes back without wedges.
s.execute("graph.setType", &json!({"pieLegend": "none", "legend": true})).unwrap();
assert!(!spec(&s, id).legend);
s.execute("graph.setType", &json!({"legend": true})).unwrap();
assert!(!spec(&s, id).pie_legend_in_wedges);
assert!(s.execute("graph.setType", &json!({"pieLegend": "inside"})).is_err());
let path = vectorcraft_testkit::temp_dir("graph-pie").join("pie.vectorcraft");
s.execute("graph.setType", &json!({"pieLegend": "wedges", "piePosition": "stacked", "pieSort": "all"})).unwrap();
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
let g = spec(&s, id);
assert_eq!((g.pie_legend_in_wedges, g.pie_position, g.pie_sort), (true, vectorcraft_doc::PiePosition::Stacked, vectorcraft_doc::PieSort::All));
let v = serde_json::to_value(GraphSpec::default()).unwrap();
for k in ["pieLegendInWedges", "piePosition", "pieSort"] {
assert!(v.get(k).is_none(), "{k}");
}
}
#[test]
fn stacked_pies_are_rings_so_none_covers_another() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "pie", ",a,b\nSmall,5,5\nBig,20,20\nSame,20,20");
s.execute("graph.setType", &json!({"piePosition": "stacked"})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
// Series b's wedges: of the two equal big pies one is left out, the other a ring outside the small pie
// (clear of the centre), and the small pie's whole.
let b: Vec<_> = series(&n, 1).children().unwrap().iter().map(|c| (**c).clone()).collect();
assert_eq!(b.len(), 3, "two wedges and the swatch");
let small = b[1].geometric_bounds().unwrap();
let NodeKind::Path { path, .. } = &b[0].kind else { panic!() };
let c = small.center();
let near: Vec<f64> = path.to_bezpath().elements().iter().filter_map(|e| e.end_point()).map(|p| (p - c).hypot()).collect();
let min = near.iter().copied().fold(f64::MAX, f64::min);
assert!(min > 1.0, "the ring keeps clear of the centre: {near:?}");
// The names are drawn over the pies: after every series group.
let kids = n.children().unwrap();
let last_series = kids.iter().rposition(|c| c.extras().series_index.is_some()).unwrap();
let names: Vec<usize> = kids.iter().enumerate().filter(|(_, c)| matches!(c.kind, NodeKind::Text(_))).map(|(i, _)| i).collect();
assert_eq!(names.len(), 2, "of two equal pies, one has no ring and no name");
assert!(names.iter().all(|i| *i > last_series), "{names:?} after {last_series}");
}
#[test]
fn transpose_keeps_within_the_series_cap_and_drops_stale_axis_series() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let rows: Vec<Vec<f64>> = (0..300).map(|i| vec![i as f64]).collect();
let id = NodeId(
s.execute("graph.create", &json!({"type": "column", "x": 0, "y": 0, "width": 300, "height": 200, "rows": rows})).unwrap()["id"]
.as_u64()
.unwrap(),
);
assert!(s.execute("graph.setData", &json!({"transpose": true})).is_err(), "300 categories can't become series");
assert_eq!(spec(&s, id).cells().len(), 300);
let g = make(&mut s, "column", ",a,b,c\nQ1,1,2,3");
s.execute("graph.setType", &json!({"seriesIndexes": [2], "valueAxis": "right"})).unwrap();
s.execute("graph.setData", &json!({"transpose": true})).unwrap();
assert!(spec(&s, g).right_series.is_empty(), "one series left");
// An all-blank series becomes no category.
let h = make(&mut s, "column", ",a,b\nQ1,1,\nQ2,2,");
s.execute("graph.setData", &json!({"transpose": true})).unwrap();
assert_eq!(spec(&s, h).categories, ["a", "b"], "b keeps its label");
assert!(s.execute("graph.setData", &json!({"id": h.0, "transpose": false})).unwrap().get("csv").is_some(), "no change: the query");
}
#[test]
fn switch_xy_moves_the_labels_with_their_columns() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "scatter", ",y1,x1,y2\nP,1,10,2,20");
s.execute("graph.setData", &json!({"switchXY": true})).unwrap();
let g = spec(&s, id);
assert_eq!(g.series, ["x1", "y1", "", "y2"]);
assert_eq!(g.cells(), [[Some(10.0), Some(1.0), Some(20.0), Some(2.0)]]);
}
#[test]
fn a_label_of_only_bars_is_blank_and_non_pie_graphs_list_no_pie_options() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "column", ",||,b\nQ1,1,2");
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
assert_eq!(group(&n, "Legend").children().unwrap().len(), 1, "only b");
let v = s.execute("graph.setType", &json!({})).unwrap();
assert!(v.get("pieLegend").is_none() && v.get("legend").is_some());
// Bar and radar labels with a `|` centre their lines on the category.
for ty in ["bar", "radar"] {
let g = make(&mut s, ty, ",a\nOne|Two|Three,1\nQ2,2\nQ3,3");
assert!(axis_texts(&s, g).contains(&"One\nTwo\nThree".to_string()), "{ty}");
}
}
#[test]
fn drop_shadows_sit_behind_the_columns_lines_and_wedges() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
s.execute("graph.setType", &json!({"dropShadow": true})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let kids = n.children().unwrap();
let at = kids.iter().position(|c| c.name.as_deref() == Some("Drop Shadow")).unwrap();
assert!(kids.iter().position(|c| c.extras().series_index.is_some()).unwrap() > at, "behind the series");
let shadow = &kids[at];
assert!((shadow.opacity - 0.35).abs() < 1e-6);
let copies: Vec<_> = shadow.children().unwrap().iter().filter_map(|c| c.geometric_bounds()).collect();
let cols: Vec<_> = (0..2).flat_map(|i| marks(&s, id, i)).collect();
assert_eq!(copies.len(), cols.len(), "one per column, none for the swatches");
for c in &cols {
assert!(copies.iter().any(|b| (b.x0 - c.x0 - 2.0).abs() < 1e-6 && (b.y0 - c.y0 - 2.0).abs() < 1e-6), "{c:?}");
}
let black = shadow.children().unwrap().iter().all(|c| c.appearance.fill_paint().color().map(|c| c.to_hex()) == Some("#000000".into()));
assert!(black);
// Lines cast one too (not their markers); pies one per wedge.
let line = graph(&mut s, "line");
s.execute("graph.setType", &json!({"dropShadow": true})).unwrap();
let n = s.doc().unwrap().doc.node(line).unwrap().clone();
assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), 2, "one per series line");
let pie = graph(&mut s, "pie");
s.execute("graph.setType", &json!({"dropShadow": true})).unwrap();
let n = s.doc().unwrap().doc.node(pie).unwrap().clone();
assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), 5, "the wedges with a value above 0");
assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["dropShadow"], true);
s.execute("graph.setType", &json!({"dropShadow": false})).unwrap();
let n = s.doc().unwrap().doc.node(pie).unwrap().clone();
assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop Shadow")));
}
#[test]
fn the_legend_across_top_runs_in_rows_above_the_plot() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap();
let swatch = |s: &Session, i: u32| series_parts(s, id, i).last().unwrap().geometric_bounds().unwrap();
let (a, b) = (swatch(&s, 0), swatch(&s, 1));
assert!(a.y1 <= 100.0 - 8.0 + 1e-6 && (a.y0 - b.y0).abs() < 1e-6 && b.x0 > a.x1 && (a.x0 - 100.0).abs() < 1e-6, "{a:?} {b:?}");
assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["legendAcrossTop"], true);
// Long labels wrap into rows within the plot's width, the last row just above it.
let labels: Vec<String> = (0..6).map(|i| format!("A rather long series name {i}")).collect();
let csv = format!(",{}\nQ1,1,2,3,4,5,6", labels.join(","));
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
let ys: Vec<f64> = (0..6).map(|i| swatch(&s, i).y0).collect();
assert!(ys.windows(2).any(|w| w[1] > w[0]), "a second row: {ys:?}");
assert!(ys.iter().all(|y| *y + 8.0 <= 100.0 - 8.0 + 1e-6), "{ys:?}");
let xs: Vec<f64> = (0..6).map(|i| swatch(&s, i).x1).collect();
assert!(xs.iter().all(|x| *x <= 400.0), "{xs:?}");
let path = vectorcraft_testkit::temp_dir("graph-top").join("top.vectorcraft");
s.execute("graph.setType", &json!({"dropShadow": true})).unwrap();
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
let g = spec(&s, id);
assert!(g.legend_across_top && g.drop_shadow);
let v = serde_json::to_value(GraphSpec::default()).unwrap();
assert!(v.get("dropShadow").is_none() && v.get("legendAcrossTop").is_none());
}
#[test]
fn each_layer_keeps_its_shadows_just_behind_it() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "column", ",a,b,c\nQ1,3,2,1\nQ2,5,,2\nQ3,4,6,3");
s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "area"})).unwrap();
s.execute("graph.setType", &json!({"seriesIndexes": [2], "type": "line", "dropShadow": true})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let order: Vec<String> = n
.children()
.unwrap()
.iter()
.map(|c| c.extras().series_index.map_or_else(|| c.name.clone().unwrap_or_default(), |i| format!("s{i}")))
.collect();
// Areas cast none; the columns' shadows sit between the area and the columns, the line's between the columns
// and the line.
let at = |k: &str| order.iter().position(|o| o == k).unwrap();
let shadows: Vec<usize> = order.iter().enumerate().filter(|(_, o)| *o == "Drop Shadow").map(|(i, _)| i).collect();
assert_eq!(shadows.len(), 2, "{order:?}");
assert!(at("s0") < shadows[0] && shadows[0] < at("s1") && at("s1") < shadows[1] && shadows[1] < at("s2"), "{order:?}");
// b's blank leaves two columns; a line with a blank casts a shadow per run.
let line = make(&mut s, "line", ",a\nQ1,1\nQ2,\nQ3,3\nQ4,4");
s.execute("graph.setType", &json!({"dropShadow": true})).unwrap();
let n = s.doc().unwrap().doc.node(line).unwrap().clone();
assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), 1, "Q1 alone draws no line; Q3–Q4 one");
// Scatter and radar lines cast one too; a stacked segment of 0 doesn't.
for (ty, want) in [("scatter", 1), ("radar", 2)] {
let g = graph(&mut s, ty);
s.execute("graph.setType", &json!({"dropShadow": true})).unwrap();
let n = s.doc().unwrap().doc.node(g).unwrap().clone();
assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), want, "{ty}");
}
let st = make(&mut s, "stackedColumn", ",a,b\nQ1,0,2");
s.execute("graph.setType", &json!({"dropShadow": true})).unwrap();
let n = s.doc().unwrap().doc.node(st).unwrap().clone();
assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), 1);
}
#[test]
fn the_legend_across_top_keeps_above_labels_over_the_plot() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "radar", ",a,b\nTop|of|chart,1,2\nQ2,2,3\nQ3,3,1");
s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let axes_top = group(&n, "Axes").children().unwrap().iter().filter_map(|c| c.geometric_bounds()).map(|b| b.y0).fold(f64::MAX, f64::min);
let legend_bottom = group(&n, "Legend").children().unwrap().iter().filter_map(|c| c.geometric_bounds()).map(|b| b.y1).fold(f64::MIN, f64::max);
assert!(legend_bottom < axes_top, "{legend_bottom} above {axes_top}");
// No legend: nothing across the top either.
s.execute("graph.setType", &json!({"legend": false})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Legend")));
// Labels of a row other than the last stay within the plot's width.
let labels: Vec<String> = (0..6).map(|i| format!("A rather long series name {i}")).collect();
let col = make(&mut s, "column", &format!(",{}\nQ1,1,2,3,4,5,6", labels.join(",")));
s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap();
let n = s.doc().unwrap().doc.node(col).unwrap().clone();
let texts: Vec<_> = group(&n, "Legend").children().unwrap().iter().filter_map(|c| c.geometric_bounds()).collect();
let last_row = texts.iter().map(|b| b.y0).fold(f64::MIN, f64::max);
assert!(texts.iter().filter(|b| b.y0 < last_row - 1e-6).all(|b| b.x1 <= 400.0 + 1e-6), "{texts:?}");
}
#[test]
fn widths_past_100_overlap_and_the_first_row_or_column_goes_in_front() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
s.execute("graph.setType", &json!({"columnWidth": 250, "clusterWidth": 300})).unwrap();
let g = spec(&s, id);
assert_eq!((g.column_width, g.cluster_width), (250.0, 300.0));
let (a, b) = (marks(&s, id, 0), marks(&s, id, 1));
assert!(a[0].x1 > b[0].x0, "the columns of a cluster overlap: {:?} {:?}", a[0], b[0]);
assert!(b[0].x1 > a[1].x0, "and so do the clusters: {:?} {:?}", b[0], a[1]);
assert_eq!(s.execute("graph.setType", &json!({"clusterWidth": 5000})).map(|_| spec(&s, id).cluster_width).unwrap(), 1000.0);
let order = |s: &Session| -> Vec<u32> {
s.doc().unwrap().doc.node(id).unwrap().children().unwrap().iter().filter_map(|c| c.extras().series_index).collect()
};
assert_eq!(order(&s), [0, 1]);
s.execute("graph.setType", &json!({"firstColumnInFront": true})).unwrap();
assert_eq!(order(&s), [1, 0], "the first series drawn last, on top");
// First Row in Front: each series' first category drawn last.
let xs = |s: &Session| marks(s, id, 0).iter().map(|b| b.x0).collect::<Vec<_>>();
let before = xs(&s);
assert!(before.windows(2).all(|w| w[0] < w[1]));
s.execute("graph.setType", &json!({"firstRowInFront": true})).unwrap();
let after = xs(&s);
assert!(after.windows(2).all(|w| w[0] > w[1]), "{after:?}");
let v = s.execute("graph.setType", &json!({})).unwrap();
assert_eq!((v["firstRowInFront"].clone(), v["firstColumnInFront"].clone()), (json!(true), json!(true)));
}
#[test]
fn filled_lines_are_bands_in_the_series_colour() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "line", ",a\nQ1,2\nQ2,2\nQ3,2");
let thin = marks(&s, id, 0)[0];
assert!(thin.height() < 1e-6, "a flat 1 pt line");
s.execute("graph.setType", &json!({"filledLines": true})).unwrap();
let parts = series_parts(&s, id, 0);
let band = parts[0].geometric_bounds().unwrap();
assert!((band.height() - 3.0).abs() < 1e-6 && (band.width() - thin.width()).abs() < 1e-6, "{band:?}");
assert_eq!(parts[0].appearance.fill_paint().color().unwrap().to_hex(), "#000000", "the first series' colour");
assert_eq!(parts[0].appearance.stroke_width(), 0.25);
// A series colour set with Group Selection fills the band.
let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id;
s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#ff0000"})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "rows": [[2], [3], [2]]})).unwrap();
assert_eq!(series_parts(&s, id, 0)[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff0000");
let path = vectorcraft_testkit::temp_dir("graph-filled").join("filled.vectorcraft");
s.execute("graph.setType", &json!({"firstRowInFront": true, "firstColumnInFront": true})).unwrap();
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
let g = spec(&s, id);
assert!(g.filled_lines && g.first_row_in_front && g.first_column_in_front);
let v = serde_json::to_value(GraphSpec::default()).unwrap();
for k in ["filledLines", "firstRowInFront", "firstColumnInFront"] {
assert!(v.get(k).is_none(), "{k}");
}
}
#[test]
fn filled_lines_keep_the_line_colour_and_fall_back_to_thin_lines() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "line", ",a\nQ1,2\nQ2,3\nQ3,2");
// A colour given to the thin line (its stroke) fills the band.
let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id;
s.execute("paint.setStroke", &json!({"ids": [grp.0], "color": "#00ff00"})).unwrap();
s.execute("graph.setType", &json!({"id": id.0, "filledLines": true})).unwrap();
let band = &series_parts(&s, id, 0)[0];
assert_eq!(band.appearance.fill_paint().color().unwrap().to_hex(), "#00ff00");
// A radar series of zeros has every point at the centre: no band to make, so a thin line.
let radar = make(&mut s, "radar", ",a\nQ1,0\nQ2,0\nQ3,0");
s.execute("graph.setType", &json!({"id": radar.0, "filledLines": true})).unwrap();
let line = &series_parts(&s, radar, 0)[0];
assert!(line.appearance.fill_paint().is_none(), "a stroked line");
// Past the budget, thin lines.
let rows: Vec<Vec<f64>> = (0..8_000).map(|i| vec![(i % 7) as f64, 1.0, 2.0]).collect();
let big = NodeId(
s.execute("graph.create", &json!({"type": "line", "x": 0, "y": 0, "width": 300, "height": 200, "rows": rows})).unwrap()["id"]
.as_u64()
.unwrap(),
);
s.execute("graph.setType", &json!({"id": big.0, "filledLines": true, "markPoints": false})).unwrap();
assert!(series_parts(&s, big, 0)[0].appearance.fill_paint().is_none());
}
#[test]
fn overlapping_columns_keep_clear_of_the_legend_and_options_follow_the_graph_type() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
s.execute("graph.setType", &json!({"columnWidth": 1000, "clusterWidth": 1000})).unwrap();
let reach = (0..2).flat_map(|i| marks(&s, id, i)).map(|b| b.x1).fold(f64::MIN, f64::max);
assert!(reach > 400.0, "past the plot: {reach}");
let swatch = series_parts(&s, id, 0).last().unwrap().geometric_bounds().unwrap();
assert!(swatch.x0 >= reach + 14.0 - 1e-6, "{swatch:?} after {reach}");
let v = s.execute("graph.setType", &json!({})).unwrap();
assert!(v.get("firstRowInFront").is_some() && v.get("filledLines").is_none());
let line = graph(&mut s, "line");
let v = s.execute("graph.setType", &json!({"id": line.0})).unwrap();
assert!(v.get("firstRowInFront").is_none() && v.get("filledLines").is_some());
}
/// The Axes and Legend texts with their size and fill, after the graph is drawn again.
fn label_looks(s: &mut Session, id: NodeId) -> Vec<(String, f64, String)> {
s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let mut out = vec![];
for g in ["Axes", "Legend"] {
for c in group(&n, g).children().unwrap() {
if let NodeKind::Text(t) = &c.kind {
let st = &t.runs[0].style;
out.push((t.plain_text(), st.size, st.fill.color().map(|c| c.to_hex()).unwrap_or_default()));
}
}
}
out
}
fn text_id(s: &Session, id: NodeId, grp: &str, text: &str) -> NodeId {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
group(&n, grp).children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap().id
}
#[test]
fn formatting_the_whole_graphs_text_survives_it_being_drawn_again() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("text.setStyle", &json!({"size": 12})).unwrap();
let looks = label_looks(&mut s, id);
assert!(looks.iter().all(|(_, size, _)| (*size - 12.0).abs() < 1e-6), "{looks:?}");
let g = spec(&s, id);
assert!(g.label_styles.values.is_some() && g.label_styles.categories.is_some() && g.label_styles.legend.is_some());
// One undo takes the data change back, the next the formatting and the kept styles together.
s.execute("edit.undo", &json!({})).unwrap();
assert!(spec(&s, id).label_styles.values.is_some());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).label_styles, vectorcraft_doc::LabelStyles::default());
}
/// A stroke given to all of a graph's text without a weight is kept at 1 pt, as `paint.setStroke` keeps it.
#[test]
fn a_stroke_given_to_the_whole_graphs_text_is_kept_at_1_pt() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("text.setStyle", &json!({"stroke": "#ff0000"})).unwrap();
let legend = spec(&s, id).label_styles.legend.unwrap();
assert_eq!((legend.stroke.color().map(|c| c.to_hex()), legend.stroke_width), (Some("#ff0000".into()), 1.0));
}
#[test]
fn formatting_one_label_keeps_that_kind_of_label() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
// A legend label (Group Selection), recoloured with the Character panel's fill.
let l = text_id(&s, id, "Legend", "2025");
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
s.execute("text.setStyle", &json!({"fill": "#ff0000"})).unwrap();
// A category label, resized.
let c = text_id(&s, id, "Axes", "Q2");
s.execute("select.set", &json!({"ids": [c.0]})).unwrap();
s.execute("text.setStyle", &json!({"size": 14})).unwrap();
// A value axis number, painted with the Appearance fill.
let v = text_id(&s, id, "Axes", "4");
s.execute("paint.setFill", &json!({"ids": [v.0], "color": "#0000ff"})).unwrap();
let looks = label_looks(&mut s, id);
let look = |t: &str| looks.iter().find(|(x, _, _)| x == t).cloned().unwrap();
assert_eq!(look("2024"), ("2024".into(), 9.0, "#ff0000".into()), "every legend label");
assert_eq!(look("Q1").1, 14.0, "every category label");
assert_eq!(look("Q1").2, "#000000");
assert_eq!(look("0").2, "#0000ff", "every value axis number");
assert_eq!(look("0").1, 9.0);
// Kept through save and open; nothing written when unset.
let path = vectorcraft_testkit::temp_dir("graph-labels").join("labels.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
assert_eq!(spec(&s, id).label_styles.categories.as_ref().unwrap().size, 14.0);
assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("labelStyles").is_none());
}
#[test]
fn pie_names_and_wedge_labels_keep_their_style() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "pie", ",a,b\nP1,1,2");
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let name = n.children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == "P1")).unwrap().id;
s.execute("select.set", &json!({"ids": [name.0]})).unwrap();
s.execute("text.setStyle", &json!({"size": 16})).unwrap();
assert_eq!(spec(&s, id).label_styles.categories.as_ref().unwrap().size, 16.0);
s.execute("graph.setType", &json!({"id": id.0, "pieLegend": "wedges"})).unwrap();
let w = text_id(&s, id, "Legend", "a");
s.execute("select.set", &json!({"ids": [w.0]})).unwrap();
s.execute("text.setStyle", &json!({"fill": "#00ff00"})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "rows": [[3, 1]]})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let fills: Vec<String> = group(&n, "Legend")
.children()
.unwrap()
.iter()
.filter_map(|c| match &c.kind {
NodeKind::Text(t) => t.runs[0].style.fill.color().map(|c| c.to_hex()),
_ => None,
})
.collect();
assert_eq!(fills, ["#00ff00", "#00ff00"], "the kept fill, also on the dark wedge");
}
#[test]
fn labels_are_told_apart_by_where_they_come_not_by_their_text() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
// Categories named like the value axis numbers.
let id = make(&mut s, "column", ",a\n2,1\n4,3\n6,5");
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let texts: Vec<(NodeId, String)> = group(&n, "Axes")
.children()
.unwrap()
.iter()
.filter_map(|c| match &c.kind {
NodeKind::Text(t) => Some((c.id, t.plain_text())),
_ => None,
})
.collect();
// The value axis "2" comes before the category "2".
let value_two = texts.iter().find(|(_, t)| t == "2").unwrap().0;
s.execute("select.set", &json!({"ids": [value_two.0]})).unwrap();
s.execute("text.setStyle", &json!({"size": 7})).unwrap();
let g = spec(&s, id);
assert!(g.label_styles.values.is_some() && g.label_styles.categories.is_none(), "{:?}", g.label_styles);
// A scatter graph's numbers are all values.
let sc = graph(&mut s, "scatter");
s.execute("select.set", &json!({"ids": [sc.0]})).unwrap();
s.execute("text.setStyle", &json!({"size": 8})).unwrap();
let g = spec(&s, sc);
assert!(g.label_styles.values.is_some() && g.label_styles.categories.is_none());
// Part of a label's text: nothing kept.
let col = graph(&mut s, "column");
let q = text_id(&s, col, "Axes", "Q2");
s.execute("select.set", &json!({"ids": [q.0]})).unwrap();
s.execute("text.setStyle", &json!({"size": 20, "start": 0, "end": 1})).unwrap();
assert!(spec(&s, col).label_styles.categories.is_none());
// Only the change is kept: a size given to the whole graph leaves the label colours as they were.
let pie = make(&mut s, "pie", ",a,b\nP,1,1");
s.execute("graph.setType", &json!({"id": pie.0, "pieLegend": "wedges"})).unwrap();
s.execute("select.set", &json!({"ids": [pie.0]})).unwrap();
s.execute("text.setStyle", &json!({"size": 11})).unwrap();
let legend = spec(&s, pie).label_styles.legend.unwrap();
assert_eq!((legend.size, legend.fill.color().unwrap().to_hex()), (11.0, "#000000".into()));
s.execute("graph.setData", &json!({"id": pie.0, "rows": [[2, 1]]})).unwrap();
let n = s.doc().unwrap().doc.node(pie).unwrap().clone();
let fill = match &group(&n, "Legend").children().unwrap()[0].kind {
NodeKind::Text(t) => t.runs[0].style.fill.color().unwrap().to_hex(),
_ => panic!(),
};
assert_eq!(fill, "#ffffff", "still white on the black wedge");
}
#[test]
fn character_formats_are_kept_and_bigger_labels_space_out() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("text.setFormat", &json!({"underline": true, "baselineShift": 1})).unwrap();
let g = spec(&s, id);
let st = g.label_styles.legend.unwrap();
assert!(st.underline && (st.baseline_shift - 1.0).abs() < 1e-9);
// 20 pt legend labels: the rows move apart so they don't overlap.
s.execute("text.setStyle", &json!({"size": 20})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": ",a,b,c\nQ1,1,2,3"})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let mut rows: Vec<_> = group(&n, "Legend").children().unwrap().iter().filter_map(|c| c.geometric_bounds()).collect();
rows.sort_by(|a, b| a.y0.total_cmp(&b.y0));
assert!(rows.windows(2).all(|w| w[1].y0 >= w[0].y1 - 1e-6), "{rows:?}");
// A file's absurd style is kept within the Character limits.
let mut g = spec(&s, id);
g.label_styles.values = Some(CharStyle { size: 1e300, tracking: f64::INFINITY, ..CharStyle::default() });
let mut d = (*s.doc().unwrap().doc).clone();
let art = super::generate(&mut d, &g);
let axes = art.iter().find(|n| n.name.as_deref() == Some("Axes")).unwrap();
for t in axes.children().unwrap() {
if let NodeKind::Text(t) = &t.kind {
assert!(t.runs[0].style.size <= 1296.0 && t.runs[0].style.tracking.is_finite());
}
}
}