Files
vectorcraft/crates/engine/src/tests_layers.rs
T

774 lines
37 KiB
Rust

//! The Layers panel's commands: rows at every depth (layers, sublayers, groups, objects) are
//! clickable, highlighted and targeted; their art is selected through the selection column; rows
//! move, copy and reparent with validation; and the panel menu's operations (merge, flatten,
//! release to layers, reverse, template, others/all) are single undo steps.
use serde_json::{Value, json};
use vectorcraft_doc::{LayerColor, Node, NodeId, NodeKind};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s
}
fn id(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn ids(v: &Value) -> Vec<NodeId> {
v["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect()
}
fn rect(s: &mut Session, x: f64) -> NodeId {
id(&s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 20, "height": 20})).unwrap())
}
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
fn parent(s: &Session, id: NodeId) -> Option<NodeId> {
s.doc().unwrap().doc.parent_of(id)
}
fn children(s: &Session, id: NodeId) -> Vec<NodeId> {
node(s, id).children().unwrap().iter().map(|c| c.id).collect()
}
fn undos(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
/// Layer 1 holding a sublayer `sub` with a rectangle `a` and a group `g` of rectangle `b`, plus a
/// rectangle `c` on Layer 1 itself → (layer, sub, a, g, b, c). Nothing selected.
fn tree(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId, NodeId, NodeId) {
let layer = s.doc().unwrap().doc.layers[0].id;
let c = rect(s, 300.0);
let sub = id(&run(s, "layer.newSublayer", json!({"name": "Sub"})));
let a = rect(s, 0.0);
let b = rect(s, 100.0);
run(s, "select.set", json!({"ids": [b.0]}));
let g = id(&run(s, "object.group", json!({})));
run(s, "select.none", json!({}));
(layer, sub, a, g, b, c)
}
#[test]
fn art_in_a_sublayer_is_selected_as_objects_not_the_sublayer() {
let mut s = session();
let (_, sub, a, g, _, c) = tree(&mut s);
assert_eq!(parent(&s, a), Some(sub), "new art went into the current sublayer");
let n = run(&mut s, "select.all", json!({}))["count"].as_u64().unwrap();
assert_eq!(n, 3);
let sel = s.doc().unwrap().selection.objects.clone();
assert!(sel.contains(&a) && sel.contains(&g) && sel.contains(&c) && !sel.contains(&sub), "{sel:?}");
// The Selection tool's click takes the object in the sublayer.
let h = vectorcraft_doc::hit::hit_test(&s.doc().unwrap().doc, vectorcraft_geom::Point::new(10.0, 10.0), Default::default()).unwrap();
assert_eq!(h.top_object(None), a);
// Deleting it works (layers were skipped by Clear before).
run(&mut s, "select.set", json!({"ids": [a.0]}));
run(&mut s, "edit.clear", json!({}));
assert!(s.doc().unwrap().doc.node(a).is_none());
}
/// #1002: with no layer shown and unlocked, new art is refused (the document unchanged) rather
/// than put on a hidden or locked layer; another layer that takes art still gets it.
#[test]
fn new_art_is_refused_when_no_layer_is_shown_and_unlocked() {
for prop in ["visible", "locked"] {
let mut s = session();
let layer = s.doc().unwrap().doc.layers[0].id;
let copied = rect(&mut s, 0.0);
run(&mut s, "select.set", json!({"ids": [copied.0]}));
run(&mut s, "edit.copy", json!({}));
run(&mut s, "select.none", json!({}));
run(&mut s, "layer.setProps", json!({"ids": [layer.0], prop: prop == "locked"}));
let before = s.doc().unwrap().doc.clone();
for (cmd, p) in [
("shape.ellipse", json!({"x": 10, "y": 10, "width": 20, "height": 20})),
("shape.polarGrid", json!({"x": 10, "y": 10, "width": 20, "height": 20})),
("text.create", json!({"x": 10, "y": 40, "text": "A"})),
("edit.paste", json!({})),
] {
let e = s.execute(cmd, &p).unwrap_err();
assert_eq!(e.to_string(), "the target layer is locked or hidden", "{prop} {cmd}");
}
assert_eq!(s.doc().unwrap().doc.layers, before.layers, "{prop}: nothing was added");
let other = id(&run(&mut s, "layer.new", json!({})));
run(&mut s, "layer.setCurrent", json!({"id": layer.0}));
let n = rect(&mut s, 50.0);
assert_eq!(parent(&s, n), Some(other), "{prop}");
}
}
/// Send to Current Layer fails with the error that new art gets (#1002) when the current layer is
/// hidden or locked, or is inside a hidden or locked layer: the objects stay where they were and stay
/// selected, and no undo step is recorded. Once the layer is shown and unlocked again, the command
/// moves them.
#[test]
fn send_to_current_layer_refuses_a_hidden_or_locked_current_layer() {
for prop in ["visible", "locked"] {
let mut s = session();
let layer = s.doc().unwrap().doc.layers[0].id;
let a = rect(&mut s, 0.0);
let other = id(&run(&mut s, "layer.new", json!({})));
let sub = id(&run(&mut s, "layer.newSublayer", json!({})));
assert_eq!(parent(&s, sub), Some(other), "the sublayer is in the new layer");
run(&mut s, "layer.setProps", json!({"ids": [other.0], prop: prop == "locked"}));
run(&mut s, "select.set", json!({"ids": [a.0]}));
for current in [other, sub] {
// A click on the layer's row makes that layer current and keeps the selection.
run(&mut s, "layer.setCurrent", json!({"id": current.0}));
let (before, undo) = (s.doc().unwrap().doc.clone(), undos(&s));
let e = s.execute("object.arrange.sendToCurrentLayer", &json!({})).unwrap_err();
assert_eq!(e.to_string(), "the target layer is locked or hidden", "{prop} {current:?}");
assert_eq!(s.doc().unwrap().doc.layers, before.layers, "{prop} {current:?}: nothing moved");
assert_eq!(parent(&s, a), Some(layer), "{prop} {current:?}");
assert_eq!(undos(&s), undo, "{prop} {current:?}: no undo step");
assert_eq!(s.doc().unwrap().selection.objects, vec![a], "{prop} {current:?}");
}
run(&mut s, "layer.setProps", json!({"ids": [other.0], prop: prop != "locked"}));
run(&mut s, "object.arrange.sendToCurrentLayer", json!({}));
assert_eq!(parent(&s, a), Some(sub), "{prop}");
}
}
#[test]
fn clicking_rows_at_any_depth_highlights_them_and_sets_the_current_layer() {
let mut s = session();
let (layer, sub, a, g, b, c) = tree(&mut s);
let before = undos(&s);
for (row, current) in [(layer, layer), (sub, sub), (g, sub), (b, sub), (a, sub), (c, layer)] {
let r = run(&mut s, "layer.setCurrent", json!({"id": row.0}));
assert_eq!(NodeId(r["layer"].as_u64().unwrap()), current);
let st = s.doc().unwrap();
assert_eq!((st.highlighted_rows(), st.current_layer()), (vec![row], Some(current)));
}
assert_eq!(undos(&s), before, "clicking rows is not an undo step");
// New art goes into the current sublayer.
run(&mut s, "layer.setCurrent", json!({"id": a.0}));
let n = rect(&mut s, 50.0);
assert_eq!(parent(&s, n), Some(sub));
assert!(s.execute("layer.setCurrent", &json!({"id": 9999})).is_err());
// Shift/Ctrl: several rows.
run(&mut s, "layer.highlight", json!({"ids": [a.0, g.0], "mode": "set"}));
run(&mut s, "layer.highlight", json!({"ids": [c.0], "mode": "add"}));
run(&mut s, "layer.highlight", json!({"ids": [a.0], "mode": "toggle"}));
let st = s.doc().unwrap();
assert_eq!((st.highlighted_rows(), st.current_layer()), (vec![g, c], Some(layer)));
assert!(s.execute("layer.highlight", &json!({"ids": [a.0], "mode": "nope"})).is_err());
assert_eq!(document_rows(&s), vec![g.0, c.0], "document.inspect lists the rows");
}
fn document_rows(s: &Session) -> Vec<u64> {
crate::inspect::document(s)["layerRows"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
}
#[test]
fn selecting_art_makes_its_layer_current() {
let mut s = session();
let (layer, sub, a, _, _, c) = tree(&mut s);
run(&mut s, "select.set", json!({"ids": [c.0]}));
assert_eq!(s.doc().unwrap().current_layer(), Some(layer));
run(&mut s, "select.set", json!({"ids": [a.0]}));
assert_eq!(s.doc().unwrap().current_layer(), Some(sub));
let n = rect(&mut s, 200.0);
assert_eq!(parent(&s, n), Some(sub));
// A locked sublayer takes no new art: it goes to the top layer.
run(&mut s, "select.none", json!({}));
run(&mut s, "layer.setProps", json!({"id": sub.0, "locked": true}));
run(&mut s, "layer.setCurrent", json!({"id": sub.0}));
let n = rect(&mut s, 200.0);
assert_eq!(parent(&s, n), Some(layer));
}
#[test]
fn the_selection_column_selects_a_rows_art_and_shift_adds_or_removes_it() {
let mut s = session();
let (layer, sub, a, g, _, c) = tree(&mut s);
run(&mut s, "layer.setCurrent", json!({"id": sub.0}));
let sel = |s: &Session| s.doc().unwrap().selection.objects.clone();
run(&mut s, "layer.selectAll", json!({"id": layer.0}));
assert_eq!(sel(&s), vec![c, a, g], "the layer's art, its sublayer's too");
run(&mut s, "layer.selectAll", json!({"id": sub.0}));
assert_eq!(sel(&s), vec![a, g]);
run(&mut s, "layer.selectAll", json!({"id": c.0, "add": true}));
assert_eq!(sel(&s), vec![a, g, c]);
run(&mut s, "layer.selectAll", json!({"id": sub.0, "add": true}));
assert_eq!(sel(&s), vec![c], "all selected already: removed");
assert_eq!(s.doc().unwrap().highlighted_rows(), vec![sub], "the highlighted row stays");
// Locked rows select nothing.
run(&mut s, "layer.setProps", json!({"id": sub.0, "locked": true}));
run(&mut s, "layer.selectAll", json!({"id": a.0}));
assert!(sel(&s).is_empty());
// Targeting a layer selects its sublayers' art too.
run(&mut s, "layer.setProps", json!({"id": sub.0, "locked": false}));
assert_eq!(ids_sel(&run(&mut s, "layer.target", json!({"id": layer.0}))), vec![c, a, g]);
}
fn ids_sel(v: &Value) -> Vec<NodeId> {
v["selected"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect()
}
#[test]
fn visibility_and_lock_per_row_are_undoable() {
let mut s = session();
let (_, sub, a, g, b, _) = tree(&mut s);
run(&mut s, "select.set", json!({"ids": [a.0, g.0]}));
let before = undos(&s);
run(&mut s, "layer.setProps", json!({"ids": [b.0, sub.0], "visible": false}));
assert!(!node(&s, b).visible && !node(&s, sub).visible);
assert!(s.doc().unwrap().selection.is_empty(), "hidden art is deselected");
assert_eq!(undos(&s), before + 1, "one step for several rows");
run(&mut s, "edit.undo", json!({}));
assert!(node(&s, b).visible && node(&s, sub).visible);
run(&mut s, "layer.setProps", json!({"id": a.0, "locked": true, "name": "Box"}));
assert!(node(&s, a).locked);
assert_eq!(node(&s, a).display_name(), "Box");
run(&mut s, "layer.setProps", json!({"id": a.0, "name": ""}));
assert_eq!(node(&s, a).display_name(), "<Rectangle>", "an empty name goes back to the generated one");
run(&mut s, "layer.setProps", json!({"id": sub.0, "name": ""}));
assert_eq!(node(&s, sub).display_name(), "Sub", "a layer keeps its name");
}
#[test]
fn object_set_props_deselects_the_objects_it_hides_or_locks() {
let mut s = session();
let (a, b) = (rect(&mut s, 0.0), rect(&mut s, 100.0));
for (prop, value) in [("visible", false), ("locked", true)] {
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
run(&mut s, "select.key", json!({"id": b.0}));
let mut p = json!({"ids": [b.0]});
p[prop] = json!(value);
run(&mut s, "object.setProps", p);
assert!(!s.doc().unwrap().doc.is_editable(b));
let sel = s.doc().unwrap().selection.clone();
assert_eq!((sel.objects, sel.key), (vec![a], None), "{prop}: {value} deselects the key object");
run(&mut s, "edit.undo", json!({}));
assert!(s.doc().unwrap().doc.is_editable(b));
let sel = s.doc().unwrap().selection.clone();
assert_eq!((sel.objects, sel.key), (vec![a, b], Some(b)), "undoing {prop}: {value} selects it again as the key");
}
}
#[test]
fn object_set_props_deselects_the_objects_inside_a_group_or_layer_it_hides_or_locks() {
let mut s = session();
let (_, sub, a, g, b, c) = tree(&mut s);
run(&mut s, "select.set", json!({"ids": [a.0, b.0, c.0]}));
run(&mut s, "object.setProps", json!({"ids": [g.0], "locked": true}));
assert_eq!(s.doc().unwrap().selection.objects, vec![a, c], "b is in the locked group");
run(&mut s, "select.key", json!({"id": a.0}));
assert_eq!(s.doc().unwrap().selection.key, Some(a));
run(&mut s, "object.setProps", json!({"ids": [sub.0], "visible": false}));
let sel = s.doc().unwrap().selection.clone();
assert_eq!((sel.objects, sel.key), (vec![c], None), "a, the key object, is in the hidden sublayer");
}
#[test]
fn object_set_props_that_hides_or_locks_nothing_keeps_the_selection() {
let mut s = session();
let layer = s.doc().unwrap().doc.layers[0].id;
let x = rect(&mut s, 0.0);
run(&mut s, "select.set", json!({"ids": [x.0]}));
run(&mut s, "object.lock", json!({}));
run(&mut s, "layer.setProps", json!({"id": layer.0, "locked": true}));
run(&mut s, "object.unlockAll", json!({}));
let st = s.doc().unwrap();
assert!(st.selection.objects == vec![x] && !st.doc.is_editable(x), "Unlock All selects x, and its layer still locks it");
for p in [json!({"opacity": 50}), json!({"visible": true, "locked": false})] {
run(&mut s, "object.setProps", p.clone());
assert_eq!(s.doc().unwrap().selection.objects, vec![x], "{p} keeps x selected");
}
assert_eq!(node(&s, x).opacity, 0.5);
}
#[test]
fn object_set_props_that_hides_or_locks_deselects_objects_hidden_or_locked_before() {
let mut s = session();
let (x, y, z) = (rect(&mut s, 0.0), rect(&mut s, 100.0), rect(&mut s, 200.0));
run(&mut s, "object.setProps", json!({"ids": [x.0], "visible": false}));
run(&mut s, "select.set", json!({"ids": [x.0, y.0, z.0]}));
assert_eq!(s.doc().unwrap().selection.objects, vec![x, y, z], "select.set selects the hidden x");
run(&mut s, "object.setProps", json!({"ids": [y.0], "locked": true}));
assert_eq!(s.doc().unwrap().selection.objects, vec![z], "locking y deselects x, hidden before the call, too");
}
#[test]
fn object_set_props_that_changes_nothing_deselects_what_it_leaves_hidden_or_locked() {
let mut s = session();
let (x, y) = (rect(&mut s, 0.0), rect(&mut s, 100.0));
for (prop, value) in [("visible", false), ("locked", true)] {
let mut p = json!({"ids": [x.0]});
p[prop] = json!(value);
run(&mut s, "object.setProps", p.clone());
run(&mut s, "select.set", json!({"ids": [x.0, y.0]}));
run(&mut s, "select.key", json!({"id": x.0}));
let before = undos(&s);
run(&mut s, "object.setProps", p);
assert_eq!(undos(&s), before, "{prop}: {value} again records no undo step");
let sel = s.doc().unwrap().selection.clone();
assert_eq!((sel.objects, sel.key), (vec![y], None), "{prop}: {value} again deselects x, the key object");
run(&mut s, "edit.undo", json!({}));
assert!(s.doc().unwrap().doc.is_editable(x));
}
}
#[test]
fn layer_options_validate_and_template_locks_and_dims() {
let mut s = session();
let (layer, ..) = tree(&mut s);
for bad in [json!({"color": 27}), json!({"color": "#zz"}), json!({"color": [1]}), json!({"locked": "yes"}), json!({"dimImages": "half"})] {
let mut p = bad.clone();
p["id"] = json!(layer.0);
assert!(s.execute("layer.setProps", &p).is_err(), "{bad}");
}
let color = |s: &Session| match node(s, layer).kind {
NodeKind::Layer { color, .. } => color,
_ => panic!("not a layer"),
};
run(&mut s, "layer.setProps", json!({"id": layer.0, "color": "red"}));
assert_eq!(color(&s), LayerColor::Preset(1));
run(&mut s, "layer.setProps", json!({"id": layer.0, "color": "#123456"}));
assert_eq!(color(&s), LayerColor::Custom([0x12, 0x34, 0x56]));
run(&mut s, "layer.setProps", json!({"id": layer.0, "color": 4}));
assert_eq!(color(&s), LayerColor::Preset(4));
run(&mut s, "layer.template", json!({"ids": [layer.0]}));
let n = node(&s, layer);
assert!(n.is_template() && n.locked);
assert!(matches!(n.kind, NodeKind::Layer { dim_images: Some(50), .. }));
run(&mut s, "layer.template", json!({"ids": [layer.0]}));
let n = node(&s, layer);
assert!(!n.is_template() && !n.locked);
assert!(matches!(n.kind, NodeKind::Layer { dim_images: None, .. }));
run(&mut s, "layer.setProps", json!({"id": layer.0, "preview": false, "dimImages": 30, "printable": false}));
assert!(matches!(node(&s, layer).kind, NodeKind::Layer { preview: false, dim_images: Some(30), printable: false, .. }));
let summary = &crate::inspect::document(&s)["layers"][0];
assert_eq!((summary["preview"].clone(), summary["dimImages"].clone()), (json!(false), json!(30)));
}
#[test]
fn new_layers_go_above_the_current_one_at_its_level() {
let mut s = session();
let (layer, sub, _, g, ..) = tree(&mut s);
run(&mut s, "layer.setCurrent", json!({"id": sub.0}));
let n = id(&run(&mut s, "layer.new", json!({})));
assert_eq!(parent(&s, n), Some(layer), "a sublayer beside the current sublayer");
assert_eq!(children(&s, layer).iter().position(|c| *c == n), children(&s, layer).iter().position(|c| *c == sub).map(|i| i + 1));
assert_eq!(s.doc().unwrap().highlighted_rows(), vec![n]);
let top = id(&run(&mut s, "layer.new", json!({"top": true, "name": "Top", "color": "green", "locked": true})));
let st = s.doc().unwrap();
assert_eq!(st.doc.layers.last().unwrap().id, top);
assert!(st.doc.layers.last().unwrap().locked);
// Sublayers only go into layers.
assert!(s.execute("layer.newSublayer", &json!({"parent": g.0})).is_err());
let inner = id(&run(&mut s, "layer.newSublayer", json!({"parent": sub.0})));
assert_eq!(parent(&s, inner), Some(sub));
// Names count sublayers: no two layers share one.
let mut names = vec![];
s.doc().unwrap().doc.walk(|n| {
if n.is_layer() {
names.push(n.display_name());
}
});
let unique: std::collections::HashSet<&String> = names.iter().collect();
assert_eq!(unique.len(), names.len(), "{names:?}");
}
#[test]
fn rows_move_above_below_and_into_layers_and_groups() {
let mut s = session();
let (layer, sub, a, g, b, c) = tree(&mut s);
let before = undos(&s);
// Into the group: on top of its contents.
run(&mut s, "layer.move", json!({"ids": [a.0], "target": g.0, "place": "inside"}));
assert_eq!(children(&s, g), vec![b, a]);
// Below b, in the group.
run(&mut s, "layer.move", json!({"ids": [a.0], "target": b.0, "place": "below"}));
assert_eq!(children(&s, g), vec![a, b]);
// Above the sublayer, out into Layer 1.
run(&mut s, "layer.move", json!({"ids": [a.0], "target": sub.0, "place": "above"}));
assert_eq!(parent(&s, a), Some(layer));
assert_eq!(undos(&s), before + 3);
// Out of the layer into a new top-level layer: an object beside a top-level layer goes in it.
let l2 = id(&run(&mut s, "layer.new", json!({"top": true})));
run(&mut s, "layer.move", json!({"ids": [c.0], "target": l2.0, "place": "above"}));
assert_eq!(parent(&s, c), Some(l2));
// A sublayer becomes a top-level layer, and back.
run(&mut s, "layer.move", json!({"ids": [sub.0], "target": l2.0, "place": "above"}));
assert_eq!(parent(&s, sub), None);
assert_eq!(s.doc().unwrap().doc.layers.last().unwrap().id, sub);
run(&mut s, "layer.move", json!({"ids": [sub.0], "target": layer.0}));
assert_eq!(parent(&s, sub), Some(layer));
// Several rows keep their stacking order.
run(&mut s, "layer.move", json!({"ids": [g.0, a.0], "target": l2.0}));
assert_eq!(children(&s, l2), vec![c, a, g]);
// Undo walks it back.
for _ in 0..4 {
run(&mut s, "edit.undo", json!({}));
}
assert_eq!(parent(&s, sub), Some(layer));
}
#[test]
fn moves_are_validated() {
let mut s = session();
let (layer, sub, a, g, b, _) = tree(&mut s);
let doc_before = s.doc().unwrap().doc.clone();
let bad = [
json!({"ids": [sub.0], "target": g.0, "place": "inside"}),
json!({"ids": [layer.0], "target": a.0, "place": "inside"}),
json!({"ids": [layer.0], "target": sub.0, "place": "above"}),
json!({"ids": [sub.0], "target": sub.0}),
json!({"ids": [g.0], "target": b.0, "place": "inside"}),
json!({"ids": [a.0], "target": b.0, "place": "inside"}),
json!({"ids": [a.0], "target": 9999}),
json!({"ids": [9999], "target": a.0}),
json!({"ids": [a.0], "target": g.0, "place": "sideways"}),
json!({"ids": [], "target": g.0}),
json!({"target": g.0}),
];
for p in bad {
assert!(s.execute("layer.move", &p).is_err(), "{p}");
}
// A locked group takes nothing.
run(&mut s, "layer.setProps", json!({"id": g.0, "locked": true}));
assert!(s.execute("layer.move", &json!({"ids": [a.0], "target": g.0})).is_err());
run(&mut s, "edit.undo", json!({}));
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &doc_before), "nothing changed");
// node.move: layers only at the top level or in layers.
assert!(s.execute("node.move", &json!({"id": sub.0, "parent": g.0, "index": 0})).is_err());
assert!(s.execute("node.move", &json!({"id": a.0, "index": 0})).is_err());
assert!(s.execute("node.move", &json!({"id": layer.0, "parent": sub.0, "index": 0})).is_err());
run(&mut s, "node.move", json!({"id": sub.0, "index": 5}));
assert_eq!(parent(&s, sub), None);
}
#[test]
fn alt_drag_copies_rows_and_the_selected_art_moves_between_layers() {
let mut s = session();
let (layer, sub, a, g, _, c) = tree(&mut s);
let count = s.doc().unwrap().doc.node_count();
let copies = ids(&run(&mut s, "layer.move", json!({"ids": [sub.0], "target": layer.0, "place": "above", "copy": true})));
assert_eq!(copies.len(), 1);
assert_eq!(s.doc().unwrap().doc.node_count(), count + 4, "the sublayer and its art (a, the group, b), copied");
assert_eq!(parent(&s, copies[0]), None, "a copy of the sublayer at the top level");
// Dragging the selected-art square: the selection moves into another layer.
run(&mut s, "select.set", json!({"ids": [a.0, g.0]}));
run(&mut s, "layer.move", json!({"ids": [a.0, g.0], "target": layer.0}));
assert_eq!(children(&s, layer), vec![c, sub, a, g]);
assert_eq!(s.doc().unwrap().selection.objects, vec![a, g], "it stays selected");
}
#[test]
fn delete_and_duplicate_rows() {
let mut s = session();
let (layer, sub, a, g, _, c) = tree(&mut s);
run(&mut s, "layer.highlight", json!({"ids": [a.0, g.0]}));
let copies = ids(&run(&mut s, "layer.duplicate", json!({})));
assert_eq!(copies.len(), 2);
assert_eq!(children(&s, sub).len(), 4);
assert_eq!(s.doc().unwrap().highlighted_rows(), copies);
run(&mut s, "layer.duplicate", json!({"id": sub.0}));
assert!(children(&s, layer).iter().any(|l| node(&s, *l).display_name() == "Sub copy"));
run(&mut s, "layer.highlight", json!({"ids": copies.iter().map(|c| c.0).collect::<Vec<_>>()}));
assert_eq!(run(&mut s, "layer.delete", json!({}))["deleted"], 2);
assert_eq!(children(&s, sub), vec![a, g]);
run(&mut s, "layer.delete", json!({"ids": [c.0, a.0]}));
assert!(s.doc().unwrap().doc.node(c).is_none() && s.doc().unwrap().doc.node(a).is_none());
run(&mut s, "edit.undo", json!({}));
assert!(s.doc().unwrap().doc.node(c).is_some());
assert!(s.execute("layer.delete", &json!({"ids": [layer.0]})).is_err(), "the last layer stays");
run(&mut s, "layer.setCurrent", json!({"id": sub.0}));
run(&mut s, "layer.delete", json!({}));
assert!(s.doc().unwrap().doc.node(sub).is_none());
assert_eq!(s.doc().unwrap().current_layer(), Some(layer), "the current layer moves on");
}
#[test]
fn collect_merge_flatten_and_reverse() {
let mut s = session();
let (layer, sub, a, g, _, c) = tree(&mut s);
// Collect two rows of the sublayer in a new sublayer of it.
run(&mut s, "layer.highlight", json!({"ids": [a.0, g.0]}));
let col = id(&run(&mut s, "layer.collectInNew", json!({})));
assert_eq!((parent(&s, col), children(&s, col)), (Some(sub), vec![a, g]));
// Reverse their order.
run(&mut s, "layer.reverse", json!({"ids": [a.0, g.0]}));
assert_eq!(children(&s, col), vec![g, a]);
assert!(s.execute("layer.reverse", &json!({"ids": [a.0]})).is_err());
run(&mut s, "edit.undo", json!({}));
// Merge the sublayer into Layer 1 (listed last).
run(&mut s, "layer.merge", json!({"ids": [col.0, layer.0]}));
assert!(s.doc().unwrap().doc.node(col).is_none());
assert_eq!(children(&s, layer), vec![c, sub, a, g]);
assert!(s.execute("layer.merge", &json!({"ids": [layer.0, sub.0]})).is_err(), "not into its own sublayer");
assert!(s.execute("layer.merge", &json!({"ids": [layer.0]})).is_err());
// Flatten: other visible layers merge in, hidden ones go, templates stay.
let l2 = id(&run(&mut s, "layer.new", json!({"top": true})));
let d = rect(&mut s, 0.0);
let l3 = id(&run(&mut s, "layer.new", json!({"top": true, "visible": false})));
let l4 = id(&run(&mut s, "layer.new", json!({"top": true, "template": true})));
let before = undos(&s);
let r = run(&mut s, "layer.flatten", json!({"id": layer.0}));
assert_eq!(r["discarded"], 1);
let st = s.doc().unwrap();
assert_eq!(st.doc.layers.iter().map(|l| l.id).collect::<Vec<_>>(), vec![layer, l4]);
assert!(st.doc.node(l2).is_none() && st.doc.node(l3).is_none());
assert_eq!(parent(&s, d), Some(layer));
assert_eq!(undos(&s), before + 1);
}
#[test]
fn release_to_layers_sequence_and_build() {
let mut s = session();
let layer = s.doc().unwrap().doc.layers[0].id;
let (a, b, c) = (rect(&mut s, 0.0), rect(&mut s, 50.0), rect(&mut s, 100.0));
let new = ids(&run(&mut s, "layer.releaseToLayers", json!({"id": layer.0})));
assert_eq!(new.len(), 3);
assert_eq!(children(&s, layer), new);
assert_eq!(new.iter().map(|l| children(&s, *l)).collect::<Vec<_>>(), vec![vec![a], vec![b], vec![c]]);
run(&mut s, "edit.undo", json!({}));
let new = ids(&run(&mut s, "layer.releaseToLayers", json!({"id": layer.0, "build": true})));
let counts: Vec<usize> = new.iter().map(|l| children(&s, *l).len()).collect();
assert_eq!(counts, vec![1, 2, 3], "the layers add up");
assert_eq!(children(&s, new[2]), vec![a, b, c], "the top one keeps the originals");
run(&mut s, "edit.undo", json!({}));
// A group's objects go into sublayers where the group was.
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
let g = id(&run(&mut s, "object.group", json!({})));
let new = ids(&run(&mut s, "layer.releaseToLayersBuild", json!({"id": g.0})));
assert_eq!(new.len(), 2);
assert!(s.doc().unwrap().doc.node(g).is_none());
assert_eq!(children(&s, layer), vec![new[0], new[1], c]);
assert!(s.execute("layer.releaseToLayers", &json!({"id": c.0})).is_err());
}
#[test]
fn hide_lock_and_outline_others_and_their_all_counterparts() {
let mut s = session();
let l1 = s.doc().unwrap().doc.layers[0].id;
let l2 = id(&run(&mut s, "layer.new", json!({})));
let l3 = id(&run(&mut s, "layer.new", json!({})));
run(&mut s, "layer.setCurrent", json!({"id": l2.0}));
let flags = |s: &Session, f: fn(&Node) -> bool| [l1, l2, l3].map(|l| f(&node(s, l)));
assert_eq!(run(&mut s, "layer.hideOthers", json!({}))["count"], 2);
assert_eq!(flags(&s, |n| n.visible), [false, true, false]);
run(&mut s, "layer.showAll", json!({}));
assert_eq!(flags(&s, |n| n.visible), [true; 3]);
run(&mut s, "layer.lockOthers", json!({}));
assert_eq!(flags(&s, |n| n.locked), [true, false, true]);
run(&mut s, "layer.unlockAll", json!({}));
assert_eq!(flags(&s, |n| n.locked), [false; 3]);
run(&mut s, "layer.outlineOthers", json!({}));
let preview = |n: &Node| matches!(n.kind, NodeKind::Layer { preview: true, .. });
assert_eq!(flags(&s, preview), [false, true, false]);
run(&mut s, "layer.previewAll", json!({}));
assert_eq!(flags(&s, preview), [true; 3]);
assert_eq!(run(&mut s, "layer.previewAll", json!({}))["count"], 0, "nothing to do: no undo step");
}
/// #1158: Alt-click on a hidden (locked) layer's eye (lock) isolates it: it shows (unlocks) as the
/// others hide (lock), in one undo step.
#[test]
fn hide_and_lock_others_show_and_unlock_the_layer_kept() {
let mut s = session();
let l1 = s.doc().unwrap().doc.layers[0].id;
let l2 = id(&run(&mut s, "layer.new", json!({})));
let flags = |s: &Session, f: fn(&Node) -> bool| [l1, l2].map(|l| f(&node(s, l)));
run(&mut s, "layer.setProps", json!({"ids": [l2.0], "visible": false, "locked": true}));
assert_eq!(run(&mut s, "layer.hideOthers", json!({"ids": [l2.0]}))["count"], 2);
assert_eq!(flags(&s, |n| n.visible), [false, true]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(flags(&s, |n| n.visible), [true, false], "one undo step");
run(&mut s, "layer.lockOthers", json!({"ids": [l2.0]}));
assert_eq!(flags(&s, |n| n.locked), [true, false]);
}
#[test]
fn locate_object_highlights_its_row() {
let mut s = session();
let (layer, sub, _, g, b, _) = tree(&mut s);
run(&mut s, "select.set", json!({"ids": [g.0]}));
let r = run(&mut s, "layer.locate", json!({}));
assert_eq!(r["ancestry"], json!([layer.0, sub.0, g.0]));
assert_eq!(s.doc().unwrap().highlighted_rows(), vec![g]);
assert_eq!(run(&mut s, "layer.locate", json!({"id": b.0}))["id"], b.0);
run(&mut s, "select.none", json!({}));
assert!(s.execute("layer.locate", &json!({})).is_err());
}
#[test]
fn the_clipping_mask_button_acts_on_the_highlighted_group() {
let mut s = session();
let (_, _, a, g, _, _) = tree(&mut s);
run(&mut s, "layer.move", json!({"ids": [a.0], "target": g.0}));
run(&mut s, "layer.setCurrent", json!({"id": g.0}));
assert_eq!(run(&mut s, "layer.clippingMask.toggle", json!({}))["clip"], true);
assert!(node(&s, g).clips());
}
/// #785: explicit malformed layer targets must never become the current-layer default.
#[test]
fn layer_delete_rejects_invalid_explicit_targets() {
let mut unexpected = vec![];
for params in [json!({"id": "1"}), json!({"ids": [1, "2"]}), json!({"id": "$1.id"})] {
let mut s = session();
run(&mut s, "layer.new", json!({}));
run(&mut s, "layer.new", json!({}));
let before = layer_target_snapshot(&s);
let result = s.execute("layer.delete", &params);
if result.is_ok() {
let names: Vec<_> = s.doc().unwrap().doc.layers.iter().map(|l| l.display_name()).collect();
unexpected.push(format!("{params}: {result:?}; remaining layers {names:?}"));
} else {
assert_eq!(layer_target_snapshot(&s), before, "{params}");
}
}
assert!(unexpected.is_empty(), "malformed requests succeeded: {}", unexpected.join("; "));
}
fn layer_target_snapshot(s: &Session) -> Value {
let st = s.doc().unwrap();
let history =
|entries: &[HistoryEntry]| entries.iter().map(|e| json!({"label": e.label, "doc": e.doc, "selection": e.selection})).collect::<Vec<_>>();
json!({"doc": st.doc, "selection": st.selection, "revision": st.revision, "activeLayer": st.active_layer,
"rows": st.layer_rows, "undo": history(&st.history.undo), "redo": history(&st.history.redo)})
}
fn layer_target_fixture() -> (Session, Vec<NodeId>) {
let mut s = session();
let first = s.doc().unwrap().doc.layers[0].id;
rect(&mut s, 0.0);
let second = id(&run(&mut s, "layer.new", json!({})));
rect(&mut s, 40.0);
let third = id(&run(&mut s, "layer.new", json!({})));
rect(&mut s, 80.0);
run(&mut s, "layer.highlight", json!({"ids": [first.0, second.0]}));
(s, vec![first, second, third])
}
fn malformed_layer_targets(first: NodeId, second: NodeId) -> Vec<Value> {
let mut params = vec![];
for bad in [json!("1"), json!("$1.id"), json!(1.0), json!(1.5), json!(-1), Value::Null, json!(true), json!({}), json!([])] {
params.push(json!({"id": bad}));
params.push(json!({"ids": [first.0, bad]}));
}
for bad in [Value::Null, json!("1"), json!(1), json!(false), json!({})] {
params.push(json!({"ids": bad, "id": first.0}));
}
for missing in [0, u64::MAX] {
params.push(json!({"id": missing}));
params.push(json!({"ids": [first.0, second.0, missing]}));
}
params
}
fn assert_layer_target_error(s: &mut Session, command: &str, params: Value) {
let before = layer_target_snapshot(s);
let error = s.execute(command, &params).unwrap_err();
assert!(error.to_string().contains(command), "{command} {params}: {error}");
assert_eq!(layer_target_snapshot(s), before, "{command} {params}: rejected targets must leave all state unchanged");
}
/// #785: shared row commands reject malformed or unknown targets before any partial edits.
#[test]
fn layer_target_validation_keeps_document_selection_revision_and_history_unchanged() {
let (_, rows) = layer_target_fixture();
for command in [
"layer.delete",
"layer.duplicate",
"layer.setProps",
"layer.template",
"layer.merge",
"layer.reverse",
"layer.hideOthers",
"layer.outlineOthers",
"layer.lockOthers",
] {
for mut params in malformed_layer_targets(rows[0], rows[1]) {
let (mut s, _) = layer_target_fixture();
if command == "layer.setProps" {
params["name"] = json!("Changed");
}
assert_layer_target_error(&mut s, command, params);
}
}
}
/// These commands have their own selectors, not the shared row-default parser.
#[test]
fn layer_collect_and_flatten_validate_their_own_explicit_targets() {
let (_, rows) = layer_target_fixture();
for params in malformed_layer_targets(rows[0], rows[1]) {
let command = if params.get("ids").is_some() { "layer.collectInNew" } else { "layer.flatten" };
let (mut s, _) = layer_target_fixture();
assert_layer_target_error(&mut s, command, params);
}
}
/// Truncation limits work, not validation: a malformed or unknown tail cannot be ignored.
#[test]
fn layer_target_arrays_are_fully_validated_before_the_row_limit() {
// The existing panel row limit is 100,000; exercise the public command beyond it.
const ROW_LIMIT: usize = 100_000;
for tail in [json!("$1.id"), json!(u64::MAX)] {
let (mut s, rows) = layer_target_fixture();
let mut values = vec![json!(rows[0].0); ROW_LIMIT + 1];
*values.last_mut().unwrap() = tail;
assert_layer_target_error(&mut s, "layer.delete", json!({"ids": values}));
}
let (mut s, rows) = layer_target_fixture();
let mut values = vec![json!(rows[0].0); ROW_LIMIT + 1];
*values.last_mut().unwrap() = json!(rows[1].0);
run(&mut s, "layer.setProps", json!({"ids": values, "name": "Within limit"}));
assert_eq!(node(&s, rows[0]).name.as_deref(), Some("Within limit"));
assert_ne!(node(&s, rows[1]).name.as_deref(), Some("Within limit"), "the valid tail remains beyond the work cap");
}
#[test]
fn layer_target_defaults_and_valid_explicit_precedence_are_preserved() {
let (mut s, rows) = layer_target_fixture();
run(&mut s, "layer.setProps", json!({"name": "Highlighted"}));
assert_eq!(node(&s, rows[0]).name.as_deref(), Some("Highlighted"));
assert_eq!(node(&s, rows[1]).name.as_deref(), Some("Highlighted"));
run(&mut s, "layer.setProps", json!({"id": rows[2].0, "name": "Single"}));
assert_eq!(node(&s, rows[2]).name.as_deref(), Some("Single"));
let before = undos(&s);
run(&mut s, "layer.setProps", json!({"ids": [rows[1].0, rows[0].0, rows[1].0], "id": "$1.id", "name": "Explicit"}));
assert_eq!(node(&s, rows[0]).name.as_deref(), Some("Explicit"));
assert_eq!(node(&s, rows[1]).name.as_deref(), Some("Explicit"));
assert_eq!(node(&s, rows[2]).name.as_deref(), Some("Single"));
assert_eq!(undos(&s), before + 1, "duplicates remain one edit");
assert_layer_target_error(&mut s, "layer.setProps", json!({"ids": [], "id": rows[2].0, "name": "Must not fall back"}));
run(&mut s, "layer.setCurrent", json!({"id": rows[2].0}));
run(&mut s, "layer.highlight", json!({"ids": []}));
run(&mut s, "layer.setProps", json!({"name": "Current"}));
assert_eq!(node(&s, rows[2]).name.as_deref(), Some("Current"));
let before = undos(&s);
run(&mut s, "layer.delete", json!({}));
assert!(s.doc().unwrap().doc.node(rows[2]).is_none());
assert_eq!(undos(&s), before + 1);
}
#[test]
fn layer_collect_and_flatten_keep_valid_and_omitted_target_behavior() {
let (mut s, rows) = layer_target_fixture();
let r = run(&mut s, "layer.flatten", json!({"id": rows[2].0}));
assert_eq!(r["id"], json!(rows[2].0));
assert_eq!(s.doc().unwrap().doc.layers.len(), 1);
let (mut s, _) = layer_target_fixture();
let selected = s.doc().unwrap().selection.objects.clone();
s.doc_mut().unwrap().layer_rows.clear();
let r = run(&mut s, "layer.collectInNew", json!({}));
let collected = id(&r);
assert!(selected.iter().all(|id| parent(&s, *id) == Some(collected)));
}