Files
vectorcraft/crates/engine/src/tests_slices.rs
arhammusheer 5beac4f122 #1244: smaller objects: Node 352 -> 152 bytes, NodeKind 200 -> 80, and a cheaper undo estimate
Rarely set fields move into one optional boxed NodeExtras, and large NodeKind payloads (images,
live shapes, blends, envelopes) are boxed. Every object, and every undo step that copies it,
shrinks: a 50k-path move keeps ~24.5 MB per step instead of ~40 MB. Files stay byte-identical
(Node keeps its flat JSON through a borrowed Serialize and a from-wire Deserialize, no flatten),
and loading is slightly faster. Undo and redo reuse their step's size estimate instead of
walking the document again (8.3 -> 5.6 ms for a 50k-path move).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc

# Conflicts:
#	crates/engine/src/cmd/graph.rs
#	crates/svg/src/export.rs
2026-10-12 11:02:32 +07:00

330 lines
16 KiB
Rust

//! Object → Slice, View → Hide/Lock Slices, Select → Object → Slices and `slice.list`.
use serde_json::{Value, json};
use vectorcraft_doc::SliceKind;
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
// Unstroked art: visual bounds are the geometry.
s.paint.stroke = vectorcraft_color::Paint::None;
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"))
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> u64 {
run(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].as_u64().unwrap()
}
/// `slice.list`'s slices.
fn slices(s: &mut Session) -> Vec<Value> {
run(s, "slice.list", json!({}))["slices"].as_array().unwrap().clone()
}
/// The laid-out rectangle of slice `id` as [x, y, width, height].
fn rect_of(s: &mut Session, id: u64) -> [f64; 4] {
let v = slices(s).into_iter().find(|v| v["id"] == json!(id)).unwrap();
["x", "y", "width", "height"].map(|k| v[k].as_f64().unwrap())
}
fn user_count(s: &Session) -> usize {
s.doc().unwrap().doc.slices.len()
}
/// A user slice over `[x, y, w, h]`, made from a temporary rectangle.
fn user_slice(s: &mut Session, r: [f64; 4]) -> u64 {
let tmp = rect(s, r[0], r[1], r[2], r[3]);
let id = run(s, "object.slice.fromSelection", json!({"ids": [tmp]}))["id"].as_u64().unwrap();
run(s, "select.set", json!({"ids": [tmp]}));
run(s, "edit.clear", json!({}));
id
}
#[test]
fn an_object_slice_follows_its_object_and_releases_back_to_it() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0);
let made = run(&mut s, "object.slice.make", json!({}));
assert_eq!(made["ids"], json!([a]));
assert_eq!(rect_of(&mut s, a), [100.0, 100.0, 50.0, 40.0]);
let list = slices(&mut s);
assert!(list.iter().any(|v| v["source"] == "auto"), "auto slices fill the artboard");
let area: f64 = list.iter().map(|v| v["width"].as_f64().unwrap() * v["height"].as_f64().unwrap()).sum();
assert_eq!(area, 300.0 * 300.0);
// Moving the object moves its slice; one undo step brings it back.
run(&mut s, "object.move", json!({"dx": 20, "dy": 10}));
assert_eq!(rect_of(&mut s, a), [120.0, 110.0, 50.0, 40.0]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(rect_of(&mut s, a), [100.0, 100.0, 50.0, 40.0]);
// Make again changes nothing; Release leaves the object.
assert_eq!(run(&mut s, "object.slice.make", json!({}))["ids"], json!([]));
assert_eq!(run(&mut s, "object.slice.release", json!({}))["ids"], json!([a]));
assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().slice.is_none());
assert!(slices(&mut s).is_empty());
run(&mut s, "edit.undo", json!({}));
assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().slice.is_some());
}
#[test]
fn releasing_a_user_slice_leaves_an_unpainted_rectangle() {
let mut s = session();
let id = user_slice(&mut s, [10.0, 20.0, 30.0, 40.0]);
run(&mut s, "select.object.slices", json!({}));
let r = run(&mut s, "object.slice.release", json!({}));
assert_eq!(s.doc().unwrap().doc.slice(NodeId(id)), None);
let rid = r["ids"][0].as_u64().unwrap();
let d = &s.doc().unwrap().doc;
assert!(d.slices.is_empty());
let n = d.node(NodeId(rid)).unwrap();
assert_eq!(n.geometric_bounds().unwrap(), vectorcraft_geom::Rect::new(10.0, 20.0, 40.0, 60.0));
assert!(matches!(n.appearance.fill_paint(), vectorcraft_color::Paint::None));
assert_eq!(s.doc().unwrap().selection.objects, vec![NodeId(rid)]);
}
#[test]
fn guides_give_nine_slices() {
let mut s = session();
for (vertical, pos) in [(true, 100.0), (true, 200.0), (false, 50.0), (false, 250.0), (true, 400.0)] {
run(&mut s, "guide.add", json!({"vertical": vertical, "pos": pos}));
}
user_slice(&mut s, [0.0, 0.0, 10.0, 10.0]);
let ids = run(&mut s, "object.slice.fromGuides", json!({}))["ids"].as_array().unwrap().len();
assert_eq!(ids, 9, "the guide off the artboard cuts nothing");
assert_eq!(user_count(&s), 9, "the earlier slice is replaced");
let list = slices(&mut s);
assert_eq!(list.len(), 9, "the grid covers the artboard: no auto slices");
assert_eq!(list[4]["x"], 100.0);
assert_eq!(list[4]["width"], 100.0);
assert_eq!(list[4]["height"], 200.0);
assert_eq!(list.iter().map(|v| v["number"].as_u64().unwrap()).collect::<Vec<_>>(), (1..=9).collect::<Vec<_>>());
// One undo step takes the grid back.
run(&mut s, "edit.undo", json!({}));
assert_eq!(user_count(&s), 1);
}
#[test]
fn divide_two_by_three_and_by_size() {
let mut s = session();
user_slice(&mut s, [0.0, 0.0, 90.0, 60.0]);
run(&mut s, "select.object.slices", json!({}));
run(&mut s, "object.slice.options", json!({"name": "hero", "url": "https://example.com"}));
let pieces = run(&mut s, "object.slice.divide", json!({"rows": 2, "columns": 3}))["ids"].as_array().unwrap().clone();
assert_eq!(pieces.len(), 6);
let d = &s.doc().unwrap().doc;
let rects: Vec<_> = d.slices.iter().map(|sl| (sl.rect.x0, sl.rect.y0, sl.rect.width(), sl.rect.height())).collect();
assert_eq!(
rects,
[
(0.0, 0.0, 30.0, 30.0),
(30.0, 0.0, 30.0, 30.0),
(60.0, 0.0, 30.0, 30.0),
(0.0, 30.0, 30.0, 30.0),
(30.0, 30.0, 30.0, 30.0),
(60.0, 30.0, 30.0, 30.0)
]
);
assert!(d.slices.iter().all(|sl| sl.options.url == "https://example.com"));
assert_eq!(d.slices.iter().filter(|sl| sl.options.name == "hero").count(), 1, "only the first piece keeps the name");
assert_eq!(s.doc().unwrap().selection.slices.len(), 6, "the pieces are selected");
run(&mut s, "edit.undo", json!({}));
assert_eq!(user_count(&s), 1);
// By size: 25 pt rows of a 60 pt slice: 25, 25 and 10.
run(&mut s, "select.object.slices", json!({}));
run(&mut s, "object.slice.divide", json!({"rowHeight": 25}));
let hs: Vec<f64> = s.doc().unwrap().doc.slices.iter().map(|sl| sl.rect.height()).collect();
assert_eq!(hs, [25.0, 25.0, 10.0]);
assert!(s.execute("object.slice.divide", &json!({"rows": 0})).is_err());
assert!(s.execute("object.slice.divide", &json!({"columns": 5000})).is_err());
assert!(s.execute("object.slice.divide", &json!({})).is_err(), "nothing to divide by");
}
#[test]
fn combine_and_duplicate() {
let mut s = session();
user_slice(&mut s, [0.0, 0.0, 20.0, 20.0]);
run(&mut s, "select.object.slices", json!({}));
assert!(s.execute("object.slice.combine", &json!({})).is_err(), "two or more");
let b = rect(&mut s, 50.0, 40.0, 10.0, 10.0);
run(&mut s, "object.slice.make", json!({}));
run(&mut s, "select.object.slices", json!({}));
let c = run(&mut s, "object.slice.combine", json!({}))["id"].as_u64().unwrap();
assert_eq!(rect_of(&mut s, c), [0.0, 0.0, 60.0, 50.0]);
assert_eq!(user_count(&s), 1);
assert!(s.doc().unwrap().doc.node(NodeId(b)).unwrap().extras().slice.is_none(), "the object slice was released");
assert_eq!(s.doc().unwrap().selection.slices, vec![NodeId(c)]);
let dup = run(&mut s, "object.slice.duplicate", json!({}))["ids"][0].as_u64().unwrap();
assert_eq!(rect_of(&mut s, dup), [10.0, 10.0, 60.0, 50.0]);
assert_eq!(s.doc().unwrap().selection.slices, vec![NodeId(dup)]);
}
#[test]
fn slice_options_set_read_and_undo_in_one_step() {
let mut s = session();
user_slice(&mut s, [0.0, 0.0, 50.0, 50.0]);
run(&mut s, "select.object.slices", json!({}));
let o = run(&mut s, "object.slice.options", json!({}));
assert_eq!((o["kind"].clone(), o["htmlText"].clone()), (json!("image"), json!(false)));
let undo = s.doc().unwrap().history.undo.len();
let o = run(
&mut s,
"object.slice.options",
json!({"kind": "No Image", "name": " cell ", "text": " <b>Hi</b>", "background": "#FF0000", "hAlign": "center", "vAlign": "bottom"}),
);
assert_eq!(
o,
json!({"kind": "noImage", "name": "cell", "url": "", "target": "", "message": "", "alt": "", "text": " <b>Hi</b>", "background": "#ff0000", "hAlign": "center", "vAlign": "bottom", "htmlText": false})
);
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
assert_eq!(slices(&mut s)[0]["name"], "cell");
assert!(s.execute("object.slice.options", &json!({"kind": "htmlText"})).is_err(), "HTML Text is for type");
assert!(s.execute("object.slice.options", &json!({"background": "pink-ish"})).is_err());
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc().unwrap().doc.slices[0].options, Default::default());
// An object slice of type can be HTML Text.
let t = run(&mut s, "text.create", json!({"x": 10, "y": 100, "text": "Hello"}))["id"].as_u64().unwrap();
run(&mut s, "object.slice.make", json!({"ids": [t]}));
run(&mut s, "select.set", json!({"ids": [t]}));
let o = run(&mut s, "object.slice.options", json!({"kind": "htmlText"}));
assert_eq!((o["kind"].clone(), o["htmlText"].clone()), (json!("htmlText"), json!(true)));
assert_eq!(s.doc().unwrap().doc.node(NodeId(t)).unwrap().extras().slice.as_ref().unwrap().kind, SliceKind::HtmlText);
}
#[test]
fn clip_to_artboard_hide_lock_select_and_delete_all() {
let mut s = session();
let id = user_slice(&mut s, [250.0, 250.0, 100.0, 100.0]);
assert_eq!(rect_of(&mut s, id), [250.0, 250.0, 50.0, 50.0], "clipped to the artboard");
assert_eq!(run(&mut s, "object.slice.clipToArtboard", json!({}))["on"], false);
assert_eq!(rect_of(&mut s, id), [250.0, 250.0, 100.0, 100.0]);
run(&mut s, "edit.undo", json!({}));
assert!(s.doc().unwrap().doc.slices_clip_to_artboard);
assert_eq!(run(&mut s, "object.slice.clipToArtboard", json!({"on": true}))["on"], true);
assert_eq!(run(&mut s, "view.slices.hide", json!({}))["hidden"], true);
assert!(s.slices_hidden());
assert_eq!(run(&mut s, "view.slices.lock", json!({"locked": true}))["locked"], true);
let l = run(&mut s, "slice.list", json!({}));
assert_eq!((l["hidden"].clone(), l["locked"].clone(), l["clipToArtboard"].clone()), (json!(true), json!(true), json!(true)));
run(&mut s, "view.slices.hide", json!({"hidden": false}));
assert!(!s.slices_hidden());
let a = rect(&mut s, 10.0, 10.0, 10.0, 10.0);
run(&mut s, "object.slice.make", json!({}));
assert_eq!(run(&mut s, "select.object.slices", json!({}))["count"], 2);
let sel = &s.doc().unwrap().selection;
assert_eq!((sel.slices.clone(), sel.objects.is_empty()), (vec![NodeId(id), NodeId(a)], true));
assert!(slices(&mut s).iter().filter(|v| v["selected"] == true).count() == 2);
// Selecting objects drops the slice selection.
run(&mut s, "select.all", json!({}));
assert!(s.doc().unwrap().selection.slices.is_empty());
assert_eq!(run(&mut s, "object.slice.deleteAll", json!({}))["count"], 2);
assert_eq!(user_count(&s), 0);
assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().slice.is_none());
assert!(s.execute("object.slice.deleteAll", &json!({})).is_err(), "nothing to delete");
}
#[test]
fn slices_survive_a_native_round_trip() {
let mut s = session();
user_slice(&mut s, [100.0, 100.0, 40.0, 40.0]);
run(&mut s, "select.object.slices", json!({}));
run(&mut s, "object.slice.options", json!({"kind": "noImage", "text": "x"}));
let a = rect(&mut s, 10.0, 10.0, 10.0, 10.0);
run(&mut s, "object.slice.make", json!({}));
run(&mut s, "object.slice.options", json!({"alt": "logo"}));
run(&mut s, "object.slice.clipToArtboard", json!({"on": false}));
let doc = s.doc().unwrap().doc.clone();
vectorcraft_testkit::invariants::check_native_roundtrip_exact(&doc).unwrap();
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
assert_eq!(back.slices, doc.slices);
assert_eq!(back.node(NodeId(a)).unwrap().extras().slice.as_ref().unwrap().alt, "logo");
assert!(!back.slices_clip_to_artboard);
// A copy of an object slice is an object slice too, under its own id.
run(&mut s, "select.set", json!({"ids": [a]}));
run(&mut s, "edit.duplicate", json!({}));
assert_eq!(s.doc().unwrap().doc.object_slices().len(), 2);
}
#[test]
fn slice_tool_gestures_are_one_undo_step_each() {
use vectorcraft_tools::{Mods, PointerEvent, PointerKind as K, ToolKey};
let mut s = session();
let view = ViewInfo { smart_guides: false, ..Default::default() };
let undo = |s: &Session| s.doc().unwrap().history.undo.len();
let gesture = |s: &mut Session, pts: &[(K, f64, f64)]| {
for (k, x, y) in pts {
s.pointer(&PointerEvent::new(*k, *x, *y), view).unwrap();
}
};
s.select_tool("slice", view).unwrap();
let n = undo(&s);
gesture(&mut s, &[(K::Down, 10.0, 10.0), (K::Drag, 50.0, 30.0), (K::Drag, 110.0, 60.0), (K::Up, 110.0, 60.0)]);
assert_eq!(undo(&s), n + 1);
let id = s.doc().unwrap().doc.slices[0].id;
assert_eq!(s.doc().unwrap().doc.slices.len(), 1, "the previews left one slice");
assert_eq!(rect_of(&mut s, id.0), [10.0, 10.0, 100.0, 50.0]);
assert_eq!(s.doc().unwrap().selection.slices, vec![id]);
// Slice Selection: drag the slice, then its bottom-right handle, then Delete.
s.select_tool("sliceSelection", view).unwrap();
gesture(&mut s, &[(K::Down, 20.0, 20.0), (K::Drag, 30.0, 40.0), (K::Drag, 40.0, 50.0), (K::Up, 40.0, 50.0)]);
assert_eq!(undo(&s), n + 2);
assert_eq!(rect_of(&mut s, id.0), [30.0, 40.0, 100.0, 50.0]);
gesture(&mut s, &[(K::Down, 130.0, 90.0), (K::Drag, 140.0, 95.0), (K::Drag, 150.0, 100.0), (K::Up, 150.0, 100.0)]);
assert_eq!(undo(&s), n + 3);
assert_eq!(rect_of(&mut s, id.0), [30.0, 40.0, 120.0, 60.0]);
assert!(s.tool_claims_key(ToolKey::Delete, view));
s.tool_key(ToolKey::Delete, Mods::default(), view).unwrap();
assert!(s.doc().unwrap().doc.slices.is_empty());
assert_eq!(undo(&s), n + 4);
// Undo walks the gestures back one at a time.
run(&mut s, "edit.undo", json!({}));
assert_eq!(rect_of(&mut s, id.0), [30.0, 40.0, 120.0, 60.0]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(rect_of(&mut s, id.0), [30.0, 40.0, 100.0, 50.0]);
// An object slice moves its object; locked slices stay put.
let a = rect(&mut s, 200.0, 200.0, 40.0, 40.0);
run(&mut s, "object.slice.make", json!({}));
gesture(&mut s, &[(K::Down, 220.0, 220.0), (K::Drag, 230.0, 220.0), (K::Drag, 240.0, 230.0), (K::Up, 240.0, 230.0)]);
assert_eq!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().geometric_bounds().unwrap().x0, 220.0);
run(&mut s, "view.slices.lock", json!({"locked": true}));
let before = undo(&s);
gesture(&mut s, &[(K::Down, 250.0, 240.0), (K::Drag, 280.0, 260.0), (K::Up, 280.0, 260.0)]);
assert_eq!(undo(&s), before);
// The journal replays the gestures as commands.
let journal: Vec<String> = s.journal.iter().map(|(c, _)| c.clone()).collect();
for c in ["object.slice.create", "object.slice.move", "object.slice.setRect", "object.slice.delete", "object.slice.select"] {
assert!(journal.iter().any(|j| j == c), "{c} journaled: {journal:?}");
}
}
#[test]
fn slice_commands_check_their_params() {
let mut s = session();
assert!(s.execute("object.slice.create", &json!({"x": 0, "y": 0, "width": 0, "height": 10})).is_err(), "no area");
assert!(s.execute("object.slice.create", &json!({"x": 1e9, "y": 0, "width": 10, "height": 10})).is_err(), "off the canvas");
let id = run(&mut s, "object.slice.create", json!({"x": 50, "y": 60, "width": -20, "height": 10}))["id"].as_u64().unwrap();
assert_eq!(rect_of(&mut s, id), [30.0, 60.0, 20.0, 10.0], "a negative size draws the other way");
run(&mut s, "object.slice.setRect", json!({"id": id, "x": 0, "y": 0, "width": 5, "height": 5}));
assert_eq!(rect_of(&mut s, id), [0.0, 0.0, 5.0, 5.0]);
let a = rect(&mut s, 100.0, 100.0, 10.0, 10.0);
run(&mut s, "object.slice.make", json!({}));
assert!(
s.execute("object.slice.setRect", &json!({"id": a, "x": 0, "y": 0, "width": 5, "height": 5})).is_err(),
"object slices follow their object"
);
assert!(s.execute("object.slice.move", &json!({"slices": [12345], "dx": 1, "dy": 1})).is_err(), "not a slice");
assert_eq!(run(&mut s, "object.slice.select", json!({"slices": [id, a]}))["selected"], json!([id, a]));
assert_eq!(run(&mut s, "object.slice.select", json!({"slices": [a], "toggle": true}))["selected"], json!([id]));
assert_eq!(run(&mut s, "object.slice.delete", json!({"slices": [a]}))["count"], 1);
assert!(s.doc().unwrap().doc.node(NodeId(a)).is_some(), "deleting an object slice keeps its object");
assert_eq!(run(&mut s, "object.slice.select", json!({}))["selected"], json!([]));
}