mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 16:38:07 +00:00
1988 lines
109 KiB
Rust
1988 lines
109 KiB
Rust
//! Panel-support commands (`cmd/{panelcmds,gradient,appearance,stroke,swatch,style}.rs`).
|
||
|
||
use serde_json::json;
|
||
use vectorcraft_color::{Color, GradientKind, Paint};
|
||
use vectorcraft_doc::{AppearanceItem, NodeKind};
|
||
|
||
use crate::*;
|
||
|
||
fn session() -> Session {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||
s
|
||
}
|
||
|
||
fn rect(s: &mut Session) -> NodeId {
|
||
let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
|
||
NodeId(r["id"].as_u64().unwrap())
|
||
}
|
||
|
||
fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node {
|
||
s.doc().unwrap().doc.node(id).unwrap().clone()
|
||
}
|
||
|
||
#[test]
|
||
fn edit_gradient_converts_and_keeps_stops() {
|
||
let mut s = session();
|
||
let id = rect(&mut s);
|
||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||
s.execute(
|
||
"paint.editGradient",
|
||
&json!({"stroke": false, "kind": "radial", "stops": [
|
||
{"offset": 1.0, "color": "#0000ff"},
|
||
{"offset": 0.0, "color": "#ff0000", "opacity": 50, "midpoint": 0.3},
|
||
{"offset": 0.5, "color": "#00ff00"}]}),
|
||
)
|
||
.unwrap();
|
||
let Paint::Gradient(g) = node(&s, id).appearance.fill_paint() else { panic!("not a gradient") };
|
||
assert_eq!(g.gradient.kind, GradientKind::Radial);
|
||
assert_eq!(g.gradient.stops.len(), 3);
|
||
assert_eq!(g.gradient.stops[0].color.to_hex(), "#ff0000");
|
||
assert!((g.gradient.stops[0].opacity - 0.5).abs() < 1e-6);
|
||
assert!((g.gradient.stops[0].midpoint - 0.3).abs() < 1e-6);
|
||
// One undo step.
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert!(node(&s, id).appearance.fill_paint().color().is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn edit_gradient_angle_reverse_and_errors() {
|
||
let mut s = session();
|
||
let id = rect(&mut s);
|
||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||
s.execute("paint.editGradient", &json!({"stroke": false, "angle": 450})).unwrap();
|
||
let Paint::Gradient(g) = node(&s, id).appearance.fill_paint() else { panic!() };
|
||
assert!((g.angle - 90.0).abs() < 1e-9);
|
||
s.execute("paint.editGradient", &json!({"stroke": false, "reverse": true})).unwrap();
|
||
let Paint::Gradient(g) = node(&s, id).appearance.fill_paint() else { panic!() };
|
||
assert_eq!(g.gradient.stops[0].color.to_hex(), "#000000");
|
||
// #1007: an off-centre midpoint is mirrored, not reset.
|
||
let stops = json!([{"offset": 0, "color": "#ff0000", "midpoint": 0.2}, {"offset": 1, "color": "#0000ff"}]);
|
||
s.execute("paint.editGradient", &json!({"stroke": false, "stops": stops})).unwrap();
|
||
s.execute("paint.editGradient", &json!({"stroke": false, "reverse": true})).unwrap();
|
||
let Paint::Gradient(g) = node(&s, id).appearance.fill_paint() else { panic!() };
|
||
assert_eq!(g.gradient.stops[0].color.to_hex(), "#0000ff");
|
||
assert!((g.gradient.stops[0].midpoint - 0.8).abs() < 1e-6, "{:?}", g.gradient.stops);
|
||
assert!(s.execute("paint.editGradient", &json!({"stops": [{"offset": 0, "color": "#fff"}]})).is_err());
|
||
assert!(s.execute("paint.editGradient", &json!({"kind": "conic"})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn appearance_duplicate_and_move() {
|
||
let mut s = session();
|
||
let id = rect(&mut s);
|
||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||
let n0 = node(&s, id).appearance.items.len();
|
||
s.execute("appearance.duplicateItem", &json!({"index": 0})).unwrap();
|
||
assert_eq!(node(&s, id).appearance.items.len(), n0 + 1);
|
||
let last = node(&s, id).appearance.items.len() - 1;
|
||
s.execute("appearance.moveItem", &json!({"from": last, "to": 0})).unwrap();
|
||
assert!(matches!(node(&s, id).appearance.items[0], AppearanceItem::Stroke(_)));
|
||
assert!(s.execute("appearance.duplicateItem", &json!({"index": 99})).is_err());
|
||
}
|
||
|
||
/// The appearance and effect commands given an item index of 2^32 + k, where k names one of the
|
||
/// object's items, leave the object's appearance and the undo history as they were, as for any index
|
||
/// past the last item: `appearance.removeItem` removes nothing, and the others fail. A 32-bit build
|
||
/// read the index as k.
|
||
#[test]
|
||
fn appearance_commands_leave_an_item_past_u32_alone() {
|
||
let mut s = session();
|
||
let id = rect(&mut s);
|
||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||
let before = node(&s, id).appearance;
|
||
let undo = s.doc().unwrap().history.undo.len();
|
||
let past = |k: u64| (1u64 << 32) + k;
|
||
for (cmd, p, fails) in [
|
||
("appearance.removeItem", json!({"index": past(0)}), false),
|
||
("appearance.removeItem", json!({"indices": [past(0)]}), false),
|
||
("appearance.duplicateItem", json!({"index": past(0)}), true),
|
||
("appearance.moveItem", json!({"from": past(1), "to": 0}), true),
|
||
("effect.apply", json!({"effect": "distort.roughen", "item": past(1)}), true),
|
||
] {
|
||
assert_eq!(s.execute(cmd, &p).is_err(), fails, "{cmd} {p}");
|
||
assert_eq!(node(&s, id).appearance, before, "{cmd} {p}");
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{cmd} {p}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn artboards_reorder_and_duplicate() {
|
||
let mut s = session();
|
||
s.execute("artboard.new", &json!({"name": "Second"})).unwrap();
|
||
let r = s.execute("artboard.duplicate", &json!({"index": 0})).unwrap();
|
||
assert_eq!(r["index"], 2);
|
||
let d = &s.doc().unwrap().doc;
|
||
assert_eq!(d.artboards.len(), 3);
|
||
assert!(d.artboards[2].rect.x0 >= d.artboards[1].rect.x1);
|
||
s.execute("artboard.reorder", &json!({"index": 1, "to": 0})).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc.artboards[0].name, "Second");
|
||
}
|
||
|
||
/// `artboard.new {beside, side}` adds an empty artboard the size of artboard `beside`, 20 pt from
|
||
/// that side of it, and `artboard.duplicate {index, side}` puts the copy and its art there; each is
|
||
/// one undo step. An unknown side, `beside` without `side`, and `side` with a size or place are
|
||
/// refused and change nothing.
|
||
#[test]
|
||
fn artboards_are_added_and_duplicated_on_a_side() {
|
||
let mut s = session();
|
||
let id = rect(&mut s);
|
||
let board = |s: &Session, i: usize| s.doc().unwrap().doc.artboards[i].rect;
|
||
let undo = |s: &Session| s.doc().unwrap().history.undo.len();
|
||
let src = board(&s, 0);
|
||
let before = undo(&s);
|
||
assert_eq!(s.execute("artboard.new", &json!({"beside": 0, "side": "bottom"})).unwrap()["index"], 1);
|
||
assert_eq!(board(&s, 1), src + vectorcraft_geom::Vec2::new(0.0, src.height() + 20.0), "same size, 20 pt below");
|
||
assert_eq!(undo(&s), before + 1, "one undo step");
|
||
assert!(!s.doc().unwrap().doc.artboard_has_art(board(&s, 1)), "the new artboard is empty");
|
||
s.execute("artboard.new", &json!({"beside": 0, "side": "left"})).unwrap();
|
||
assert_eq!(board(&s, 2), src + vectorcraft_geom::Vec2::new(-(src.width() + 20.0), 0.0));
|
||
s.execute("artboard.new", &json!({"side": "top"})).unwrap();
|
||
assert_eq!(board(&s, 3), board(&s, 2) + vectorcraft_geom::Vec2::new(0.0, -(src.height() + 20.0)), "by default beside the last");
|
||
s.execute("artboard.new", &json!({"side": "top", "beside": null, "x": null})).unwrap();
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
|
||
let before = undo(&s);
|
||
let out = s.execute("artboard.duplicate", &json!({"index": 0, "side": "right", "art": true})).unwrap();
|
||
assert_eq!(out["index"], 4);
|
||
let dv = vectorcraft_geom::Vec2::new(src.width() + 20.0, 0.0);
|
||
assert_eq!(board(&s, 4), src + dv, "the copy sits 20 pt right of the artboard it copies");
|
||
let copies: Vec<NodeId> = out["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
|
||
assert_eq!(copies.len(), 1);
|
||
let bounds = |s: &Session, id: NodeId| s.doc().unwrap().doc.bounds_of(&[id], false).unwrap();
|
||
assert_eq!(bounds(&s, copies[0]), bounds(&s, id) + dv, "its art goes with it");
|
||
assert_eq!(undo(&s), before + 1, "one undo step");
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 4, "undo takes the copy and its art away together");
|
||
assert!(s.doc().unwrap().doc.node(copies[0]).is_none());
|
||
|
||
let before = s.doc().unwrap().doc.clone();
|
||
let steps = undo(&s);
|
||
for (cmd, p, msg) in [
|
||
("artboard.new", json!({"beside": 0, "side": "middle"}), "`side` must be"),
|
||
("artboard.new", json!({"beside": 0, "side": 3}), "`side` must be"),
|
||
("artboard.new", json!({"beside": 0}), "`beside` needs `side`"),
|
||
("artboard.new", json!({"side": "left", "width": 100}), "leave out x, y, width and height"),
|
||
("artboard.new", json!({"beside": 9, "side": "left"}), "no such artboard"),
|
||
("artboard.duplicate", json!({"index": 0, "side": "inside"}), "`side` must be"),
|
||
] {
|
||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||
assert!(e.contains(msg), "{cmd} {p}: {e}");
|
||
assert_eq!(*s.doc().unwrap().doc, *before, "{cmd} {p} changes nothing");
|
||
assert_eq!(undo(&s), steps, "{cmd} {p}");
|
||
}
|
||
}
|
||
|
||
/// An artboard's background color (Properties › Artboards › Fill): `artboard.setProps {color}` sets
|
||
/// it and `{color: null}` clears it, each in one undo step. A change without `color` leaves it, a
|
||
/// duplicate keeps it, `inspect_document` reports it, `artboard.new {color}` starts with it, an
|
||
/// `artboards` list gives each listed artboard its own color in one undo step, and a bad color is
|
||
/// refused.
|
||
#[test]
|
||
fn artboard_color_is_set_and_cleared_in_one_undo_step() {
|
||
let mut s = session();
|
||
s.execute("artboard.new", &json!({})).unwrap();
|
||
let color = |s: &Session, i: usize| s.doc().unwrap().doc.artboards[i].color;
|
||
let blue = Color::from_hex("#336699");
|
||
let black = Some(Color::cmyk(0.0, 0.0, 0.0, 1.0));
|
||
s.execute("artboard.setProps", &json!({"index": 1, "color": "#336699"})).unwrap();
|
||
assert_eq!((color(&s, 0), color(&s, 1)), (None, blue), "only that artboard");
|
||
s.execute("artboard.setProps", &json!({"index": 1, "name": "Dark"})).unwrap();
|
||
assert_eq!(color(&s, 1), blue, "a change without a color leaves it");
|
||
s.execute("artboard.setProps", &json!({"index": 1, "color": {"c": 0, "m": 0, "y": 0, "k": 100}})).unwrap();
|
||
assert_eq!(color(&s, 1), black);
|
||
s.execute("artboard.setProps", &json!({"index": 1, "color": null})).unwrap();
|
||
assert_eq!(color(&s, 1), None);
|
||
for was in [black, blue, blue, None] {
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(color(&s, 1), was);
|
||
}
|
||
s.execute("edit.redo", &json!({})).unwrap();
|
||
let doc = crate::inspect::document(&s);
|
||
assert_eq!(doc["artboards"][1]["color"], "#336699");
|
||
assert!(doc["artboards"][0].get("color").is_none(), "{doc}");
|
||
let copy = s.execute("artboard.duplicate", &json!({"index": 1})).unwrap()["index"].as_u64().unwrap() as usize;
|
||
assert_eq!(color(&s, copy), blue, "a duplicate keeps the color");
|
||
let made = s.execute("artboard.new", &json!({"color": "#336699"})).unwrap()["index"].as_u64().unwrap() as usize;
|
||
assert_eq!(color(&s, made), blue, "a new artboard takes `color` too");
|
||
let undo = s.doc().unwrap().history.undo.len();
|
||
s.execute("artboard.setProps", &json!({"artboards": [{"index": 0, "color": "#336699"}, {"index": 1, "color": null}]})).unwrap();
|
||
assert_eq!((color(&s, 0), color(&s, 1)), (blue, None), "each listed artboard takes its own color");
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "in one undo step");
|
||
let before = s.doc().unwrap().doc.clone();
|
||
assert!(s.execute("artboard.setProps", &json!({"artboards": [{"index": 0, "color": null}, {"index": 1, "color": "nope"}]})).is_err());
|
||
assert!(s.execute("artboard.setProps", &json!({"index": 1, "color": "nope"})).is_err());
|
||
assert!(s.execute("artboard.new", &json!({"color": "nope"})).is_err());
|
||
assert_eq!(*s.doc().unwrap().doc, *before, "a bad color changes nothing");
|
||
}
|
||
|
||
/// A color param given as a hex color with an alpha part (`#rrggbbaa` or `#rgba`, with or without
|
||
/// the `#`) is refused and changes nothing: a Rasterize background, an artboard's color, a fill or
|
||
/// stroke, a swatch's colors and the color of new type. `#rrggbb`, a color key and `[r, g, b]`
|
||
/// still set the color.
|
||
#[test]
|
||
fn a_color_with_an_alpha_part_is_refused() {
|
||
let mut s = session();
|
||
let id = rect(&mut s);
|
||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||
let before = s.doc().unwrap().doc.clone();
|
||
let undo = s.doc().unwrap().history.undo.len();
|
||
for bad in ["#ff000080", "ff000080", " #FF000080 ", "#f008"] {
|
||
for (cmd, p) in [
|
||
("object.rasterize", json!({"ppi": 72, "background": bad})),
|
||
("artboard.setProps", json!({"index": 0, "color": bad})),
|
||
("artboard.new", json!({"color": bad})),
|
||
("paint.setFill", json!({"ids": [id.0], "color": bad})),
|
||
("paint.setStroke", json!({"ids": [id.0], "color": bad})),
|
||
("swatch.new", json!({"name": "Half", "color": bad})),
|
||
("swatch.new", json!({"colors": ["#00ff00", bad]})),
|
||
("swatch.newGroup", json!({"name": "Half", "colors": ["#00ff00", bad]})),
|
||
("text.create", json!({"x": 10, "y": 10, "text": "a", "color": bad})),
|
||
("text.createInPath", json!({"path": id.0, "mode": "area", "text": "a", "color": bad})),
|
||
("document.export", json!({"format": "png", "background": bad})),
|
||
("document.export", json!({"format": "gif", "matte": bad})),
|
||
] {
|
||
assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}");
|
||
}
|
||
}
|
||
assert_eq!(*s.doc().unwrap().doc, *before, "a color with an alpha part changes nothing");
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "and records no undo step");
|
||
s.execute("artboard.setProps", &json!({"index": 0, "color": "#ff0000"})).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc.artboards[0].color.map(|c| c.to_hex()).as_deref(), Some("#ff0000"));
|
||
let fill = node(&s, id).appearance.fill_paint();
|
||
s.execute("paint.setFill", &json!({"ids": [id.0], "color": "cmyk 0 100 100 0"})).unwrap();
|
||
assert!(node(&s, id).appearance.fill_paint() != fill && node(&s, id).appearance.fill_paint().color().is_some());
|
||
s.execute("paint.setFill", &json!({"ids": [id.0], "color": [0, 0, 1]})).unwrap();
|
||
assert_eq!(node(&s, id).appearance.fill_paint().color().map(|c| c.to_hex()).as_deref(), Some("#0000ff"));
|
||
s.execute("swatch.new", &json!({"name": "Pure Green", "color": "#00ff00"})).unwrap();
|
||
let green = s.doc().unwrap().doc.swatches.iter().find(|w| w.name == "Pure Green").map(|w| w.paint.clone());
|
||
assert_eq!(green.and_then(|p| p.color()).map(|c| c.to_hex()).as_deref(), Some("#00ff00"));
|
||
let t = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "a", "color": "#ff0000"})).unwrap();
|
||
let NodeKind::Text(t) = node(&s, NodeId(t["id"].as_u64().unwrap())).kind else { panic!("not type") };
|
||
assert_eq!(t.runs[0].style.fill.color().map(|c| c.to_hex()).as_deref(), Some("#ff0000"));
|
||
s.execute("document.export", &json!({"format": "png", "background": "#ff0000"})).unwrap();
|
||
s.execute("document.export", &json!({"format": "gif", "matte": "#ff0000"})).unwrap();
|
||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||
s.execute("object.rasterize", &json!({"ppi": 72, "background": "#ff0000"})).unwrap();
|
||
}
|
||
|
||
/// The native format writes an artboard's background color and reads it back. An artboard without
|
||
/// one is written as before, with no `color` key.
|
||
#[test]
|
||
fn artboard_color_round_trips_through_the_native_format() {
|
||
let mut s = session();
|
||
s.execute("artboard.new", &json!({})).unwrap();
|
||
s.execute("artboard.setProps", &json!({"index": 1, "color": {"c": 10, "m": 20, "y": 30, "k": 40}})).unwrap();
|
||
let bytes = vectorcraft_format::save(&s.doc().unwrap().doc, false);
|
||
let saved: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||
let keys = |i: usize| saved["document"]["artboards"][i].as_object().unwrap().keys().cloned().collect::<Vec<_>>();
|
||
assert_eq!(keys(0), ["id", "name", "rect", "show_center_mark", "show_cross_hairs"]);
|
||
assert!(keys(1).contains(&"color".to_string()), "{:?}", keys(1));
|
||
let back = vectorcraft_format::load(&bytes).unwrap();
|
||
assert_eq!(back.artboards[0].color, None);
|
||
assert_eq!(back.artboards[1].color, Some(Color::cmyk(0.1, 0.2, 0.3, 0.4)));
|
||
assert_eq!(back.artboards, s.doc().unwrap().doc.artboards);
|
||
}
|
||
|
||
/// An artboard moved to place 2^32 goes last. A 32-bit build read the place as 0 and moved it to the
|
||
/// front.
|
||
#[test]
|
||
fn artboard_reorder_past_u32_goes_last() {
|
||
let mut s = session();
|
||
s.execute("artboard.new", &json!({"name": "Second"})).unwrap();
|
||
s.execute("artboard.new", &json!({"name": "Third"})).unwrap();
|
||
s.execute("artboard.reorder", &json!({"index": 1, "to": 1u64 << 32})).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc.artboards.last().map(|a| a.name.as_str()), Some("Second"));
|
||
}
|
||
|
||
/// `artboard.rename`: one artboard takes the name trimmed, several take it numbered in artboard
|
||
/// order in one undo step, and a locked artboard is renamed too.
|
||
#[test]
|
||
fn artboard_rename_numbers_several_in_one_undo_step() {
|
||
let mut s = session();
|
||
s.execute("artboard.new", &json!({})).unwrap();
|
||
s.execute("artboard.new", &json!({})).unwrap();
|
||
s.execute("artboard.setProps", &json!({"index": 2, "locked": true})).unwrap();
|
||
let names = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.name.clone()).collect::<Vec<_>>();
|
||
let steps = s.doc().unwrap().history.undo.len();
|
||
assert_eq!(s.execute("artboard.rename", &json!({"index": 0, "name": " Cover "})).unwrap()["renamed"], json!([0]));
|
||
assert_eq!(names(&s)[0], "Cover");
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||
// Listed out of order and twice: artboard order, once each.
|
||
assert_eq!(s.execute("artboard.rename", &json!({"indices": [2, 1, 2], "name": "Page"})).unwrap()["renamed"], json!([1, 2]));
|
||
assert_eq!(names(&s)[1..], ["Page-1", "Page-2"]);
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 2, "one undo step");
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_ne!(names(&s)[1], "Page-1", "one undo restores both");
|
||
assert_ne!(names(&s)[2], "Page-2");
|
||
}
|
||
|
||
/// `artboard.rename` with nothing to change records no undo step, and without `index` or `indices`
|
||
/// acts on the artboards the Artboard tool has selected.
|
||
#[test]
|
||
fn artboard_rename_records_nothing_when_nothing_changes() {
|
||
let mut s = session();
|
||
s.execute("artboard.new", &json!({})).unwrap();
|
||
s.execute("artboard.rename", &json!({"indices": [0, 1], "name": "Page"})).unwrap();
|
||
let steps = s.doc().unwrap().history.undo.len();
|
||
assert_eq!(s.execute("artboard.rename", &json!({"indices": [0, 1], "name": "Page"})).unwrap()["renamed"], json!([]));
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||
s.select_tool("artboard", crate::tooling::ViewInfo::default()).unwrap();
|
||
s.set_tool_option("selected", &json!([1]));
|
||
assert_eq!(s.execute("artboard.rename", &json!({"name": "Back"})).unwrap()["renamed"], json!([1]));
|
||
assert_eq!(s.doc().unwrap().doc.artboards[1].name, "Back");
|
||
}
|
||
|
||
/// `artboard.rename` refuses an empty or missing name, a name that is not a string and an unknown
|
||
/// artboard, and leaves the document and its undo history as they were.
|
||
#[test]
|
||
fn artboard_rename_refuses_bad_params() {
|
||
let mut s = session();
|
||
let steps = s.doc().unwrap().history.undo.len();
|
||
for p in [
|
||
json!({"index": 0, "name": " "}),
|
||
json!({"index": 0}),
|
||
json!({"index": 0, "name": 7}),
|
||
json!({"index": 9, "name": "A"}),
|
||
json!({"indices": [], "name": "A"}),
|
||
json!({"indices": [0, 9], "name": "A"}),
|
||
json!({"index": "0", "name": "A"}),
|
||
] {
|
||
assert!(s.execute("artboard.rename", &p).is_err(), "{p}");
|
||
}
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||
assert_eq!(s.doc().unwrap().doc.artboards[0].name, "Artboard 1");
|
||
}
|
||
|
||
/// `artboard.switchOrientation` swaps width and height of each artboard named in one undo step,
|
||
/// each top-left corner staying put; a square one and a locked one stay as they are.
|
||
#[test]
|
||
fn artboard_switch_orientation_swaps_each_in_one_undo_step() {
|
||
let mut s = session();
|
||
s.execute("artboard.new", &json!({"x": 1000, "y": 10, "width": 300, "height": 100})).unwrap();
|
||
s.execute("artboard.new", &json!({"x": 2000, "y": 20, "width": 50, "height": 50})).unwrap();
|
||
s.execute("artboard.new", &json!({"x": 3000, "y": 30, "width": 40, "height": 60})).unwrap();
|
||
s.execute("artboard.setProps", &json!({"index": 3, "locked": true})).unwrap();
|
||
let steps = s.doc().unwrap().history.undo.len();
|
||
let r = s.execute("artboard.switchOrientation", &json!({"indices": [0, 1, 2, 3]})).unwrap();
|
||
assert_eq!(r["switched"], json!([0, 1]));
|
||
let rects: Vec<_> = s.doc().unwrap().doc.artboards.iter().map(|a| (a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height())).collect();
|
||
assert_eq!(rects, [(0.0, 0.0, 600.0, 800.0), (1000.0, 10.0, 100.0, 300.0), (2000.0, 20.0, 50.0, 50.0), (3000.0, 30.0, 40.0, 60.0)]);
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step");
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc.artboards[1].rect.width(), 300.0);
|
||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect.width(), 800.0, "one undo restores both");
|
||
}
|
||
|
||
/// `artboard.switchOrientation`: a square artboard records no undo step, every artboard locked is an
|
||
/// error, and bad params change nothing.
|
||
#[test]
|
||
fn artboard_switch_orientation_refuses_and_records_nothing() {
|
||
let mut s = session();
|
||
s.execute("artboard.new", &json!({"x": 2000, "y": 0, "width": 50, "height": 50})).unwrap();
|
||
s.execute("artboard.setProps", &json!({"index": 0, "locked": true})).unwrap();
|
||
let steps = s.doc().unwrap().history.undo.len();
|
||
assert_eq!(s.execute("artboard.switchOrientation", &json!({"index": 1})).unwrap()["switched"], json!([]));
|
||
assert_eq!(s.execute("artboard.switchOrientation", &json!({"index": 0})).unwrap_err().to_string(), "the artboard is locked");
|
||
for p in [json!({"index": 9}), json!({"indices": []}), json!({"index": "1"}), json!({"index": 1, "scaleArt": "yes"})] {
|
||
assert!(s.execute("artboard.switchOrientation", &p).is_err(), "{p}");
|
||
}
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||
}
|
||
|
||
/// `artboard.switchOrientation` takes the art along with `scaleArt`, and by default follows the
|
||
/// Artboard tool's Scale Artwork with Artboard option.
|
||
#[test]
|
||
fn artboard_switch_orientation_scales_art_with_the_tools_option() {
|
||
let mut s = session();
|
||
let id = rect(&mut s);
|
||
let width = |s: &Session| s.doc().unwrap().doc.bounds_of(&[id], false).unwrap().width();
|
||
let was = width(&s);
|
||
s.execute("artboard.switchOrientation", &json!({"index": 0})).unwrap();
|
||
assert_eq!(width(&s), was, "the art stays by default");
|
||
s.select_tool("artboard", crate::tooling::ViewInfo::default()).unwrap();
|
||
s.set_tool_option("scaleArt", &json!(true));
|
||
s.execute("artboard.switchOrientation", &json!({"index": 0})).unwrap();
|
||
assert_ne!(width(&s), was, "the tool's option scales the art");
|
||
}
|
||
|
||
/// `artboard.fitToArt` records no undo step when every artboard already fits its art.
|
||
#[test]
|
||
fn artboard_fit_to_art_records_nothing_when_it_already_fits() {
|
||
let mut s = session();
|
||
rect(&mut s);
|
||
s.execute("artboard.fitToArt", &json!({"index": 0})).unwrap();
|
||
let steps = s.doc().unwrap().history.undo.len();
|
||
let was = s.doc().unwrap().doc.artboards[0].rect;
|
||
s.execute("artboard.fitToArt", &json!({"index": 0})).unwrap();
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect, was);
|
||
}
|
||
|
||
/// `edit.paste {artboardsAt}` centers the copied artboards on the point as they lay. A value that is
|
||
/// not a point is an error.
|
||
#[test]
|
||
fn paste_centers_copied_artboards_on_artboards_at() {
|
||
let mut s = session();
|
||
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 100, "height": 100})).unwrap();
|
||
s.execute("artboard.copy", &json!({"indices": [0, 1]})).unwrap();
|
||
let steps = s.doc().unwrap().history.undo.len();
|
||
for bad in [json!("here"), json!([1]), json!(["1", "2"]), json!([1, 2, 3]), json!(null)] {
|
||
assert!(s.execute("edit.paste", &json!({"artboardsAt": bad})).is_err(), "{bad}");
|
||
}
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||
let r = s.execute("edit.paste", &json!({"artboardsAt": [5000.0, 7000.0]})).unwrap();
|
||
assert_eq!(r["artboards"], json!([2, 3]));
|
||
let d = &s.doc().unwrap().doc;
|
||
let together = d.artboards[2].rect.union(d.artboards[3].rect).center();
|
||
assert_eq!((together.x, together.y), (5000.0, 7000.0));
|
||
assert_eq!(d.artboards[3].rect.x0 - d.artboards[2].rect.x0, 1000.0, "as they lay");
|
||
}
|
||
|
||
/// The artboards' left edges and names.
|
||
fn boards(s: &Session) -> Vec<(f64, String)> {
|
||
s.doc().unwrap().doc.artboards.iter().map(|a| (a.rect.x0, a.name.clone())).collect()
|
||
}
|
||
|
||
/// The left edge of object `id`.
|
||
fn left(s: &Session, id: u64) -> f64 {
|
||
s.doc().unwrap().doc.node(NodeId(id)).unwrap().geometric_bounds().unwrap().x0
|
||
}
|
||
|
||
fn ids(v: &serde_json::Value) -> Vec<u64> {
|
||
v.as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
|
||
}
|
||
|
||
/// Duplicate Artboards (#446) copies the art on the artboard too, as the Artboard tool's Move/Copy
|
||
/// Artwork with Artboard says, in one undo step.
|
||
#[test]
|
||
fn duplicate_artboard_copies_its_art() {
|
||
let mut s = session();
|
||
let r = rect(&mut s);
|
||
let out = s.execute("artboard.duplicate", &json!({"index": 0})).unwrap();
|
||
assert_eq!(out["index"], 1);
|
||
let copies = ids(&out["ids"]);
|
||
assert_eq!(copies.len(), 1);
|
||
assert_ne!(copies[0], r.0);
|
||
assert_eq!(boards(&s), vec![(0.0, "Artboard 1".into()), (820.0, "Artboard 1 copy".into())]);
|
||
assert_eq!(left(&s, copies[0]), 830.0, "the copy sits on the new artboard as the original on its own");
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
let d = &s.doc().unwrap().doc;
|
||
assert_eq!((d.artboards.len(), d.layers[0].children().unwrap().len()), (1, 1), "one undo step");
|
||
// Off (the tool's option or the param): the artboard alone.
|
||
s.set_tool_options(Some("artboard"), json!({"moveArt": false}).as_object().unwrap());
|
||
assert_eq!(s.execute("artboard.duplicate", &json!({})).unwrap()["ids"], json!([]));
|
||
assert_eq!(ids(&s.execute("artboard.duplicate", &json!({"index": 0, "art": true})).unwrap()["ids"]).len(), 1);
|
||
}
|
||
|
||
/// New Artboard and Artboard Options trim the name they are given, as the Artboards panel's rename
|
||
/// does, and a blank name gives the artboard its default name, Artboard N for the Nth artboard.
|
||
#[test]
|
||
fn new_and_renamed_artboards_trim_their_names() {
|
||
let mut s = session();
|
||
let new = |s: &mut Session, name: &str| s.execute("artboard.new", &json!({"name": name})).unwrap();
|
||
new(&mut s, " Cover \t");
|
||
new(&mut s, " \t ");
|
||
new(&mut s, "");
|
||
let names = |s: &Session| boards(s).into_iter().map(|(_, n)| n).collect::<Vec<_>>();
|
||
assert_eq!(names(&s), ["Artboard 1", "Cover", "Artboard 3", "Artboard 4"]);
|
||
s.execute("artboard.setProps", &json!({"index": 0, "name": " Back\t"})).unwrap();
|
||
s.execute("artboard.setProps", &json!({"index": 1, "name": " "})).unwrap();
|
||
s.execute("artboard.setProps", &json!({"index": 3, "name": ""})).unwrap();
|
||
assert_eq!(names(&s), ["Back", "Artboard 2", "Artboard 3", "Artboard 4"]);
|
||
}
|
||
|
||
/// Duplicate, Copy and Cut Artboard and Move Artboard Up/Down fail with `no such artboard` on an
|
||
/// `index` that names no artboard (past the last, negative, fractional, a string or null), and the
|
||
/// artboards, the art and the clipboard stay as they were.
|
||
#[test]
|
||
fn artboard_copies_refuse_an_index_that_names_no_artboard() {
|
||
let mut s = session();
|
||
rect(&mut s);
|
||
s.execute("artboard.new", &json!({})).unwrap();
|
||
s.set_tool_options(Some("artboard"), json!({"active": 1}).as_object().unwrap());
|
||
let (was, n) = (boards(&s), s.doc().unwrap().history.undo.len());
|
||
for index in [json!(99), json!(-1), json!(1.5), json!("1"), serde_json::Value::Null] {
|
||
for cmd in ["artboard.duplicate", "artboard.copy", "artboard.cut", "artboard.reorder"] {
|
||
let p = if cmd == "artboard.reorder" { json!({"index": index, "to": 0}) } else { json!({"index": index}) };
|
||
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), "no such artboard", "{cmd} {p}");
|
||
}
|
||
}
|
||
let st = s.doc().unwrap();
|
||
assert_eq!((boards(&s), st.doc.layers[0].children().unwrap().len(), st.history.undo.len()), (was, 1, n));
|
||
assert!(s.clipboard.nodes.is_empty() && s.clipboard.artboards.is_empty(), "nothing copied");
|
||
}
|
||
|
||
/// Artboard Options, Move Artboard, Duplicate Artboards, Copy Artboard and Cut Artboard refuse an
|
||
/// option given as text or as another value that is not true or false, such as "true", and
|
||
/// Artboard Options and Move Artboard a position, size or move given as text, such as "10". The
|
||
/// artboards, the art, the undo history and the clipboard stay as they were; a null reads as the
|
||
/// option left out.
|
||
#[test]
|
||
fn artboard_commands_refuse_a_boolean_or_number_given_as_text() {
|
||
use crate::tests::{not_a_number, not_a_switch, not_refused};
|
||
let mut s = session();
|
||
let r = rect(&mut s);
|
||
s.execute("artboard.new", &json!({})).unwrap();
|
||
let mut calls: Vec<_> = [
|
||
("artboard.setProps", "scaleArt", json!({"index": 0, "width": 400, "scaleArt": "true"})),
|
||
("artboard.setProps", "strokes", json!({"index": 0, "width": 400, "scaleArt": true, "strokes": "false"})),
|
||
("artboard.setProps", "corners", json!({"index": 0, "width": 400, "scaleArt": true, "corners": 1})),
|
||
("artboard.setProps", "patterns", json!({"index": 0, "x": 50, "moveArt": true, "patterns": "true"})),
|
||
("artboard.setProps", "moveArt", json!({"index": 0, "x": 50, "moveArt": "true"})),
|
||
("artboard.setProps", "lockedAndHidden", json!({"index": 0, "x": 50, "moveArt": true, "lockedAndHidden": [true]})),
|
||
("artboard.setProps", "locked", json!({"artboards": [{"index": 1, "locked": "true"}]})),
|
||
("artboard.move", "moveArt", json!({"index": 0, "dx": 10, "moveArt": "true"})),
|
||
("artboard.move", "copy", json!({"index": 0, "dx": 10, "copy": 1})),
|
||
("artboard.move", "patterns", json!({"index": 0, "dx": 10, "moveArt": true, "patterns": "false"})),
|
||
("artboard.duplicate", "patterns", json!({"index": 0, "patterns": "true"})),
|
||
("artboard.duplicate", "art", json!({"index": 0, "art": "false"})),
|
||
("artboard.copy", "art", json!({"index": 0, "art": "true"})),
|
||
("artboard.cut", "art", json!({"index": 1, "art": 0})),
|
||
("artboard.move", "lockedAndHidden", json!({"index": 0, "dx": 10, "moveArt": true, "lockedAndHidden": "true"})),
|
||
("artboard.duplicate", "lockedAndHidden", json!({"index": 0, "lockedAndHidden": "true"})),
|
||
("artboard.copy", "lockedAndHidden", json!({"index": 0, "lockedAndHidden": 1})),
|
||
("artboard.cut", "lockedAndHidden", json!({"index": 1, "lockedAndHidden": 0})),
|
||
("artboard.rearrange", "lockedAndHidden", json!({"spacing": 100, "lockedAndHidden": "true"})),
|
||
]
|
||
.into_iter()
|
||
.map(|(cmd, key, p)| not_a_switch(cmd, p, key))
|
||
.collect();
|
||
for (cmd, key, p) in [
|
||
("artboard.setProps", "x", json!({"index": 0, "x": "50"})),
|
||
("artboard.setProps", "height", json!({"index": 1, "height": [300]})),
|
||
("artboard.move", "dx", json!({"index": 0, "dx": "10"})),
|
||
("artboard.move", "dy", json!({"index": 0, "dx": 10, "dy": "5"})),
|
||
] {
|
||
calls.push(not_a_number(cmd, p, key));
|
||
}
|
||
let wrong = not_refused(&mut s, &calls);
|
||
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
|
||
assert!(s.clipboard.nodes.is_empty() && s.clipboard.artboards.is_empty(), "nothing copied");
|
||
let p = json!({"index": 0, "dx": 10, "dy": null, "moveArt": true, "copy": null, "patterns": false});
|
||
s.execute("artboard.move", &p).unwrap();
|
||
assert_eq!(node(&s, r).geometric_bounds().map(|b| b.x0), Some(20.0));
|
||
}
|
||
|
||
/// New Artboard refuses a position or size given as text or as another value that is not a number,
|
||
/// such as "10", and New Artboard and Artboard Options refuse a name that is not text, such as 5.
|
||
/// The artboards and the undo history stay as they were; a null reads as the key left out.
|
||
#[test]
|
||
fn artboard_commands_refuse_a_number_or_name_of_the_wrong_type() {
|
||
use crate::tests::{not_a_number, not_refused};
|
||
let mut s = session();
|
||
s.execute("artboard.new", &json!({})).unwrap();
|
||
let mut calls: Vec<_> = [
|
||
("artboard.new", "x", json!({"x": "10"})),
|
||
("artboard.new", "y", json!({"x": 2000, "y": [0]})),
|
||
("artboard.new", "width", json!({"width": "200"})),
|
||
("artboard.new", "height", json!({"height": true})),
|
||
]
|
||
.into_iter()
|
||
.map(|(cmd, key, p)| not_a_number(cmd, p, key))
|
||
.collect();
|
||
let not_text = |cmd: &str, p: serde_json::Value| (cmd.to_string(), p, "`name` must be a string".to_string());
|
||
calls.extend([
|
||
not_text("artboard.new", json!({"name": 5})),
|
||
not_text("artboard.setProps", json!({"index": 0, "name": 5})),
|
||
not_text("artboard.setProps", json!({"index": 0, "x": 50, "name": true})),
|
||
not_text("artboard.setProps", json!({"artboards": [{"index": 0, "name": "Front"}, {"index": 1, "name": ["Back"]}]})),
|
||
]);
|
||
let wrong = not_refused(&mut s, &calls);
|
||
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
|
||
s.execute("artboard.new", &json!({"x": null, "y": null, "width": null, "height": null, "name": null})).unwrap();
|
||
s.execute("artboard.setProps", &json!({"index": 0, "name": "Front"})).unwrap();
|
||
s.execute("artboard.setProps", &json!({"index": 0, "name": null})).unwrap();
|
||
let names: Vec<String> = boards(&s).into_iter().map(|(_, n)| n).collect();
|
||
assert_eq!(names, ["Front", "Artboard 2", "Artboard 3"]);
|
||
}
|
||
|
||
/// Artboards 1 and 2, each with a square on it, and an artboard 3 below artboard 1 with a square on
|
||
/// it, with the Artboard tool selecting artboards 1 and 2 → the squares' ids, artboard by artboard.
|
||
fn two_selected_artboards(s: &mut Session) -> [u64; 3] {
|
||
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 100, "height": 100})).unwrap();
|
||
s.execute("artboard.new", &json!({"x": 0, "y": 700, "width": 100, "height": 100, "name": "Small"})).unwrap();
|
||
let sq = |s: &mut Session, x: u64, y: u64| {
|
||
s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()
|
||
};
|
||
let ids = [sq(s, 10, 10), sq(s, 1010, 10), sq(s, 10, 710)];
|
||
s.execute("select.none", &json!({})).unwrap();
|
||
s.select_tool("artboard", ViewInfo::default()).unwrap();
|
||
s.set_tool_option("selected", &json!([0, 1]));
|
||
ids
|
||
}
|
||
|
||
/// The objects on the document's layers, in paint order.
|
||
fn objects(s: &Session) -> Vec<u64> {
|
||
s.doc().unwrap().doc.layers.iter().flat_map(|l| l.children().into_iter().flatten().map(|n| n.id.0)).collect()
|
||
}
|
||
|
||
/// Duplicate Artboards copies every selected artboard with its art, in one undo step (the public
|
||
/// Use the artboard context menu page). The copies keep their places relative to each other, right
|
||
/// of the last artboard.
|
||
#[test]
|
||
fn duplicate_artboards_copies_every_selected_artboard_in_one_step() {
|
||
let mut s = session();
|
||
let [a, b, _] = two_selected_artboards(&mut s);
|
||
let before = (boards(&s), objects(&s));
|
||
for p in [json!({"indices": [0, 1]}), json!({})] {
|
||
let out = s.execute("artboard.duplicate", &p).unwrap();
|
||
assert_eq!((out["index"].clone(), out["indices"].clone()), (json!(3), json!([3, 4])), "{p}");
|
||
let copies = ids(&out["ids"]);
|
||
assert_eq!(copies.len(), 2, "{p}");
|
||
assert_eq!(boards(&s)[3..], [(1120.0, "Artboard 1 copy".to_string()), (2120.0, "Artboard 2 copy".to_string())], "{p}");
|
||
assert_eq!((left(&s, copies[0]), left(&s, copies[1])), (1130.0, 2130.0), "{p}: each square on its artboard's copy");
|
||
assert!(left(&s, a) == 10.0 && left(&s, b) == 1010.0, "{p}: the originals stay");
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!((boards(&s), objects(&s)), before, "{p}: one undo step");
|
||
}
|
||
}
|
||
|
||
/// #2103: with the Artboard tool, an arrow key moves the selected artboards and the art on them by
|
||
/// the keyboard increment (Shift: ten times as far) in one undo step each; the selected art
|
||
/// doesn't move on its own.
|
||
#[test]
|
||
fn artboard_tool_arrow_keys_move_the_selected_artboards_with_their_art() {
|
||
use vectorcraft_tools::{Mods, ToolKey};
|
||
let mut s = session();
|
||
let [a, b, c] = two_selected_artboards(&mut s);
|
||
s.execute("prefs.set", &json!({"key": "keyboardIncrement", "value": 4})).unwrap();
|
||
let v = ViewInfo::default();
|
||
assert!(s.tool_claims_key(ToolKey::Right, v));
|
||
let undo = s.doc().unwrap().history.undo.len();
|
||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||
s.tool_key(ToolKey::Right, Mods { shift: true, ..Mods::default() }, v).unwrap();
|
||
let xs = |s: &Session| boards(s).into_iter().map(|(x, _)| x).collect::<Vec<_>>();
|
||
assert_eq!(xs(&s), [44.0, 1044.0, 0.0]);
|
||
assert_eq!([left(&s, a), left(&s, b), left(&s, c)], [54.0, 1054.0, 10.0], "the art moves with its artboard");
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 2, "one step per press");
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!((xs(&s), left(&s, a)), (vec![4.0, 1004.0, 0.0], 14.0));
|
||
}
|
||
|
||
/// With the Artboard tool, Copy takes every selected artboard and the art on each (the public Cut,
|
||
/// copy, and paste artboards page). Paste adds a copy of each with its art in one undo step,
|
||
/// together right of the last artboard; Paste in Place puts them where they were. The tool then
|
||
/// selects the pasted artboards.
|
||
#[test]
|
||
fn artboard_tool_copy_and_paste_bring_every_selected_artboard() {
|
||
let mut s = session();
|
||
two_selected_artboards(&mut s);
|
||
let before = (boards(&s), objects(&s));
|
||
assert_eq!(s.execute("edit.copy", &json!({})).unwrap()["copied"], 2, "the squares on artboards 1 and 2");
|
||
let out = s.execute("edit.paste", &json!({})).unwrap();
|
||
assert_eq!((out["artboard"].clone(), out["artboards"].clone()), (json!(3), json!([3, 4])));
|
||
assert_eq!(boards(&s)[3..], [(1120.0, "Artboard 1 copy".to_string()), (2120.0, "Artboard 2 copy".to_string())]);
|
||
let pasted = ids(&out["ids"]);
|
||
assert_eq!((left(&s, pasted[0]), left(&s, pasted[1])), (1130.0, 2130.0));
|
||
assert_eq!(s.tool_options()["selected"], json!([3, 4]), "the tool selects the pasted artboards");
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!((boards(&s), objects(&s)), before, "one undo step");
|
||
let out = s.execute("edit.pasteInPlace", &json!({})).unwrap();
|
||
let d = &s.doc().unwrap().doc;
|
||
assert_eq!((d.artboards[3].rect, d.artboards[4].rect), (d.artboards[0].rect, d.artboards[1].rect));
|
||
let pasted = ids(&out["ids"]);
|
||
assert_eq!((left(&s, pasted[0]), left(&s, pasted[1])), (10.0, 1010.0));
|
||
}
|
||
|
||
/// With the Artboard tool, Cut takes every selected artboard and the art on each away in one undo
|
||
/// step, and leaves the other artboards and their art; it fails when every artboard is selected.
|
||
#[test]
|
||
fn artboard_tool_cut_removes_every_selected_artboard() {
|
||
let mut s = session();
|
||
let [a, b, c] = two_selected_artboards(&mut s);
|
||
let before = (boards(&s), objects(&s));
|
||
assert_eq!(s.execute("edit.cut", &json!({})).unwrap()["copied"], 2);
|
||
assert_eq!((boards(&s), objects(&s)), (vec![(0.0, "Small".into())], vec![c]));
|
||
s.execute("edit.pasteInPlace", &json!({})).unwrap();
|
||
assert_eq!(boards(&s), vec![(0.0, "Small".into()), (0.0, "Artboard 1".into()), (1000.0, "Artboard 2".into())]);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!((boards(&s), objects(&s)), before, "the cut is one undo step");
|
||
assert!(objects(&s).contains(&a) && objects(&s).contains(&b));
|
||
s.set_tool_option("selected", &json!([0, 1, 2]));
|
||
assert!(s.execute("edit.cut", &json!({})).is_err(), "every artboard");
|
||
assert_eq!(boards(&s), before.0);
|
||
}
|
||
|
||
/// With the Artboard tool, Copy and Paste copy its artboard with the art on it (#446): Paste places
|
||
/// it right of the last artboard, Paste in Place where it was, also in another document; each paste
|
||
/// is one undo step, and the tool takes the pasted artboard.
|
||
#[test]
|
||
fn artboard_tool_copy_and_paste_artboards_with_their_art() {
|
||
let mut s = session();
|
||
let r = rect(&mut s);
|
||
s.execute("artboard.new", &json!({"x": 0, "y": 700, "width": 100, "height": 100, "name": "Small"})).unwrap();
|
||
s.execute("select.none", &json!({})).unwrap();
|
||
// Nothing selected: Copy is off, until the Artboard tool is chosen.
|
||
let copy_enabled = |s: &Session| (crate::find_command("edit.copy").unwrap().enabled)(s).is_ok();
|
||
assert!(!copy_enabled(&s));
|
||
s.select_tool("artboard", ViewInfo::default()).unwrap();
|
||
assert!(copy_enabled(&s));
|
||
assert_eq!(s.execute("edit.copy", &json!({})).unwrap()["copied"], 1, "artboard 1 and its rectangle");
|
||
let out = s.execute("edit.paste", &json!({"center": [5, 5]})).unwrap();
|
||
assert_eq!(out["artboard"], 2);
|
||
let pasted = ids(&out["ids"]);
|
||
assert_eq!(left(&s, pasted[0]), 830.0, "beside the artboards, not at the view centre");
|
||
assert_eq!(boards(&s)[2], (820.0, "Artboard 1 copy".into()));
|
||
assert_eq!(s.tool_options()["active"], 2);
|
||
let st = s.doc().unwrap();
|
||
assert_eq!(st.selection.in_paint_order(&st.doc), vec![NodeId(pasted[0])]);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(boards(&s).len(), 2);
|
||
assert!(s.doc().unwrap().doc.node(NodeId(pasted[0])).is_none(), "one undo step");
|
||
// In place: on top of the original.
|
||
let out = s.execute("edit.pasteInPlace", &json!({})).unwrap();
|
||
assert_eq!(left(&s, ids(&out["ids"])[0]), 10.0);
|
||
let d = &s.doc().unwrap().doc;
|
||
assert_eq!(d.artboards[2].rect, d.artboards[0].rect);
|
||
// Into another document.
|
||
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
|
||
let out = s.execute("edit.paste", &json!({})).unwrap();
|
||
assert_eq!(boards(&s), vec![(0.0, "Artboard 1".into()), (320.0, "Artboard 1 copy".into())]);
|
||
let n = s.doc().unwrap().doc.node(NodeId(ids(&out["ids"])[0])).unwrap().geometric_bounds().unwrap();
|
||
assert_eq!((n.x0, n.width()), (330.0, 100.0));
|
||
// Paste on All Artboards pastes the art alone.
|
||
let out = s.execute("edit.pasteOnAllArtboards", &json!({})).unwrap();
|
||
assert_eq!((out["artboard"].clone(), ids(&out["ids"]).len(), boards(&s).len()), (json!(null), 2, 2));
|
||
// The clipboard still holds the artboard for the next document, and the original is untouched.
|
||
s.execute("document.activate", &json!({"index": 0})).unwrap();
|
||
assert!(s.doc().unwrap().doc.node(r).is_some());
|
||
}
|
||
|
||
/// With the Artboard tool, Cut takes its artboard and the art on it away in one undo step (#446),
|
||
/// never the only artboard; pasting it back keeps its name.
|
||
#[test]
|
||
fn artboard_tool_cut_removes_the_artboard_and_its_art() {
|
||
let mut s = session();
|
||
let r = rect(&mut s);
|
||
let outside = s.execute("shape.rectangle", &json!({"x": 2000, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||
s.select_tool("artboard", ViewInfo::default()).unwrap();
|
||
assert!(s.execute("edit.cut", &json!({})).is_err(), "the only artboard");
|
||
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 100, "height": 100})).unwrap();
|
||
s.set_tool_option("active", &json!(0));
|
||
s.execute("edit.cut", &json!({})).unwrap();
|
||
let d = &s.doc().unwrap().doc;
|
||
assert_eq!((d.artboards.len(), d.node(r).is_none(), d.node(NodeId(outside)).is_some()), (1, true, true));
|
||
s.execute("edit.pasteInPlace", &json!({})).unwrap();
|
||
assert_eq!(boards(&s), vec![(1000.0, "Artboard 2".into()), (0.0, "Artboard 1".into())]);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
let d = &s.doc().unwrap().doc;
|
||
assert_eq!((d.artboards.len(), d.node(r).is_some()), (2, true), "the cut is one undo step");
|
||
}
|
||
|
||
/// A copy of an artboard whose id is the largest there is (only a hand-made file has one, #1718)
|
||
/// takes an id that no other artboard has, as a new artboard does, whether Duplicate Artboards, the
|
||
/// Artboard tool's Alt-drag (`artboard.move` with `copy`) or Paste makes it. Deleting either
|
||
/// artboard then leaves the other's guides.
|
||
#[test]
|
||
fn copied_artboards_take_a_free_id_and_keep_their_own_guides() {
|
||
let mut got = vec![];
|
||
for (cmd, p) in [
|
||
("artboard.duplicate", json!({"index": 0})),
|
||
("artboard.move", json!({"index": 0, "dx": 0, "dy": 900, "copy": true})),
|
||
("edit.paste", json!({})),
|
||
] {
|
||
let mut s = Session::new();
|
||
let mut d = vectorcraft_doc::Document::new(800.0, 600.0);
|
||
d.artboards[0].id = u32::MAX;
|
||
s.add_document(d, None);
|
||
s.execute("guide.add", &json!({"vertical": true, "pos": 100, "artboard": 0})).unwrap();
|
||
if cmd == "edit.paste" {
|
||
s.execute("artboard.copy", &json!({"index": 0})).unwrap();
|
||
}
|
||
s.execute(cmd, &p).unwrap();
|
||
// Each pass deletes one artboard (the copy, then the original), adds the artboard ids of
|
||
// the remaining guides to `kept`, and undoes the delete.
|
||
let mut kept = vec![];
|
||
for i in [1, 0] {
|
||
s.execute("artboard.delete", &json!({"index": i})).unwrap();
|
||
kept.push(s.doc().unwrap().doc.guides.iter().map(|g| g.artboard).collect::<Vec<_>>());
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
}
|
||
got.push((cmd, s.doc().unwrap().doc.artboards.iter().map(|a| a.id).collect::<Vec<_>>(), kept));
|
||
}
|
||
let max = Some(u32::MAX);
|
||
assert_eq!(
|
||
got,
|
||
[
|
||
("artboard.duplicate", vec![u32::MAX, 1], vec![vec![max], vec![Some(1)]]),
|
||
("artboard.move", vec![u32::MAX, 1], vec![vec![max], vec![Some(1)]]),
|
||
("edit.paste", vec![u32::MAX, 1], vec![vec![max], vec![]]),
|
||
]
|
||
);
|
||
}
|
||
|
||
/// `artboard.setProps {locked}` locks an artboard in one undo step and deselects the art on it.
|
||
/// Select All, `select.set` and Select › Inverse then pass over that art, a command that takes the
|
||
/// selection leaves it where it is, and the document summary reports `locked: true`. Unlocking
|
||
/// gives the art back. The art of pattern editing mode is on no artboard.
|
||
#[test]
|
||
fn a_locked_artboard_protects_the_art_on_it() {
|
||
let mut s = session();
|
||
let a = rect(&mut s);
|
||
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 200, "height": 200})).unwrap();
|
||
let b = NodeId(s.execute("shape.rectangle", &json!({"x": 1010, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap());
|
||
let selected = |s: &Session| s.doc().unwrap().selection.objects.clone();
|
||
let locked = |s: &Session| s.doc().unwrap().doc.artboards[1].locked;
|
||
s.execute("select.all", &json!({})).unwrap();
|
||
s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap();
|
||
assert_eq!(selected(&s), [a], "locking deselects b");
|
||
let doc = crate::inspect::document(&s);
|
||
assert_eq!(doc["artboards"][1]["locked"], true);
|
||
assert!(doc["artboards"][0].get("locked").is_none(), "{doc}");
|
||
for (cmd, p) in [("select.all", json!({})), ("select.set", json!({"ids": [a.0, b.0]}))] {
|
||
s.execute(cmd, &p).unwrap();
|
||
assert_eq!(selected(&s), [a], "{cmd}");
|
||
}
|
||
s.execute("select.none", &json!({})).unwrap();
|
||
s.execute("select.inverse", &json!({})).unwrap();
|
||
assert_eq!(selected(&s), [a]);
|
||
s.execute("select.all", &json!({})).unwrap();
|
||
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
|
||
assert_eq!((left(&s, a.0), left(&s, b.0)), (15.0, 1010.0), "b stays");
|
||
// Art drawn on it stays selected until the selection leaves it; art moved onto it is
|
||
// deselected by the edit.
|
||
let drawn = NodeId(s.execute("shape.rectangle", &json!({"x": 1100, "y": 100, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap());
|
||
assert_eq!(selected(&s), [drawn], "drawn on it");
|
||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||
s.execute("object.move", &json!({"dx": 1000, "dy": 0})).unwrap();
|
||
assert!(selected(&s).is_empty(), "moved onto it");
|
||
s.execute("select.set", &json!({"ids": [drawn.0]})).unwrap();
|
||
assert!(selected(&s).is_empty(), "drawn on it, then left out of the selection");
|
||
s.execute("artboard.setProps", &json!({"index": 1, "locked": false})).unwrap();
|
||
s.execute("select.all", &json!({})).unwrap();
|
||
assert_eq!(selected(&s), [a, b, drawn]);
|
||
for was in [true, true, true, true, false] {
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(locked(&s), was);
|
||
}
|
||
// The art of pattern editing mode is on no artboard: Select All selects it on a locked one.
|
||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||
s.execute("object.pattern.make", &json!({"name": "P", "edit": false})).unwrap();
|
||
s.execute("artboard.setProps", &json!({"index": 0, "locked": true})).unwrap();
|
||
s.execute("object.pattern.edit", &json!({"name": "P"})).unwrap();
|
||
s.execute("select.none", &json!({})).unwrap();
|
||
s.execute("select.all", &json!({})).unwrap();
|
||
let tile = selected(&s);
|
||
assert!(!tile.is_empty() && !tile.contains(&a), "{tile:?}");
|
||
}
|
||
|
||
/// New art on a locked artboard stays selected and editable until the selection leaves it: a
|
||
/// second Pen click goes on the path the first one started, and pasted art moves. Once another
|
||
/// object is clicked, a click, a marquee, `select.set` and Select All pass over that art. Undo, in
|
||
/// a batch too, and a failed batch bring back the selection the step had, and the new art with
|
||
/// it. Isolation mode starts with nothing selected, so the art is protected in it, and leaving it
|
||
/// doesn't select that art. Art drawn on an unlocked artboard, or
|
||
/// new art that leaves the locked artboard, is deselected when it is moved onto one. Selection
|
||
/// changes leave the document as it is, and once one has left the new art out, an unlocked
|
||
/// artboard over it moves without it. A member of a new group can be selected, and ungrouping it
|
||
/// keeps the members selected.
|
||
#[test]
|
||
fn new_art_on_a_locked_artboard_stays_editable_until_the_selection_leaves_it() {
|
||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||
let mut s = session();
|
||
let a = rect(&mut s);
|
||
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 400})).unwrap();
|
||
s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap();
|
||
// An unlocked artboard over part of the locked one.
|
||
s.execute("artboard.new", &json!({"x": 1100, "y": 200, "width": 200, "height": 200})).unwrap();
|
||
let selected = |s: &Session| s.doc().unwrap().selection.objects.iter().map(|id| id.0).collect::<Vec<_>>();
|
||
let v = ViewInfo { zoom: 1.0, smart_guides: false, snap_to_grid: false, snap_to_pixel: false, snap_to_point: false, ..Default::default() };
|
||
let click = |s: &mut Session, x, y| {
|
||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||
s.pointer(&PointerEvent::new(kind, x, y), v).unwrap();
|
||
}
|
||
};
|
||
// Art drawn on the unlocked artboard and moved onto the locked one isn't new art there.
|
||
let off = s.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||
assert_eq!(selected(&s), [off]);
|
||
s.execute("object.move", &json!({"dx": 1000, "dy": 0})).unwrap();
|
||
assert!(selected(&s).is_empty(), "moved onto it");
|
||
s.select_tool("pen", v).unwrap();
|
||
click(&mut s, 1050.0, 50.0);
|
||
click(&mut s, 1150.0, 50.0);
|
||
let [path] = selected(&s)[..] else { panic!("one path selected: {:?}", selected(&s)) };
|
||
assert_eq!(node(&s, NodeId(path)).path_data().unwrap().anchor_count(), 2, "the second click goes on the same path");
|
||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||
s.execute("edit.copy", &json!({})).unwrap();
|
||
let pasted = ids(&s.execute("edit.paste", &json!({"center": [1200, 300]})).unwrap()["ids"]);
|
||
assert_eq!(selected(&s), pasted);
|
||
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
|
||
assert_eq!((selected(&s), left(&s, pasted[0])), (pasted.clone(), 1160.0), "pasted art moves");
|
||
// Moved off the locked artboard and back, it is no longer new art.
|
||
s.execute("object.move", &json!({"dx": -1000, "dy": 0})).unwrap();
|
||
assert_eq!(selected(&s), pasted);
|
||
s.execute("object.move", &json!({"dx": 1000, "dy": 0})).unwrap();
|
||
assert!(selected(&s).is_empty(), "moved off and back");
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(selected(&s), pasted, "undone twice");
|
||
s.select_tool("selection", v).unwrap();
|
||
let doc = s.doc().unwrap().doc.clone();
|
||
click(&mut s, 10.0, 30.0);
|
||
assert_eq!(selected(&s), [a.0], "a click on other art");
|
||
assert!(Arc::ptr_eq(&doc, &s.doc().unwrap().doc), "a selection change leaves the document as it is");
|
||
let moved = s.execute("artboard.move", &json!({"index": 2, "dx": 0, "dy": 1000, "moveArt": true})).unwrap();
|
||
assert!(!ids(&moved["moved"]).contains(&pasted[0]), "the artboard over it moves without it");
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
click(&mut s, 1160.0, 300.0);
|
||
assert!(selected(&s).is_empty(), "a click on the pasted art's edge");
|
||
s.pointer(&PointerEvent::new(PointerKind::Down, 1010.0, 10.0), v).unwrap();
|
||
s.pointer(&PointerEvent::new(PointerKind::Drag, 1390.0, 390.0), v).unwrap();
|
||
s.pointer(&PointerEvent::new(PointerKind::Up, 1390.0, 390.0), v).unwrap();
|
||
assert!(selected(&s).is_empty(), "a marquee");
|
||
s.execute("select.set", &json!({"ids": [path, pasted[0]]})).unwrap();
|
||
assert!(selected(&s).is_empty(), "select.set");
|
||
s.execute("select.all", &json!({})).unwrap();
|
||
assert_eq!(selected(&s), [a.0], "Select All");
|
||
// Undoing the move brings back the pasted art selected, as it was before the move.
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(selected(&s), pasted);
|
||
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
|
||
assert_eq!(left(&s, pasted[0]), 1160.0);
|
||
batches_and_isolation_keep_new_art_protected(&mut s, pasted);
|
||
}
|
||
|
||
/// The batch, isolation and group cases of [`new_art_on_a_locked_artboard_stays_editable_until_the_selection_leaves_it`],
|
||
/// with the pasted art `pasted` selected.
|
||
fn batches_and_isolation_keep_new_art_protected(s: &mut Session, pasted: Vec<u64>) {
|
||
let selected = |s: &Session| s.doc().unwrap().selection.objects.iter().map(|id| id.0).collect::<Vec<_>>();
|
||
let editable = |s: &Session| s.doc().unwrap().doc.is_editable(NodeId(pasted[0]));
|
||
// A batch that deselects it and fails brings it back selected and editable, and so does
|
||
// undoing a batch that deselects it and draws.
|
||
let failed = json!({"commands": [{"command": "select.none"}, {"command": "no.such.command"}]});
|
||
assert!(s.execute("command.batch", &failed).is_err());
|
||
assert_eq!((selected(s), editable(s)), (pasted.clone(), true), "after a failed batch");
|
||
let drawn =
|
||
json!({"commands": [{"command": "select.none"}, {"command": "shape.rectangle", "params": {"x": 300, "y": 300, "width": 20, "height": 20}}]});
|
||
s.execute("command.batch", &drawn).unwrap();
|
||
assert!(!editable(s), "deselected by the batch");
|
||
// Undo inside a later batch gives the document the new art of the selection it brings back:
|
||
// the unlocked artboard over that art then moves it along.
|
||
let up = json!({"command": "artboard.move", "params": {"index": 2, "dx": 0, "dy": 1000, "moveArt": true}});
|
||
let r = s.execute("command.batch", &json!({"commands": [{"command": "edit.undo"}, up.clone()]})).unwrap();
|
||
assert!(ids(&r["results"][1]["moved"]).contains(&pasted[0]), "undone inside a batch: {r}");
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!((selected(s), editable(s)), (pasted.clone(), true), "the batch undone");
|
||
// Isolation mode inside a batch protects it before the next step.
|
||
let isolated = json!({"commands": [{"command": "object.isolate", "params": {"id": pasted[0]}}, up]});
|
||
let r = s.execute("command.batch", &isolated).unwrap();
|
||
assert!(!ids(&r["results"][1]["moved"]).contains(&pasted[0]), "isolated inside a batch: {r}");
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
s.execute("object.exitIsolation", &json!({})).unwrap();
|
||
// Isolation mode starts with nothing selected, so leaving it doesn't select the art again,
|
||
// and the art, protected again, isn't isolated, as locked art isn't.
|
||
assert!(selected(s).is_empty(), "after isolation mode");
|
||
assert!(!editable(s), "after isolation mode");
|
||
assert!(s.execute("object.isolate", &json!({"id": pasted[0]})).is_err(), "isolating it again");
|
||
// A member of a new group can be selected while the group is new art.
|
||
let copy = ids(&s.execute("edit.paste", &json!({"center": [1200, 150]})).unwrap()["ids"]);
|
||
s.execute("object.group", &json!({})).unwrap();
|
||
let group = selected(s);
|
||
s.execute("select.set", &json!({"ids": copy})).unwrap();
|
||
assert_eq!(selected(s), copy, "a member of a new group");
|
||
s.execute("select.set", &json!({"ids": group})).unwrap();
|
||
assert_eq!(selected(s), group, "from the member back to the group");
|
||
s.execute("object.ungroup", &json!({})).unwrap();
|
||
assert_eq!(selected(s), copy, "ungrouped");
|
||
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
|
||
assert_eq!(selected(s), copy, "the ungrouped art moves");
|
||
}
|
||
|
||
/// When an artboard moves, scales or is cut with its art, the art inside it that is also on a
|
||
/// locked artboard stays where it is, unless prefs moveLockedWithArtboard is on, as locked art
|
||
/// does; a copy of the artboard copies that art.
|
||
#[test]
|
||
fn art_also_on_a_locked_artboard_stays_when_another_artboard_moves() {
|
||
let mut s = session();
|
||
// A locked artboard over the right edge of artboard 1, and `held` inside artboard 1 and on it.
|
||
s.execute("artboard.new", &json!({"x": 700, "y": 0, "width": 300, "height": 300})).unwrap();
|
||
let free = rect(&mut s);
|
||
let held = NodeId(s.execute("shape.rectangle", &json!({"x": 710, "y": 10, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap());
|
||
s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap();
|
||
let undo = |s: &mut Session| s.execute("edit.undo", &json!({})).unwrap();
|
||
let moved = s.execute("artboard.move", &json!({"index": 0, "dx": 0, "dy": 1000, "moveArt": true})).unwrap();
|
||
assert_eq!(ids(&moved["moved"]), [free.0]);
|
||
undo(&mut s);
|
||
let scaled = s.execute("artboard.setProps", &json!({"index": 0, "width": 1600, "scaleArt": true})).unwrap();
|
||
assert_eq!(ids(&scaled["scaled"]), [free.0]);
|
||
undo(&mut s);
|
||
s.execute("artboard.cut", &json!({"index": 0, "art": true})).unwrap();
|
||
let d = &s.doc().unwrap().doc;
|
||
assert!(d.node(held).is_some() && d.node(free).is_none(), "the cut leaves it");
|
||
undo(&mut s);
|
||
assert_eq!(ids(&s.execute("artboard.duplicate", &json!({"index": 0, "art": true})).unwrap()["ids"]).len(), 2, "a copy takes it");
|
||
undo(&mut s);
|
||
s.prefs.move_locked_with_artboard = true;
|
||
let moved = s.execute("artboard.move", &json!({"index": 0, "dx": 0, "dy": 1000, "moveArt": true})).unwrap();
|
||
assert_eq!(ids(&moved["moved"]), [free.0, held.0], "with the preference on, it moves");
|
||
}
|
||
|
||
/// Two artboards with the Artboard tool on the second, which holds a plain, a locked and a hidden
|
||
/// rectangle, under prefs moveLockedWithArtboard `on`.
|
||
fn locked_art_session(on: bool) -> Session {
|
||
let mut s = session();
|
||
s.execute("artboard.new", &json!({})).unwrap();
|
||
let mut shape = |x: u32| s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 40, "height": 20})).unwrap()["id"].clone();
|
||
let (_, locked, hidden) = (shape(840), shape(900), shape(960));
|
||
s.execute("object.lock", &json!({"ids": [locked]})).unwrap();
|
||
s.execute("object.hide", &json!({"ids": [hidden]})).unwrap();
|
||
s.execute("select.none", &json!({})).unwrap();
|
||
s.select_tool("artboard", ViewInfo::default()).unwrap();
|
||
s.set_tool_option("selected", &json!([1]));
|
||
s.prefs.move_locked_with_artboard = on;
|
||
s
|
||
}
|
||
|
||
/// The bounds of every object in the document, and how many objects the clipboard holds.
|
||
fn art_and_clipboard(s: &Session) -> (Vec<Option<vectorcraft_geom::Rect>>, usize) {
|
||
let mut art = vec![];
|
||
for layer in &s.doc().unwrap().doc.layers {
|
||
layer.walk(&mut |n| {
|
||
if !n.is_layer() {
|
||
art.push(n.geometric_bounds());
|
||
}
|
||
});
|
||
}
|
||
(art, s.clipboard.nodes.len())
|
||
}
|
||
|
||
/// A command that takes art with an artboard journals whether locked and hidden art went along, so
|
||
/// a replay under the other preference takes the same art, in one undo step.
|
||
#[test]
|
||
fn artboard_commands_journal_the_locked_art_preference() {
|
||
let run = |on: bool, cmd: &str, p: &serde_json::Value| {
|
||
let mut s = locked_art_session(on);
|
||
s.execute(cmd, p).unwrap();
|
||
(art_and_clipboard(&s), s.journal.last().unwrap().clone())
|
||
};
|
||
let mut differ = vec![];
|
||
for (cmd, p) in [
|
||
("artboard.duplicate", json!({"index": 1, "art": true})),
|
||
("artboard.copy", json!({"index": 1, "art": true})),
|
||
("artboard.cut", json!({"index": 1, "art": true})),
|
||
("artboard.move", json!({"index": 1, "dx": 0, "dy": 1000, "moveArt": true})),
|
||
("artboard.move", json!({"index": 1, "dx": 0, "dy": 1000, "moveArt": true, "copy": true})),
|
||
("artboard.rearrange", json!({"layout": "row", "spacing": 100})),
|
||
("edit.copy", json!({})),
|
||
("edit.cut", json!({})),
|
||
// A null reads as a key left out.
|
||
("artboard.duplicate", json!({"index": 1, "art": true, "lockedAndHidden": null})),
|
||
("artboard.rearrange", json!({"layout": "row", "spacing": 100, "lockedAndHidden": null})),
|
||
("edit.cut", json!({"lockedAndHidden": null})),
|
||
] {
|
||
let (with, (id_with, p_with)) = run(true, cmd, &p);
|
||
let (without, (id_without, p_without)) = run(false, cmd, &p);
|
||
assert_ne!(with, without, "{cmd} {p}: an omitted param reads the preference");
|
||
if run(false, &id_with, &p_with).0 != with {
|
||
differ.push(format!("{cmd} {p}, replayed with the preference off"));
|
||
}
|
||
if run(true, &id_without, &p_without).0 != without {
|
||
differ.push(format!("{cmd} {p}, replayed with the preference on"));
|
||
}
|
||
|
||
let mut s = locked_art_session(true);
|
||
let (doc, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
|
||
s.execute(cmd, &p).unwrap();
|
||
if !cmd.ends_with("copy") {
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{cmd} {p}: one undo step");
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc, doc, "{cmd} {p}: undone");
|
||
}
|
||
}
|
||
assert!(differ.is_empty(), "{differ:#?}");
|
||
// With the Artboard tool chosen, a bad value is refused as it is by the command run.
|
||
let mut s = locked_art_session(false);
|
||
let calls: Vec<_> =
|
||
["edit.copy", "edit.cut"].map(|cmd| crate::tests::not_a_switch(cmd, json!({"lockedAndHidden": "true"}), "lockedAndHidden")).to_vec();
|
||
assert_eq!(crate::tests::not_refused(&mut s, &calls), Vec::<String>::new());
|
||
}
|
||
|
||
/// A locked artboard keeps its place and size: `artboard.setProps` refuses a new rectangle but
|
||
/// still renames it, `artboard.move`, `artboard.delete`, `artboard.cut` and `artboard.fitToArt`
|
||
/// refuse it, and Rearrange leaves it and the art on it where they are. A copy of it starts
|
||
/// unlocked, and unlocked it moves again.
|
||
#[test]
|
||
fn a_locked_artboard_keeps_its_place_and_size() {
|
||
let mut s = session();
|
||
// Three artboards in a row, 800 pt wide: at 0, 820 and 1640.
|
||
for _ in 0..2 {
|
||
s.execute("artboard.new", &json!({})).unwrap();
|
||
}
|
||
// On the second and third artboards, its center on the third.
|
||
let shape = |s: &mut Session, x: u32, y: u32| {
|
||
s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 80, "height": 20})).unwrap()["id"].as_u64().unwrap()
|
||
};
|
||
let c = shape(&mut s, 1610, 10);
|
||
// In a sublayer, one like it and one on the third artboard alone.
|
||
s.execute("layer.newSublayer", &json!({"name": "Sub"})).unwrap();
|
||
let (c2, d2) = (shape(&mut s, 1610, 100), shape(&mut s, 2000, 100));
|
||
s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap();
|
||
let rects = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
|
||
let was = rects(&s);
|
||
let refused = |r: Result<serde_json::Value>| matches!(r, Err(EngineError::Other(m)) if m == "the artboard is locked");
|
||
assert!(refused(s.execute("artboard.setProps", &json!({"index": 1, "width": 300}))));
|
||
assert!(refused(s.execute("artboard.move", &json!({"index": 1, "dx": 5, "dy": 0}))));
|
||
assert!(refused(s.execute("artboard.delete", &json!({"index": 1}))));
|
||
assert!(refused(s.execute("artboard.cut", &json!({"index": 1}))));
|
||
assert!(refused(s.execute("artboard.fitToArt", &json!({"index": 1}))));
|
||
assert_eq!(rects(&s), was);
|
||
s.execute("artboard.setProps", &json!({"index": 1, "name": "Cover", "x": 820, "y": 0, "width": 800, "height": 600})).unwrap();
|
||
assert_eq!(boards(&s)[1], (820.0, "Cover".into()), "Artboard Options renames it");
|
||
// Its slot in the row is at 850; the third artboard's is at 1700.
|
||
s.execute("artboard.rearrange", &json!({"layout": "row", "spacing": 50})).unwrap();
|
||
assert_eq!(rects(&s)[1], was[1]);
|
||
assert_eq!(rects(&s)[2].x0, 1700.0, "the third artboard moves");
|
||
assert_eq!((left(&s, c), left(&s, c2)), (1610.0, 1610.0), "the art on the locked artboard stays, in a sublayer too");
|
||
assert_eq!(left(&s, d2), 2060.0, "the sublayer's other art moves with the third artboard");
|
||
let copy = s.execute("artboard.duplicate", &json!({"index": 1, "art": false})).unwrap()["index"].as_u64().unwrap() as usize;
|
||
assert!(!s.doc().unwrap().doc.artboards[copy].locked, "a copy starts unlocked");
|
||
s.execute("artboard.setProps", &json!({"index": 1, "locked": false, "x": 5})).unwrap();
|
||
assert_eq!(rects(&s)[1].x0, 5.0, "unlocking it lets the same change move it");
|
||
}
|
||
|
||
/// A locked artboard keeps its exact rectangle when `artboard.setProps` passes values within
|
||
/// 1e-6 pt of it, as Artboard Options does when it renames the artboard: repeated calls don't
|
||
/// move or resize it by small steps.
|
||
#[test]
|
||
fn a_locked_artboard_keeps_its_exact_rectangle() {
|
||
let mut s = session();
|
||
s.execute("artboard.setProps", &json!({"index": 0, "locked": true})).unwrap();
|
||
let rect = |s: &Session| s.doc().unwrap().doc.artboards[0].rect;
|
||
let was = rect(&s);
|
||
for i in 0..10 {
|
||
let r = rect(&s);
|
||
let p =
|
||
json!({"index": 0, "name": format!("Cover {i}"), "x": r.x0 + 5e-7, "y": r.y0 - 9e-7, "width": r.width() + 4e-7, "height": r.height()});
|
||
s.execute("artboard.setProps", &p).unwrap();
|
||
}
|
||
assert_eq!(rect(&s), was);
|
||
assert_eq!(boards(&s)[0].1, "Cover 9", "the name still changes");
|
||
}
|
||
|
||
/// `artboard.setProps {artboards}` takes `locked` from each entry, and a list that gives a locked
|
||
/// artboard a new rectangle is refused and changes no artboard.
|
||
#[test]
|
||
fn a_list_of_artboards_takes_each_entrys_lock() {
|
||
let mut s = session();
|
||
s.execute("artboard.new", &json!({})).unwrap();
|
||
s.execute("artboard.setProps", &json!({"artboards": [{"index": 0, "locked": true}, {"index": 1, "name": "Back"}]})).unwrap();
|
||
let locks = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.locked).collect::<Vec<_>>();
|
||
let widths = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect.width()).collect::<Vec<_>>();
|
||
assert_eq!(locks(&s), [true, false]);
|
||
let was = widths(&s);
|
||
let r = s.execute("artboard.setProps", &json!({"artboards": [{"index": 1, "width": 300}, {"index": 0, "width": 300}]}));
|
||
assert!(matches!(r, Err(EngineError::Other(m)) if m == "the artboard is locked"));
|
||
assert_eq!(widths(&s), was, "neither artboard changes");
|
||
s.execute("artboard.setProps", &json!({"artboards": [{"index": 1, "width": 300}, {"index": 0, "width": 300, "locked": false}]})).unwrap();
|
||
assert_eq!((locks(&s), widths(&s)), (vec![false, false], vec![300.0, 300.0]));
|
||
}
|
||
|
||
/// The native format writes an artboard's lock and reads it back. An unlocked artboard is written
|
||
/// as before, with no `locked` key: unlocked again, the document saves to the bytes it had before.
|
||
#[test]
|
||
fn artboard_lock_round_trips_through_the_native_format() {
|
||
let mut s = session();
|
||
s.execute("artboard.new", &json!({})).unwrap();
|
||
let save = |s: &Session| vectorcraft_format::save(&s.doc().unwrap().doc, false);
|
||
let before = save(&s);
|
||
s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap();
|
||
let bytes = save(&s);
|
||
let saved: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||
let keys = saved["document"]["artboards"][0].as_object().unwrap().keys().cloned().collect::<Vec<_>>();
|
||
assert_eq!(keys, ["id", "name", "rect", "show_center_mark", "show_cross_hairs"]);
|
||
assert_eq!(saved["document"]["artboards"][1]["locked"], true);
|
||
let back = vectorcraft_format::load(&bytes).unwrap();
|
||
assert!(back.artboards[1].locked && !back.artboards[0].locked);
|
||
assert_eq!(back.artboards, s.doc().unwrap().doc.artboards);
|
||
s.execute("artboard.setProps", &json!({"index": 1, "locked": false})).unwrap();
|
||
assert_eq!(save(&s), before);
|
||
}
|
||
|
||
/// Artboard Options' Show Center Mark and Show Cross Hairs: `artboard.setProps {showCenterMark,
|
||
/// showCrossHairs}` sets and clears each on that artboard only, in one undo step, also for a
|
||
/// locked artboard and in an `artboards` list. A change without them leaves them, a duplicate
|
||
/// keeps them, `artboard.new` takes them, `inspect_document` reports them, and a value that isn't
|
||
/// true or false is refused.
|
||
#[test]
|
||
fn artboard_display_marks_are_set_and_cleared_in_one_undo_step() {
|
||
let mut s = session();
|
||
s.execute("artboard.new", &json!({})).unwrap();
|
||
let marks = |s: &Session, i: usize| {
|
||
let a = &s.doc().unwrap().doc.artboards[i];
|
||
(a.show_center_mark, a.show_cross_hairs)
|
||
};
|
||
let undo = |s: &Session| s.doc().unwrap().history.undo.len();
|
||
let u0 = undo(&s);
|
||
s.execute("artboard.setProps", &json!({"index": 1, "showCenterMark": true})).unwrap();
|
||
assert_eq!((marks(&s, 0), marks(&s, 1)), ((false, false), (true, false)), "only that artboard");
|
||
s.execute("artboard.setProps", &json!({"index": 1, "showCrossHairs": true, "showCenterMark": false})).unwrap();
|
||
assert_eq!(marks(&s, 1), (false, true));
|
||
s.execute("artboard.setProps", &json!({"index": 1, "name": "Marked"})).unwrap();
|
||
assert_eq!(marks(&s, 1), (false, true), "a change without them leaves them");
|
||
assert_eq!(undo(&s), u0 + 3, "one undo step each");
|
||
for was in [(false, true), (true, false), (false, false)] {
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(marks(&s, 1), was);
|
||
}
|
||
s.execute("edit.redo", &json!({})).unwrap();
|
||
assert_eq!(marks(&s, 1), (true, false));
|
||
// A locked artboard keeps its rectangle, not its display marks.
|
||
s.execute("artboard.setProps", &json!({"index": 0, "locked": true})).unwrap();
|
||
s.execute("artboard.setProps", &json!({"index": 0, "showCrossHairs": true})).unwrap();
|
||
assert_eq!(marks(&s, 0), (false, true));
|
||
let u = undo(&s);
|
||
s.execute(
|
||
"artboard.setProps",
|
||
&json!({"artboards": [{"index": 0, "showCenterMark": true}, {"index": 1, "showCenterMark": false, "showCrossHairs": true}]}),
|
||
)
|
||
.unwrap();
|
||
assert_eq!((marks(&s, 0), marks(&s, 1)), ((true, true), (false, true)), "each listed artboard takes its own");
|
||
assert_eq!(undo(&s), u + 1, "in one undo step");
|
||
let doc = crate::inspect::document(&s);
|
||
assert_eq!((doc["artboards"][0]["showCenterMark"].clone(), doc["artboards"][1]["showCrossHairs"].clone()), (json!(true), json!(true)));
|
||
assert!(doc["artboards"][1].get("showCenterMark").is_none(), "{doc}");
|
||
let copy = s.execute("artboard.duplicate", &json!({"index": 1})).unwrap()["index"].as_u64().unwrap() as usize;
|
||
assert_eq!(marks(&s, copy), (false, true), "a duplicate keeps them");
|
||
let made = s.execute("artboard.new", &json!({"showCenterMark": true})).unwrap()["index"].as_u64().unwrap() as usize;
|
||
assert_eq!(marks(&s, made), (true, false), "a new artboard takes them too");
|
||
let plain = s.execute("artboard.new", &json!({})).unwrap()["index"].as_u64().unwrap() as usize;
|
||
assert_eq!(marks(&s, plain), (false, false));
|
||
let before = s.doc().unwrap().doc.clone();
|
||
assert!(s.execute("artboard.setProps", &json!({"index": 1, "showCenterMark": "yes"})).is_err());
|
||
assert!(
|
||
s.execute("artboard.setProps", &json!({"artboards": [{"index": 0, "showCenterMark": false}, {"index": 1, "showCrossHairs": 1}]})).is_err()
|
||
);
|
||
assert!(s.execute("artboard.new", &json!({"showCrossHairs": "yes"})).is_err());
|
||
assert_eq!(*s.doc().unwrap().doc, *before, "a bad value changes nothing");
|
||
}
|
||
|
||
/// The native format keeps Show Center Mark and Show Cross Hairs. A document that never shows them
|
||
/// is written byte for byte as before they could be set, and a file written without the two keys
|
||
/// opens with both off.
|
||
#[test]
|
||
fn artboard_display_marks_round_trip_through_the_native_format() {
|
||
let mut s = session();
|
||
s.execute("artboard.new", &json!({})).unwrap();
|
||
let save = |s: &Session| vectorcraft_format::save(&s.doc().unwrap().doc, false);
|
||
let before = save(&s);
|
||
s.execute("artboard.setProps", &json!({"artboards": [{"index": 0, "showCenterMark": true}, {"index": 1, "showCrossHairs": true}]})).unwrap();
|
||
let bytes = save(&s);
|
||
let saved: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||
let ab = |i: usize| saved["document"]["artboards"][i].clone();
|
||
assert_eq!((ab(0)["show_center_mark"].clone(), ab(0)["show_cross_hairs"].clone()), (json!(true), json!(false)));
|
||
assert_eq!((ab(1)["show_center_mark"].clone(), ab(1)["show_cross_hairs"].clone()), (json!(false), json!(true)));
|
||
let back = vectorcraft_format::load(&bytes).unwrap();
|
||
assert_eq!(back.artboards, s.doc().unwrap().doc.artboards);
|
||
s.execute("artboard.setProps", &json!({"artboards": [{"index": 0, "showCenterMark": false}, {"index": 1, "showCrossHairs": false}]})).unwrap();
|
||
assert_eq!(save(&s), before, "off again, the file is as it was");
|
||
// A file whose artboards have no such keys (older writers) opens with both off.
|
||
let mut old: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||
for a in old["document"]["artboards"].as_array_mut().unwrap() {
|
||
let a = a.as_object_mut().unwrap();
|
||
a.remove("show_center_mark");
|
||
a.remove("show_cross_hairs");
|
||
}
|
||
let back = vectorcraft_format::load(&serde_json::to_vec(&old).unwrap()).unwrap();
|
||
assert!(back.artboards.iter().all(|a| !a.show_center_mark && !a.show_cross_hairs));
|
||
}
|
||
|
||
/// Show Center Mark, Show Cross Hairs, Show Video Safe Areas and the Video Ruler Pixel Aspect Ratio
|
||
/// are screen-only: SVG and PDF exports (and print, which prints the PDF) come out the same with
|
||
/// them on as with them off.
|
||
#[test]
|
||
fn artboard_display_marks_stay_out_of_exports() {
|
||
let mut s = session();
|
||
rect(&mut s);
|
||
let doc = |s: &Session| (*s.doc().unwrap().doc).clone();
|
||
let off = doc(&s);
|
||
s.execute(
|
||
"artboard.setProps",
|
||
&json!({"index": 0, "showCenterMark": true, "showCrossHairs": true, "showVideoSafeAreas": true, "videoRulerPixelAspectRatio": 2}),
|
||
)
|
||
.unwrap();
|
||
let on = doc(&s);
|
||
assert_ne!(on.artboards, off.artboards);
|
||
assert_eq!(vectorcraft_svg::export(&on, &Default::default()), vectorcraft_svg::export(&off, &Default::default()));
|
||
// Fix the export date so metadata does not change between the two exports.
|
||
let options = vectorcraft_pdf::PdfOptions { created: Some(0), ..Default::default() };
|
||
assert_eq!(crate::export_pdf(&on, &options).unwrap(), crate::export_pdf(&off, &options).unwrap());
|
||
}
|
||
|
||
/// Artboard Options' video display options: `artboard.setProps {showVideoSafeAreas,
|
||
/// videoRulerPixelAspectRatio}` sets each on that artboard only, in one undo step, also for a
|
||
/// locked artboard and in an `artboards` list. A change without them leaves them, a duplicate
|
||
/// keeps them, `artboard.new` takes them, `inspect_document` reports them, and a value that isn't
|
||
/// true or false, or a ratio outside 0.1–10, is refused and changes nothing.
|
||
#[test]
|
||
fn artboard_video_display_options_are_set_in_one_undo_step() {
|
||
let mut s = session();
|
||
s.execute("artboard.new", &json!({})).unwrap();
|
||
let video = |s: &Session, i: usize| {
|
||
let a = &s.doc().unwrap().doc.artboards[i];
|
||
(a.show_video_safe_areas, a.video_ruler_pixel_aspect_ratio)
|
||
};
|
||
let undo = |s: &Session| s.doc().unwrap().history.undo.len();
|
||
assert_eq!((video(&s, 0), video(&s, 1)), ((false, 1.0), (false, 1.0)), "off and square by default");
|
||
let u0 = undo(&s);
|
||
s.execute("artboard.setProps", &json!({"index": 1, "showVideoSafeAreas": true})).unwrap();
|
||
assert_eq!((video(&s, 0), video(&s, 1)), ((false, 1.0), (true, 1.0)), "only that artboard");
|
||
s.execute("artboard.setProps", &json!({"index": 1, "videoRulerPixelAspectRatio": 0.9})).unwrap();
|
||
assert_eq!(video(&s, 1), (true, 0.9));
|
||
s.execute("artboard.setProps", &json!({"index": 1, "name": "Video"})).unwrap();
|
||
assert_eq!(video(&s, 1), (true, 0.9), "a change without them leaves them");
|
||
assert_eq!(undo(&s), u0 + 3, "one undo step each");
|
||
for was in [(true, 0.9), (true, 1.0), (false, 1.0)] {
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(video(&s, 1), was);
|
||
}
|
||
s.execute("edit.redo", &json!({})).unwrap();
|
||
assert_eq!(video(&s, 1), (true, 1.0));
|
||
// A locked artboard keeps its rectangle, not its video display options.
|
||
s.execute("artboard.setProps", &json!({"index": 0, "locked": true})).unwrap();
|
||
s.execute("artboard.setProps", &json!({"index": 0, "videoRulerPixelAspectRatio": 2})).unwrap();
|
||
assert_eq!(video(&s, 0), (false, 2.0));
|
||
let u = undo(&s);
|
||
s.execute(
|
||
"artboard.setProps",
|
||
&json!({"artboards": [{"index": 0, "showVideoSafeAreas": true}, {"index": 1, "showVideoSafeAreas": false, "videoRulerPixelAspectRatio": 1.2}]}),
|
||
)
|
||
.unwrap();
|
||
assert_eq!((video(&s, 0), video(&s, 1)), ((true, 2.0), (false, 1.2)), "each listed artboard takes its own");
|
||
assert_eq!(undo(&s), u + 1, "in one undo step");
|
||
let doc = crate::inspect::document(&s);
|
||
assert_eq!(doc["artboards"][0]["showVideoSafeAreas"], json!(true));
|
||
assert_eq!(
|
||
(doc["artboards"][0]["videoRulerPixelAspectRatio"].clone(), doc["artboards"][1]["videoRulerPixelAspectRatio"].clone()),
|
||
(json!(2.0), json!(1.2))
|
||
);
|
||
assert!(doc["artboards"][1].get("showVideoSafeAreas").is_none(), "{doc}");
|
||
s.execute("artboard.setProps", &json!({"index": 1, "videoRulerPixelAspectRatio": 1})).unwrap();
|
||
assert!(crate::inspect::document(&s)["artboards"][1].get("videoRulerPixelAspectRatio").is_none(), "square pixels aren't reported");
|
||
let copy = s.execute("artboard.duplicate", &json!({"index": 0})).unwrap()["index"].as_u64().unwrap() as usize;
|
||
assert_eq!(video(&s, copy), (true, 2.0), "a duplicate keeps them");
|
||
let made = s.execute("artboard.new", &json!({"showVideoSafeAreas": true, "videoRulerPixelAspectRatio": 0.1})).unwrap()["index"].as_u64().unwrap()
|
||
as usize;
|
||
assert_eq!(video(&s, made), (true, 0.1), "a new artboard takes them too");
|
||
let before = s.doc().unwrap().doc.clone();
|
||
for bad in [json!("yes"), json!(1)] {
|
||
assert!(s.execute("artboard.setProps", &json!({"index": 1, "showVideoSafeAreas": bad})).is_err());
|
||
assert!(s.execute("artboard.new", &json!({"showVideoSafeAreas": bad})).is_err());
|
||
}
|
||
for bad in [json!(0.09), json!(10.5), json!(0), json!(-1), json!("2"), json!(null)] {
|
||
assert!(s.execute("artboard.setProps", &json!({"index": 1, "videoRulerPixelAspectRatio": bad})).is_err(), "{bad}");
|
||
assert!(s.execute("artboard.new", &json!({"videoRulerPixelAspectRatio": bad})).is_err(), "{bad}");
|
||
}
|
||
assert!(
|
||
s.execute(
|
||
"artboard.setProps",
|
||
&json!({"artboards": [{"index": 0, "showVideoSafeAreas": false}, {"index": 1, "videoRulerPixelAspectRatio": 11}]})
|
||
)
|
||
.is_err()
|
||
);
|
||
assert_eq!(*s.doc().unwrap().doc, *before, "a bad value changes nothing");
|
||
s.execute("artboard.setProps", &json!({"index": 1, "videoRulerPixelAspectRatio": 10})).unwrap();
|
||
assert_eq!(video(&s, 1), (false, 10.0), "10 is in range");
|
||
}
|
||
|
||
/// The native format keeps Show Video Safe Areas and the Video Ruler Pixel Aspect Ratio. A
|
||
/// document whose artboards hide the safe areas and have square pixels is written byte for byte as
|
||
/// before they could be set, a file written without the two keys opens with the safe areas off
|
||
/// and square pixels, and a file's ratio outside 0.1–10 opens brought into that range.
|
||
#[test]
|
||
fn artboard_video_display_options_round_trip_through_the_native_format() {
|
||
let mut s = session();
|
||
s.execute("artboard.new", &json!({})).unwrap();
|
||
let save = |s: &Session| vectorcraft_format::save(&s.doc().unwrap().doc, false);
|
||
let before = save(&s);
|
||
s.execute(
|
||
"artboard.setProps",
|
||
&json!({"artboards": [{"index": 0, "showVideoSafeAreas": true}, {"index": 1, "videoRulerPixelAspectRatio": 0.9}]}),
|
||
)
|
||
.unwrap();
|
||
let bytes = save(&s);
|
||
let saved: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||
let ab = |i: usize| saved["document"]["artboards"][i].clone();
|
||
assert_eq!(ab(0)["show_video_safe_areas"], json!(true));
|
||
assert!(ab(0).get("video_ruler_pixel_aspect_ratio").is_none(), "square pixels aren't written: {}", ab(0));
|
||
assert_eq!(ab(1)["video_ruler_pixel_aspect_ratio"], json!(0.9));
|
||
assert!(ab(1).get("show_video_safe_areas").is_none(), "hidden safe areas aren't written: {}", ab(1));
|
||
let back = vectorcraft_format::load(&bytes).unwrap();
|
||
assert_eq!(back.artboards, s.doc().unwrap().doc.artboards);
|
||
assert_eq!((back.artboards[0].show_video_safe_areas, back.artboards[1].video_ruler_pixel_aspect_ratio), (true, 0.9));
|
||
s.execute("artboard.setProps", &json!({"artboards": [{"index": 0, "showVideoSafeAreas": false}, {"index": 1, "videoRulerPixelAspectRatio": 1}]}))
|
||
.unwrap();
|
||
assert_eq!(save(&s), before, "off and square again, the file is as it was");
|
||
// A file whose artboards have no such keys (older writers) opens with them off and square.
|
||
let mut old: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||
for a in old["document"]["artboards"].as_array_mut().unwrap() {
|
||
let a = a.as_object_mut().unwrap();
|
||
a.remove("show_video_safe_areas");
|
||
a.remove("video_ruler_pixel_aspect_ratio");
|
||
}
|
||
let back = vectorcraft_format::load(&serde_json::to_vec(&old).unwrap()).unwrap();
|
||
assert!(back.artboards.iter().all(|a| !a.show_video_safe_areas && a.video_ruler_pixel_aspect_ratio == 1.0));
|
||
for (written, opens) in [(json!(0), 0.1), (json!(-3), 0.1), (json!(1e9), 10.0), (json!(2.5), 2.5)] {
|
||
let mut file: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||
file["document"]["artboards"][1]["video_ruler_pixel_aspect_ratio"] = written.clone();
|
||
let back = vectorcraft_format::load(&serde_json::to_vec(&file).unwrap()).unwrap();
|
||
assert_eq!(back.artboards[1].video_ruler_pixel_aspect_ratio, opens, "{written}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn swatch_groups_duplicate_sort() {
|
||
let mut s = session();
|
||
s.execute("swatch.new", &json!({"name": "Zed", "color": "#123456"})).unwrap();
|
||
s.execute("swatch.new", &json!({"name": "Alpha", "color": "#654321"})).unwrap();
|
||
let r = s.execute("swatch.newGroup", &json!({"name": "Mine", "swatches": ["Zed"], "colors": ["#ff0000"]})).unwrap();
|
||
assert_eq!(r["name"], "Mine");
|
||
let d = &s.doc().unwrap().doc;
|
||
let g = d.swatch_groups.iter().find(|g| g.name == "Mine").unwrap();
|
||
assert_eq!(g.swatches.len(), 2);
|
||
assert!(!d.swatches.iter().any(|x| x.name == "Zed"));
|
||
let r = s.execute("swatch.duplicate", &json!({"name": "Alpha"})).unwrap();
|
||
assert_eq!(r["name"], "Alpha copy");
|
||
s.execute("swatch.sortByName", &json!({})).unwrap();
|
||
let d = &s.doc().unwrap().doc;
|
||
assert!(d.swatches[0].name.starts_with('['));
|
||
let names: Vec<_> = d.swatches.iter().skip(1).map(|x| x.name.to_lowercase()).collect();
|
||
let mut sorted = names.clone();
|
||
sorted.sort();
|
||
assert_eq!(names, sorted);
|
||
}
|
||
|
||
#[test]
|
||
fn graphic_style_delete_duplicate() {
|
||
let mut s = session();
|
||
let id = rect(&mut s);
|
||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||
s.execute("graphicStyle.new", &json!({"name": "S1"})).unwrap();
|
||
assert_eq!(s.execute("graphicStyle.duplicate", &json!({"name": "S1"})).unwrap()["name"], "S1 copy");
|
||
s.execute("graphicStyle.delete", &json!({"name": "S1"})).unwrap();
|
||
let d = &s.doc().unwrap().doc;
|
||
assert!(d.graphic_styles.iter().all(|g| g.name != "S1"));
|
||
assert!(s.execute("graphicStyle.delete", &json!({"name": "nope"})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn text_set_format() {
|
||
let mut s = session();
|
||
let r = s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Hello"})).unwrap();
|
||
let id = NodeId(r["id"].as_u64().unwrap());
|
||
s.execute(
|
||
"text.setFormat",
|
||
&json!({"ids": [id.0], "kerning": 20, "baselineShift": 3, "hScale": 120, "rotation": 370, "underline": true, "spaceBefore": 6, "hyphenate": true}),
|
||
)
|
||
.unwrap();
|
||
let NodeKind::Text(t) = node(&s, id).kind else { panic!() };
|
||
let st = t.first_style();
|
||
assert_eq!(st.kerning, Some(20.0));
|
||
assert_eq!(st.baseline_shift, 3.0);
|
||
assert_eq!(st.h_scale, 120.0);
|
||
assert!((st.rotation - 10.0).abs() < 1e-9);
|
||
assert!(st.underline);
|
||
assert_eq!(t.para.space_before, 6.0);
|
||
assert!(t.para.hyphenate);
|
||
s.execute("text.setFormat", &json!({"ids": [id.0], "kerning": "auto"})).unwrap();
|
||
let NodeKind::Text(t) = node(&s, id).kind else { panic!() };
|
||
assert_eq!(t.first_style().kerning, None);
|
||
assert!(s.execute("text.setFormat", &json!({"ids": [id.0]})).is_err());
|
||
// Paragraph composer: Every-line by default, undoable.
|
||
assert_eq!(t.para.composer, vectorcraft_doc::Composer::EveryLine);
|
||
s.execute("text.setFormat", &json!({"ids": [id.0], "composer": "singleLine"})).unwrap();
|
||
let NodeKind::Text(t) = node(&s, id).kind else { panic!() };
|
||
assert_eq!(t.para.composer, vectorcraft_doc::Composer::SingleLine);
|
||
assert!(s.execute("text.setFormat", &json!({"ids": [id.0], "composer": "best"})).is_err());
|
||
assert!(s.execute("text.setFormat", &json!({"ids": [id.0], "composer": 1})).is_err());
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
let NodeKind::Text(t) = node(&s, id).kind else { panic!() };
|
||
assert_eq!(t.para.composer, vectorcraft_doc::Composer::EveryLine);
|
||
}
|
||
|
||
#[test]
|
||
fn stroke_advanced_scale_swap_flip() {
|
||
let mut s = session();
|
||
let id = rect(&mut s);
|
||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||
s.execute("stroke.set", &json!({"startArrow": "Triangle", "profile": "taperEnd"})).unwrap();
|
||
s.execute("stroke.setAdvanced", &json!({"arrowScale": [50, 200]})).unwrap();
|
||
s.execute("stroke.setAdvanced", &json!({"swapArrows": true, "flipProfile": "along"})).unwrap();
|
||
let n = node(&s, id);
|
||
let st = n.appearance.stroke().unwrap();
|
||
assert_eq!(st.start_arrow, None);
|
||
assert_eq!(st.end_arrow, Some(vectorcraft_doc::Arrowhead::Triangle));
|
||
assert_eq!(st.arrow_scale, (200.0, 50.0));
|
||
assert_eq!(st.profile.as_ref().unwrap().points, vectorcraft_doc::WidthProfile::taper_start().points);
|
||
assert!(s.execute("stroke.setAdvanced", &json!({})).is_err());
|
||
assert!(s.execute("stroke.setAdvanced", &json!({"flipProfile": "sideways"})).is_err());
|
||
}
|
||
|
||
/// A document of `n` 200 × 150 artboards in a row, 400 pt apart (`x` = 400·i), each with a
|
||
/// guide of its own.
|
||
fn row_of_artboards(n: usize) -> Session {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 200, "height": 150})).unwrap();
|
||
for i in 1..n {
|
||
s.execute("artboard.new", &json!({"x": 400 * i, "y": 0, "width": 200, "height": 150})).unwrap();
|
||
}
|
||
for i in 0..n {
|
||
s.execute("guide.add", &json!({"vertical": true, "pos": 400 * i + 50, "artboard": i})).unwrap();
|
||
}
|
||
s
|
||
}
|
||
|
||
fn box_at(s: &mut Session, x: f64, w: f64) -> NodeId {
|
||
NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": w, "height": 20})).unwrap()["id"].as_u64().unwrap())
|
||
}
|
||
|
||
fn artboard_names(s: &Session) -> Vec<String> {
|
||
s.doc().unwrap().doc.artboards.iter().map(|a| a.name.clone()).collect()
|
||
}
|
||
|
||
/// Artboards panel › Delete Empty Artboards: with art only on artboard 2, artboards 1 and 3 go
|
||
/// with their guides in one undo step, the art and its ids untouched; Undo brings them back.
|
||
#[test]
|
||
fn delete_empty_artboards_deletes_those_with_no_art_in_one_step() {
|
||
let mut s = row_of_artboards(3);
|
||
let art = box_at(&mut s, 450.0, 20.0);
|
||
let before = s.doc().unwrap().doc.clone();
|
||
let steps = s.doc().unwrap().history.undo.len();
|
||
assert_eq!(s.execute("artboard.deleteEmpty", &json!({})).unwrap(), json!({"deleted": [0, 2]}));
|
||
let st = s.doc().unwrap();
|
||
assert_eq!(artboard_names(&s), ["Artboard 2"]);
|
||
assert_eq!(st.doc.guides.len(), 1, "the deleted artboards' guides go with them");
|
||
assert_eq!(st.doc.node(art).map(|n| n.geometric_bounds()), before.node(art).map(|n| n.geometric_bounds()), "the art stays");
|
||
assert_eq!(st.history.undo.len(), steps + 1);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(artboard_names(&s), ["Artboard 1", "Artboard 2", "Artboard 3"]);
|
||
assert_eq!(s.doc().unwrap().doc.guides.len(), 3);
|
||
}
|
||
|
||
/// What counts as art: hidden and locked objects, an object straddling two artboards and one
|
||
/// that only touches an artboard's edge; a group counts by its members, not by its box.
|
||
#[test]
|
||
fn delete_empty_artboards_keeps_artboards_with_hidden_locked_or_shared_art() {
|
||
let mut s = row_of_artboards(5);
|
||
// Hidden art on 1, locked art on 2.
|
||
box_at(&mut s, 10.0, 20.0);
|
||
s.execute("object.hide", &json!({})).unwrap();
|
||
box_at(&mut s, 410.0, 20.0);
|
||
s.execute("object.lock", &json!({})).unwrap();
|
||
// Straddling 3 (x 800–1000) and 4 (x 1200–1400).
|
||
box_at(&mut s, 900.0, 350.0);
|
||
// A group of a box on 3 and one whose right edge touches 6's left edge (x 2000): its box spans
|
||
// 5 (x 1600–1800), which stays empty, and 6 has art only by the touch.
|
||
s.execute("artboard.new", &json!({"x": 2000, "y": 0, "width": 200, "height": 150})).unwrap();
|
||
let a = box_at(&mut s, 850.0, 20.0);
|
||
let b = box_at(&mut s, 1980.0, 20.0);
|
||
// No stroke, so its visual box ends at x 2000.
|
||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||
s.execute("object.group", &json!({})).unwrap();
|
||
assert_eq!(s.execute("artboard.deleteEmpty", &json!({})).unwrap(), json!({"deleted": [4]}));
|
||
assert_eq!(artboard_names(&s), ["Artboard 1", "Artboard 2", "Artboard 3", "Artboard 4", "Artboard 6"]);
|
||
}
|
||
|
||
/// A locked empty artboard stays; with every artboard empty artboard 1 stays; with none empty
|
||
/// the command is disabled, refuses and adds no undo step.
|
||
#[test]
|
||
fn delete_empty_artboards_leaves_locked_ones_and_artboard_1() {
|
||
let mut s = row_of_artboards(3);
|
||
s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap();
|
||
assert_eq!(s.execute("artboard.deleteEmpty", &json!({})).unwrap(), json!({"deleted": [0, 2]}), "all empty, one locked");
|
||
assert_eq!(artboard_names(&s), ["Artboard 2"]);
|
||
|
||
let mut s = row_of_artboards(3);
|
||
assert_eq!(s.execute("artboard.deleteEmpty", &json!({})).unwrap(), json!({"deleted": [1, 2]}), "all empty: artboard 1 stays");
|
||
assert_eq!(artboard_names(&s), ["Artboard 1"]);
|
||
let steps = s.doc().unwrap().history.undo.len();
|
||
let spec = crate::cmd::command_specs().iter().find(|c| c.id == "artboard.deleteEmpty").unwrap();
|
||
assert!((spec.enabled)(&s).is_err(), "one artboard left: nothing to delete");
|
||
assert!(s.execute("artboard.deleteEmpty", &json!({})).is_err());
|
||
box_at(&mut s, 10.0, 20.0);
|
||
let steps_with_art = s.doc().unwrap().history.undo.len();
|
||
assert!(s.execute("artboard.deleteEmpty", &json!({})).is_err(), "nothing empty");
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), steps_with_art);
|
||
assert!(steps_with_art > steps);
|
||
}
|
||
|
||
/// Art outside a clip shows nowhere: a clip group or a clipping layer has art only where its
|
||
/// members show inside the clip, so an artboard that only the clipped-away part of a member
|
||
/// covers is empty.
|
||
#[test]
|
||
fn delete_empty_artboards_measures_clipped_art_through_the_clip() {
|
||
// Clip group: the clip on 1, a member running on to 2. Clipping layer: the clip on 3, a member
|
||
// running on to 4. Nothing else.
|
||
let mut s = row_of_artboards(4);
|
||
let long = box_at(&mut s, 50.0, 400.0);
|
||
let clip = box_at(&mut s, 10.0, 100.0);
|
||
// The front object clips (drawn after the member).
|
||
s.execute("select.set", &json!({"ids": [long.0, clip.0]})).unwrap();
|
||
s.execute("object.clippingMask.make", &json!({})).unwrap();
|
||
s.execute("layer.new", &json!({"name": "Clipped"})).unwrap();
|
||
box_at(&mut s, 850.0, 400.0);
|
||
// The layer's top object becomes its clipping path.
|
||
let layer_clip = box_at(&mut s, 810.0, 100.0);
|
||
let layer_of =
|
||
|s: &Session| s.doc().unwrap().doc.layers.iter().find(|l| l.children().unwrap().iter().any(|c| c.id == layer_clip)).unwrap().clone();
|
||
let layer = layer_of(&s);
|
||
assert_eq!(layer.children().unwrap().len(), 2, "both in their own layer");
|
||
s.execute("layer.clippingMask.toggle", &json!({"id": layer.id.0})).unwrap();
|
||
let layer = layer_of(&s);
|
||
assert!(matches!(layer.kind, vectorcraft_doc::NodeKind::Layer { clip: true, .. }));
|
||
let first = layer.children().unwrap()[0].id;
|
||
assert_eq!(first, layer_clip, "the layer's first object clips the rest");
|
||
assert_eq!(s.execute("artboard.deleteEmpty", &json!({})).unwrap(), json!({"deleted": [1, 3]}));
|
||
assert_eq!(artboard_names(&s), ["Artboard 1", "Artboard 3"]);
|
||
}
|
||
|
||
/// Is Delete Empty Artboards enabled, and does running it then delete something? The two answers
|
||
/// must agree.
|
||
fn delete_empty_enabled_and_outcome(s: &mut Session) -> (bool, bool) {
|
||
let spec = crate::cmd::command_specs().iter().find(|c| c.id == "artboard.deleteEmpty").unwrap();
|
||
let enabled = (spec.enabled)(s).is_ok();
|
||
(enabled, s.execute("artboard.deleteEmpty", &json!({})).is_ok())
|
||
}
|
||
|
||
/// Delete Empty Artboards is enabled exactly when it deletes something: never with one artboard,
|
||
/// with every artboard empty (all but artboard 1 go), with one empty, not when the only empty one
|
||
/// is locked, and with art nested in groups and sublayers counted.
|
||
#[test]
|
||
fn delete_empty_artboards_is_enabled_exactly_when_it_deletes_something() {
|
||
/// What the case is, how its session is made, and whether the command deletes something.
|
||
type Case = (&'static str, fn() -> Session, bool);
|
||
let cases: [Case; 8] = [
|
||
("one empty artboard", || row_of_artboards(1), false),
|
||
(
|
||
"one artboard with art",
|
||
|| {
|
||
let mut s = row_of_artboards(1);
|
||
box_at(&mut s, 10.0, 20.0);
|
||
s
|
||
},
|
||
false,
|
||
),
|
||
("every artboard empty", || row_of_artboards(3), true),
|
||
(
|
||
"one empty",
|
||
|| {
|
||
let mut s = row_of_artboards(2);
|
||
box_at(&mut s, 10.0, 20.0);
|
||
s
|
||
},
|
||
true,
|
||
),
|
||
(
|
||
"the empty one locked",
|
||
|| {
|
||
let mut s = row_of_artboards(2);
|
||
box_at(&mut s, 10.0, 20.0);
|
||
s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap();
|
||
s
|
||
},
|
||
false,
|
||
),
|
||
(
|
||
"every artboard empty and locked",
|
||
|| {
|
||
let mut s = row_of_artboards(2);
|
||
s.execute("artboard.setProps", &json!({"index": 0, "locked": true})).unwrap();
|
||
s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap();
|
||
s
|
||
},
|
||
false,
|
||
),
|
||
(
|
||
"nested art on each",
|
||
|| {
|
||
let mut s = row_of_artboards(2);
|
||
let a = box_at(&mut s, 10.0, 20.0);
|
||
let b = box_at(&mut s, 30.0, 20.0);
|
||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||
s.execute("object.group", &json!({})).unwrap();
|
||
s.execute("layer.newSublayer", &json!({})).unwrap();
|
||
box_at(&mut s, 410.0, 20.0);
|
||
s
|
||
},
|
||
false,
|
||
),
|
||
(
|
||
"nested art on one",
|
||
|| {
|
||
let mut s = row_of_artboards(3);
|
||
s.execute("layer.newSublayer", &json!({})).unwrap();
|
||
let a = box_at(&mut s, 410.0, 20.0);
|
||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||
s.execute("object.group", &json!({})).unwrap();
|
||
s
|
||
},
|
||
true,
|
||
),
|
||
];
|
||
for (why, make, deletes) in cases {
|
||
assert_eq!(delete_empty_enabled_and_outcome(&mut make()), (deletes, deletes), "{why}");
|
||
}
|
||
}
|
||
|
||
/// On documents made from a fixed seed (artboards in a row, some locked, boxes on, between and
|
||
/// across them, some grouped), Delete Empty Artboards' enabled state matches what it does.
|
||
#[test]
|
||
fn delete_empty_artboards_enabled_state_matches_the_command_on_varied_documents() {
|
||
let mut seed: u64 = 0x5eed_a17b_0a2d;
|
||
let mut next = |n: u64| {
|
||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||
(seed >> 33) % n
|
||
};
|
||
let mut enabled_count = 0;
|
||
for _ in 0..60 {
|
||
let mut s = row_of_artboards(1 + next(4) as usize);
|
||
let artboards = s.doc().unwrap().doc.artboards.len();
|
||
for i in 0..artboards {
|
||
if next(4) == 0 {
|
||
s.execute("artboard.setProps", &json!({"index": i, "locked": true})).unwrap();
|
||
}
|
||
}
|
||
let mut ids = vec![];
|
||
for _ in 0..next(4) {
|
||
ids.push(box_at(&mut s, next(1700) as f64 - 50.0, 5.0 + next(300) as f64).0);
|
||
}
|
||
if ids.len() > 1 && next(2) == 0 {
|
||
s.execute("select.set", &json!({"ids": ids})).unwrap();
|
||
s.execute("object.group", &json!({})).unwrap();
|
||
}
|
||
let (enabled, deleted) = delete_empty_enabled_and_outcome(&mut s);
|
||
assert_eq!(enabled, deleted, "{:?}", s.doc().unwrap().doc.artboards.iter().map(|a| (a.rect, a.locked)).collect::<Vec<_>>());
|
||
enabled_count += usize::from(enabled);
|
||
}
|
||
assert!(enabled_count > 5 && enabled_count < 55, "both answers come up: {enabled_count} of 60 enabled");
|
||
}
|
||
|
||
/// Delete Empty Artboards' enabled state, as a menu asks for it, and how many times asking measured
|
||
/// a document on this thread.
|
||
fn delete_empty_enabled_counted(s: &Session) -> (bool, usize) {
|
||
let spec = crate::cmd::command_specs().iter().find(|c| c.id == "artboard.deleteEmpty").unwrap();
|
||
let measures = || crate::doc_state::EMPTY_ARTBOARD_MEASURES.with(std::cell::Cell::get);
|
||
let before = measures();
|
||
let enabled = (spec.enabled)(s).is_ok();
|
||
(enabled, measures() - before)
|
||
}
|
||
|
||
/// A menu asks on every frame while it is open: the first frame measures the document, and the
|
||
/// others, a change of the selection among them, do not.
|
||
#[test]
|
||
fn delete_empty_artboards_check_measures_a_document_once() {
|
||
let mut s = row_of_artboards(2);
|
||
let art = box_at(&mut s, 10.0, 20.0);
|
||
assert_eq!(delete_empty_enabled_counted(&s), (true, 1), "the first frame");
|
||
for _ in 0..10 {
|
||
assert_eq!(delete_empty_enabled_counted(&s), (true, 0), "a later frame");
|
||
}
|
||
s.execute("select.set", &json!({"ids": [art.0]})).unwrap();
|
||
assert_eq!(delete_empty_enabled_counted(&s), (true, 0), "after a change of the selection");
|
||
s.execute("select.none", &json!({})).unwrap();
|
||
assert_eq!(delete_empty_enabled_counted(&s), (true, 0), "after another change of the selection");
|
||
}
|
||
|
||
/// The enabled state follows an edit, an undo, a redo, an added and a removed artboard, a locked
|
||
/// artboard, and each change measures the document once.
|
||
#[test]
|
||
fn delete_empty_artboards_check_follows_each_change_to_the_document() {
|
||
let mut s = row_of_artboards(2);
|
||
box_at(&mut s, 10.0, 20.0);
|
||
assert_eq!(delete_empty_enabled_counted(&s), (true, 1), "artboard 2 is empty");
|
||
|
||
let on_second = box_at(&mut s, 410.0, 20.0);
|
||
assert_eq!(delete_empty_enabled_counted(&s), (false, 1), "an edit puts art on artboard 2");
|
||
assert_eq!(delete_empty_enabled_counted(&s), (false, 0));
|
||
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(delete_empty_enabled_counted(&s), (true, 1), "Undo takes the art off artboard 2");
|
||
assert_eq!(delete_empty_enabled_counted(&s), (true, 0));
|
||
|
||
s.execute("edit.redo", &json!({})).unwrap();
|
||
assert_eq!(delete_empty_enabled_counted(&s), (false, 1), "Redo puts it back");
|
||
|
||
s.execute("select.set", &json!({"ids": [on_second.0]})).unwrap();
|
||
s.execute("object.move", &json!({"dx": 1000, "dy": 0})).unwrap();
|
||
assert_eq!(delete_empty_enabled_counted(&s), (true, 1), "an edit moves the art off artboard 2");
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(delete_empty_enabled_counted(&s), (false, 1));
|
||
|
||
s.execute("artboard.new", &json!({"x": 2000, "y": 0, "width": 200, "height": 150})).unwrap();
|
||
assert_eq!(delete_empty_enabled_counted(&s), (true, 1), "a new artboard is empty");
|
||
|
||
s.execute("artboard.setProps", &json!({"index": 2, "locked": true})).unwrap();
|
||
assert_eq!(delete_empty_enabled_counted(&s), (false, 1), "a locked artboard stays");
|
||
s.execute("artboard.setProps", &json!({"index": 2, "locked": false})).unwrap();
|
||
assert_eq!(delete_empty_enabled_counted(&s), (true, 1), "unlocked, it can go");
|
||
|
||
s.execute("artboard.delete", &json!({"index": 2})).unwrap();
|
||
assert_eq!(delete_empty_enabled_counted(&s), (false, 1), "with the empty artboard removed, none is left");
|
||
assert_eq!(delete_empty_enabled_counted(&s), (false, 0));
|
||
}
|
||
|
||
/// A change made to the document directly, with nothing else holding it and no new revision, is
|
||
/// measured again too.
|
||
#[test]
|
||
fn delete_empty_artboards_check_follows_a_change_in_place() {
|
||
let mut s = row_of_artboards(2);
|
||
box_at(&mut s, 10.0, 20.0);
|
||
assert_eq!(delete_empty_enabled_counted(&s), (true, 1));
|
||
let st = s.doc_mut().unwrap();
|
||
assert_eq!(Arc::strong_count(&st.doc), 1, "the undo history holds the document before the edit");
|
||
let revision = st.revision;
|
||
Arc::make_mut(&mut st.doc).artboards.truncate(1);
|
||
assert_eq!(st.revision, revision);
|
||
assert_eq!(delete_empty_enabled_counted(&s), (false, 1), "one artboard left");
|
||
}
|
||
|
||
/// Each open document has its own answer: switching documents gives the other's without measuring
|
||
/// again, and an edit to one leaves the other's alone.
|
||
#[test]
|
||
fn delete_empty_artboards_check_follows_a_document_switch() {
|
||
let mut s = row_of_artboards(2);
|
||
assert_eq!(delete_empty_enabled_counted(&s), (true, 1), "two empty artboards: the second can go");
|
||
s.execute("file.new", &json!({"width": 200, "height": 150})).unwrap();
|
||
assert_eq!(delete_empty_enabled_counted(&s), (false, 1), "the new document has one artboard");
|
||
assert!(s.set_active(0));
|
||
assert_eq!(delete_empty_enabled_counted(&s), (true, 0), "the first document's answer stands");
|
||
assert!(s.set_active(1));
|
||
assert_eq!(delete_empty_enabled_counted(&s), (false, 0), "the second document's answer stands");
|
||
s.execute("artboard.new", &json!({"x": 400, "y": 0, "width": 200, "height": 150})).unwrap();
|
||
assert_eq!(delete_empty_enabled_counted(&s), (true, 1), "the second document has an empty artboard now");
|
||
assert!(s.set_active(0));
|
||
assert_eq!(delete_empty_enabled_counted(&s), (true, 0), "the edit to the second document leaves the first's answer");
|
||
}
|
||
|
||
/// On documents made from a fixed seed, after each of a run of changes, the enabled state is what
|
||
/// measuring every artboard of the document gives.
|
||
#[test]
|
||
fn delete_empty_artboards_check_matches_measuring_after_varied_changes() {
|
||
let mut seed: u64 = 0x0de1_e7e0_e3b7;
|
||
let mut next = |n: u64| {
|
||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||
(seed >> 33) % n
|
||
};
|
||
let mut answers = [0usize; 2];
|
||
let mut measured = 0;
|
||
for _ in 0..30 {
|
||
let mut s = row_of_artboards(1 + next(3) as usize);
|
||
for _ in 0..12 {
|
||
let artboards = s.doc().unwrap().doc.artboards.len();
|
||
let index = next(artboards as u64);
|
||
// A change can be refused (nothing to undo, a locked artboard): the state still has to match.
|
||
let _ = match next(7) {
|
||
0 => s.execute("shape.rectangle", &json!({"x": next(1200) as f64 - 50.0, "y": 10, "width": 20, "height": 20})),
|
||
1 => s.execute("edit.undo", &json!({})),
|
||
2 => s.execute("edit.redo", &json!({})),
|
||
3 => s.execute("artboard.new", &json!({"x": 400 * (artboards + 1), "y": 0, "width": 200, "height": 150})),
|
||
4 => s.execute("artboard.delete", &json!({"index": index})),
|
||
5 => s.execute("artboard.setProps", &json!({"index": index, "locked": next(2) == 0})),
|
||
_ => s.execute("select.all", &json!({})),
|
||
};
|
||
let (enabled, count) = delete_empty_enabled_counted(&s);
|
||
measured += count;
|
||
answers[usize::from(enabled)] += 1;
|
||
let d = &s.doc().unwrap().doc;
|
||
assert_eq!(enabled, d.artboards.len() > 1 && d.artboards.iter().any(|a| !a.locked && !d.artboard_has_art(a.rect)));
|
||
assert_eq!(delete_empty_enabled_counted(&s), (enabled, 0), "a second frame");
|
||
}
|
||
}
|
||
assert!(answers[0] > 30 && answers[1] > 30, "both answers come up: {answers:?}");
|
||
assert!(measured < 360, "changes of the selection alone do not measure: {measured} of 360");
|
||
}
|
||
|
||
/// On paths and compound paths made from a fixed seed (curves, open and closed, with strokes of
|
||
/// every join, cap and alignment, miter limits, arrowheads, width profiles and dashes), an
|
||
/// artboard has art exactly when an object's visual bounds meet it, for artboards just inside,
|
||
/// on and just outside each object's visual box and far from it; and
|
||
/// [`Document::any_artboard_without_art`] answers as asking [`Document::artboard_has_art`] of each.
|
||
#[test]
|
||
fn empty_artboard_checks_match_the_visual_bounds_on_varied_strokes() {
|
||
use vectorcraft_doc::{Appearance, AppearanceItem, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile};
|
||
use vectorcraft_geom::Rect;
|
||
let mut seed: u64 = 0xa27b_0a2d_e6b7;
|
||
let mut next = |n: u64| {
|
||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||
(seed >> 33) % n
|
||
};
|
||
let (mut rects_checked, mut with_art) = (0, 0);
|
||
for _ in 0..40 {
|
||
let mut s = session();
|
||
let path = |s: &mut Session, next: &mut dyn FnMut(u64) -> u64| {
|
||
let mut d = format!("M {} {}", 100 + next(400), 100 + next(300));
|
||
for _ in 0..1 + next(4) {
|
||
let mut pt = || format!("{} {}", 100 + next(400), 100 + next(300));
|
||
d += &format!(" C {} {} {}", pt(), pt(), pt());
|
||
}
|
||
if next(2) == 0 {
|
||
d += " Z";
|
||
}
|
||
s.execute("path.create", &json!({"d": d})).unwrap()["id"].as_u64().unwrap()
|
||
};
|
||
let mut objects = vec![];
|
||
for _ in 0..1 + next(3) {
|
||
let id = if next(3) == 0 {
|
||
let ids = [path(&mut s, &mut next), path(&mut s, &mut next)];
|
||
s.execute("select.set", &json!({"ids": ids})).unwrap();
|
||
s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()
|
||
} else {
|
||
path(&mut s, &mut next)
|
||
};
|
||
let mut look = Appearance::default();
|
||
for _ in 0..1 + next(2) {
|
||
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 0.5 + next(30) as f64);
|
||
st.join = [LineJoin::Miter, LineJoin::Round, LineJoin::Bevel][next(3) as usize];
|
||
st.cap = [LineCap::Butt, LineCap::Round, LineCap::Square][next(3) as usize];
|
||
st.align = [StrokeAlign::Center, StrokeAlign::Inside, StrokeAlign::Outside][next(3) as usize];
|
||
st.miter_limit = 1.0 + next(20) as f64;
|
||
let mut head = || (next(3) == 0).then(|| Arrowhead::ALL[next(Arrowhead::ALL.len() as u64) as usize]);
|
||
(st.start_arrow, st.end_arrow) = (head(), head());
|
||
st.arrow_scale = (50.0 + next(250) as f64, 50.0 + next(250) as f64);
|
||
if next(3) == 0 {
|
||
st.profile = Some(WidthProfile { points: vec![(0.0, next(3) as f64, 1.0), (0.5, 0.5, next(4) as f64), (1.0, 1.0, 0.25)] });
|
||
}
|
||
if next(3) == 0 {
|
||
st.dash = Some(Dash { pattern: vec![1.0 + next(10) as f64, 1.0 + next(10) as f64], offset: 0.0, align_corners: false });
|
||
}
|
||
look.items.push(AppearanceItem::Stroke(st));
|
||
}
|
||
s.edit("Strokes", |d, _| {
|
||
d.node_mut(NodeId(id)).unwrap().appearance = look.into();
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
objects.push(NodeId(id));
|
||
}
|
||
let d = &s.doc().unwrap().doc;
|
||
let symbols = d.symbol_boxes();
|
||
let boxes: Vec<Rect> = objects.iter().filter_map(|id| d.node(*id).unwrap().visual_bounds_with(&symbols)).collect();
|
||
let has_art = |r: Rect| {
|
||
let r = r.inflate(1e-6, 1e-6);
|
||
boxes.iter().any(|b| b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1)
|
||
};
|
||
let mut rects = vec![Rect::new(5000.0, 5000.0, 5200.0, 5150.0)];
|
||
for b in &boxes {
|
||
for gap in [-0.01, 0.0, 5e-7, 2e-6, 0.01, 1.0] {
|
||
rects.push(Rect::new(b.x1 + gap, b.y0, b.x1 + gap + 10.0, b.y1));
|
||
rects.push(Rect::new(b.x0 - gap - 10.0, b.y0, b.x0 - gap, b.y1));
|
||
rects.push(Rect::new(b.x0, b.y1 + gap, b.x1, b.y1 + gap + 10.0));
|
||
rects.push(Rect::new(b.x0, b.y0 - gap - 10.0, b.x1, b.y0 - gap));
|
||
}
|
||
}
|
||
for r in &rects {
|
||
assert_eq!(d.artboard_has_art(*r), has_art(*r), "{r:?} against {boxes:?}");
|
||
rects_checked += 1;
|
||
with_art += usize::from(has_art(*r));
|
||
}
|
||
for _ in 0..20 {
|
||
let pick: Vec<Rect> = (0..1 + next(3)).map(|_| rects[next(rects.len() as u64) as usize]).collect();
|
||
assert_eq!(d.any_artboard_without_art(pick.iter().copied()), pick.iter().any(|r| !has_art(*r)), "{pick:?} against {boxes:?}");
|
||
}
|
||
}
|
||
assert!(with_art > rects_checked / 4 && with_art < rects_checked * 3 / 4, "both answers come up: {with_art} of {rects_checked}");
|
||
}
|