mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 13:36:53 +00:00
Merge main into fix/plugin-grants-delete-test-portable
This commit is contained in:
@@ -115,6 +115,9 @@ fn constrain_angle_draws_boxes_along_the_turned_axes() {
|
||||
let mut s = new_doc();
|
||||
set_pref(&mut s, "constrainAngle", json!(90));
|
||||
for tool in ["rectangle", "ellipse"] {
|
||||
// The shape drawn before stays selected, and a press on its box's corner handle would
|
||||
// scale it instead of drawing.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.select_tool(tool, ViewInfo::default()).unwrap();
|
||||
// 60 right and 20 down on the page: along axes turned 90°, 20 wide and 60 high.
|
||||
gesture(&mut s, Mods::default(), &[(Down, 100.0, 100.0), (Drag, 130.0, 110.0), (Drag, 160.0, 120.0), (Up, 160.0, 120.0)]);
|
||||
|
||||
@@ -215,6 +215,291 @@ fn type_picks_up_font_and_size() {
|
||||
assert_eq!((st.size, justify, st.fill.color().unwrap().to_hex()), (12.0, preserved_alignment, "#00ff00".into()));
|
||||
}
|
||||
|
||||
/// Sample document-space typography between text objects with different local transforms.
|
||||
#[test]
|
||||
fn scaled_type_pick_matches_effective_character_dimensions() {
|
||||
for source_area in [false, true] {
|
||||
for target_area in [false, true] {
|
||||
let mut s = session();
|
||||
let create = |s: &mut Session, area: bool, x: f64| {
|
||||
let mut p = json!({"x": x, "y": 100, "text": "Sample", "size": 12});
|
||||
if area {
|
||||
p["area"] = json!({"width": 160, "height": 80});
|
||||
}
|
||||
id_of(s.execute("text.create", &p).unwrap())
|
||||
};
|
||||
let src = create(&mut s, source_area, 100.0);
|
||||
s.execute("object.transform", &json!({"matrix": [3, 0, 0, 2, 0, 0]})).unwrap();
|
||||
let dst = create(&mut s, target_area, 300.0);
|
||||
s.execute("object.transform", &json!({"matrix": [0.5, 0, 0, 1.5, 0, 0]})).unwrap();
|
||||
let before = node(&s, dst);
|
||||
let source = node(&s, src);
|
||||
let history = undo_len(&s);
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap();
|
||||
let after = node(&s, dst);
|
||||
let NodeKind::Text(t) = &after.kind else { panic!("not text") };
|
||||
let NodeKind::Text(old) = &before.kind else { panic!("not text") };
|
||||
let effective = t.effective_char_style(&t.first_style()).unwrap();
|
||||
assert!((effective.size - 24.0).abs() < 1e-8, "sampled font size: {}", effective.size);
|
||||
assert!((effective.h_scale - 150.0).abs() < 1e-8, "sampled horizontal scale: {}", effective.h_scale);
|
||||
assert_eq!(t.xf, old.xf, "sampling keeps the target's transform");
|
||||
assert_eq!(node(&s, src), source);
|
||||
assert_eq!(undo_len(&s), history + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, dst), before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, dst), after);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A text object snapshot, including the local attributes and retained affine transform.
|
||||
fn sampled_type(s: &Session, id: NodeId) -> vectorcraft_doc::TextObject {
|
||||
let NodeKind::Text(t) = node(s, id).kind else { panic!("not text") };
|
||||
*t
|
||||
}
|
||||
|
||||
/// Compare the dimensional character attributes that text controls display in document units.
|
||||
fn assert_sampled_dimensions(actual: &vectorcraft_doc::CharStyle, expected: &vectorcraft_doc::CharStyle) {
|
||||
assert_eq!(actual.leading.is_some(), expected.leading.is_some());
|
||||
for (a, e) in [
|
||||
(actual.size, expected.size),
|
||||
(actual.h_scale, expected.h_scale),
|
||||
(actual.v_scale, expected.v_scale),
|
||||
(actual.leading.unwrap_or(0.0), expected.leading.unwrap_or(0.0)),
|
||||
(actual.baseline_shift, expected.baseline_shift),
|
||||
(actual.tcy_up_down, expected.tcy_up_down),
|
||||
(actual.tcy_left_right, expected.tcy_left_right),
|
||||
] {
|
||||
assert!((a - e).abs() < 1e-8, "sampled dimension: {a} != {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotation, shear, reflection and grouping stay on the objects while typography is copied.
|
||||
#[test]
|
||||
fn scaled_type_pick_preserves_affines_and_document_dimensions() {
|
||||
for grouped in [false, true] {
|
||||
let mut s = session();
|
||||
let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "size": 12})).unwrap());
|
||||
s.execute("text.setStyle", &json!({"leading": 18})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"hScale": 80, "vScale": 125, "baselineShift": 3, "tcyUpDown": 2, "tcyLeftRight": -1})).unwrap();
|
||||
if grouped {
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
}
|
||||
s.execute("object.transform", &json!({"matrix": [0, -3, -2, 1, 0, 0]})).unwrap();
|
||||
let src_text = sampled_type(&s, src);
|
||||
let expected = src_text.effective_char_style(&src_text.first_style()).unwrap();
|
||||
let dst = id_of(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Target", "size": 20})).unwrap());
|
||||
if grouped {
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
}
|
||||
s.execute("object.transform", &json!({"matrix": [-1, 0.5, 1, 2, 0, 0]})).unwrap();
|
||||
let before = sampled_type(&s, dst);
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap();
|
||||
let after = sampled_type(&s, dst);
|
||||
assert_sampled_dimensions(&after.effective_char_style(&after.first_style()).unwrap(), &expected);
|
||||
assert_eq!(after.xf, before.xf);
|
||||
assert_eq!(sampled_type(&s, src), src_text);
|
||||
let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc);
|
||||
let reopened = vectorcraft_format::load(&bytes).unwrap();
|
||||
let NodeKind::Text(t) = &reopened.node(dst).unwrap().kind else { panic!("not text") };
|
||||
assert_sampled_dimensions(&t.effective_char_style(&t.first_style()).unwrap(), &expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// A transformed target compensates only the highlighted characters, retaining the others.
|
||||
#[test]
|
||||
fn scaled_type_pick_changes_only_the_highlighted_range() {
|
||||
let mut s = session();
|
||||
let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "size": 12})).unwrap());
|
||||
s.execute("object.transform", &json!({"matrix": [3, 0, 0, 2, 0, 0]})).unwrap();
|
||||
let source = sampled_type(&s, src);
|
||||
let expected = source.effective_char_style(&source.first_style()).unwrap();
|
||||
let dst = id_of(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Sample", "size": 10})).unwrap());
|
||||
s.execute("object.transform", &json!({"matrix": [0.5, 0, 0, 3, 0, 0]})).unwrap();
|
||||
let before = sampled_type(&s, dst);
|
||||
s.select_tool("type", ViewInfo::default()).unwrap();
|
||||
s.set_tool_option("textSelection", &json!({"id": dst.0, "start": 1, "end": 4}));
|
||||
s.select_tool("eyedropper", ViewInfo::default()).unwrap();
|
||||
let history = undo_len(&s);
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap();
|
||||
let after = sampled_type(&s, dst);
|
||||
assert_eq!(after.runs.len(), 3);
|
||||
assert_eq!((&*after.runs[0].text, &*after.runs[1].text, &*after.runs[2].text), ("S", "amp", "le"));
|
||||
assert_eq!(after.runs[0].style, before.first_style());
|
||||
assert_eq!(after.runs[2].style, before.first_style());
|
||||
assert_sampled_dimensions(&after.effective_char_style(&after.runs[1].style).unwrap(), &expected);
|
||||
assert_eq!(after.xf, before.xf);
|
||||
assert_eq!(undo_len(&s), history + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(sampled_type(&s, dst), before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(sampled_type(&s, dst), after);
|
||||
}
|
||||
|
||||
/// Reject unusable source or target transforms before any part of a multi-object pick changes.
|
||||
#[test]
|
||||
fn scaled_type_pick_rejects_unrepresentable_dimensions_atomically() {
|
||||
for (bad_source, factor) in [(true, 0.0), (false, 0.0), (false, 1e-306), (true, f64::from_bits(1))] {
|
||||
let mut s = session();
|
||||
let size = if bad_source && factor > 0.0 { 0.1 } else { 24.0 };
|
||||
let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "size": size, "color": "#00ff00"})).unwrap());
|
||||
if bad_source {
|
||||
s.execute("object.transform", &json!({"matrix": [factor, 0, 0, factor, 0, 0]})).unwrap();
|
||||
}
|
||||
let good = id_of(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Good", "size": 10})).unwrap());
|
||||
let other = id_of(s.execute("text.create", &json!({"x": 300, "y": 200, "text": "Other", "size": 12})).unwrap());
|
||||
if !bad_source {
|
||||
s.execute("object.transform", &json!({"matrix": [factor, 0, 0, factor, 0, 0]})).unwrap();
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": [good.0, other.0]})).unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let selection = s.doc().unwrap().selection.clone();
|
||||
let paint = s.paint.clone();
|
||||
let sample = s.eyedropper_sample.clone();
|
||||
let history = undo_len(&s);
|
||||
let error = s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap_err();
|
||||
assert!(error.to_string().contains("transform"), "{error}");
|
||||
assert_eq!(s.doc().unwrap().doc, doc);
|
||||
assert_eq!(s.doc().unwrap().selection, selection);
|
||||
assert_eq!(s.paint, paint);
|
||||
assert_eq!(s.eyedropper_sample, sample);
|
||||
assert_eq!(undo_len(&s), history);
|
||||
// A paint-only pick does not need a usable text transform.
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0, "apply": {"character": false, "paragraph": false}})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A failed dimensional pick retains highlighted characters and the previously remembered sample.
|
||||
#[test]
|
||||
fn scaled_type_pick_rejection_preserves_the_highlighted_range_and_sample() {
|
||||
let mut s = session();
|
||||
let saved = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Saved", "size": 18})).unwrap());
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.execute("appearance.copyFrom", &json!({"source": saved.0})).unwrap();
|
||||
let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Source", "size": 24, "color": "#00ff00"})).unwrap());
|
||||
s.execute("object.transform", &json!({"matrix": [0, 0, 0, 0, 0, 0]})).unwrap();
|
||||
let dst = id_of(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Sample", "size": 10})).unwrap());
|
||||
s.select_tool("type", ViewInfo::default()).unwrap();
|
||||
s.set_tool_option("textSelection", &json!({"id": dst.0, "start": 1, "end": 4}));
|
||||
s.select_tool("eyedropper", ViewInfo::default()).unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let selection = s.doc().unwrap().selection.clone();
|
||||
let range = s.tool_options();
|
||||
let paint = s.paint.clone();
|
||||
let sample = s.eyedropper_sample.clone();
|
||||
let history = undo_len(&s);
|
||||
assert!(s.execute("appearance.copyFrom", &json!({"source": src.0})).is_err());
|
||||
assert_eq!(s.doc().unwrap().doc, doc);
|
||||
assert_eq!(s.doc().unwrap().selection, selection);
|
||||
assert_eq!(s.tool_options(), range);
|
||||
assert_eq!(s.paint, paint);
|
||||
assert_eq!(s.eyedropper_sample, sample);
|
||||
assert_eq!(undo_len(&s), history);
|
||||
// Alt-click can still apply the last successful sample after the rejected pick.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.execute("appearance.copyFrom", &json!({"source": dst.0, "reverse": true})).unwrap();
|
||||
assert_eq!(sampled_type(&s, dst).first_style().size, 18.0);
|
||||
}
|
||||
|
||||
/// A pick that would extend text beyond the canvas keeps the previous sample and defaults.
|
||||
#[test]
|
||||
fn scaled_type_pick_canvas_rejection_preserves_sample_and_defaults() {
|
||||
for mode in ["selection", "ids", "reverse", "range"] {
|
||||
let mut s = session();
|
||||
let saved = id_of(s.execute("text.create", &json!({"x": 40, "y": 40, "text": "Saved", "size": 18, "color": "#0000ff"})).unwrap());
|
||||
let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "size": 96, "color": "#ff0000"})).unwrap());
|
||||
let dst = id_of(s.execute("text.create", &json!({"x": MAX_COORD - 100.0, "y": 200, "text": "WWWW", "size": 10})).unwrap());
|
||||
s.execute("appearance.copyFrom", &json!({"source": saved.0, "ids": []})).unwrap();
|
||||
let pick = match mode {
|
||||
"ids" | "reverse" => {
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
if mode == "ids" { json!({"source": src.0, "ids": [dst.0]}) } else { json!({"source": dst.0, "ids": [src.0], "reverse": true}) }
|
||||
}
|
||||
"range" => {
|
||||
s.select_tool("type", ViewInfo::default()).unwrap();
|
||||
s.set_tool_option("textSelection", &json!({"id": dst.0, "start": 0, "end": 3}));
|
||||
s.select_tool("eyedropper", ViewInfo::default()).unwrap();
|
||||
json!({"source": src.0})
|
||||
}
|
||||
_ => json!({"source": src.0}),
|
||||
};
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let selection = s.doc().unwrap().selection.clone();
|
||||
let range = s.tool_options();
|
||||
let paint = s.paint.clone();
|
||||
let sample = s.eyedropper_sample.clone();
|
||||
let history = undo_len(&s);
|
||||
|
||||
let error = s.execute("appearance.copyFrom", &pick).unwrap_err();
|
||||
assert!(error.to_string().contains("coordinates out of range"), "{mode}: {error}");
|
||||
assert_eq!(s.doc().unwrap().doc, doc, "{mode}");
|
||||
assert_eq!(s.doc().unwrap().selection, selection, "{mode}");
|
||||
assert_eq!(s.tool_options(), range, "{mode}");
|
||||
assert_eq!(s.paint, paint, "{mode}");
|
||||
assert_eq!(s.eyedropper_sample, sample, "{mode}");
|
||||
assert_eq!(undo_len(&s), history, "{mode}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Changing justification can move explicitly targeted point text outside the canvas even
|
||||
/// when character copying is disabled.
|
||||
#[test]
|
||||
fn paragraph_only_pick_rejects_out_of_bounds_explicit_target() {
|
||||
let mut s = session();
|
||||
let saved = id_of(s.execute("text.create", &json!({"x": 40, "y": 40, "text": "Saved", "color": "#0000ff"})).unwrap());
|
||||
let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "color": "#ff0000"})).unwrap());
|
||||
s.execute("text.setStyle", &json!({"justify": "left"})).unwrap();
|
||||
let dst = id_of(s.execute("text.create", &json!({"x": 100, "y": 200, "text": "WWWW", "size": 16})).unwrap());
|
||||
s.execute("text.setStyle", &json!({"justify": "right"})).unwrap();
|
||||
s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, MAX_COORD - 101.0, 0]})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.execute("appearance.copyFrom", &json!({"source": saved.0, "ids": []})).unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let selection = s.doc().unwrap().selection.clone();
|
||||
let paint = s.paint.clone();
|
||||
let sample = s.eyedropper_sample.clone();
|
||||
let history = undo_len(&s);
|
||||
|
||||
let pick = json!({"source": src.0, "ids": [dst.0], "apply": {"character": false}});
|
||||
let error = s.execute("appearance.copyFrom", &pick).unwrap_err();
|
||||
assert!(error.to_string().contains("coordinates out of range"), "{error}");
|
||||
assert_eq!(s.doc().unwrap().doc, doc);
|
||||
assert_eq!(s.doc().unwrap().selection, selection);
|
||||
assert_eq!(s.paint, paint);
|
||||
assert_eq!(s.eyedropper_sample, sample);
|
||||
assert_eq!(undo_len(&s), history);
|
||||
}
|
||||
|
||||
/// Disabled character copying and append preserve typography; reverse and cached picks convert it.
|
||||
#[test]
|
||||
fn scaled_type_pick_respects_options_and_reverse_sampling() {
|
||||
let mut s = session();
|
||||
let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "size": 12})).unwrap());
|
||||
s.execute("object.scale", &json!({"sx": 300, "sy": 200})).unwrap();
|
||||
let source = sampled_type(&s, src);
|
||||
let expected = source.effective_char_style(&source.first_style()).unwrap();
|
||||
let dst = id_of(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Target", "size": 20})).unwrap());
|
||||
s.execute("object.scale", &json!({"sx": 50, "sy": 150})).unwrap();
|
||||
let before = sampled_type(&s, dst);
|
||||
for options in [json!({"pickUp": {"character": false}}), json!({"apply": {"character": false}}), json!({"append": true})] {
|
||||
let mut p = json!({"source": src.0, "ids": [dst.0]});
|
||||
p.as_object_mut().unwrap().extend(options.as_object().unwrap().clone());
|
||||
s.execute("appearance.copyFrom", &p).unwrap();
|
||||
assert_sampled_dimensions(&sampled_type(&s, dst).first_style(), &before.first_style());
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": [src.0]})).unwrap();
|
||||
s.execute("appearance.copyFrom", &json!({"source": dst.0, "reverse": true})).unwrap();
|
||||
let after = sampled_type(&s, dst);
|
||||
assert_sampled_dimensions(&after.effective_char_style(&after.first_style()).unwrap(), &expected);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap();
|
||||
s.execute("appearance.copyFrom", &json!({"source": dst.0, "reverse": true})).unwrap();
|
||||
let cached = sampled_type(&s, dst);
|
||||
assert_sampled_dimensions(&cached.effective_char_style(&cached.first_style()).unwrap(), &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_click_applies_the_selection_to_the_clicked_object() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -296,6 +296,59 @@ fn link_info_stays_within_the_asking_callers_roots() {
|
||||
assert_ne!(i["status"], "ok", "{i}");
|
||||
}
|
||||
|
||||
/// A confined caller's `links.list`, `links.check` and `links.info` replies, and its
|
||||
/// `document.open` reply, leave out the path and folder of a linked file outside its read roots
|
||||
/// and keep the row with the file's name. A file found by name inside the roots shows its path.
|
||||
#[test]
|
||||
fn link_paths_outside_the_read_roots_are_left_out() {
|
||||
let away = Folder::new("lrr-away");
|
||||
let home = Folder::new("lrr-home");
|
||||
let pic = away.file("photo.png");
|
||||
let bytes = png(60, 30, RED);
|
||||
write(&pic, &bytes);
|
||||
let mut s = session();
|
||||
let id = place(&mut s, &pic);
|
||||
let roots = crate::file_access::AutomationRoots::new(&[&home.0], &[]).unwrap().unwrap();
|
||||
let ask = |s: &mut Session, cmd: &str, p: Value| crate::file_access::confine(Some(&roots), || run(s, cmd, p));
|
||||
let hidden = |r: &Value| assert!(!r.to_string().contains("lrr-away"), "{r}");
|
||||
let list = ask(&mut s, "links.list", json!({}));
|
||||
hidden(&list);
|
||||
let row = &list["links"][0];
|
||||
assert_eq!((row["id"].as_u64(), row["name"].as_str(), row["status"].as_str()), (Some(id.0), Some("photo.png"), Some("missing")), "{list}");
|
||||
let check = ask(&mut s, "links.check", json!({}));
|
||||
hidden(&check);
|
||||
assert_eq!((check["links"][0]["name"].as_str(), check["missing"].as_u64()), (Some("photo.png"), Some(1)), "{check}");
|
||||
let info = ask(&mut s, "links.info", json!({"id": id.0}));
|
||||
hidden(&info);
|
||||
assert_eq!(
|
||||
(info["fileName"].as_str(), info["status"].as_str(), info["linked"].as_bool()),
|
||||
(Some("photo.png"), Some("missing"), Some(true)),
|
||||
"{info}"
|
||||
);
|
||||
for k in ["path", "link", "location", "fileSize", "modified", "created"] {
|
||||
assert!(info.get(k).is_none(), "{k}: {info}");
|
||||
}
|
||||
// Unconfined, the same replies carry the paths.
|
||||
assert_eq!(run(&mut s, "links.list", json!({}))["links"][0]["path"], json!(pic));
|
||||
let i = run(&mut s, "links.info", json!({"id": id.0}));
|
||||
assert_eq!((&i["path"], &i["link"], i["fileSize"].as_u64()), (&json!(pic), &json!(pic), Some(bytes.len() as u64)), "{i}");
|
||||
// Saved inside the roots next to a copy of the file: the copy is found by name and shown.
|
||||
save(&mut s, &home.file("poster.vectorcraft"));
|
||||
let copy = home.file("photo.png");
|
||||
write(©, &bytes);
|
||||
let check = ask(&mut s, "links.check", json!({}));
|
||||
hidden(&check);
|
||||
assert_eq!((check["links"][0]["status"].as_str(), &check["links"][0]["found"]), (Some("ok"), &json!(copy)), "{check}");
|
||||
let list = ask(&mut s, "links.list", json!({}));
|
||||
hidden(&list);
|
||||
assert_eq!(list["links"][0]["found"], json!(copy), "{list}");
|
||||
// Opened again without the copy: the missing link's row has no path.
|
||||
std::fs::remove_file(©).unwrap();
|
||||
let opened = ask(&mut Session::new(), "document.open", json!({"path": home.file("poster.vectorcraft")}));
|
||||
hidden(&opened);
|
||||
assert_eq!(opened["missingLinks"][0]["name"], "photo.png", "{opened}");
|
||||
}
|
||||
|
||||
/// A 600×300 pt image at the centre of the artboard, its placement options set to `options`,
|
||||
/// relinked to a 300×100 px file → the image's id and session.
|
||||
fn relinked(name: &str, options: Value) -> (Session, NodeId, Folder) {
|
||||
|
||||
@@ -73,14 +73,13 @@ fn arrowheads_are_part_of_the_outline() {
|
||||
assert_eq!(path_of(&n).subpaths.len(), 1, "line and head are one shape");
|
||||
}
|
||||
|
||||
/// Outline Stroke of a Lens-profile line covers 7/12 of the full-width rectangle.
|
||||
/// Outline Stroke of a Lens-profile line covers 2/3 of the full-width rectangle.
|
||||
#[test]
|
||||
fn a_lens_profile_outlines_to_seven_twelfths_of_the_rectangle() {
|
||||
fn a_lens_profile_outlines_to_two_thirds_of_the_rectangle() {
|
||||
let mut s = session();
|
||||
line(&mut s, json!({"weight": 10, "profile": "lens"}));
|
||||
let n = outline(&mut s);
|
||||
// Each half of the lens swells on a cubic, flat at the middle (#5714).
|
||||
assert!(near(area(&n), 7.0 / 12.0 * 10.0 * 100.0, 2.0), "{}", area(&n));
|
||||
assert!(near(area(&n), 2.0 / 3.0 * 10.0 * 100.0, 2.0), "{}", area(&n));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1060,6 +1060,30 @@ fn link_info_gives_the_artboards_size_not_pixels() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A confined caller's Link Info of a placed document whose file is outside its read roots has no
|
||||
/// `link`, `path` or `location`, and keeps the file's name.
|
||||
#[test]
|
||||
fn link_info_leaves_out_a_placed_documents_path_outside_the_read_roots() {
|
||||
let away = Folder::new("placed-lrr-away");
|
||||
let home = Folder::new("placed-lrr-home");
|
||||
let src = away.file("logo.vectorcraft");
|
||||
source(&src, 100.0, 50.0, RED, 0);
|
||||
let mut s = session();
|
||||
let id = place(&mut s, &src);
|
||||
let roots = crate::file_access::AutomationRoots::new(&[&home.0], &[]).unwrap().unwrap();
|
||||
let i = crate::file_access::confine(Some(&roots), || s.execute("links.info", &json!({"id": id.0}))).unwrap();
|
||||
assert!(!i.to_string().contains("placed-lrr-away"), "{i}");
|
||||
assert_eq!(
|
||||
(i["fileName"].as_str(), i["document"].as_bool(), i["status"].as_str()),
|
||||
(Some("logo.vectorcraft"), Some(true), Some("missing")),
|
||||
"{i}"
|
||||
);
|
||||
let l = crate::file_access::confine(Some(&roots), || s.execute("links.list", &json!({}))).unwrap();
|
||||
assert!(!l.to_string().contains("placed-lrr-away") && l["links"][0]["name"] == "logo.vectorcraft", "{l}");
|
||||
let i = s.execute("links.info", &json!({"id": id.0})).unwrap();
|
||||
assert_eq!((&i["link"], &i["path"]), (&json!(src), &json!(src)), "unconfined: {i}");
|
||||
}
|
||||
|
||||
/// Save As reads its defaults from the catalog; using them must retain current editable links.
|
||||
#[test]
|
||||
fn native_save_catalog_defaults_keep_placed_documents_editable() {
|
||||
|
||||
@@ -2326,6 +2326,194 @@ fn a_failed_store_write_keeps_the_stored_module_with_its_grant() {
|
||||
vectorcraft_plugins::registry::revoke(id);
|
||||
}
|
||||
|
||||
/// Runs the test `name` of this module again in a process of its own → whether this is that
|
||||
/// process. The list of disabled plug-ins is kept for the whole process, and the other tests here
|
||||
/// read it again each time they attach a plug-in store.
|
||||
fn in_own_process(name: &str) -> bool {
|
||||
const CHILD: &str = "VECTORCRAFT_TEST_PLUGIN_DISABLED_LIST";
|
||||
if std::env::var_os(CHILD).is_some() {
|
||||
return true;
|
||||
}
|
||||
let output = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", &format!("tests::plugins::{name}"), "--nocapture"])
|
||||
.env(CHILD, "1")
|
||||
.output()
|
||||
.unwrap();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"),
|
||||
"{name} failed in its own process:\n{stdout}\n{}",
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
false
|
||||
}
|
||||
|
||||
/// An update whose grant can't be written, and whose old module can't be put back either, stays in
|
||||
/// the store: a disabled plug-in is turned on, as for any update the store holds. The old grant
|
||||
/// doesn't cover the new module, so the next start skips it until it is approved.
|
||||
#[test]
|
||||
fn an_update_left_in_the_store_by_a_failed_put_back_turns_a_disabled_plugin_on() {
|
||||
if !in_own_process("an_update_left_in_the_store_by_a_failed_put_back_turns_a_disabled_plugin_on") {
|
||||
return;
|
||||
}
|
||||
use vectorcraft_plugins::{permissions::sha256_hex, registry, safe_mode};
|
||||
let folder = crate::tests_links::Folder::new("plugin-store-failed-put-back-update");
|
||||
let id = "test.engine.permissions.putbackupdate";
|
||||
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
|
||||
let changed = asking(id, r#"["doc.read","doc.write"]"#, &json!({"objects": []}));
|
||||
let mut s = session();
|
||||
s.attach_plugin_store(folder.0.clone());
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]}));
|
||||
run(&mut s, "plugin.setEnabled", json!({"id": id, "enabled": false}));
|
||||
let grants = folder.0.join("grants.json");
|
||||
let recorded = std::fs::read(&grants).unwrap();
|
||||
std::fs::remove_file(&grants).unwrap();
|
||||
std::fs::create_dir_all(grants.join("block")).unwrap();
|
||||
|
||||
crate::store::PUT_BACK_FAILS.set(true);
|
||||
let update = json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read", "doc.write"], "replace": true});
|
||||
let installed = s.execute("plugin.install", &update);
|
||||
crate::store::PUT_BACK_FAILS.set(false);
|
||||
assert_eq!(installed.unwrap()["hash"], sha256_hex(&changed), "the new module runs in this session");
|
||||
assert_eq!(registry::get(id).map(|p| p.hash().to_string()), Some(sha256_hex(&changed)));
|
||||
assert_eq!(std::fs::read(s.stored_plugin_file(id).unwrap()).unwrap(), changed, "the store holds the new module");
|
||||
assert!(safe_mode::disabled().is_empty(), "the module the store holds is turned on");
|
||||
let saved = std::fs::read(folder.0.join(crate::store::DISABLED_FILE)).unwrap();
|
||||
assert!(safe_mode::parse_disabled_file(&saved).is_empty(), "nothing disabled is saved");
|
||||
|
||||
std::fs::remove_dir_all(&grants).unwrap();
|
||||
std::fs::write(&grants, &recorded).unwrap();
|
||||
registry::remove(id);
|
||||
registry::revoke(id);
|
||||
let mut next = session();
|
||||
next.attach_plugin_store(folder.0.clone());
|
||||
assert!(registry::get(id).is_none(), "the old grant doesn't cover the new module");
|
||||
safe_mode::set_disabled(vec![]);
|
||||
}
|
||||
|
||||
/// An update whose grant can't be written, and whose put-back writes the old module and then
|
||||
/// reports a failure, leaves the old module in the store: a disabled plug-in stays disabled, and the
|
||||
/// next start doesn't load it.
|
||||
#[test]
|
||||
fn an_update_put_back_that_fails_after_writing_keeps_a_disabled_plugin_off() {
|
||||
if !in_own_process("an_update_put_back_that_fails_after_writing_keeps_a_disabled_plugin_off") {
|
||||
return;
|
||||
}
|
||||
use vectorcraft_plugins::{registry, safe_mode};
|
||||
let folder = crate::tests_links::Folder::new("plugin-store-put-back-fails-after-write");
|
||||
let id = "test.engine.permissions.putbacklate";
|
||||
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
|
||||
let changed = asking(id, r#"["doc.read","doc.write"]"#, &json!({"objects": []}));
|
||||
let mut s = session();
|
||||
s.attach_plugin_store(folder.0.clone());
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]}));
|
||||
run(&mut s, "plugin.setEnabled", json!({"id": id, "enabled": false}));
|
||||
let disabled = safe_mode::disabled();
|
||||
assert!(!disabled.is_empty());
|
||||
let grants = folder.0.join("grants.json");
|
||||
let recorded = std::fs::read(&grants).unwrap();
|
||||
std::fs::remove_file(&grants).unwrap();
|
||||
std::fs::create_dir_all(grants.join("block")).unwrap();
|
||||
|
||||
crate::store::PUT_BACK_FAILS_AFTER_WRITE.set(true);
|
||||
let update = json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read", "doc.write"], "replace": true});
|
||||
let installed = s.execute("plugin.install", &update);
|
||||
crate::store::PUT_BACK_FAILS_AFTER_WRITE.set(false);
|
||||
installed.unwrap();
|
||||
assert_eq!(std::fs::read(s.stored_plugin_file(id).unwrap()).unwrap(), first, "the store holds the old module");
|
||||
assert_eq!(safe_mode::disabled(), disabled, "the plug-in stays disabled");
|
||||
let saved = std::fs::read(folder.0.join(crate::store::DISABLED_FILE)).unwrap();
|
||||
assert!(!safe_mode::parse_disabled_file(&saved).is_empty(), "the disabled list on disk is kept");
|
||||
|
||||
std::fs::remove_dir_all(&grants).unwrap();
|
||||
std::fs::write(&grants, &recorded).unwrap();
|
||||
registry::remove(id);
|
||||
registry::revoke(id);
|
||||
let mut next = session();
|
||||
next.attach_plugin_store(folder.0.clone());
|
||||
assert!(registry::get(id).is_none(), "the disabled old module doesn't load");
|
||||
safe_mode::set_disabled(vec![]);
|
||||
}
|
||||
|
||||
/// A module without permissions whose install can't remove the old grant, and whose put-back writes
|
||||
/// the old module and then reports a failure, leaves the old module in the store: a disabled plug-in
|
||||
/// stays disabled.
|
||||
#[test]
|
||||
fn a_plain_update_put_back_that_fails_after_writing_keeps_a_disabled_plugin_off() {
|
||||
if !in_own_process("a_plain_update_put_back_that_fails_after_writing_keeps_a_disabled_plugin_off") {
|
||||
return;
|
||||
}
|
||||
use vectorcraft_plugins::safe_mode;
|
||||
let folder = crate::tests_links::Folder::new("plugin-store-plain-put-back-fails-after-write");
|
||||
let id = "test.engine.permissions.plainputbacklate";
|
||||
let empty = json!({"objects": []});
|
||||
let approved = asking(id, r#"["doc.read"]"#, &empty);
|
||||
let plain = asking(id, "null", &empty);
|
||||
let grants = folder.0.join("grants.json");
|
||||
let mut s = session();
|
||||
s.attach_plugin_store(folder.0.clone());
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&approved), "grant": ["doc.read"]}));
|
||||
run(&mut s, "plugin.setEnabled", json!({"id": id, "enabled": false}));
|
||||
let disabled = safe_mode::disabled();
|
||||
assert!(!disabled.is_empty());
|
||||
let file = s.stored_plugin_file(id).unwrap();
|
||||
// `1e9` is written back as `1000000000.0`, which makes the rewritten file larger than the 1 MiB that is read.
|
||||
let entry = serde_json::from_slice::<Vec<Value>>(&std::fs::read(&grants).unwrap()).unwrap().remove(0);
|
||||
let full = format!(r#"[{entry},{{"n":[{}]}}]"#, vec!["1e9"; 200_000].join(",")).into_bytes();
|
||||
std::fs::write(&grants, &full).unwrap();
|
||||
|
||||
crate::store::PUT_BACK_FAILS_AFTER_WRITE.set(true);
|
||||
let installed = s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&plain), "replace": true}));
|
||||
crate::store::PUT_BACK_FAILS_AFTER_WRITE.set(false);
|
||||
installed.unwrap();
|
||||
assert_eq!(std::fs::read(&file).unwrap(), approved, "the store holds the old module");
|
||||
assert_eq!(safe_mode::disabled(), disabled, "the plug-in stays disabled");
|
||||
let saved = std::fs::read(folder.0.join(crate::store::DISABLED_FILE)).unwrap();
|
||||
assert!(!safe_mode::parse_disabled_file(&saved).is_empty(), "the disabled list on disk is kept");
|
||||
|
||||
std::fs::remove_file(&grants).unwrap();
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
safe_mode::set_disabled(vec![]);
|
||||
}
|
||||
|
||||
/// A first install whose grant can't be written, and whose module can't be taken out of the store
|
||||
/// again, stays in the store: the plug-in's file is turned on. No grant covers the module, so the
|
||||
/// next start skips it until it is approved.
|
||||
#[test]
|
||||
fn a_first_install_left_in_the_store_by_a_failed_put_back_turns_its_file_on() {
|
||||
if !in_own_process("a_first_install_left_in_the_store_by_a_failed_put_back_turns_its_file_on") {
|
||||
return;
|
||||
}
|
||||
use vectorcraft_plugins::{registry, safe_mode};
|
||||
let folder = crate::tests_links::Folder::new("plugin-store-failed-put-back-first");
|
||||
let id = "test.engine.permissions.putbackfirst";
|
||||
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
|
||||
let mut s = session();
|
||||
s.attach_plugin_store(folder.0.clone());
|
||||
let module = s.stored_plugin_file(id).unwrap();
|
||||
run(&mut s, "plugin.setEnabled", json!({"file": module, "enabled": false}));
|
||||
let grants = folder.0.join("grants.json");
|
||||
std::fs::create_dir_all(grants.join("block")).unwrap();
|
||||
|
||||
crate::store::PUT_BACK_FAILS.set(true);
|
||||
let installed = s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]}));
|
||||
crate::store::PUT_BACK_FAILS.set(false);
|
||||
installed.unwrap();
|
||||
assert!(registry::get(id).is_some(), "the module runs in this session");
|
||||
assert_eq!(std::fs::read(&module).unwrap(), first, "the store holds the module");
|
||||
assert!(safe_mode::disabled().is_empty(), "the file the store holds is turned on");
|
||||
let saved = std::fs::read(folder.0.join(crate::store::DISABLED_FILE)).unwrap();
|
||||
assert!(safe_mode::parse_disabled_file(&saved).is_empty(), "nothing disabled is saved");
|
||||
|
||||
std::fs::remove_dir_all(&grants).unwrap();
|
||||
registry::remove(id);
|
||||
registry::revoke(id);
|
||||
let mut next = session();
|
||||
next.attach_plugin_store(folder.0.clone());
|
||||
assert!(registry::get(id).is_none(), "no grant covers the module");
|
||||
safe_mode::set_disabled(vec![]);
|
||||
}
|
||||
|
||||
/// A plug-in folder reports a changed module as needing approval and still loads the other files;
|
||||
/// granted on its own, the changed module loads from the folder. A session without a store uses the
|
||||
/// grants held by the process.
|
||||
|
||||
@@ -55,6 +55,42 @@ fn file_constructor_identity_and_folder_path() {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// `exists` is true only when the path names an entry of the object's own kind.
|
||||
#[test]
|
||||
fn script_file_exists_checks_the_kind() {
|
||||
let base = folder("kind");
|
||||
write(&base.join("main/data.txt"), b"x");
|
||||
std::fs::create_dir_all(base.join("main/sub")).unwrap();
|
||||
let log =
|
||||
logged(&base, "vc.log([new File('sub').exists, new Folder('data.txt').exists, new File('data.txt').exists, new Folder('sub').exists]);");
|
||||
assert_eq!(log, json!([[false, false, true, true]]));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// A property a script puts on `Object.prototype` is not read as part of what the app returns.
|
||||
#[test]
|
||||
fn script_file_ignores_inherited_result_fields() {
|
||||
let base = folder("inherited");
|
||||
write(&base.join("main/data.txt"), b"data");
|
||||
let log = logged(
|
||||
&base,
|
||||
"Object.prototype.error = 'inherited'; Object.prototype.exists = true;\n\
|
||||
var f = new File('data.txt'); var opened = f.open('r');\n\
|
||||
vc.log([f.exists, new File('nope').exists, opened, f.error, f.read()]);",
|
||||
);
|
||||
assert_eq!(log, json!([[true, false, true, "", "data"]]));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// `File.encode`, `File.decode`, `File.isEncodingAvailable` and the `Folder` locations are counted as
|
||||
/// calls to the app.
|
||||
#[test]
|
||||
fn script_file_class_functions_are_counted() {
|
||||
let source = "File.encode('x'); File.decode('y'); Folder.isEncodingAvailable('UTF-8'); Folder.temp;";
|
||||
let r = Session::new().execute("script.run", &json!({"source": source})).unwrap();
|
||||
assert_eq!(r["hostCallCounts"]["fileCalls"], 4, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_names_are_uri_encoded_and_current_relative() {
|
||||
let base = folder("names");
|
||||
@@ -74,7 +110,7 @@ fn file_names_are_uri_encoded_and_current_relative() {
|
||||
assert_eq!(
|
||||
log,
|
||||
json!([
|
||||
["caf%C3%A9%20x.txt", "caf\u{e9} x.txt", "caf%C3%A9%20x.txt", true, "%C3%A9%20%2F", "\u{e9}", "sub/y.txt", true, true],
|
||||
["caf%C3%A9%20x.txt", "caf\u{e9} x.txt", "caf%C3%A9%20x.txt", true, "%C3%A9%20/", "\u{e9}", "sub/y.txt", true, true],
|
||||
["sub", "w", "w"],
|
||||
["literal%20name.txt", "literal%2520name.txt", "literal%2520name.txt", true, "p%41", "p%41"]
|
||||
])
|
||||
|
||||
@@ -221,9 +221,9 @@ fn new_width_point_adjust_adjoining_uses_sampled_widths() {
|
||||
let (mut s, id) = line_session();
|
||||
let original = vec![(0.0, 9.0, 9.0), (0.25, 1.0, 0.0), (0.75, 3.0, 0.0), (1.0, 7.0, 7.0)];
|
||||
s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": original})).unwrap();
|
||||
// At t=0.5 the old left side is where the profile's curve is there (#5714) and the right
|
||||
// side is 0pt. Indexed editing of that sampled point is the existing convention: scale the
|
||||
// nonzero side, add from zero.
|
||||
// At t=0.5 the old left side is where the profile's curve is there and the right side is
|
||||
// 0pt. Indexed editing of that sampled point is the existing convention: scale the nonzero
|
||||
// side, add from zero.
|
||||
let sampled = vectorcraft_doc::WidthProfile { points: original }.at(0.5).0;
|
||||
let index = if indexed { Some(set(&mut s, json!({"id": id.0, "t": 0.5, "left": sampled * 5.0, "right": 0}))) } else { None };
|
||||
set(&mut s, json!({"id": id.0, "index": index, "t": 0.5, "left": 20, "right": 5, "adjustAdjoining": true}));
|
||||
|
||||
Reference in New Issue
Block a user