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"); } #[test] fn fmt_value_tiny_negative_is_zero() { assert_eq!(super::fmt_value(-0.004), "0"); assert_eq!(super::fmt_value(-0.0049), "0"); assert_eq!(super::fmt_value(-0.006), "-0.01"); } 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 { 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 { let n = s.doc().unwrap().doc.node(id).unwrap().clone(); let mut xs: Vec = 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| 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); } /// A row or a row opacity is not given to a graph design's copy inside a series, and the graph keeps no opacity 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 { .. })); assert!(s.execute("appearance.addFill", &json!({"ids": [copy.id.0]})).is_err()); assert!(s.execute("transparency.set", &json!({"ids": [copy.id.0], "item": 0, "opacity": 40})).is_err()); assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill_opacity.is_none())); } /// A column graph whose series 0 draws the design "Square": the graph, series 0's first copy of the design, a path /// inside that copy, and the first mark of series 1, drawn plain. fn design_art() -> (Session, NodeId, NodeId, NodeId, NodeId) { 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, "seriesIndexes": [0], "design": "Square"})).unwrap(); let n = s.doc().unwrap().doc.node(id).unwrap().clone(); let copy = series(&n, 0).children().unwrap()[0].clone(); assert!(matches!(copy.kind, NodeKind::Group { .. })); let mut art = vec![]; copy.walk(&mut |m| { if matches!(m.kind, NodeKind::Path { .. }) { art.push(m.id); } }); let mark = series(&n, 1).children().unwrap()[0].clone(); assert!(matches!(mark.kind, NodeKind::Path { .. })); (s, id, copy.id, art[0], mark.id) } /// `cmd` with `p` is refused as an edit of graph design art, and the document keeps its undo history. fn refused_on_design_art(s: &mut Session, cmd: &str, p: serde_json::Value) { let steps = s.doc().unwrap().history.undo.len(); let e = s.execute(cmd, &p).unwrap_err().to_string(); assert!(e.contains("drawn again from the design"), "{cmd}: {e}"); assert_eq!(s.doc().unwrap().history.undo.len(), steps, "{cmd}"); } /// The commands that give rows, effects or graphic styles refuse the art of a graph design inside a series, given by /// id (the design's copy or a path inside it) or selected: the graph draws that art again from the design. #[test] fn appearance_commands_refuse_a_graph_designs_art_in_a_series() { let (mut s, _, copy, art, _) = design_art(); let (rect, _) = blue_style(&mut s, "Blue", false); let graphic = s.execute("shape.rectangle", &json!({"x": 700, "y": 700, "width": 10, "height": 10})).unwrap()["id"].clone(); s.execute("threeD.addGraphic", &json!({"name": "Mark", "ids": [graphic]})).unwrap(); let fill = top_row(&s, art, true); for ids in [json!([copy.0]), json!([art.0])] { refused_on_design_art( &mut s, "graphicStyle.addFromLibrary", json!({"library": "painted-sketched", "name": "Wet Ink", "apply": true, "ids": ids}), ); for cmd in ["appearance.addFill", "appearance.addStroke", "appearance.clear", "appearance.reduceToBasic", "appearance.showAllHidden"] { refused_on_design_art(&mut s, cmd, json!({"ids": ids})); } refused_on_design_art(&mut s, "appearance.moveItem", json!({"ids": ids, "from": "contents", "to": 0})); refused_on_design_art(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": ids})); refused_on_design_art(&mut s, "appearance.addEffect", json!({"effect": "stylize.dropShadow", "ids": ids})); refused_on_design_art(&mut s, "effect.apply", json!({"effect": "threeD.extrude", "item": null, "ids": ids})); refused_on_design_art(&mut s, "effect.move", json!({"ids": ids, "from": 0, "to": 0})); refused_on_design_art(&mut s, "effect.remove", json!({"ids": ids, "index": 0})); refused_on_design_art(&mut s, "threeD.mapGraphic", json!({"ids": ids, "symbol": "Mark"})); refused_on_design_art(&mut s, "graphicStyle.apply", json!({"name": "Blue", "ids": ids})); } for cmd in ["appearance.removeItem", "appearance.duplicateItem"] { refused_on_design_art(&mut s, cmd, json!({"ids": [art.0], "index": fill})); } refused_on_design_art(&mut s, "appearance.setItem", json!({"ids": [art.0], "index": fill, "color": "#00ff00"})); refused_on_design_art(&mut s, "appearance.moveItem", json!({"ids": [art.0], "from": fill, "to": 0})); refused_on_design_art(&mut s, "transparency.set", json!({"ids": [art.0], "item": fill, "opacity": 40})); refused_on_design_art(&mut s, "appearance.copyFrom", json!({"source": rect.0, "ids": [art.0]})); refused_on_design_art(&mut s, "appearance.transfer", json!({"source": rect.0, "target": art.0, "copy": true})); refused_on_design_art(&mut s, "appearance.transfer", json!({"source": art.0, "target": rect.0})); // Selected, and through the contents of the design's copy. s.execute("select.set", &json!({"ids": [art.0]})).unwrap(); refused_on_design_art(&mut s, "appearance.addFill", json!({})); refused_on_design_art(&mut s, "graphicStyle.addFromLibrary", json!({"library": "painted-sketched", "name": "Wet Ink", "apply": true})); refused_on_design_art(&mut s, "appearance.addStroke", json!({"ids": [copy.0], "target": "contents"})); // A Pathfinder effect does not group the selected paths of the design's copy. let mut paths = vec![]; s.doc().unwrap().doc.node(copy).unwrap().walk(&mut |m| { if matches!(m.kind, NodeKind::Path { .. }) { paths.push(m.id.0); } }); assert_eq!(paths.len(), 2); s.execute("select.set", &json!({"ids": paths})).unwrap(); refused_on_design_art(&mut s, "effect.apply", json!({"effect": "pathfinder.add"})); // Read as a source, the art gives its look. s.execute("appearance.transfer", &json!({"source": art.0, "target": rect.0, "copy": true})).unwrap(); } /// Given the art of a graph design in a series with other objects, the commands edit the other objects only. #[test] fn appearance_commands_edit_the_objects_given_with_a_graph_designs_art() { let (mut s, id, copy, art, mark) = design_art(); let (rect, _) = blue_style(&mut s, "Blue", false); let rows = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().appearance.clone(); let (copy_was, art_was, mark_was) = (rows(&s, copy), rows(&s, art), rows(&s, mark)); s.execute("appearance.addFill", &json!({"ids": [copy.0, mark.0]})).unwrap(); assert_eq!((rows(&s, copy), rows(&s, art)), (copy_was.clone(), art_was.clone())); assert_eq!(rows(&s, mark).items.len(), mark_was.items.len() + 1); s.execute("edit.undo", &json!({})).unwrap(); s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [art.0, mark.0]})).unwrap(); assert_eq!(rows(&s, art), art_was); assert_eq!(rows(&s, mark).effects.len(), 1); s.execute("edit.undo", &json!({})).unwrap(); s.execute("appearance.copyFrom", &json!({"source": rect.0, "ids": [art.0, mark.0]})).unwrap(); assert_eq!(rows(&s, art), art_was); assert_ne!(rows(&s, mark), mark_was); s.execute("edit.undo", &json!({})).unwrap(); // The graph's contents: series 1's marks take the fill, the design's art does not. s.execute("appearance.addFill", &json!({"ids": [id.0], "target": "contents"})).unwrap(); assert_eq!((rows(&s, copy), rows(&s, art)), (copy_was, art_was)); assert_eq!(rows(&s, mark).items.len(), mark_was.items.len() + 1); } /// The paint commands leave the art of a graph design in a series out, a copy of the design given by id with an item /// included. #[test] fn paint_targets_leave_a_graph_designs_copy_out() { let (s, _, copy, art, mark) = design_art(); let d = &s.doc().unwrap().doc; assert_eq!(super::paint_targets(d, &[copy, art, mark]), [mark]); } /// The art of a marker design in a series is refused too. #[test] fn effect_commands_refuse_a_marker_designs_art() { 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!({"id": id.0, "seriesIndexes": [1], "design": "Pill"})).unwrap(); let marker = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[1].clone(); assert!(matches!(marker.kind, NodeKind::Group { .. }), "the design's art"); refused_on_design_art(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [marker.id.0]})); } /// 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 { 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())); } /// Randomize Opacity on marks of one series stores the opacity of the frontmost of them on the graph in the same undo /// step, and every mark of the series has it when the graph is drawn again. #[test] fn a_random_opacity_on_series_marks_survives_regeneration() { let (mut s, id, _, marks) = column_marks(); s.execute("addon.arrange.randomizeOpacity", &json!({"ids": [marks[1].0, marks[0].0], "min": 30, "max": 60, "seed": 7})).unwrap(); let front = s.doc().unwrap().doc.node(marks[1]).unwrap().opacity; assert!(front < 1.0); assert_eq!(spec(&s, id).series_paints[0].opacity, Some(front)); s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); assert!(transparency_of(&s, id, 0).iter().all(|t| t.0 == front), "{:?}", transparency_of(&s, id, 0)); assert!(transparency_of(&s, id, 1).iter().all(|t| t.0 == 1.0), "{:?}", transparency_of(&s, id, 1)); s.execute("edit.undo", &json!({})).unwrap(); s.execute("edit.undo", &json!({})).unwrap(); assert!(spec(&s, id).series_paints.is_empty(), "undo takes back the opacities and what the graph stored"); } /// Sort Objects giving series marks new opacities stores the opacity of the frontmost of them on the graph, in place of /// the one another mark left there. #[test] fn an_opacity_sort_objects_gives_series_marks_survives_regeneration() { let (mut s, id, _, marks) = column_marks(); s.execute("object.setProps", &json!({"id": marks[2].0, "opacity": 30})).unwrap(); assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3)); // From left to right the three columns get 30%, 65% and 100%: the frontmost, the rightmost, is fully opaque. let ids = [marks[0].0, marks[1].0, marks[2].0]; s.execute("addon.arrange.sort", &json!({"ids": ids, "by": "x", "apply": "opacity", "distribute": true})).unwrap(); let opacities: Vec = marks[..3].iter().map(|m| s.doc().unwrap().doc.node(*m).unwrap().opacity).collect(); assert_eq!(opacities, [0.3, 0.65, 1.0]); 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.3), "undo takes back the sort and what the graph stored"); } /// Sort Objects stores the opacity of the frontmost series mark whose opacity it changes: a mark in front that keeps its /// opacity leaves the stored value to the marks it changed. #[test] fn an_opacity_sort_objects_stores_the_frontmost_mark_it_changes() { let (mut s, id, _, marks) = column_marks(); s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 60})).unwrap(); s.execute("object.setProps", &json!({"id": marks[1].0, "opacity": 30})).unwrap(); assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3)); // From left to right the columns trade 60%, 30% and 100% for 30%, 60% and 100%: the frontmost keeps its 100%. let ids = [marks[0].0, marks[1].0, marks[2].0]; s.execute("addon.arrange.sort", &json!({"ids": ids, "by": "x", "apply": "opacity"})).unwrap(); let opacities: Vec = marks[..3].iter().map(|m| s.doc().unwrap().doc.node(*m).unwrap().opacity).collect(); assert_eq!(opacities, [0.3, 0.6, 1.0]); assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.6)); } /// An object opacity, blend mode, fill, stroke and weight that Global Edit gives a mark of another series from the mark /// that was edited are stored on the graph for that series in the same undo step, and every mark of the series has them /// when the graph is drawn again. #[test] fn what_global_edit_gives_series_marks_survives_regeneration() { let (mut s, id, _, _) = column_marks(); // Equal values: the first column of each series has the same shape. s.execute("graph.setData", &json!({"rows": [[3, 3], [5, 2]]})).unwrap(); let n = s.doc().unwrap().doc.node(id).unwrap().clone(); let (first, other) = (series(&n, 0).children().unwrap()[0].id, series(&n, 1).children().unwrap()[0].id); s.execute("object.isolate", &json!({"id": id.0})).unwrap(); s.execute("select.set", &json!({"ids": [first.0]})).unwrap(); let r = s.execute("select.globalEdit", &json!({})).unwrap(); assert!(r["ids"].as_array().unwrap().contains(&json!(other.0)), "{r}"); let steps = s.doc().unwrap().history.undo.len(); s.execute("transparency.set", &json!({"ids": [first.0], "opacity": 40, "blend": "multiply"})).unwrap(); s.execute("paint.setFill", &json!({"ids": [first.0], "color": "#e8573f"})).unwrap(); s.execute("paint.setStroke", &json!({"ids": [first.0], "color": "#1f4e9c"})).unwrap(); s.execute("stroke.set", &json!({"ids": [first.0], "weight": 5})).unwrap(); let mark = s.doc().unwrap().doc.node(other).unwrap().clone(); assert_eq!((mark.opacity, mark.blend), (0.4, BlendMode::Multiply), "Global Edit reaches the other series' mark"); let kept = spec(&s, id).series_paints[1].clone(); assert_eq!((kept.opacity, kept.blend), (Some(0.4), Some(BlendMode::Multiply))); assert_eq!(kept.fill, Some(mark.appearance.fill_paint())); assert_eq!((kept.stroke, kept.stroke_width), (Some(mark.appearance.stroke_paint()), Some(5.0))); s.execute("object.exitIsolation", &json!({})).unwrap(); s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); let normal = BlendMode::Normal; assert!(transparency_of(&s, id, 1).iter().all(|t| *t == (0.4, BlendMode::Multiply, normal, normal)), "{:?}", transparency_of(&s, id, 1)); let n = s.doc().unwrap().doc.node(id).unwrap().clone(); assert!(series(&n, 1).children().unwrap().iter().all(|m| m.appearance.fill_paint() == mark.appearance.fill_paint())); while s.doc().unwrap().history.undo.len() > steps { s.execute("edit.undo", &json!({})).unwrap(); } assert!(spec(&s, id).series_paints.iter().all(|p| *p == vectorcraft_doc::SeriesPaint::default()), "{:?}", spec(&s, id).series_paints); } /// With a whole series group picked and Global Edit on, the other series groups of the same form take the edit of its /// marks, and the graph stores it for their series too. #[test] fn what_global_edit_gives_the_marks_of_another_series_group_survives_regeneration() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); // Series without names draw no legend, so each series group holds only its columns, laid out like the other's. let csv = ",,\nQ1,3,3\nQ2,5,5"; let made = s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap(); let id = NodeId(made["id"].as_u64().unwrap()); let n = s.doc().unwrap().doc.node(id).unwrap().clone(); let (first, other) = (series(&n, 0).clone(), series(&n, 1).id); s.execute("object.isolate", &json!({"id": id.0})).unwrap(); s.execute("select.set", &json!({"ids": [first.id.0]})).unwrap(); let r = s.execute("select.globalEdit", &json!({})).unwrap(); assert!(r["ids"].as_array().unwrap().contains(&json!(other.0)), "{r}"); let marks: Vec = first.children().unwrap().iter().map(|m| m.id.0).collect(); s.execute("paint.setFill", &json!({"ids": marks, "color": "#e8573f"})).unwrap(); let n = s.doc().unwrap().doc.node(id).unwrap().clone(); let given = series(&n, 1).children().unwrap()[0].appearance.fill_paint(); assert_eq!(given, series(&n, 0).children().unwrap()[0].appearance.fill_paint(), "Global Edit reaches the other group"); assert_eq!(spec(&s, id).series_paints[1].fill, Some(given.clone())); s.execute("object.exitIsolation", &json!({})).unwrap(); s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); let n = s.doc().unwrap().doc.node(id).unwrap().clone(); assert!(series(&n, 1).children().unwrap().iter().all(|m| m.appearance.fill_paint() == given)); } /// 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) { 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); } /// A column graph whose series 0 stores the 40% Multiply fill set on its first mark, and a mark of series 1 whose top fill /// row has no paint and is opaque and Normal: the session, the graph, series 0's marks and that source. fn marks_and_unpainted_source() -> (Session, NodeId, Vec, NodeId) { let (mut s, id, _, marks) = column_marks(); 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), "none": true})).unwrap(); let fill = top_row(&s, marks[0], true); s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "opacity": 40, "blend": "multiply"})).unwrap(); let kept = spec(&s, id).series_paints[0].clone(); assert_eq!((kept.fill_opacity, kept.fill_blend), (Some(0.4), Some(BlendMode::Multiply))); (s, id, marks, source) } /// An Eyedropper pick from a fill row without paint onto a mark whose fill had paint replaces the fill opacity and blend /// mode another mark of the series stored: the series is drawn opaque and Normal again. #[test] fn an_eyedropper_pick_that_unpaints_a_series_fill_clears_its_stored_transparency() { let (mut s, id, marks, source) = marks_and_unpainted_source(); let mark = s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.fill().unwrap().clone(); assert_eq!((mark.paint.is_none(), mark.opacity, mark.blend), (false, 1.0, BlendMode::Normal), "the target's fill row"); s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0]})).unwrap(); let picked = s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.fill().unwrap().clone(); assert_eq!((picked.paint.is_none(), picked.opacity, picked.blend), (true, 1.0, BlendMode::Normal)); let kept = spec(&s, id).series_paints[0].clone(); assert_eq!((kept.fill_opacity, kept.fill_blend), (None, None)); s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); let n = s.doc().unwrap().doc.node(id).unwrap().clone(); let fills: Vec<_> = series(&n, 0).children().unwrap().iter().filter_map(|m| m.appearance.fill().map(|f| (f.opacity, f.blend))).collect(); assert!(fills.iter().all(|f| *f == (1.0, BlendMode::Normal)), "{fills:?}"); } /// An Eyedropper pick from a fill row without paint onto a mark whose fill had no paint before the pick either leaves the /// fill opacity and blend mode another mark of the series stored. #[test] fn an_eyedropper_pick_of_an_already_unpainted_row_leaves_series_transparency() { let (mut s, id, marks, source) = marks_and_unpainted_source(); s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "none": true})).unwrap(); let kept = spec(&s, id).series_paints[0].clone(); assert_eq!((kept.fill_opacity, kept.fill_blend), (Some(0.4), Some(BlendMode::Multiply)), "a color set to None writes no opacity"); s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0]})).unwrap(); let kept = spec(&s, id).series_paints[0].clone(); assert_eq!((kept.fill_opacity, kept.fill_blend), (Some(0.4), Some(BlendMode::Multiply))); } /// A whole Eyedropper pick from a source with no fill row, or no stroke row, removes that row from the mark. Where the /// mark's row had paint, the pick replaces the opacity and blend mode another mark of the series stored for that kind /// of row. Where the mark's row had no paint, the stored values stay. #[test] fn a_whole_eyedropper_pick_that_removes_a_painted_row_clears_its_stored_transparency() { let stored = |s: &Session, id: NodeId, fill: bool| { let kept = spec(s, id).series_paints[0].clone(); if fill { (kept.fill_opacity, kept.fill_blend) } else { (kept.stroke_opacity, kept.stroke_blend) } }; for (fill, painted) in [(true, true), (false, true), (false, false)] { let (mut s, id, _, marks) = column_marks(); let row = top_row(&s, marks[0], fill); if painted { s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": row, "color": "#ff0000"})).unwrap(); } s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": row, "color": "#ff0000", "opacity": 40, "blend": "multiply"})).unwrap(); assert_eq!(stored(&s, id, fill), (Some(0.4), Some(BlendMode::Multiply)), "fill {fill}, painted {painted}"); 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, fill)})).unwrap(); let was = spec(&s, id).series_paints; s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0]})).unwrap(); let mark = s.doc().unwrap().doc.node(marks[1]).unwrap().clone(); assert!(!mark.appearance.items.iter().any(|it| it.is_fill() == fill), "the pick removed the row: fill {fill}"); let want = if painted { (None, None) } else { (Some(0.4), Some(BlendMode::Multiply)) }; assert_eq!(stored(&s, id, fill), want, "fill {fill}, painted {painted}"); if painted { let after = spec(&s, id).series_paints; s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(spec(&s, id).series_paints, was); s.execute("edit.redo", &json!({})).unwrap(); assert_eq!(spec(&s, id).series_paints, after); } } } /// 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) { 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}"); } } /// An Eyedropper pick from a stroke row without paint onto a marker whose stroke had paint replaces the stroke opacity and /// blend mode the series' Filled Lines band left, in the pick's undo step: the band's outline is drawn opaque and Normal /// again. A pick onto a marker whose stroke had no paint before the pick leaves them as they are. #[test] fn an_eyedropper_pick_that_unpaints_a_series_stroke_clears_its_stored_transparency() { for stroked in [true, false] { let (mut s, id, band) = band_graph(); let parts = series_parts(&s, id, 0); let (marker, source) = (parts[1].id, parts[2].id); let st = parts[2].appearance.stroke().unwrap(); assert_eq!((st.paint.is_none(), st.opacity, st.blend), (true, 1.0, BlendMode::Normal), "the source's stroke row"); 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(); let was = spec(&s, id).series_paints; assert_eq!((was[0].stroke_opacity, was[0].stroke_blend), (Some(0.4), Some(BlendMode::Multiply))); s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marker.0]})).unwrap(); let picked = s.doc().unwrap().doc.node(marker).unwrap().appearance.stroke().unwrap().clone(); assert_eq!((picked.paint.is_none(), picked.opacity, picked.blend), (true, 1.0, BlendMode::Normal), "stroked {stroked}"); 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}"); if stroked { let after = spec(&s, id).series_paints; s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(spec(&s, id).series_paints, was); assert!(!s.doc().unwrap().doc.node(marker).unwrap().appearance.stroke_paint().is_none(), "the undo took back the pick"); s.execute("edit.redo", &json!({})).unwrap(); assert_eq!(spec(&s, id).series_paints, after); } 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!(spec(&s, id).series_paints.first().is_none_or(|p| p.stroke.is_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> = (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 { .. })); assert!(s.execute("appearance.addFill", &json!({"ids": [copy.id.0]})).is_err()); assert!(s.execute("appearance.setItem", &json!({"ids": [copy.id.0], "index": 0, "color": "#0000ff"})).is_err()); 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]; assert!(s.execute("appearance.setItem", &json!({"ids": [art.0], "index": top_row(&s, art, true), "color": "#00ff00"})).is_err()); 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) { 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); } /// Series 0's group, and whether it is as the graph draws it: the group has no rows of its own and each path directly /// inside it has rows. fn series_0_group(s: &Session, id: NodeId) -> (NodeId, bool) { let n = s.doc().unwrap().doc.node(id).unwrap().clone(); let g = series(&n, 0); let drawn = g.appearance.items.is_empty() && g.children().unwrap().iter().all(|m| !m.appearance.items.is_empty()); (g.id, drawn) } /// `appearance.setItem` given a series group's id edits the paths directly inside it, so the graph stores the color and /// opacity and every mark has them after the graph is drawn again. The group keeps no rows of its own. #[test] fn an_appearance_item_set_on_a_series_group_edits_its_marks() { let (mut s, id, _, marks) = column_marks(); let (group, drawn) = series_0_group(&s, id); assert!(drawn); let was = spec(&s, id).series_paints; let row = top_row(&s, marks[0], true); s.execute("appearance.setItem", &json!({"ids": [group.0], "index": row, "color": "#0000ff", "opacity": 40})).unwrap(); assert!(series_0_group(&s, id).1, "the group has no rows of its own"); let blue = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint(); let kept = spec(&s, id).series_paints[0].clone(); assert_eq!((kept.fill.clone(), kept.fill_opacity), (Some(blue.clone()), Some(0.4))); let (stroke, weight) = { let m = s.doc().unwrap().doc.node(marks[0]).unwrap().clone(); (m.appearance.stroke_paint(), m.appearance.stroke_width()) }; series_0_is(&mut s, id, (&blue, 0.4, &stroke, weight), &was); } /// The other Appearance commands given a series group's id, picked with Group Selection or given in `ids`, edit the /// paths directly inside it, and the graph stores what they change: Add New Fill, Clear Appearance, Move Appearance onto /// the group, and an opacity set on a row with `transparency.set`. #[test] fn appearance_commands_given_a_series_group_edit_its_marks() { let (mut s, id, marks) = stored_red_stroke(); let (group, _) = series_0_group(&s, id); s.execute("select.set", &json!({"ids": [group.0]})).unwrap(); s.execute("appearance.addFill", &json!({})).unwrap(); assert!(series_0_group(&s, id).1, "Add New Fill on the selected group"); let rows = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.items.iter().filter(|it| it.is_fill()).count(); assert_eq!(rows, 2, "each mark has a new fill row"); s.execute("edit.undo", &json!({})).unwrap(); let row = top_row(&s, marks[0], true); s.execute("transparency.set", &json!({"ids": [group.0], "item": row, "opacity": 30})).unwrap(); assert!(series_0_group(&s, id).1); assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.3)); s.execute("edit.undo", &json!({})).unwrap(); s.execute("appearance.clear", &json!({"ids": [group.0]})).unwrap(); assert_eq!(spec(&s, id).series_paints[0].fill, Some(Paint::None)); s.execute("edit.undo", &json!({})).unwrap(); let (rect, blue) = blue_style(&mut s, "Blue", false); let was = spec(&s, id).series_paints; s.execute("object.setProps", &json!({"ids": [group.0], "opacity": 50})).unwrap(); s.execute("appearance.transfer", &json!({"source": rect.0, "target": group.0, "copy": true})).unwrap(); assert!(series_0_group(&s, id).1); assert_eq!(s.doc().unwrap().doc.node(group).unwrap().opacity, 1.0, "the appearance replaces the group's own opacity"); let kept = spec(&s, id).series_paints[0].clone(); assert_eq!((kept.fill.clone(), kept.fill_opacity, kept.stroke), (Some(blue.clone()), Some(0.5), Some(Paint::None))); series_0_is(&mut s, id, (&blue, 0.5, &Paint::None, 0.0), &was); } /// Moving the Contents row of a series group as the graph draws it, with no rows of its own, moves the group's own /// row: the paths inside it have none. #[test] fn moving_a_series_groups_contents_row_moves_its_own_row() { let (mut s, id, _, _) = column_marks(); let (group, drawn) = series_0_group(&s, id); assert!(drawn); let out = s.execute("appearance.moveItem", &json!({"ids": [group.0], "from": "contents", "to": 0})).unwrap(); assert_eq!(out["contents"], json!(0)); } /// Add New Stroke, Duplicate Item, Reorder Appearance Item and Remove Item given a series group's id edit the rows of /// the paths directly inside it, and the group keeps no rows of its own. #[test] fn row_commands_given_a_series_group_edit_its_marks_rows() { let (mut s, id, _, marks) = column_marks(); let (group, _) = series_0_group(&s, id); let count = |s: &Session, fill: bool| -> Vec { let d = &s.doc().unwrap().doc; marks.iter().map(|m| d.node(*m).unwrap().appearance.items.iter().filter(|it| it.is_fill() == fill).count()).collect() }; let (fills, strokes) = (count(&s, true), count(&s, false)); s.execute("appearance.addStroke", &json!({"ids": [group.0]})).unwrap(); assert_eq!(count(&s, false), strokes.iter().map(|n| n + 1).collect::>(), "each mark has a new stroke row"); let fill = top_row(&s, marks[0], true); s.execute("appearance.duplicateItem", &json!({"ids": [group.0], "index": fill})).unwrap(); assert_eq!(count(&s, true), fills.iter().map(|n| n + 1).collect::>(), "each mark has a copy of its fill row"); let rows = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.items.len(); let out = s.execute("appearance.moveItem", &json!({"ids": [group.0], "from": 0, "to": rows - 1})).unwrap(); assert_eq!(out["index"], json!(rows - 1)); s.execute("appearance.removeItem", &json!({"ids": [group.0], "index": top_row(&s, marks[0], true)})).unwrap(); assert_eq!(count(&s, true), fills); assert!(series_0_group(&s, id).1, "the group has no rows of its own"); } /// Reduce to Basic Appearance given a series group's id reduces the paths directly inside it and drops an effect the /// group holds. #[test] fn reducing_a_series_group_reduces_its_marks_and_drops_its_own_effect() { let (mut s, id, _, marks) = column_marks(); let (group, _) = series_0_group(&s, id); s.execute("appearance.addFill", &json!({"ids": [group.0]})).unwrap(); s.execute("effect.apply", &json!({"ids": [group.0], "effect": "distort.twist"})).unwrap(); s.execute("appearance.reduceToBasic", &json!({"ids": [group.0]})).unwrap(); let d = &s.doc().unwrap().doc; assert!(d.node(group).unwrap().appearance.effects.is_empty(), "the group's effect is dropped"); assert!(marks.iter().all(|m| d.node(*m).unwrap().appearance.items.iter().filter(|it| it.is_fill()).count() == 1)); assert!(series_0_group(&s, id).1); } /// A graphic style added on top (Alt-click) given a series group's id adds its rows to the paths directly inside the /// group and unlinks a group that held a style link. #[test] fn a_graphic_style_added_to_a_series_group_adds_rows_to_its_marks_and_unlinks_the_group() { let (mut s, id, _, marks) = column_marks(); let (group, _) = series_0_group(&s, id); blue_style(&mut s, "Blue", false); let style = s.doc().unwrap().doc.graphic_style("Blue").unwrap().id; s.edit("Link", |d, _| { d.node_mut(group).unwrap().extras_mut().graphic_style = Some(style); Ok(()) }) .unwrap(); let fills = |s: &Session, m: NodeId| s.doc().unwrap().doc.node(m).unwrap().appearance.items.iter().filter(|it| it.is_fill()).count(); let was = fills(&s, marks[0]); s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [group.0], "add": true})).unwrap(); assert!(marks.iter().all(|m| fills(&s, *m) == was + 1), "each mark has the style's fill on top"); assert!(series_0_group(&s, id).1, "the group takes no rows of the style"); assert_eq!(s.doc().unwrap().doc.node(group).unwrap().extras().graphic_style, None, "the group is unlinked"); } /// Move Appearance from one of a series group's paths onto the group gives the other paths its look and leaves that /// path as it was. When the path is the only one directly in the group, the command is refused and the group takes no /// rows. #[test] fn moving_an_appearance_from_a_mark_onto_its_own_series_group_keeps_the_mark() { let (mut s, id, _, marks) = column_marks(); let (group, _) = series_0_group(&s, id); s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#00ff00"})).unwrap(); let green = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint(); s.execute("appearance.transfer", &json!({"source": marks[0].0, "target": group.0})).unwrap(); let d = &s.doc().unwrap().doc; assert!(marks.iter().all(|m| d.node(*m).unwrap().appearance.fill_paint() == green), "every mark, the source too, is green"); assert_eq!(spec(&s, id).series_paints[0].fill, Some(green)); s.edit("One path", |d, _| { d.children_mut(Some(group)).unwrap().truncate(1); Ok(()) }) .unwrap(); let only = s.doc().unwrap().doc.node(group).unwrap().children().unwrap()[0].id; let err = s.execute("appearance.transfer", &json!({"source": only.0, "target": group.0})).unwrap_err(); assert!(err.to_string().contains("only path"), "{err}"); assert!(series_0_group(&s, id).1, "the group has no rows of its own"); } /// A series group that holds an opacity and an effect of its own is read itself as a source, as any group is: a new /// graphic style and Move Appearance take the group's opacity and effect, and the group, not a path, is linked. #[test] fn a_series_group_with_a_look_of_its_own_is_read_itself_as_a_source() { let (mut s, id, _, marks) = column_marks(); let (group, _) = series_0_group(&s, id); s.execute("transparency.set", &json!({"ids": [group.0], "opacity": 50, "item": null})).unwrap(); s.execute("effect.apply", &json!({"ids": [group.0], "effect": "distort.twist"})).unwrap(); s.execute("graphicStyle.new", &json!({"id": group.0, "name": "Series"})).unwrap(); let d = &s.doc().unwrap().doc; let style = d.graphic_style("Series").unwrap(); assert_eq!((style.opacity, style.appearance.effects.len()), (0.5, 1)); assert_eq!(d.node(group).unwrap().extras().graphic_style, Some(style.id)); assert!(marks.iter().all(|m| d.node(*m).unwrap().extras().graphic_style.is_none())); let rect = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()); s.execute("appearance.transfer", &json!({"source": group.0, "target": rect.0, "copy": true})).unwrap(); let r = s.doc().unwrap().doc.node(rect).unwrap().clone(); assert_eq!((r.opacity, r.appearance.effects.len()), (0.5, 1)); } /// Clear Appearance and Show All Hidden Attributes given a series group's id also reach an effect the group holds, and /// Break Link to Graphic Style unlinks the paths a style applied to the group linked. #[test] fn whole_appearance_commands_given_a_series_group_edit_the_group_and_its_marks() { let (mut s, id, marks) = stored_red_stroke(); let (group, _) = series_0_group(&s, id); let effects = |s: &Session| s.doc().unwrap().doc.node(group).unwrap().appearance.effects.clone(); s.execute("effect.apply", &json!({"ids": [group.0], "effect": "distort.twist"})).unwrap(); s.execute("effect.setParams", &json!({"ids": [group.0], "index": 0, "visible": false, "item": null})).unwrap(); assert!(!effects(&s)[0].visible); let shown = s.execute("appearance.showAllHidden", &json!({"ids": [group.0]})).unwrap(); assert_eq!(shown["ids"], json!([group.0])); assert!(effects(&s)[0].visible); s.execute("appearance.clear", &json!({"ids": [group.0]})).unwrap(); assert!(effects(&s).is_empty(), "the group's effect is cleared with its marks' rows"); assert_eq!(spec(&s, id).series_paints[0].fill, Some(Paint::None)); s.execute("edit.undo", &json!({})).unwrap(); blue_style(&mut s, "Blue", false); s.execute("select.set", &json!({"ids": [group.0]})).unwrap(); s.execute("graphicStyle.apply", &json!({"name": "Blue"})).unwrap(); assert_eq!(s.selection_graphic_style().map(|(g, linked)| (g.name.clone(), linked)), Some(("Blue".into(), true))); s.execute("graphicStyle.breakLink", &json!({})).unwrap(); let d = &s.doc().unwrap().doc; assert!(marks.iter().all(|m| d.node(*m).unwrap().extras().graphic_style.is_none()), "each mark is unlinked"); assert!(s.selection_graphic_style().is_none()); } /// Redefine Graphic Style on a selected series group whose paths a style links takes the look of the topmost of them. #[test] fn redefining_a_graphic_style_from_a_selected_series_group_reads_its_topmost_mark() { let (mut s, id, marks) = stored_red_stroke(); let (group, _) = series_0_group(&s, id); blue_style(&mut s, "Blue", false); s.execute("select.set", &json!({"ids": [group.0]})).unwrap(); s.execute("graphicStyle.apply", &json!({"name": "Blue"})).unwrap(); let top = *marks.last().unwrap(); s.execute("appearance.setItem", &json!({"ids": [top.0], "index": top_row(&s, top, true), "color": "#00ff00"})).unwrap(); let green = s.doc().unwrap().doc.node(top).unwrap().appearance.fill_paint(); s.execute("graphicStyle.redefine", &json!({})).unwrap(); let d = &s.doc().unwrap().doc; assert_eq!(d.graphic_style("Blue").unwrap().appearance.fill_paint(), green); assert!(marks.iter().all(|m| d.node(*m).unwrap().appearance.fill_paint() == green), "the marks still linked follow"); } /// A series group as the graph draws it, read as a source, gives the look of the topmost path directly inside it: New /// Graphic Style keeps that path's fill and links it, Move Appearance copies it, and a move clears every path of the /// group. #[test] fn a_series_group_read_as_a_source_gives_its_topmost_marks_look() { let (mut s, id, marks) = stored_red_stroke(); let (group, _) = series_0_group(&s, id); let top = *marks.last().unwrap(); s.execute("appearance.setItem", &json!({"ids": [group.0], "index": top_row(&s, marks[0], true), "color": "#0000ff", "opacity": 30})).unwrap(); s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#00ff00"})).unwrap(); let look = s.doc().unwrap().doc.node(top).unwrap().appearance.clone(); assert_ne!(look.fill_paint(), s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint()); s.execute("graphicStyle.new", &json!({"id": group.0, "name": "Series"})).unwrap(); let d = &s.doc().unwrap().doc; assert_eq!(d.graphic_style("Series").unwrap().appearance.fill_paint(), look.fill_paint()); assert_eq!(d.node(top).unwrap().extras().graphic_style, Some(d.graphic_style("Series").unwrap().id)); let rect = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()); s.execute("appearance.transfer", &json!({"source": group.0, "target": rect.0})).unwrap(); let d = &s.doc().unwrap().doc; assert_eq!(d.node(rect).unwrap().appearance.fill_paint(), look.fill_paint()); assert!(marks.iter().all(|m| d.node(*m).unwrap().appearance.fill_paint() == Paint::None), "the move clears each mark"); assert_eq!(spec(&s, id).series_paints[0].fill, Some(Paint::None)); } /// A series group that has a row of its own, as a document saved earlier can hold, is edited itself: the Appearance /// panel lists that row and edits it by its index. #[test] fn a_series_group_with_a_row_of_its_own_is_edited_itself() { let (mut s, id, _, marks) = column_marks(); let (group, _) = series_0_group(&s, id); s.edit("Row", |d, _| { let g = d.node_mut(group).unwrap(); g.appearance = vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 1.0).into(); Ok(()) }) .unwrap(); let was = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.clone(); s.execute("appearance.setItem", &json!({"ids": [group.0], "index": top_row(&s, group, true), "color": "#0000ff"})).unwrap(); let d = &s.doc().unwrap().doc; assert_ne!(d.node(group).unwrap().appearance.fill_paint(), Paint::None); assert_eq!(d.node(marks[0]).unwrap().appearance, was, "the marks keep their rows"); } /// A graphic style applied to a series group's id styles and links the paths directly inside it, and the graph stores /// the style's fill and stroke. #[test] fn a_graphic_style_applied_to_a_series_group_styles_its_marks() { let (mut s, id, marks) = stored_red_stroke(); let (group, _) = series_0_group(&s, id); let (_, blue) = blue_style(&mut s, "Blue", false); let was = spec(&s, id).series_paints; let own = json!({"ids": [group.0], "opacity": 50, "blend": "multiply", "isolate": true, "knockout": "on", "item": null}); s.execute("transparency.set", &own).unwrap(); s.execute("effect.apply", &json!({"ids": [group.0], "effect": "distort.twist"})).unwrap(); let g = s.doc().unwrap().doc.node(group).unwrap().clone(); assert!(g.isolate && g.knockout != Default::default() && g.blend != vectorcraft_color::BlendMode::Normal); s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [group.0]})).unwrap(); assert!(series_0_group(&s, id).1, "the group takes no rows of the style"); let g = s.doc().unwrap().doc.node(group).unwrap().clone(); assert_eq!((g.opacity, g.isolate), (1.0, false), "the style replaces the group's own opacity and isolation"); assert_eq!((g.blend, g.knockout), (vectorcraft_color::BlendMode::Normal, Default::default()), "and its blend mode and knockout"); assert!(g.appearance.effects.is_empty(), "and its effects"); let style = s.doc().unwrap().doc.graphic_style("Blue").unwrap().id; let d = &s.doc().unwrap().doc; assert!(marks.iter().all(|m| d.node(*m).unwrap().extras().graphic_style == Some(style)), "each mark is linked to the style"); assert_eq!(d.node(group).unwrap().extras().graphic_style, None); let kept = spec(&s, id).series_paints[0].clone(); assert_eq!((kept.fill.clone(), kept.fill_opacity, kept.stroke), (Some(blue.clone()), Some(0.5), Some(Paint::None))); series_0_is(&mut s, id, (&blue, 0.5, &Paint::None, 0.0), &was); } /// Redefine Graphic Style on a series group linked to the style (as a file can hold it) restyles the paths directly /// inside the group and links them, and the graph stores the new look; the group goes back to no rows of its own. #[test] fn redefining_a_graphic_style_restyles_the_marks_of_a_linked_series_group() { let (mut s, id, marks) = stored_red_stroke(); let (group, _) = series_0_group(&s, id); let (rect, _) = blue_style(&mut s, "Blue", false); let style = s.doc().unwrap().doc.graphic_style("Blue").unwrap().clone(); s.edit("Link", |d, _| { let g = d.node_mut(group).unwrap(); g.appearance = style.appearance.clone().into(); g.extras_mut().graphic_style = Some(style.id); Ok(()) }) .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!(series_0_group(&s, id).1, "the group has no rows of its own"); let d = &s.doc().unwrap().doc; assert_eq!(d.node(group).unwrap().extras().graphic_style, None); assert!(marks.iter().all(|m| d.node(*m).unwrap().extras().graphic_style == Some(style.id))); assert!(marks.iter().all(|m| d.node(*m).unwrap().appearance.fill_paint() == green)); let kept = spec(&s, id).series_paints[0].clone(); assert_eq!((kept.fill.clone(), kept.fill_opacity, kept.stroke), (Some(green.clone()), None, Some(Paint::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(); series_0_is(&mut s, id, (&green, 1.0, &Paint::None, 0.0), &was); } /// Redefine Graphic Style on a linked series group leaves a path of the group that is linked to the style but no /// longer has its look as it is, unlinked, and restyles the group's other paths. #[test] fn redefining_a_graphic_style_keeps_an_edited_mark_of_a_linked_series_group() { let (mut s, id, _, marks) = column_marks(); let (group, _) = series_0_group(&s, id); let (rect, _) = blue_style(&mut s, "Blue", false); let style = s.doc().unwrap().doc.graphic_style("Blue").unwrap().clone(); s.edit("Link", |d, _| { let g = d.node_mut(group).unwrap(); g.appearance = style.appearance.clone().into(); g.extras_mut().graphic_style = Some(style.id); d.node_mut(marks[0]).unwrap().extras_mut().graphic_style = Some(style.id); Ok(()) }) .unwrap(); let edited = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.clone(); 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(); s.execute("graphicStyle.redefine", &json!({"name": "Blue", "id": rect.0})).unwrap(); let d = &s.doc().unwrap().doc; let first = d.node(marks[0]).unwrap(); assert_eq!((&first.appearance, first.extras().graphic_style), (&edited, None), "the edited mark keeps its look, unlinked"); assert!(marks[1..].iter().all(|m| d.node(*m).unwrap().appearance.fill_paint() == green)); assert!(marks[1..].iter().all(|m| d.node(*m).unwrap().extras().graphic_style == Some(style.id))); } /// 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 column graph whose series 0 stores a red fill and a red 2 pt stroke set on its second mark, while the topmost fill /// (over a visible one) and the green stroke of its first mark are hidden: the graph and the first mark. fn hidden_top_rows() -> (Session, NodeId, NodeId) { 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(); let stroke = top_row(&s, marks[0], false); s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3, "visible": false})).unwrap(); s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#ff0000"})).unwrap(); s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], false), "color": "#ff0000", "weight": 2})).unwrap(); (s, id, marks[0]) } /// `paint.setFill`, `paint.setStroke`, `paint.none`, Apply Fill to Stroke and Apply Stroke to Fill write the topmost row /// of the kind even when it is hidden. The mark shows what it showed before, and the series keeps the paint it stored. #[test] fn a_paint_set_on_a_hidden_top_row_leaves_the_stored_paint() { let cases = [ ("paint.setFill", json!({"color": "#0000ff"})), ("paint.setFill", json!({"none": true})), ("paint.setStroke", json!({"color": "#0000ff"})), ("paint.setStroke", json!({"none": true})), ("paint.none", json!({})), ("addon.color.fillToStroke", json!({})), ("addon.color.strokeToFill", json!({})), ]; every_case(cases, |(cmd, params)| { let (mut s, id, mark) = hidden_top_rows(); let was = spec(&s, id).series_paints; let red = was[0].fill.clone().unwrap(); assert_eq!((was[0].stroke.clone(), was[0].stroke_width), (Some(red.clone()), Some(2.0))); let mut p = params.clone(); if *cmd == "paint.none" { s.execute("select.set", &json!({"ids": [mark.0]})).unwrap(); } else { p["ids"] = json!([mark.0]); } let before = s.doc().unwrap().doc.node(mark).unwrap().clone(); s.execute(cmd, &p).unwrap(); assert_ne!(*s.doc().unwrap().doc.node(mark).unwrap(), before, "the command wrote the hidden row"); assert_eq!(spec(&s, id).series_paints, was); s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); assert!(marks_of(&s, id, 0).iter().all(|m| *m == (red.clone(), red.clone(), 2.0)), "{:?}", marks_of(&s, id, 0)); }); } /// `paint.setFill` with `item` on the visible fill under a hidden topmost one changes what the mark shows, and the series /// stores it. On a fill under a visible topmost one, the series keeps the paint of the topmost one. #[test] fn a_paint_set_on_a_row_under_the_top_one_is_stored_only_when_it_shows() { let (mut s, id, mark) = hidden_top_rows(); let (lower, _) = fill_rows(&s, mark); s.execute("paint.setFill", &json!({"ids": [mark.0], "item": lower, "color": "#0000ff"})).unwrap(); let blue = s.doc().unwrap().doc.node(mark).unwrap().appearance.paint_at(Some(lower), true).cloned(); assert!(blue.as_ref().is_some_and(|p| !p.is_none())); assert_eq!(spec(&s, id).series_paints[0].fill, blue); s.execute("edit.undo", &json!({})).unwrap(); assert_ne!(spec(&s, id).series_paints[0].fill, blue); // The first mark has a green fill over another one, and the series stores the red fill of the second mark. let (mut s, id, _, marks) = column_marks(); s.execute("appearance.addFill", &json!({"ids": [marks[0].0]})).unwrap(); let (lower, upper) = fill_rows(&s, marks[0]); s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "color": "#00ff00"})).unwrap(); s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#ff0000"})).unwrap(); let red = Some(fill_of(&s, marks[1])); assert_ne!(red, Some(fill_of(&s, marks[0]))); assert_eq!(spec(&s, id).series_paints[0].fill, red); s.execute("paint.setFill", &json!({"ids": [marks[0].0], "item": lower, "color": "#0000ff"})).unwrap(); assert_ne!(s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.paint_at(Some(lower), true), red.as_ref()); assert_eq!(spec(&s, id).series_paints[0].fill, red); s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); assert!(marks_of(&s, id, 0).iter().all(|m| Some(&m.0) == red.as_ref()), "{:?}", marks_of(&s, id, 0)); } /// A library gradient applied to a selected mark goes to the topmost fill as `paint.setFill` puts it there: the series /// stores it from a visible row, and keeps its stored paint when that row is hidden. #[test] fn a_library_gradient_on_a_series_mark_is_stored_only_from_a_row_it_shows() { for hidden in [false, true] { let (mut s, id, mark) = hidden_top_rows(); if !hidden { s.execute("appearance.showAllHidden", &json!({"ids": [mark.0]})).unwrap(); } let lib = s.execute("library.create", &json!({})).unwrap()["id"].as_str().unwrap().to_string(); let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 20})).unwrap()["id"].clone(); let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]); s.execute("paint.setFill", &json!({"gradient": {"kind": "linear", "stops": stops}, "ids": [rect]})).unwrap(); s.execute("select.set", &json!({"ids": [rect]})).unwrap(); s.execute("library.add", &json!({"library": lib, "kind": "fillGradient"})).unwrap(); s.execute("select.set", &json!({"ids": [mark.0]})).unwrap(); let was = spec(&s, id).series_paints; s.execute("library.use", &json!({"library": lib, "kind": "fillGradient", "item": "Gradient"})).unwrap(); let fill = fill_of(&s, mark); assert!(matches!(fill, Paint::Gradient(_)), "{hidden}"); let now = spec(&s, id).series_paints; if hidden { assert_eq!(now, was); } else { assert_eq!(now[0].fill, Some(fill)); } } } /// A mark's fill and stroke rows already have the paint a command writes, while the series stores the paint of another /// mark. `paint.setFill`, `paint.setStroke`, Apply Fill to Stroke and Apply Stroke to Fill leave the mark as it was, and /// the series stores the mark's paint for the kind of row the command wrote, and only for that kind. #[test] fn writing_the_paint_a_mark_already_has_stores_it_for_the_series() { let cases = [ ("paint.setFill", json!({"color": "#ff0000"}), true), ("paint.setStroke", json!({"color": "#ff0000"}), false), ("addon.color.fillToStroke", json!({}), false), ("addon.color.strokeToFill", json!({}), true), ("addon.color.fillToStroke", json!({"skipNone": true}), false), ]; every_case(cases, |(cmd, params, fill)| { let (mut s, id, _, marks) = column_marks(); for (mark, hex, weight) in [(marks[0], "#ff0000", 3), (marks[1], "#0000ff", 2)] { s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap(); s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, false), "color": hex, "weight": weight})).unwrap(); } let was = spec(&s, id).series_paints; let (red, blue) = (fill_of(&s, marks[0]), fill_of(&s, marks[1])); assert_eq!((was[0].fill.clone(), was[0].stroke.clone(), was[0].stroke_width), (Some(blue.clone()), Some(blue.clone()), Some(2.0))); let before = s.doc().unwrap().doc.node(marks[0]).unwrap().clone(); let mut p = params.clone(); p["ids"] = json!([marks[0].0]); s.execute(cmd, &p).unwrap(); assert_eq!(*s.doc().unwrap().doc.node(marks[0]).unwrap(), before, "the mark is as it was"); let now = spec(&s, id).series_paints; let expected = if *fill { (Some(red), Some(blue), Some(2.0)) } else { (Some(blue), Some(red), Some(3.0)) }; assert_eq!((now[0].fill.clone(), now[0].stroke.clone(), now[0].stroke_width), expected); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(spec(&s, id).series_paints, was); }); } /// Apply Fill to Stroke and Apply Stroke to Fill on a mark store the paint they give its topmost visible row, and every /// mark of the series has it after the graph is drawn again. With `skipNone`, a mark without the source paint keeps what /// the series stores. #[test] fn applying_one_paint_kind_to_the_other_on_a_series_mark_is_kept() { let (mut s, id, marks) = stored_red_stroke(); s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#0000ff"})).unwrap(); let blue = fill_of(&s, marks[0]); s.execute("addon.color.fillToStroke", &json!({"ids": [marks[0].0]})).unwrap(); let kept = spec(&s, id).series_paints; assert_eq!((kept[0].fill.clone(), kept[0].stroke.clone()), (Some(blue.clone()), Some(blue.clone()))); s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); assert!(marks_of(&s, id, 0).iter().all(|m| (&m.0, &m.1) == (&blue, &blue)), "{:?}", marks_of(&s, id, 0)); let (mut s, id, marks) = stored_red_stroke(); let red = spec(&s, id).series_paints[0].stroke.clone(); s.execute("addon.color.strokeToFill", &json!({"ids": [marks[1].0]})).unwrap(); assert_eq!(spec(&s, id).series_paints[0].fill, red); s.execute("paint.setFill", &json!({"ids": [marks[0].0], "none": true})).unwrap(); s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#0000ff"})).unwrap(); let was = spec(&s, id).series_paints; s.execute("addon.color.fillToStroke", &json!({"ids": [marks[0].0], "skipNone": true})).unwrap(); assert_eq!(spec(&s, id).series_paints, was); } /// When one paint command writes several marks of a series with different paints, the frontmost mark sets what the /// graph stores, whatever order the ids are given in and whatever the marks' ids are. #[test] fn a_paint_command_on_several_marks_of_a_series_stores_the_frontmost_ones_paint() { every_case([[0, 1, 2], [2, 1, 0], [1, 2, 0]], |order| { let (mut s, id, _, marks) = column_marks(); // The mark made last goes to the back, so the marks' ids are not in paint order. s.execute("select.set", &json!({"ids": [marks[2].0]})).unwrap(); s.execute("object.arrange.sendToBack", &json!({})).unwrap(); let drawn: Vec = series_parts(&s, id, 0).iter().map(|m| m.id).filter(|m| marks[..3].contains(m)).collect(); assert_eq!(drawn, [marks[2], marks[0], marks[1]]); for (mark, hex) in [(marks[0], "#ff0000"), (marks[1], "#00ff00"), (marks[2], "#0000ff")] { s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap(); } s.execute("addon.color.fillToStroke", &json!({"ids": order.map(|i| marks[i].0)})).unwrap(); assert_eq!(spec(&s, id).series_paints[0].stroke, Some(fill_of(&s, marks[1])), "{order:?}"); }); // The frontmost mark loses its stroke and a mark under it takes one: the series stores the frontmost mark's. let (mut s, id, marks) = stored_red_stroke(); s.execute("appearance.setItem", &json!({"ids": [marks[2].0], "index": top_row(&s, marks[2], false), "color": "#00ff00", "weight": 2})).unwrap(); s.execute("paint.setFill", &json!({"ids": [marks[2].0], "none": true})).unwrap(); s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#0000ff"})).unwrap(); s.execute("addon.color.fillToStroke", &json!({"ids": [marks[0].0, marks[2].0]})).unwrap(); assert!(!s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.stroke_paint().is_none()); assert_eq!(spec(&s, id).series_paints[0].stroke, Some(Paint::None)); } /// Apply Fill to Stroke on two marks whose strokes already have their fills, while the series stores the front mark's /// stroke: the back mark's stroke is taken first and the front mark's after it, so the graph ends up storing what it /// stored before. The document and its undo history stay as they are. #[test] fn a_paint_command_that_leaves_a_series_stored_paint_as_it_was_records_no_undo_step() { let (mut s, id, _, marks) = column_marks(); for (mark, hex, weight) in [(marks[0], "#ff0000", 3), (marks[1], "#0000ff", 2)] { s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap(); s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, false), "color": hex, "weight": weight})).unwrap(); } let drawn: Vec = series_parts(&s, id, 0).iter().map(|m| m.id).filter(|m| marks[..2].contains(m)).collect(); assert_eq!(drawn, [marks[0], marks[1]], "the second mark is in front"); let was = s.doc().unwrap().doc.clone(); let blue = fill_of(&s, marks[1]); assert_eq!(spec(&s, id).series_paints[0].stroke, Some(blue)); let undo = s.doc().unwrap().history.undo.len(); s.execute("addon.color.fillToStroke", &json!({"ids": [marks[1].0, marks[0].0]})).unwrap(); assert_eq!(s.doc().unwrap().doc, was); assert_eq!(s.doc().unwrap().history.undo.len(), undo); } /// A stroke set on a Filled Lines band with `paint.setStroke` or Apply Fill to Stroke is not stored: the band keeps its /// color and its thin black outline when the graph is drawn again. A fill set with `paint.setFill` is stored. #[test] fn a_paint_command_stroke_on_a_filled_line_band_does_not_repaint_it() { every_case([("paint.setStroke", json!({"color": "#ff0000"})), ("addon.color.fillToStroke", json!({}))], |(cmd, params)| { let (mut s, id, band) = band_graph(); let was = series_parts(&s, id, 0)[0].appearance.fill_paint(); let mut p = params.clone(); p["ids"] = json!([band.0]); s.execute(cmd, &p).unwrap(); assert!(!s.doc().unwrap().doc.node(band).unwrap().appearance.stroke_paint().is_none()); 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)); }); let (mut s, id, band) = band_graph(); s.execute("paint.setFill", &json!({"ids": [band.0], "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)); } /// `paint.setStroke` with None on a marker that shows no stroke changes nothing, and the line keeps the stroke the series /// stores. On the line itself it stores the empty stroke. #[test] fn removing_the_stroke_of_a_mark_without_one_leaves_the_stored_stroke() { let (mut s, id, line, marker) = line_and_marker(); s.execute("paint.setStroke", &json!({"ids": [line.0], "color": "#ff0000"})).unwrap(); let red = s.doc().unwrap().doc.node(line).unwrap().appearance.stroke_paint(); assert_eq!(spec(&s, id).series_paints[0].stroke, Some(red.clone())); s.execute("paint.setStroke", &json!({"ids": [marker.0], "none": true})).unwrap(); assert_eq!(spec(&s, id).series_paints[0].stroke, Some(red.clone())); s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); assert_eq!(line_parts(&s, id).0.appearance.stroke_paint(), red); let line = line_parts(&s, id).0.id; s.execute("paint.setStroke", &json!({"ids": [line.0], "none": true})).unwrap(); assert_eq!(spec(&s, id).series_paints[0].stroke, Some(Paint::None)); } /// Run `case` on each of `cases`, and fail naming every case that failed, not only the first one. fn every_case(cases: impl IntoIterator, case: impl Fn(&T)) { let failed: Vec = cases.into_iter().filter(|c| std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| case(c))).is_err()).collect(); assert!(failed.is_empty(), "failed cases: {failed:?}"); } /// The top fill paint of `mark`. fn fill_of(s: &Session, mark: NodeId) -> Paint { s.doc().unwrap().doc.node(mark).unwrap().appearance.fill_paint() } /// A stroke weight set with `stroke.set`, or stepped with `stroke.weightUp` or `stroke.weightDown`, on a series mark is /// stored on the graph with the mark's stroke paint, and undo takes it back with its edit. #[test] fn a_stroke_weight_set_on_a_series_mark_is_kept() { let cases = [("stroke.set", json!({"weight": 7}), 7.0), ("stroke.weightUp", json!({}), 3.0), ("stroke.weightDown", json!({}), 1.0)]; every_case(cases, |(cmd, params, weight)| { let (mut s, id, marks) = stored_red_stroke(); let was = spec(&s, id).series_paints; let mut p = params.clone(); p["ids"] = json!([marks[1].0]); s.execute(cmd, &p).unwrap(); let mark = s.doc().unwrap().doc.node(marks[1]).unwrap().clone(); let (fill, stroke) = (mark.appearance.fill_paint(), mark.appearance.stroke_paint()); let kept = spec(&s, id).series_paints[0].clone(); assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(stroke.clone()), Some(*weight)), "{cmd}"); series_0_is(&mut s, id, (&fill, 0.4, &stroke, *weight), &was); }); } /// A gradient made on a series mark's fill with `paint.editGradient` is stored on the graph, every mark of the series has /// that fill after the graph is drawn again, and undo takes it back with its edit. #[test] fn a_gradient_edit_on_a_series_mark_is_kept() { let (mut s, id, marks) = stored_red_stroke(); let was = spec(&s, id).series_paints; s.execute("paint.editGradient", &json!({"ids": [marks[0].0], "kind": "linear"})).unwrap(); let fill = fill_of(&s, marks[0]); assert!(matches!(fill, Paint::Gradient(_)), "{fill:?}"); assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone())); let red = was[0].stroke.clone().unwrap(); series_0_is(&mut s, id, (&fill, 0.4, &red, 2.0), &was); } /// A gradient placed on a series mark's fill with `paint.setGradientGeom` is stored on the graph as it is placed, and /// every mark of the series has that fill, placement included, after the graph is drawn again. #[test] fn a_gradient_placed_on_a_series_mark_is_kept_with_its_placement() { let (mut s, id, _, marks) = column_marks(); // `paint.setFill` stores the gradient before it is placed. s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "linear"}})).unwrap(); let fill = fill_of(&s, marks[0]); assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone())); s.execute("paint.setGradientGeom", &json!({"ids": [marks[0].0], "start": [0, 0], "end": [10, 40]})).unwrap(); let placed = fill_of(&s, marks[0]); assert_ne!(placed, fill, "the command places the gradient"); assert_eq!(spec(&s, id).series_paints[0].fill, Some(placed.clone())); s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == placed), "{:?}", marks_of(&s, id, 0)); } /// A point recolored with `paint.freeform.setPoint` in a freeform gradient on a series mark's fill is stored on the graph. #[test] fn a_freeform_gradient_edit_on_a_series_mark_is_kept() { let (mut s, id, _, marks) = column_marks(); let points = json!([{"at": [20, 20], "color": "#53aadf"}, {"at": [50, 50], "color": "#ff8000"}]); // `paint.setFill` stores the gradient before its point is recolored. s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "freeform", "freeform": {"points": points}}})).unwrap(); let fill = fill_of(&s, marks[0]); assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone())); s.execute("paint.freeform.setPoint", &json!({"ids": [marks[0].0], "index": 1, "color": "#00ff00"})).unwrap(); let edited = fill_of(&s, marks[0]); assert_ne!(edited, fill, "the command recolors the point"); assert_eq!(spec(&s, id).series_paints[0].fill, Some(edited.clone())); s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == edited), "{:?}", marks_of(&s, id, 0)); } /// A series mark's fill recolored with Invert or Complement (the proxy, or one appearance row), an Edit Colors command, /// a color filter or Recolor Artwork is stored on the graph, and undo takes it back with its edit. #[test] fn a_recolored_series_mark_is_kept() { let cases = [ ("paint.invert", json!({}), false), ("paint.complement", json!({}), false), ("paint.invert", json!({}), true), ("paint.complement", json!({}), true), ("edit.colors.invert", json!({}), false), ("edit.colors.toGrayscale", json!({}), false), ("addon.color.filter", json!({"effect": "addon.color.invert"}), false), ("recolor.apply", json!({"map": {"#3366cc": "#00ff00"}}), false), ]; every_case(cases, |(cmd, params, item)| { let (mut s, id, marks) = stored_red_stroke(); // Not the gray the series starts in: Complement leaves a gray as it is. s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#3366cc"})).unwrap(); let was = spec(&s, id).series_paints; let mut p = params.clone(); if *cmd == "recolor.apply" { // Recolor Artwork works on the selection. s.execute("select.set", &json!({"ids": [marks[0].0]})).unwrap(); } else { p["ids"] = json!([marks[0].0]); } if *item { p["item"] = json!(top_row(&s, marks[0], true)); } let was_fill = fill_of(&s, marks[0]); s.execute(cmd, &p).unwrap(); let fill = fill_of(&s, marks[0]); assert_ne!(fill, was_fill, "{cmd} recolors the fill"); assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone()), "{cmd} item {item}"); let red = was[0].stroke.clone().unwrap(); series_0_is(&mut s, id, (&fill, 0.4, &red, 2.0), &was); }); } /// The ink limit of Print Ink Fixes on a series mark's fill is stored on the graph. #[test] fn an_ink_limit_on_a_series_mark_is_kept() { let mut s = Session::new(); s.execute("file.new", &json!({"colorMode": "cmyk"})).unwrap(); let id = graph(&mut s, "column"); let mark = series(&s.doc().unwrap().doc.node(id).unwrap().clone(), 0).children().unwrap()[0].id; // `paint.setFill` stores the heavy color before the limit lowers it. s.execute("paint.setFill", &json!({"ids": [mark.0], "color": {"c": 100, "m": 100, "y": 100, "k": 100}})).unwrap(); let heavy = fill_of(&s, mark); assert_eq!(spec(&s, id).series_paints[0].fill, Some(heavy.clone())); s.execute("addon.color.printFixes", &json!({"ids": [mark.0], "inkLimit": 300})).unwrap(); let fill = fill_of(&s, mark); assert_ne!(fill, heavy, "the limit lowers the ink"); assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone())); s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == fill), "{:?}", marks_of(&s, id, 0)); } /// A recolor of a whole graph, or of one series group, stores the new fill of each series it reaches, and the marks have /// it after the graph is drawn again. #[test] fn recoloring_a_graph_or_a_series_group_stores_its_marks_fills() { let cases = [("edit.colors.invert", true), ("edit.colors.invert", false), ("paint.invert", true), ("paint.invert", false)]; every_case(cases, |(cmd, whole)| { let (mut s, id, gray, _) = column_marks(); let n = s.doc().unwrap().doc.node(id).unwrap().clone(); let (group, other) = (series(&n, 0).id, series(&n, 1).children().unwrap()[0].clone()); let target = if *whole { id } else { group }; s.execute(cmd, &json!({"ids": [target.0]})).unwrap(); let fill = marks_of(&s, id, 0).last().unwrap().0.clone(); assert_ne!(fill, gray, "{cmd} recolors the series"); let kept = spec(&s, id).series_paints; assert_eq!(kept[0].fill, Some(fill.clone()), "{cmd} whole {whole}"); let other_now = fill_of(&s, other.id); if *whole { assert_ne!(other_now, other.appearance.fill_paint()); assert_eq!(kept[1].fill, Some(other_now.clone())); } else { assert_eq!(other_now, other.appearance.fill_paint(), "the other series is left as it was"); assert!(kept.get(1).is_none_or(|p| p.fill.is_none()), "{kept:?}"); } s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == fill), "{:?}", marks_of(&s, id, 0)); assert!(marks_of(&s, id, 1).iter().all(|m| m.0 == other_now), "{:?}", marks_of(&s, id, 1)); }); } /// A recolor that changes several marks of one series to different fills stores the frontmost mark's fill, whatever /// order the ids are given in. #[test] fn recoloring_several_marks_of_a_series_stores_the_frontmost_ones_fill() { let cases = [("edit.colors.invert", false), ("paint.invert", true)].into_iter().flat_map(|c| [[0, 1, 2], [2, 1, 0], [1, 2, 0]].map(|o| (c.0, c.1, o))); every_case(cases, |(cmd, item, order)| { let (mut s, id, _, marks) = column_marks(); for (mark, hex) in [(marks[0], "#ff0000"), (marks[1], "#00ff00"), (marks[2], "#0000ff")] { s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap(); } let mut p = json!({"ids": order.map(|i| marks[i].0)}); if *item { p["item"] = json!(top_row(&s, marks[0], true)); } s.execute(cmd, &p).unwrap(); assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill_of(&s, marks[2])), "{cmd} {order:?}"); }); } /// Blend Horizontally across a series' three marks stores the fill it gives the middle one on the graph. #[test] fn blending_a_series_marks_fills_is_kept() { let (mut s, id, marks) = stored_red_stroke(); for (mark, hex) in [(marks[0], "#ff0000"), (marks[2], "#0000ff")] { s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap(); } let was = spec(&s, id).series_paints; s.execute("select.set", &json!({"ids": [marks[0].0, marks[1].0, marks[2].0]})).unwrap(); s.execute("edit.colors.blendHorizontally", &json!({})).unwrap(); let mid = s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.fill_paint(); assert_ne!(Some(&mid), was[0].fill.as_ref()); assert_eq!(spec(&s, id).series_paints[0].fill, Some(mid.clone())); let red = was[0].stroke.clone().unwrap(); series_0_is(&mut s, id, (&mid, 0.4, &red, 2.0), &was); } /// 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::TempDir::new("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!["#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::::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 { 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 = 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 = 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 = 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 dir = vectorcraft_testkit::TempDir::new("graph-kinds"); let path = dir.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::().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 = 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::>(), 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::>(); 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 dir = vectorcraft_testkit::TempDir::new("graph-axes"); let path = dir.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::>(); (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 { 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 { 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::().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 = 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 dir = vectorcraft_testkit::TempDir::new("graph-ticks"); let path = dir.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 = 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})).unwrap(); let g = spec(&s, id); assert_eq!((g.category_tick_marks, g.right_tick_marks, g.right_ticks), (1, 20, 0)); // A negative count is refused. s.execute("graph.setType", &json!({"rightTicks": -2.0})).unwrap_err(); assert_eq!(spec(&s, id).right_ticks, 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> = (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 = 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 { 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 dir = vectorcraft_testkit::TempDir::new("graph-designs"); let path = dir.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 = (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> = (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 = (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> = 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 { 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 dir = vectorcraft_testkit::TempDir::new("graph-column-designs"); let path = dir.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 { 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]) -> 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::>(), 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::>(), ["-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::>(), ["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::>(), [("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 { 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::>(), [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 = 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 dir = vectorcraft_testkit::TempDir::new("graph-pie"); let path = dir.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 = 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 = 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> = (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 = (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 = (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 = (0..6).map(|i| swatch(&s, i).x1).collect(); assert!(xs.iter().all(|x| *x <= 400.0), "{xs:?}"); let dir = vectorcraft_testkit::TempDir::new("graph-top"); let path = dir.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 = 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 = 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 = (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 { 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::>(); 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 dir = vectorcraft_testkit::TempDir::new("graph-filled"); let path = dir.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> = (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()); } /// An anti-aliasing method given to one category label is kept for every category label when the graph is drawn /// again, through save and open, and undoes with the change. #[test] fn a_graph_labels_anti_aliasing_method_survives_it_being_drawn_again() { use vectorcraft_doc::text::TypeAntiAlias; let methods = |s: &Session, id: NodeId| -> Vec<(String, TypeAntiAlias)> { 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 { out.push((t.plain_text(), t.anti_alias)); } } } out }; let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); let id = graph(&mut s, "column"); let c = text_id(&s, id, "Axes", "Q2"); s.execute("text.setFormat", &json!({"ids": [c.0], "antiAlias": "strong"})).unwrap(); label_looks(&mut s, id); let drawn = methods(&s, id); let method = |t: &str| drawn.iter().find(|(x, _)| x == t).map(|(_, m)| *m).unwrap(); assert_eq!((method("Q1"), method("Q3")), (TypeAntiAlias::Strong, TypeAntiAlias::Strong), "every category label: {drawn:?}"); assert_eq!((method("0"), method("2024")), (TypeAntiAlias::Sharp, TypeAntiAlias::Sharp), "other labels: {drawn:?}"); // One undo takes the data change back, the next the method and the kept method together. s.execute("edit.undo", &json!({})).unwrap(); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(spec(&s, id).label_styles.categories_anti_alias, None); s.execute("edit.redo", &json!({})).unwrap(); let dir = vectorcraft_testkit::TempDir::new("graph-label-anti-alias"); let path = dir.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_anti_alias, Some(TypeAntiAlias::Strong)); assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("labelStyles").is_none()); } /// 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 dir = vectorcraft_testkit::TempDir::new("graph-labels"); let path = dir.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()); } /// Each Axes and Legend text after the graph is drawn again, with its object rows (fill or stroke, color, opacity, and /// for a stroke its weight) and its object opacity. type RowLooks = Vec<(bool, String, f32, f64)>; fn redrawn_label_rows(s: &mut Session, id: NodeId) -> Vec<(String, RowLooks, f32)> { 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 rows = c .appearance .items .iter() .map(|it| { let w = match it { vectorcraft_doc::AppearanceItem::Stroke(st) => st.width, vectorcraft_doc::AppearanceItem::Fill(_) => 0.0, }; (it.is_fill(), it.paint().color().map(|c| c.to_hex()).unwrap_or_default(), it.opacity(), w) }) .collect(); out.push((t.plain_text(), rows, c.opacity)); } } } out } fn rows_of(looks: &[(String, RowLooks, f32)], text: &str) -> (RowLooks, f32) { looks.iter().find(|(t, _, _)| t == text).map(|(_, r, o)| (r.clone(), *o)).unwrap_or_else(|| panic!("no label {text}")) } /// Fill and stroke rows given to one label with the Appearance commands are kept for every label of its kind when the /// graph is drawn again, through save and open, and undo with the change. #[test] fn appearance_rows_on_one_label_are_kept_for_its_kind() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); let id = graph(&mut s, "column"); // A category label: a red fill row at 50%. let c = text_id(&s, id, "Axes", "Q2"); s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000", "opacity": 50})).unwrap(); // A value axis number: a 2 pt blue stroke row. let v = text_id(&s, id, "Axes", "4"); s.execute("appearance.addStroke", &json!({"ids": [v.0]})).unwrap(); s.execute("appearance.setItem", &json!({"ids": [v.0], "index": 0, "color": "#0000ff", "weight": 2})).unwrap(); let looks = redrawn_label_rows(&mut s, id); assert_eq!(rows_of(&looks, "Q1"), (vec![(true, "#ff0000".into(), 0.5, 0.0)], 1.0), "every category label: {looks:?}"); assert_eq!(rows_of(&looks, "0"), (vec![(false, "#0000ff".into(), 1.0, 2.0)], 1.0), "every value axis number: {looks:?}"); assert_eq!(rows_of(&looks, "2024"), (vec![], 1.0), "the legend keeps none: {looks:?}"); // One undo takes the data change back, the next the stroke's color and weight together with what the graph keeps. s.execute("edit.undo", &json!({})).unwrap(); s.execute("edit.undo", &json!({})).unwrap(); let kept = spec(&s, id).label_styles.values_rows.unwrap(); assert_ne!(kept.paint.stroke.and_then(|p| p.color()).map(|c| c.to_hex()), Some("#0000ff".into())); assert_eq!(kept.paint.stroke_width, Some(1.0)); s.execute("edit.redo", &json!({})).unwrap(); let path = vectorcraft_testkit::temp_dir("graph-label-rows").join("labels.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.label_styles.categories_rows.unwrap().paint.fill_opacity, Some(0.5)); assert_eq!(g.label_styles.values_rows.unwrap().paint.stroke_width, Some(2.0)); assert!(serde_json::to_value(vectorcraft_doc::LabelStyles::default()).unwrap().as_object().unwrap().is_empty()); } /// Removing a label's rows, clearing its appearance or hiding its row leaves its kind with what the label then shows. #[test] fn removing_a_labels_rows_changes_what_its_kind_keeps() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); let id = graph(&mut s, "column"); let c = text_id(&s, id, "Axes", "Q2"); s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000"})).unwrap(); s.execute("appearance.removeItem", &json!({"ids": [c.0], "index": 0})).unwrap(); assert_eq!(spec(&s, id).label_styles.categories_rows, None, "no row left"); s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000", "visible": false})).unwrap(); assert_eq!(spec(&s, id).label_styles.categories_rows, None, "a hidden row shows nothing"); s.execute("appearance.showAllHidden", &json!({"ids": [c.0]})).unwrap(); let looks = redrawn_label_rows(&mut s, id); assert_eq!(rows_of(&looks, "Q3").0, vec![(true, "#ff0000".into(), 1.0, 0.0)], "{looks:?}"); let c = text_id(&s, id, "Axes", "Q1"); s.execute("appearance.clear", &json!({"ids": [c.0]})).unwrap(); let looks = redrawn_label_rows(&mut s, id); assert_eq!(rows_of(&looks, "Q3").0, vec![(true, String::new(), 1.0, 0.0), (false, String::new(), 1.0, 1.0)], "{looks:?}"); } /// A fill row painted with `paint.setFill {item}` is kept with the label's rows, and its characters keep their own fill. #[test] fn painting_a_labels_row_keeps_the_row_and_not_its_characters() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); let id = graph(&mut s, "column"); let l = text_id(&s, id, "Legend", "2025"); s.execute("appearance.addFill", &json!({"ids": [l.0]})).unwrap(); s.execute("paint.setFill", &json!({"ids": [l.0], "item": 0, "color": "#00ff00"})).unwrap(); let g = spec(&s, id); assert_eq!(g.label_styles.legend_rows.and_then(|r| r.paint.fill).and_then(|p| p.color()).map(|c| c.to_hex()), Some("#00ff00".into())); assert_eq!(g.label_styles.legend, None, "the characters keep the default fill"); let looks = label_looks(&mut s, id); assert!(looks.iter().find(|(t, _, _)| t == "2024").is_some_and(|(_, _, fill)| fill == "#000000"), "{looks:?}"); } /// Moving or copying an appearance onto a label (Layers panel drag) keeps the source's rows and object opacity for the /// label's kind. #[test] fn an_appearance_moved_onto_a_label_is_kept_for_its_kind() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); let id = graph(&mut s, "column"); let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 30, "height": 40})).unwrap()["id"].as_u64().unwrap()); s.execute("paint.setFill", &json!({"ids": [r.0], "color": "#12ab34"})).unwrap(); s.execute("transparency.set", &json!({"ids": [r.0], "opacity": 40})).unwrap(); let v = text_id(&s, id, "Axes", "2"); s.execute("appearance.transfer", &json!({"source": r.0, "target": v.0, "copy": true})).unwrap(); let looks = redrawn_label_rows(&mut s, id); let (rows, opacity) = rows_of(&looks, "6"); assert_eq!((rows.first().map(|r| r.1.clone()), opacity), (Some("#12ab34".into()), 0.4), "{looks:?}"); } /// The Eyedropper onto a label keeps the characters' style, the paragraph attributes and the appended rows it gave the /// label for every label of its kind. #[test] fn the_eyedropper_on_a_label_is_kept_for_its_kind() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); let id = graph(&mut s, "column"); let src = NodeId(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "x", "size": 14})).unwrap()["id"].as_u64().unwrap()); s.execute("paint.setFill", &json!({"ids": [src.0], "color": "#00ff00"})).unwrap(); let v = text_id(&s, id, "Axes", "4"); let steps = s.doc().unwrap().history.undo.len(); s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [v.0]})).unwrap(); assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one step"); let looks = label_looks(&mut s, id); assert!(looks.iter().any(|(t, size, fill)| t == "0" && *size == 14.0 && fill == "#00ff00"), "every value axis number: {looks:?}"); assert!(looks.iter().any(|(t, size, fill)| t == "Q1" && *size == 9.0 && fill == "#000000"), "other labels: {looks:?}"); // Appended rows (Shift+Alt-click) go with the label's rows. let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 30, "height": 40})).unwrap()["id"].as_u64().unwrap()); s.execute("paint.setFill", &json!({"ids": [r.0], "color": "#abcdef"})).unwrap(); let c = text_id(&s, id, "Axes", "Q1"); s.execute("appearance.copyFrom", &json!({"source": r.0, "ids": [c.0], "append": true})).unwrap(); let looks = redrawn_label_rows(&mut s, id); assert!(rows_of(&looks, "Q3").0.iter().any(|(fill, hex, _, _)| *fill && hex == "#abcdef"), "{looks:?}"); } /// Kept rows read from a saved graph's label styles are held to the ranges the controls keep to, and a place for the /// Characters row past the kept rows puts every row below the characters. #[test] fn kept_label_rows_from_a_file_stay_in_range() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); let id = graph(&mut s, "column"); let paint = |hex: &str| serde_json::to_value(Paint::solid(vectorcraft_color::Color::from_hex(hex).unwrap())).unwrap(); let saved = json!({"legendRows": { "fill": paint("#ff0000"), "fillOpacity": 7.0, "stroke": paint("#0000ff"), "strokeWidth": 1e9, "strokeOpacity": -2.0, "opacity": -3.0, "below": 9, }}); let styles: vectorcraft_doc::LabelStyles = serde_json::from_value(saved).unwrap(); s.edit("rows", |d, _| { d.node_mut(id).unwrap().extras_mut().graph.as_deref_mut().unwrap().label_styles = styles; Ok(()) }) .unwrap(); let looks = redrawn_label_rows(&mut s, id); assert_eq!(rows_of(&looks, "2024"), (vec![(true, "#ff0000".into(), 1.0, 0.0), (false, "#0000ff".into(), 0.0, 1000.0)], 0.0), "{looks:?}"); assert_eq!(label_node(&s, id, "Legend", "2024").appearance.contents_at(), 2); } /// Label `text` of group `grp` of graph `id`. fn label_node(s: &Session, id: NodeId, grp: &str, text: &str) -> vectorcraft_doc::Node { s.doc().unwrap().doc.node(text_id(s, id, grp, text)).unwrap().clone() } /// An edit that leaves a label showing what it showed still sets what its kind keeps. #[test] fn a_label_already_showing_a_row_still_sets_what_its_kind_keeps() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); let id = graph(&mut s, "column"); let c = text_id(&s, id, "Axes", "Q2"); s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000"})).unwrap(); redrawn_label_rows(&mut s, id); let a = text_id(&s, id, "Axes", "Q1"); s.execute("appearance.setItem", &json!({"ids": [a.0], "index": 0, "color": "#0000ff"})).unwrap(); // Q3 shows the red the graph drew it with. let b = text_id(&s, id, "Axes", "Q3"); s.execute("appearance.setItem", &json!({"ids": [b.0], "index": 0, "color": "#ff0000"})).unwrap(); let looks = redrawn_label_rows(&mut s, id); assert_eq!(rows_of(&looks, "Q1").0, vec![(true, "#ff0000".into(), 1.0, 0.0)], "{looks:?}"); } /// Paint given to a label's characters leaves the rows its kind keeps as they are. #[test] fn paint_on_a_labels_characters_leaves_the_rows_its_kind_keeps() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); let id = graph(&mut s, "column"); let c = text_id(&s, id, "Axes", "Q2"); s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000"})).unwrap(); redrawn_label_rows(&mut s, id); let a = text_id(&s, id, "Axes", "Q1"); s.execute("appearance.setItem", &json!({"ids": [a.0], "index": 0, "color": "#0000ff"})).unwrap(); // Q3 still shows red; its characters take green. let b = text_id(&s, id, "Axes", "Q3"); s.execute("paint.setFill", &json!({"ids": [b.0], "color": "#00ff00"})).unwrap(); s.execute("paint.setStroke", &json!({"ids": [b.0], "color": "#00ff00"})).unwrap(); // The Eyedropper from type, transparency included, onto its characters. let src = NodeId(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "x", "size": 14})).unwrap()["id"].as_u64().unwrap()); s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [b.0]})).unwrap(); let looks = redrawn_label_rows(&mut s, id); assert_eq!(rows_of(&looks, "Q1").0, vec![(true, "#0000ff".into(), 1.0, 0.0)], "{looks:?}"); } /// When labels of one kind are edited together, the last one in the order of `ids` sets what the kind keeps. #[test] fn the_last_label_of_a_kind_edited_together_sets_what_it_keeps() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); let id = graph(&mut s, "column"); let (a, b) = (text_id(&s, id, "Axes", "Q1"), text_id(&s, id, "Axes", "Q2")); for (l, color) in [(a, "#ff0000"), (b, "#0000ff")] { s.execute("appearance.addFill", &json!({"ids": [l.0]})).unwrap(); s.execute("appearance.setItem", &json!({"ids": [l.0], "index": 0, "color": color})).unwrap(); } let kept = |s: &Session| spec(s, id).label_styles.categories_rows.unwrap().paint.fill.unwrap().color().unwrap().to_hex(); s.execute("appearance.addFill", &json!({"ids": [a.0, b.0]})).unwrap(); assert_eq!(kept(&s), "#0000ff"); s.execute("appearance.addFill", &json!({"ids": [b.0, a.0]})).unwrap(); assert_eq!(kept(&s), "#ff0000"); } /// A label's object opacity and blend mode set without a row (`transparency.set` without `item`) are kept for its kind. #[test] fn a_labels_object_opacity_and_blend_mode_are_kept_for_its_kind() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); let id = graph(&mut s, "column"); // Another value axis number sets the kind's fill row; the label below has none, and the kind keeps the row. let u = text_id(&s, id, "Axes", "2"); s.execute("appearance.addFill", &json!({"ids": [u.0]})).unwrap(); s.execute("appearance.setItem", &json!({"ids": [u.0], "index": 0, "color": "#0000ff"})).unwrap(); let v = text_id(&s, id, "Axes", "4"); s.execute("transparency.set", &json!({"ids": [v.0], "opacity": 50, "blend": "multiply"})).unwrap(); let looks = redrawn_label_rows(&mut s, id); assert_eq!(rows_of(&looks, "0"), (vec![(true, "#0000ff".into(), 1.0, 0.0)], 0.5), "{looks:?}"); assert_eq!(label_node(&s, id, "Axes", "0").blend, BlendMode::Multiply); assert_eq!(label_node(&s, id, "Axes", "Q1").blend, BlendMode::Normal); // Another label of the kind sets them again. let w = text_id(&s, id, "Axes", "2"); s.execute("transparency.set", &json!({"ids": [w.0], "opacity": 50, "blend": "screen"})).unwrap(); assert_eq!(label_node(&s, id, "Axes", "0").blend, BlendMode::Multiply); let looks = redrawn_label_rows(&mut s, id); assert_eq!(rows_of(&looks, "0").1, 0.5, "{looks:?}"); assert_eq!(label_node(&s, id, "Axes", "0").blend, BlendMode::Screen); } /// Stroke options given to a label's stroke row, a fill row painted as the Appearance panel's active row, and a row's /// blend mode are kept for the label's kind. #[test] fn stroke_options_and_the_active_row_on_a_label_are_kept_for_its_kind() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); let id = graph(&mut s, "column"); let v = text_id(&s, id, "Axes", "4"); s.execute("appearance.addStroke", &json!({"ids": [v.0]})).unwrap(); s.execute("stroke.set", &json!({"ids": [v.0], "item": 0, "weight": 3})).unwrap(); let c = text_id(&s, id, "Axes", "Q2"); s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); s.execute("select.set", &json!({"ids": [c.0]})).unwrap(); s.execute("appearance.setActiveItem", &json!({"index": 0})).unwrap(); s.execute("paint.setFill", &json!({"color": "#00ffff"})).unwrap(); s.execute("transparency.set", &json!({"item": 0, "blend": "multiply"})).unwrap(); let looks = redrawn_label_rows(&mut s, id); assert_eq!(rows_of(&looks, "0").0.first().map(|r| r.3), Some(3.0), "{looks:?}"); assert_eq!(rows_of(&looks, "Q1").0.first().map(|r| r.1.clone()), Some("#00ffff".into()), "{looks:?}"); let q1 = label_node(&s, id, "Axes", "Q1"); assert_eq!(q1.appearance.items.first().map(|it| it.blend()), Some(BlendMode::Multiply)); let NodeKind::Text(t) = &q1.kind else { panic!("not type") }; assert_eq!(t.runs[0].style.fill.color().map(|c| c.to_hex()), Some("#000000".into()), "the characters keep their fill"); } /// A row below the Characters row stays below the characters when the graph is drawn again. #[test] fn a_kept_row_keeps_its_place_below_the_characters() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); let id = graph(&mut s, "column"); let c = text_id(&s, id, "Axes", "Q2"); s.execute("appearance.addStroke", &json!({"ids": [c.0]})).unwrap(); s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); // The stroke below the characters, the fill above them. s.execute("appearance.moveItem", &json!({"ids": [c.0], "from": "contents", "to": 1})).unwrap(); let look = |ap: &vectorcraft_doc::Appearance| (ap.contents_at(), ap.items.iter().map(|it| it.is_fill()).collect::>()); assert_eq!(look(&label_node(&s, id, "Axes", "Q2").appearance), (1, vec![false, true])); redrawn_label_rows(&mut s, id); assert_eq!(look(&label_node(&s, id, "Axes", "Q1").appearance), (1, vec![false, true])); } /// The Eyedropper keeps for a label's kind only the attributes it applied: a fill the label had of its own stays out. #[test] fn the_eyedropper_keeps_only_what_it_applied_for_a_labels_kind() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); let id = graph(&mut s, "column"); let src = NodeId(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "x", "size": 14})).unwrap()["id"].as_u64().unwrap()); s.execute("paint.setFill", &json!({"ids": [src.0], "color": "#00ff00"})).unwrap(); let v = text_id(&s, id, "Axes", "4"); // A fill of the label's own, as the generator gives labels on dark pie wedges. s.edit("own fill", |d, _| { if let NodeKind::Text(t) = &mut d.node_mut(v).unwrap().kind { t.runs.iter_mut().for_each(|r| r.style.fill = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0))); } Ok(()) }) .unwrap(); // A pick of transparency alone leaves the kind's character style unset. let transparency = json!({"character": false, "paragraph": false, "appearance": {"fill": false, "stroke": false}}); s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [v.0], "apply": transparency})).unwrap(); assert_eq!(spec(&s, id).label_styles.values, None); s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [v.0], "apply": {"appearance": false}})).unwrap(); let looks = label_looks(&mut s, id); assert!(looks.iter().any(|(t, size, fill)| t == "0" && *size == 14.0 && fill == "#000000"), "{looks:?}"); } /// Paint given to all of a label's text highlighted with the Type tool is kept for its kind, as for the whole label. #[test] fn paint_on_a_labels_highlighted_text_is_kept_for_its_kind() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); let id = graph(&mut s, "column"); let v = text_id(&s, id, "Axes", "4"); s.select_tool("type", crate::ViewInfo::default()).unwrap(); s.set_tool_option("textSelection", &json!({"id": v.0, "start": 0, "end": 1})); s.execute("paint.setFill", &json!({"color": "#ff8800"})).unwrap(); s.select_tool("selection", crate::ViewInfo::default()).unwrap(); let looks = label_looks(&mut s, id); assert!(looks.iter().any(|(t, _, fill)| t == "0" && fill == "#ff8800"), "{looks:?}"); } /// Paint given to the whole graph without a row is kept for the characters of every kind of label. #[test] fn paint_on_the_whole_graph_is_kept_for_every_kind_of_label() { 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("paint.setFill", &json!({"color": "#ff00ff"})).unwrap(); let looks = label_looks(&mut s, id); for t in ["0", "Q1", "2024"] { assert!(looks.iter().any(|(x, _, fill)| x == t && fill == "#ff00ff"), "{t}: {looks:?}"); } } /// A gradient placed on one label's characters or on its row is kept without that placement, so it fits each label of /// the kind when the graph is drawn again. #[test] fn a_placed_gradient_on_a_label_is_kept_to_fit_each_label() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); let id = graph(&mut s, "column"); let gradient = json!({"start": [30, 60], "end": [90, 60]}); let v = text_id(&s, id, "Axes", "4"); s.execute("paint.setFill", &json!({"ids": [v.0], "gradient": gradient})).unwrap(); let c = text_id(&s, id, "Axes", "Q2"); s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); s.execute("paint.setFill", &json!({"ids": [c.0], "item": 0, "gradient": gradient})).unwrap(); let placed = |p: &Paint| matches!(p, Paint::Gradient(g) if g.geom.is_some()); let g = spec(&s, id); assert!(g.label_styles.values.is_some_and(|st| matches!(st.fill, Paint::Gradient(_)) && !placed(&st.fill))); assert!(g.label_styles.categories_rows.and_then(|r| r.paint.fill).is_some_and(|f| matches!(f, Paint::Gradient(_)) && !placed(&f))); } /// A graphic style applied to a label is kept with the label's rows for its kind. #[test] fn a_graphic_style_on_a_label_is_kept_for_its_kind() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); let id = graph(&mut s, "column"); let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 30, "height": 40})).unwrap()["id"].as_u64().unwrap()); s.execute("paint.setFill", &json!({"ids": [r.0], "color": "#ff8000"})).unwrap(); s.execute("graphicStyle.new", &json!({"id": r.0, "name": "Orange"})).unwrap(); let v = text_id(&s, id, "Axes", "4"); s.execute("graphicStyle.apply", &json!({"name": "Orange", "ids": [v.0]})).unwrap(); let looks = redrawn_label_rows(&mut s, id); assert!(rows_of(&looks, "0").0.iter().any(|(fill, hex, _, _)| *fill && hex == "#ff8000"), "{looks:?}"); // A label still linked to the style follows a redefinition, and its kind keeps the new look. let v = text_id(&s, id, "Axes", "4"); s.execute("graphicStyle.apply", &json!({"name": "Orange", "ids": [v.0]})).unwrap(); s.execute("paint.setFill", &json!({"ids": [r.0], "color": "#0080ff"})).unwrap(); s.execute("graphicStyle.redefine", &json!({"name": "Orange", "id": r.0})).unwrap(); let looks = redrawn_label_rows(&mut s, id); assert!(rows_of(&looks, "0").0.iter().any(|(fill, hex, _, _)| *fill && hex == "#0080ff"), "{looks:?}"); } #[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 = 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()); } } } #[test] fn csv_leading_byte_order_mark_is_ignored() { assert_eq!(super::parse_csv("\u{feff}1,2\n3,4"), super::parse_csv("1,2\n3,4")); } #[test] fn csv_round_trip_keeps_blank_row_in_single_column_graph() { let mut g = GraphSpec::default(); g.set_cells(vec![vec![Some(1.0)], vec![None], vec![Some(3.0)]]); let (_, c, r) = super::parse_csv(&g.to_csv()); assert!(c.is_empty()); assert_eq!(r, g.cells()); } /// Each generated label's text, paragraph attributes and alignment. fn label_paras(s: &Session, id: NodeId) -> Vec<(String, vectorcraft_doc::ParaStyle)> { let n = s.doc().unwrap().doc.node(id).unwrap().clone(); let mut out = vec![]; for name in ["Axes", "Legend"] { for c in group(&n, name).children().unwrap() { if let NodeKind::Text(t) = &c.kind { out.push((t.plain_text(), t.para.clone())); } } } out } #[test] fn paragraph_formats_are_kept_when_the_graph_is_drawn_again() { use vectorcraft_doc::{Composer, ParaDirection, text::Kashida}; let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); let id = graph(&mut s, "column"); let aligned: Vec<_> = label_paras(&s, id).into_iter().map(|(t, p)| (t, p.justify)).collect(); s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); s.execute("text.setFormat", &json!({"composer": "singleLine", "kashida": "long", "direction": "rightToLeft", "wordSpacing": [70, 90, 120]})) .unwrap(); s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); let paras = label_paras(&s, id); assert!(!paras.is_empty()); for (text, p) in ¶s { assert_eq!(p.composer, Composer::SingleLine, "{text}"); assert_eq!(p.kashida, Kashida::Long, "{text}"); assert_eq!(p.direction, Some(ParaDirection::RightToLeft), "{text}"); assert_eq!(p.justification.word_spacing, [70.0, 90.0, 120.0], "{text}"); } // The graph still aligns each label to its place. assert_eq!(paras.iter().map(|(t, p)| (t.clone(), p.justify)).collect::>(), aligned); // One kind of label: only that kind keeps it, also through save and open. let col = graph(&mut s, "column"); let v = text_id(&s, col, "Axes", "4"); s.execute("select.set", &json!({"ids": [v.0]})).unwrap(); s.execute("text.setFormat", &json!({"composer": "singleLine"})).unwrap(); let dir = vectorcraft_testkit::TempDir::new("graph-label-paras"); let path = dir.join("labels.vectorcraft"); s.execute("document.save", &json!({"path": path})).unwrap(); s.execute("document.open", &json!({"path": path})).unwrap(); s.execute("select.set", &json!({"ids": [col.0]})).unwrap(); s.execute("graph.setData", &json!({"id": col.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); let paras = label_paras(&s, col); let composer = |t: &str| paras.iter().find(|(x, _)| x == t).unwrap().1.composer; assert_eq!(composer("0"), Composer::SingleLine, "every value axis number"); assert_eq!(composer("Q1"), Composer::default(), "category labels as they were"); assert_eq!(composer("2024"), Composer::default(), "legend labels as they were"); // Part of a label's text: nothing kept. let q = text_id(&s, col, "Axes", "Q2"); s.execute("select.set", &json!({"ids": [q.0]})).unwrap(); s.execute("text.setFormat", &json!({"kashida": "short", "start": 0, "end": 1})).unwrap(); s.execute("graph.setData", &json!({"id": col.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); assert!(label_paras(&s, col).iter().all(|(_, p)| p.kashida == Kashida::default())); // Spacing that would be out of order in the kept attributes is refused, even when it is in order on the label. let v = text_id(&s, col, "Axes", "4"); s.execute("select.set", &json!({"ids": [col.0]})).unwrap(); s.execute("text.setFormat", &json!({"wordSpacing": [60, 70, 80]})).unwrap(); s.execute("select.set", &json!({"ids": [v.0]})).unwrap(); s.execute("text.setFormat", &json!({"wordSpacing": [80, 100, 133], "start": 0, "end": 1})).unwrap(); assert!(s.execute("text.setFormat", &json!({"wordSpacing": {"min": 90}})).is_err()); let kept = spec(&s, col).label_styles.values_para.unwrap(); assert_eq!(kept.justification.word_spacing, [60.0, 70.0, 80.0]); } #[test] fn a_legend_across_top_spaces_its_labels_by_their_kept_paragraph_settings() { 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 l = text_id(&s, id, "Legend", "2024"); s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); s.execute("text.setFormat", &json!({"leftIndent": 40})).unwrap(); s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); 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 label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == "2024")).unwrap(); let label = label.geometric_bounds().unwrap(); let next = series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap(); assert!(next.x0 >= label.x1, "the next swatch starts after the indented label: {label:?} {next:?}"); } #[test] fn a_centered_label_with_kept_leading_stays_centered_when_the_graph_is_drawn_again() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); let csv = ",2024,2025\nQ|1,3,2\nQ2,5,4\nQ3,-1,6"; let id = NodeId( s.execute("graph.create", &json!({"type": "bar", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] .as_u64() .unwrap(), ); let middle = |s: &Session| { let n = s.doc().unwrap().doc.node(id).unwrap().clone(); let label = group(&n, "Axes").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == "Q\n1")).unwrap(); let b = label.geometric_bounds().unwrap(); (b.y0 + b.y1) / 2.0 }; let before = middle(&s); let l = text_id(&s, id, "Axes", "Q\n1"); s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); s.execute("text.setFormat", &json!({"autoLeading": 400, "spaceBefore": 6})).unwrap(); s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); let after = middle(&s); assert!((after - before).abs() < 0.01, "the two-line category label keeps its middle: {before} {after}"); } #[test] fn a_centered_label_with_an_empty_middle_line_and_kept_leading_stays_centered() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); let csv = ",2024,2025\nQ||1,3,2\nQ2,5,4\nQ3,-1,6"; let id = NodeId( s.execute("graph.create", &json!({"type": "bar", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] .as_u64() .unwrap(), ); let middle = |s: &Session| { let n = s.doc().unwrap().doc.node(id).unwrap().clone(); let label = group(&n, "Axes").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == "Q\n\n1")).unwrap(); let b = label.geometric_bounds().unwrap(); (b.y0 + b.y1) / 2.0 }; let before = middle(&s); let l = text_id(&s, id, "Axes", "Q\n\n1"); s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); s.execute("text.setFormat", &json!({"autoLeading": 400})).unwrap(); s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); let after = middle(&s); assert!((after - before).abs() < 0.01, "the three-line category label keeps its middle: {before} {after}"); } #[test] fn a_legend_across_top_measures_its_labels_with_system_layout() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); s.execute("type.systemLayout", &json!({"on": true})).unwrap(); // Each `i` at 2 pt advances a whole point with System Layout, far more than its own width. let name = "i".repeat(60); let csv = format!(",{name},2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"); 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!({"legendAcrossTop": true})).unwrap(); let l = text_id(&s, id, "Legend", &name); s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); s.execute("text.setStyle", &json!({"size": 2})).unwrap(); s.execute("text.setFormat", &json!({"leftIndent": 1})).unwrap(); s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); let n = s.doc().unwrap().doc.node(id).unwrap().clone(); let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == name)).unwrap(); let label = label.geometric_bounds().unwrap(); let next = series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap(); assert!(next.x0 >= label.x1, "the next swatch starts after the label set with System Layout: {label:?} {next:?}"); } #[test] fn a_legend_label_with_a_kept_negative_indent_stays_clear_of_its_swatch() { for across_top in [true, false] { 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": across_top})).unwrap(); let l = text_id(&s, id, "Legend", "2025"); s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); s.execute("text.setFormat", &json!({"leftIndent": -40})).unwrap(); s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); 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 bounds = |text: &str| { let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap(); label.geometric_bounds().unwrap() }; let (first, label) = (bounds("2024"), bounds("2025")); let swatch = series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap(); assert!(label.x0 >= swatch.x1, "the label starts after its swatch (across top: {across_top}): {swatch:?} {label:?}"); if across_top { assert!(swatch.x0 >= first.x1, "the swatch starts after the label before it: {first:?} {swatch:?}"); } } } #[test] fn a_legend_makes_room_for_the_kept_leading_of_its_labels() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); let csv = ",20|24,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"; 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(), ); let l = text_id(&s, id, "Legend", "20\n24"); s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); s.execute("text.setFormat", &json!({"autoLeading": 400})).unwrap(); s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); let n = s.doc().unwrap().doc.node(id).unwrap().clone(); let bounds = |text: &str| { let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap(); label.geometric_bounds().unwrap() }; let (first, next) = (bounds("20\n24"), bounds("2025")); assert!(next.y0 >= first.y1, "the next legend label starts below the first one: {first:?} {next:?}"); } #[test] fn a_legend_label_with_kept_negative_space_before_stays_below_the_entry_above_it() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); let csv = ",2024,20|25\nQ1,3,2\nQ2,5,4\nQ3,-1,6"; 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(), ); let l = text_id(&s, id, "Legend", "20\n25"); s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); s.execute("text.setFormat", &json!({"spaceBefore": -60})).unwrap(); s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); let n = s.doc().unwrap().doc.node(id).unwrap().clone(); let bounds = |text: &str| { let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap(); label.geometric_bounds().unwrap() }; let (first, next) = (bounds("2024"), bounds("20\n25")); assert!(next.y0 >= first.y1, "the second legend label starts below the first one: {first:?} {next:?}"); } /// A graph command given a value of the wrong type refuses it, naming the key, and leaves the graph and the undo /// history as they were; a null axis bound still goes back to the calculated value. #[test] fn graph_commands_refuse_params_of_the_wrong_type() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let id = graph(&mut s, "line"); s.execute("graph.setType", &json!({"id": id.0, "axisMin": 0, "axisMax": 10})).unwrap(); let spec = |s: &Session| s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().cloned().unwrap(); let undo_len = |s: &Session| s.doc().unwrap().history.undo.len(); let (before, steps) = (spec(&s), undo_len(&s)); let refused = |s: &mut Session, cmd: &str, p: serde_json::Value, msg: &str| { let e = s.execute(cmd, &p).unwrap_err().to_string(); assert!(e.contains(msg), "{cmd} {p}: {e}"); }; for (key, v, msg) in [ ("axisMin", json!("10"), "`axisMin` must be a number or null, not \"10\""), ("axisMax", json!(true), "`axisMax` must be a number or null"), ("rightAxisMin", json!([1]), "`rightAxisMin` must be a number or null"), ("rightAxisMax", json!({}), "`rightAxisMax` must be a number or null"), ("columnWidth", json!("50"), "`columnWidth` must be a number, not \"50\""), ("clusterWidth", json!(false), "`clusterWidth` must be a number"), ("ticks", json!("5"), "`ticks` must be a number, not \"5\""), ("ticks", json!(-2), "`ticks` must be 0 or more"), ("rightTicks", json!("5"), "`rightTicks` must be a number"), ("tickMarks", json!("2"), "`tickMarks` must be a number"), ("rightTickMarks", json!(-1), "`rightTickMarks` must be 0 or more"), ("categoryTickMarks", json!(true), "`categoryTickMarks` must be a number"), ("legend", json!("false"), "`legend` must be true or false"), ("markPoints", json!(0), "`markPoints` must be true or false"), ("connectPoints", json!("no"), "`connectPoints` must be true or false"), ("edgeToEdge", json!(1), "`edgeToEdge` must be true or false"), ("separateScales", json!("true"), "`separateScales` must be true or false"), ("ticksBetweenLabels", json!(1), "`ticksBetweenLabels` must be true or false"), ("firstRowInFront", json!("yes"), "`firstRowInFront` must be true or false"), ("firstColumnInFront", json!(0), "`firstColumnInFront` must be true or false"), ("filledLines", json!("on"), "`filledLines` must be true or false"), ("dropShadow", json!(1), "`dropShadow` must be true or false"), ("legendAcrossTop", json!("x"), "`legendAcrossTop` must be true or false"), ("type", json!(5), "`type` must be a string"), ("valueAxis", json!(true), "`valueAxis` must be a string"), ("tickLength", json!(1), "`tickLength` must be a string"), ("rightTickLength", json!(1), "`rightTickLength` must be a string"), ("categoryTickLength", json!(1), "`categoryTickLength` must be a string"), ("prefix", json!(1), "`prefix` must be a string"), ("suffix", json!(true), "`suffix` must be a string"), ("rightPrefix", json!([]), "`rightPrefix` must be a string"), ("rightSuffix", json!(2), "`rightSuffix` must be a string"), ("pieLegend", json!(1), "`pieLegend` must be a string"), ("piePosition", json!(1), "`piePosition` must be a string"), ("pieSort", json!(1), "`pieSort` must be a string"), ] { refused(&mut s, "graph.setType", json!({"id": id.0, key: v}), msg); } for (key, v, msg) in [ ("csv", json!(5), "`csv` must be a string"), ("series", json!("a"), "`series` must be a list, not \"a\""), ("categories", json!({}), "`categories` must be a list"), ("rows", json!("1,2"), "`rows` must be a list"), ("rows", json!([1, 2]), "`rows` must be lists of numbers or nulls, not 1"), ("rows", json!([[1, "2"]]), "`rows` must be lists of numbers or nulls"), ("transpose", json!("true"), "`transpose` must be true or false"), ("switchXY", json!(1), "`switchXY` must be true or false"), ] { refused(&mut s, "graph.setData", json!({"id": id.0, key: v}), msg); } assert_eq!(spec(&s), before); assert_eq!(undo_len(&s), steps); let count = |s: &Session| s.doc().unwrap().doc.node_count(); let objects = count(&s); for (key, v, msg) in [ ("type", json!(1), "`type` must be a string"), ("width", json!("300"), "`width` must be a number, not \"300\""), ("height", json!(true), "`height` must be a number"), ("csv", json!(1), "`csv` must be a string"), ("rows", json!([[true]]), "`rows` must be lists of numbers or nulls"), ] { let mut p = json!({"type": "column", "x": 0, "y": 0, "width": 100, "height": 100}); p[key] = v; refused(&mut s, "graph.create", p, msg); } assert_eq!((count(&s), undo_len(&s)), (objects, steps)); // A null is a key left out, except for an axis bound, where it goes back to the calculated value. s.execute("graph.setType", &json!({"id": id.0, "legend": null, "ticks": null, "prefix": null, "axisMin": null})).unwrap(); assert_eq!(spec(&s).axis_min, None); assert_eq!(spec(&s).axis_max, Some(10.0)); s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, null], [2, 3]], "series": null, "transpose": null})).unwrap(); assert_eq!(spec(&s).cells(), vec![vec![Some(1.0), None], vec![Some(2.0), Some(3.0)]]); } /// A `.ai` in PostScript form whose graph's records read whole (version 7 specification, appendix A) /// opens as a graph whose data can be edited: Graph Data redraws it on its plot. Saving it as `.ai` /// says its data and settings aren't written, in either form. #[test] fn a_graph_opened_from_ai_keeps_its_data_for_editing() { let graph = "%_Gs\n%_100 200 300 100 Gb\n%_5 0 0 14 0 1 0 0 6 44 Gy\n%_10 2 0 0 90 80 0 Gd\n\ %_1 () Ga\n%_0 13 0 0 0 0 0 () GA\n%_2 () Ga\n%_0 14 0 0 0 0 0 () GA\n%_4 () Ga\n%_0 14 0 0 0 0 0 () GA\n\ %_2 2 1 1 Gz\n%_() Gc (East) Gc (Q1) Gc 5 Gc GC\n\ u\nu\n0.6 0 1 0 k\n110 100 m\n110 150 L\n130 150 L\n130 100 L\nf\nU\n%_2 1 0 0 Go\n\ u\n0 0 0 1 K\n100 200 m\n100 100 L\n300 100 L\nS\nU\nU\n%_GS\n"; let text = format!( "%!PS-Adobe-3.0\n%%Creator: a script\n%%BoundingBox: 0 0 400 400\n%AI5_FileFormat 3\n%AI3_Cropmarks: 0 0 400 400\n%%EndComments\n\ %%BeginProlog\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n%AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n(Graph) Ln\n0 A\n{graph}LB\n\ %AI5_EndLayer--\n%%PageTrailer\n%%Trailer\n%%EOF\n" ); let dir = vectorcraft_testkit::temp_dir("graph-ai-open"); let path = dir.join("graph.ai"); std::fs::write(&path, text).unwrap(); let mut s = Session::new(); s.execute("document.open", &json!({"path": path})).unwrap(); let mut found = vec![]; s.doc().unwrap().doc.walk(|n| { if n.extras().graph.is_some() { found.push(n.id); } }); assert_eq!(found.len(), 1); let id = found[0]; let before = spec(&s, id); assert_eq!( (before.series.as_slice(), before.categories.as_slice(), before.rows.as_slice()), (&["East".to_string()][..], &["Q1".to_string()][..], &[vec![5.0]][..]) ); // The fill of the series' first mark. let series_fill = |s: &Session| { let mut fill = None; s.doc().unwrap().doc.walk(|n| { if n.extras().series_index == Some(0) && fill.is_none() { let first = n.children().and_then(|c| c.first().cloned()); fill = first.and_then(|m| m.appearance.items.iter().find(|i| i.is_fill()).map(|i| i.paint().clone())); } }); fill }; let painted = series_fill(&s); assert!(painted.is_some()); s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); s.execute("graph.setData", &json!({"id": id.0, "csv": ",East\nQ1,8\nQ2,2"})).unwrap(); let after = spec(&s, id); assert_eq!(after.rows, [[8.0], [2.0]]); assert_eq!(after.rect, before.rect, "the graph is redrawn on its plot"); assert_eq!(series_fill(&s), painted, "the series keeps the file's paint"); for (name, version) in [("v7.ai", Some("7")), ("pdf.ai", None)] { let mut p = json!({"path": dir.join(name), "format": "ai"}); if let Some(v) = version { p["aiVersion"] = json!(v); } let r = s.execute("file.saveAs", &p).unwrap(); let notes = r["warnings"].as_array().cloned().unwrap_or_default(); assert!(notes.iter().any(|w| w.as_str().is_some_and(|w| w.starts_with("graphs are written as their art"))), "{name}: {r}"); } }