Files
vectorcraft/crates/engine/src/tests/attributes.rs
T
dotCooCoo 800715b42f Actions: Select Object matches letter case per character and rejects mistyped dialog fields
select.byNote compares letter case one character at a time, so a final
sigma matches σ, and Whole Word checks its boundaries against the
note's own characters, so İ neither ends a word early nor matches half
of itself. The search runs in time proportional to the note's length.

Set Selection passes its fields to ui.action.insertSelectObject as they
are, so a note or option of another type set through ui.dialog.set is
an error and the dialog stays open.
2026-11-12 16:03:57 -08:00

383 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Window → Attributes (`attributes.set`, `attributes.info`, `path.setFillRule`, `path.reverse`,
//! and `select.byNote`, which selects objects by their note),
//! the links SVG export writes for objects with a URL, and what objects keep for other software
//! (their `data-*` attributes and their attributes in other applications' namespaces).
use serde_json::{Value, json};
use vectorcraft_doc::{ImageMap, NodeKind};
use vectorcraft_geom::{Affine, FillRule};
use vectorcraft_render::{RenderOptions, Renderer};
use crate::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap()
}
fn rect(s: &mut Session, x: f64) -> NodeId {
NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 10, "width": 30, "height": 30}))["id"].as_u64().unwrap())
}
fn info(s: &mut Session, p: Value) -> Value {
run(s, "attributes.info", p)
}
#[test]
fn each_attribute_sets_reads_back_and_undoes_in_one_step() {
let mut s = session();
let a = rect(&mut s, 10.0);
let i = info(&mut s, json!({}));
assert_eq!((i["overprintFill"].clone(), i["showCenter"].clone(), i["imageMap"].clone()), (json!(false), json!(true), json!("none")));
assert_eq!((i["url"].clone(), i["note"].clone(), i["fillRule"].clone()), (json!(""), json!(""), json!("nonZero")));
let changed = run(
&mut s,
"attributes.set",
json!({"overprintFill": true, "overprintStroke": true, "showCenter": false, "imageMap": "Rectangle", "url": " https://example.com ", "note": "hello"}),
);
assert_eq!(changed["changed"], 1);
let i = info(&mut s, json!({}));
for (k, v) in [
("overprintFill", json!(true)),
("overprintStroke", json!(true)),
("showCenter", json!(false)),
("imageMap", json!("rectangle")),
("url", json!("https://example.com")),
("note", json!("hello")),
("recentUrls", json!(["https://example.com"])),
] {
assert_eq!(i[k], v, "{k}");
}
let n = s.doc().unwrap().doc.node(a).unwrap().clone();
let at = n.extras().attrs.as_deref().unwrap();
assert_eq!((at.show_center, at.image_map, n.url()), (Some(false), ImageMap::Rectangle, Some("https://example.com")));
assert!(!n.shows_center());
// One undo step takes everything back, and the attributes go away entirely.
run(&mut s, "edit.undo", json!({}));
let n = s.doc().unwrap().doc.node(a).unwrap().clone();
assert!(n.extras().attrs.is_none() && !n.has_overprint());
// Nothing to change: no undo step; bad values are refused.
let before = s.doc().unwrap().revision;
assert_eq!(run(&mut s, "attributes.set", json!({"showCenter": true}))["changed"], 0);
assert_eq!(s.doc().unwrap().revision, before);
assert!(s.execute("attributes.set", &json!({"imageMap": "circle"})).is_err());
assert!(s.execute("attributes.set", &json!({})).is_err());
// Clearing the URL and note drops the attributes.
run(&mut s, "attributes.set", json!({"url": "u", "note": "n"}));
run(&mut s, "attributes.set", json!({"url": "", "note": ""}));
assert!(s.doc().unwrap().doc.node(a).unwrap().extras().attrs.is_none());
}
#[test]
fn values_that_differ_read_null_and_recent_urls_keep_the_newest_first() {
let mut s = session();
let a = rect(&mut s, 0.0);
let b = rect(&mut s, 50.0);
run(&mut s, "attributes.set", json!({"ids": [a.0], "url": "https://a.example", "note": "a"}));
run(&mut s, "attributes.set", json!({"ids": [b.0], "url": "https://b.example"}));
run(&mut s, "attributes.set", json!({"ids": [a.0], "url": "https://a.example"}));
let i = info(&mut s, json!({"ids": [a.0, b.0]}));
assert_eq!((i["url"].clone(), i["note"].clone(), i["imageMap"].clone()), (Value::Null, Value::Null, json!("none")));
assert_eq!(i["recentUrls"], json!(["https://a.example", "https://b.example"]));
// A pen path doesn't show its centre by default; a rectangle does.
let p = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 0, "y": 0}, {"x": 10, "y": 10}]}))["id"].as_u64().unwrap());
assert_eq!(info(&mut s, json!({"ids": [p.0]}))["showCenter"], false);
assert_eq!(info(&mut s, json!({"ids": [p.0, a.0]}))["showCenter"], Value::Null);
}
/// A five-pointed star drawn as one self-intersecting path (its centre is wound twice).
fn star(s: &mut Session) -> NodeId {
let (c, r) = (50.0f64, 45.0f64);
let anchors: Vec<Value> = (0..5)
.map(|i| {
let a = (-90.0 + 144.0 * i as f64).to_radians();
json!({"x": c + r * a.cos(), "y": c + r * a.sin()})
})
.collect();
let id = NodeId(run(s, "path.create", json!({"anchors": anchors, "closed": true}))["id"].as_u64().unwrap());
run(s, "paint.setFill", json!({"ids": [id.0], "color": "#ff0000"}));
run(s, "paint.setStroke", json!({"ids": [id.0], "none": true}));
id
}
fn red_at_centre(s: &Session) -> bool {
let doc = s.doc().unwrap().doc.clone();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let p = Renderer::new().render(&doc, 100, 100, Affine::IDENTITY, &opts).pixel(50, 52);
p[0] > 200 && p[1] < 80
}
#[test]
fn an_even_odd_star_has_a_hollow_centre() {
let mut s = session();
let id = star(&mut s);
assert!(red_at_centre(&s), "non-zero fills the centre");
assert_eq!(run(&mut s, "path.setFillRule", json!({"rule": "evenOdd"}))["changed"], 1);
assert!(matches!(s.doc().unwrap().doc.node(id).unwrap().kind, NodeKind::Path { rule: FillRule::EvenOdd, .. }));
assert_eq!(info(&mut s, json!({}))["fillRule"], "evenOdd");
assert!(!red_at_centre(&s), "even-odd leaves it empty");
assert_eq!(run(&mut s, "path.setFillRule", json!({"rule": "evenOdd"}))["changed"], 0);
run(&mut s, "edit.undo", json!({}));
assert!(red_at_centre(&s));
assert!(s.execute("path.setFillRule", &json!({"rule": "odd"})).is_err());
}
#[test]
fn fill_rule_reaches_compound_paths_and_the_paths_in_groups() {
let mut s = session();
let a = rect(&mut s, 0.0);
let b = rect(&mut s, 50.0);
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
run(&mut s, "object.compoundPath.make", json!({}));
let compound = s.doc().unwrap().selection.objects[0];
let d = rect(&mut s, 0.0);
run(&mut s, "select.set", json!({"ids": [compound.0, d.0]}));
run(&mut s, "object.group", json!({}));
assert_eq!(run(&mut s, "path.setFillRule", json!({"rule": "evenOdd"}))["changed"], 2, "the compound path and the rectangle");
let doc = &s.doc().unwrap().doc;
assert!(matches!(doc.node(compound).unwrap().kind, NodeKind::Compound { rule: FillRule::EvenOdd, .. }));
assert!(matches!(doc.node(d).unwrap().kind, NodeKind::Path { rule: FillRule::EvenOdd, .. }));
}
#[test]
fn reverse_path_direction_on_and_off_set_the_winding() {
let mut s = session();
let a = rect(&mut s, 10.0);
let ccw = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().subpaths[0].signed_area() < 0.0;
let start = ccw(&s);
assert_eq!(info(&mut s, json!({}))["reversed"], start);
// Off / On set it; asking again changes nothing.
assert_eq!(run(&mut s, "path.reverse", json!({"reversed": !start}))["changed"], 1);
assert_eq!(ccw(&s), !start);
assert_eq!(info(&mut s, json!({}))["reversed"], !start);
assert_eq!(run(&mut s, "path.reverse", json!({"reversed": !start}))["changed"], 0);
// Without `reversed` it flips.
run(&mut s, "path.reverse", json!({}));
assert_eq!(ccw(&s), start);
run(&mut s, "select.none", json!({}));
assert!(s.execute("path.reverse", &json!({})).is_err(), "nothing to reverse");
}
#[test]
fn attributes_survive_a_native_round_trip() {
let mut s = session();
let a = rect(&mut s, 10.0);
run(&mut s, "attributes.set", json!({"showCenter": false, "imageMap": "polygon", "url": "https://example.com/x", "note": "keep"}));
let doc = s.doc().unwrap().doc.clone();
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
assert_eq!(back.node(a).unwrap().extras().attrs, doc.node(a).unwrap().extras().attrs);
assert!(back.node(a).unwrap().extras().attrs.is_some());
}
#[test]
fn svg_export_links_objects_and_import_reads_the_links_back() {
let mut s = session();
let a = rect(&mut s, 10.0);
rect(&mut s, 50.0);
run(&mut s, "attributes.set", json!({"ids": [a.0], "url": "https://example.com/?a=1&b=2"}));
let svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
assert_eq!(svg.matches("<a ").count(), 1, "{svg}");
assert!(svg.contains("xlink:href=\"https://example.com/?a=1&amp;b=2\""), "{svg}");
let back = vectorcraft_svg::import(&svg).unwrap();
let mut urls = vec![];
back.walk(|n| {
if let NodeKind::Path { .. } = n.kind {
urls.push(n.url().map(str::to_string));
}
});
assert_eq!(urls, [Some("https://example.com/?a=1&b=2".to_string()), None], "the link wraps the path alone");
}
/// #522: an object's own data (SVG `data-*` attributes) is set with `object.setProps {data}`,
/// undone in one step, written to SVG and kept by the native format.
#[test]
fn object_data_is_set_saved_and_written_to_svg() {
let mut s = session();
let a = rect(&mut s, 10.0);
let data = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().extras().attrs.as_deref().map(|x| x.data.clone()).unwrap_or_default();
let pair = |k: &str, v: &str| (k.to_string(), v.to_string());
run(&mut s, "object.setProps", json!({"data": {"pivot": "60,100", "z": 3}}));
assert_eq!(data(&s), [pair("pivot", "60,100"), pair("z", "3")], "values are text");
run(&mut s, "object.setProps", json!({"data": {"data-pivot": "1,2", "z": null}}));
assert_eq!(data(&s), [pair("pivot", "1,2")], "a data- prefix is optional; null removes a key");
run(&mut s, "edit.undo", json!({}));
assert_eq!(data(&s).len(), 2, "one step");
for bad in [json!({"data": {"vc-x": "1"}}), json!({"data": {"name": "x"}}), json!({"data": {"a b": "1"}}), json!({"data": "pivot"})] {
assert!(s.execute("object.setProps", &bad).is_err(), "{bad}");
}
let svg = run(&mut s, "document.serialize", json!({"format": "svg"}))["text"].as_str().unwrap().to_string();
assert!(svg.contains("data-pivot=\"60,100\" data-z=\"3\""), "{svg}");
let native = vectorcraft_format::save(&s.doc().unwrap().doc, false);
let back = vectorcraft_format::load(&native).unwrap();
assert_eq!(back.node(a).unwrap().extras().attrs, s.doc().unwrap().doc.node(a).unwrap().extras().attrs);
}
/// The selected objects after `select.byNote` with `p`, or its error.
fn by_note(s: &mut Session, p: Value) -> std::result::Result<Vec<u64>, String> {
s.execute("select.byNote", &p).map_err(|e| e.to_string())?;
Ok(s.doc().unwrap().selection.objects.iter().map(|id| id.0).collect())
}
fn note(s: &mut Session, id: NodeId, note: &str) {
run(s, "attributes.set", json!({"ids": [id.0], "note": note}));
}
/// Select by Note finds a note anywhere and in any letter case by default, replaces the selection
/// with every object it finds, in paint order, and is not an undo step. Case Sensitive and Whole
/// Word narrow it; Reselect repeats it.
#[test]
fn select_by_note_matches_part_of_a_note_in_any_case_unless_asked() {
let mut s = session();
let [a, b, c, d] = [0.0, 20.0, 40.0, 60.0].map(|x| rect(&mut s, x));
note(&mut s, a, "Logo front");
note(&mut s, b, "logo");
note(&mut s, c, "Logotype");
note(&mut s, d, "back");
run(&mut s, "select.set", json!({"ids": [d.0]}));
let undo = s.doc().unwrap().history.undo.len();
assert_eq!(by_note(&mut s, json!({"note": " LOGO "})), Ok(vec![a.0, b.0, c.0]), "outer spaces are left out");
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step");
assert_eq!(by_note(&mut s, json!({"note": "logo", "caseSensitive": true})), Ok(vec![b.0]));
assert_eq!(by_note(&mut s, json!({"note": "logo", "wholeWord": true})), Ok(vec![a.0, b.0]));
assert_eq!(by_note(&mut s, json!({"note": "Logo", "caseSensitive": true, "wholeWord": true})), Ok(vec![a.0]));
assert_eq!(by_note(&mut s, json!({"note": "o f"})), Ok(vec![a.0]), "spaces inside the note count");
run(&mut s, "select.none", json!({}));
run(&mut s, "select.reselect", json!({}));
assert_eq!(s.doc().unwrap().selection.objects, [a]);
}
/// Whole Word looks at every place the text occurs, also places that overlap.
#[test]
fn select_by_note_whole_word_matches_overlapping_places_and_other_scripts() {
let mut s = session();
let a = rect(&mut s, 0.0);
let matches = |s: &mut Session, text: &str, p: Value| {
note(s, a, text);
by_note(s, p).is_ok()
};
assert!(matches(&mut s, "a aa a a", json!({"note": "a a", "wholeWord": true})));
assert!(!matches(&mut s, "aa_a", json!({"note": "a", "wholeWord": true})));
assert!(matches(&mut s, "x-ray, x", json!({"note": "x", "wholeWord": true})));
assert!(matches(&mut s, "ÉTIQUETTE", json!({"note": "étiquette"})));
assert!(!matches(&mut s, "ÉTIQUETTE", json!({"note": "étiquette", "caseSensitive": true})));
assert!(matches(&mut s, "标签 一", json!({"note": "一", "wholeWord": true})));
assert!(!matches(&mut s, "", json!({"note": "a"})));
}
/// Letter case is compared one character at a time: a final Σ, whose lowercase is ς, matches Σ
/// and σ. Whole Word looks at the characters of the note itself, so a letter whose lowercase
/// takes two characters (İ) neither ends a word early nor matches half of itself.
#[test]
fn select_by_note_compares_letter_case_per_character() {
let mut s = session();
let a = rect(&mut s, 0.0);
let matches = |s: &mut Session, text: &str, p: Value| {
note(s, a, text);
by_note(s, p).is_ok()
};
assert!(matches(&mut s, "ΟΣ", json!({"note": "Σ"})));
assert!(matches(&mut s, "ΟΣ", json!({"note": "σ"})));
assert!(matches(&mut s, "ΟΣ", json!({"note": "ος", "wholeWord": true})));
assert!(!matches(&mut s, "ΟΣ", json!({"note": "σ", "caseSensitive": true})));
assert!(!matches(&mut s, "İx", json!({"note": "i", "wholeWord": true})));
assert!(matches(&mut s, "İ x", json!({"note": "İ", "wholeWord": true})));
assert!(!matches(&mut s, "İ x", json!({"note": "i", "wholeWord": true})));
}
/// Select by Note leaves out hidden and locked objects, selects a group whose note matches without
/// its members and a member whose note matches on its own, keeps to the isolated group, and leaves
/// the selection as it was when it finds nothing. Bad params are errors that change nothing.
#[test]
fn select_by_note_takes_only_objects_that_can_be_selected() {
let mut s = session();
let [a, b, c, d, e] = [0.0, 10.0, 20.0, 30.0, 40.0].map(|x| rect(&mut s, x));
for id in [a, b, c, d, e] {
note(&mut s, id, "tag");
}
run(&mut s, "select.set", json!({"ids": [a.0]}));
run(&mut s, "object.hide", json!({}));
run(&mut s, "select.set", json!({"ids": [b.0]}));
run(&mut s, "object.lock", json!({}));
run(&mut s, "select.set", json!({"ids": [c.0, d.0]}));
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
note(&mut s, g, "tag group");
assert_eq!(by_note(&mut s, json!({"note": "tag"})), Ok(vec![g.0, e.0]), "the group, not its members");
note(&mut s, g, "box");
assert_eq!(by_note(&mut s, json!({"note": "tag"})), Ok(vec![c.0, d.0, e.0]));
run(&mut s, "object.isolate", json!({"id": g.0}));
assert_eq!(by_note(&mut s, json!({"note": "tag"})), Ok(vec![c.0, d.0]), "inside the isolated group only");
run(&mut s, "select.set", json!({"ids": [c.0]}));
let missing = by_note(&mut s, json!({"note": "nothing"})).unwrap_err();
assert!(missing.contains("no object that can be selected has a note matching “nothing”"), "{missing}");
assert_eq!(s.doc().unwrap().selection.objects, [c], "the selection stays");
for bad in [
json!({}),
json!({"note": " "}),
json!({"note": 5}),
json!({"note": "x".repeat(1025)}),
json!({"note": "tag", "caseSensitive": "yes"}),
json!({"note": "tag", "wholeWord": 1}),
] {
assert!(s.execute("select.byNote", &bad).is_err(), "{bad}");
assert_eq!(s.doc().unwrap().selection.objects, [c], "{bad}");
}
assert!(by_note(&mut s, json!({"note": "x".repeat(1024)})).is_err_and(|e| e.contains("no object")), "1,024 bytes is allowed");
}
/// Under Lock Guides a guide whose note matches is not a match, and a search that finds only such
/// guides leaves the selection and the Puppet Warp pins on it as they were.
#[test]
fn select_by_note_leaves_locked_guides_out_before_changing_the_selection() {
let mut s = session();
let a = rect(&mut s, 10.0);
let g = rect(&mut s, 50.0);
note(&mut s, g, "edge");
run(&mut s, "select.set", json!({"ids": [g.0]}));
run(&mut s, "view.guides.make", json!({}));
run(&mut s, "view.guides.lock", json!({"locked": true}));
run(&mut s, "select.set", json!({"ids": [a.0]}));
run(&mut s, "object.puppetWarp", json!({"id": a.0, "rest": true, "pins": [[25, 25]], "moved": [[25, 25]]}));
run(&mut s, "select.set", json!({"ids": [a.0]}));
assert!(s.doc().unwrap().doc.puppet.is_some(), "a pin on the rectangle");
let missing = by_note(&mut s, json!({"note": "edge"})).unwrap_err();
assert!(missing.contains("no object that can be selected"), "{missing}");
let st = s.doc().unwrap();
assert_eq!(st.selection.objects, [a], "the selection stays");
assert!(st.doc.puppet.is_some(), "the pins stay");
}
/// An object's attributes in other applications' XML namespaces are its own, as its `data-*`
/// attributes are: a pasted copy and a duplicate keep them, deleting the object takes them with
/// it, and the native file and SVG export keep them.
#[test]
fn other_applications_attributes_go_with_their_object() {
let mut s = Session::new();
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:plotter="https://example.org/plotter" width="100" height="100">
<rect width="20" height="20" plotter:pen="2"/></svg>"#;
run(&mut s, "document.open", json!({"name": "pens.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())}));
// The `plotter:pen` values on the document's objects.
let pens = |d: &vectorcraft_doc::Document| {
let mut out = vec![];
d.walk(|n| out.extend(n.extras().attrs.iter().flat_map(|a| a.foreign.iter().map(|f| f.value.clone()))));
out
};
assert_eq!(pens(&s.doc().unwrap().doc), ["2"]);
run(&mut s, "select.all", json!({}));
run(&mut s, "edit.copy", json!({}));
run(&mut s, "edit.paste", json!({}));
assert_eq!(pens(&s.doc().unwrap().doc), ["2", "2"], "a pasted copy keeps them");
run(&mut s, "edit.duplicate", json!({}));
assert_eq!(pens(&s.doc().unwrap().doc), ["2", "2", "2"], "so does a duplicate");
run(&mut s, "edit.clear", json!({}));
assert_eq!(pens(&s.doc().unwrap().doc), ["2", "2"], "a deleted object leaves none behind");
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
assert_eq!(pens(&back), ["2", "2"], "the native file keeps them");
let out = run(&mut s, "document.serialize", json!({"format": "svg"}))["text"].as_str().unwrap().to_string();
assert_eq!((out.matches("xmlns:plotter=\"https://example.org/plotter\"").count(), out.matches("plotter:pen=\"2\"").count()), (1, 2), "{out}");
}