Merge main into fix/save-screens-unknown-options

This commit is contained in:
dotCooCoo
2026-11-08 18:13:32 -08:00
190 changed files with 11062 additions and 579 deletions
+97 -1
View File
@@ -1,6 +1,6 @@
//! Blend Options (defaults with nothing selected, `object.blend.info`) and the Blend tool:
//! start points from clicked anchors, adding keys to a blend instead of nesting it (M8.18), and
//! Escape dropping the first pick.
//! Escape, or an edit made outside the tool, dropping the first pick.
use serde_json::{Value, json};
use vectorcraft_doc::live::{BlendOrientation, BlendSpacing, BlendSpec};
@@ -233,6 +233,102 @@ fn escape_drops_the_blend_tools_first_pick() {
assert_eq!(spec(&s, g).0, vec![b, c], "a was not added");
}
#[test]
fn an_edit_undo_or_redo_made_outside_the_blend_tool_drops_its_first_pick() {
for case in ["edit", "undo", "redo", "group"] {
let undo = case == "undo";
let mut s = session();
let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0));
if case == "redo" {
s.execute("paint.setFill", &json!({"ids": [c.0], "color": "#ff0000"})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
}
// In an open undo group, the edit after the pick is merged into the group's step.
if case == "group" {
s.begin_undo_group();
s.execute("paint.setFill", &json!({"ids": [c.0], "color": "#00ff00"})).unwrap();
}
s.select_tool("blend", ViewInfo::default()).unwrap();
let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap();
// Undo takes away c, the object picked first; an edit or a redo recolors c, which is not picked.
click(&mut s, if undo { 420.0 } else { 20.0 }, 20.0);
assert!(!s.overlays(ViewInfo::default()).is_empty());
match case {
"undo" => s.execute("edit.undo", &json!({})).unwrap(),
"redo" => s.execute("edit.redo", &json!({})).unwrap(),
_ => s.execute("paint.setFill", &json!({"ids": [c.0], "color": "#ff0000"})).unwrap(),
};
assert!(s.overlays(ViewInfo::default()).is_empty(), "{case}: the pick is not marked");
let (layers, depth) = (s.doc().unwrap().doc.layers.clone(), s.doc().unwrap().history.undo.len());
click(&mut s, 220.0, 20.0);
assert_eq!(s.doc().unwrap().selection.objects, vec![b], "{case}: b is picked first");
assert_eq!((&s.doc().unwrap().doc.layers, s.doc().unwrap().history.undo.len()), (&layers, depth), "{case}: the click changes no art");
click(&mut s, if undo { 20.0 } else { 420.0 }, 20.0);
let g = s.doc().unwrap().selection.objects[0];
assert_eq!(spec(&s, g).0, if undo { vec![a, b] } else { vec![b, c] }, "{case}");
}
}
#[test]
fn two_picks_with_no_edit_between_them_blend() {
let mut s = session();
let (a, b) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0));
s.select_tool("blend", ViewInfo::default()).unwrap();
let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap();
click(&mut s, 20.0, 20.0);
// Drawing the overlays between the picks does not drop the first one.
assert!(!s.overlays(ViewInfo::default()).is_empty());
click(&mut s, 220.0, 20.0);
let g = s.doc().unwrap().selection.objects[0];
assert_eq!(spec(&s, g).0, vec![a, b]);
}
#[test]
fn removing_the_blend_the_blend_tool_adds_to_ends_adding() {
let mut s = session();
let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0));
let (d, e) = (rect(&mut s, 0.0, 300.0, 40.0), rect(&mut s, 200.0, 300.0, 40.0));
let other = id_of(&s.execute("object.blend.make", &json!({"ids": [d.0, e.0]})).unwrap());
s.select_tool("blend", ViewInfo::default()).unwrap();
let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap();
click(&mut s, 20.0, 20.0);
click(&mut s, 220.0, 20.0);
let g = s.doc().unwrap().selection.objects[0];
s.execute("edit.clear", &json!({"ids": [g.0]})).unwrap();
sel(&mut s, &[other]);
click(&mut s, 420.0, 20.0);
assert_eq!(s.doc().unwrap().selection.objects, vec![c], "c is picked first");
assert_eq!(spec(&s, other).0, vec![d, e], "c is not added to the selected blend");
assert!([a, b, g].iter().all(|id| s.doc().unwrap().doc.node(*id).is_none()), "the blend went with its keys");
}
#[test]
fn a_selection_change_keeps_the_blend_tools_first_pick() {
for drag in [false, true] {
let mut s = session();
let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0));
let view = ViewInfo::default();
s.select_tool("blend", view).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 20.0, 20.0), view).unwrap();
sel(&mut s, &[c]);
// Cmd lends the selection tool: a click on c selects it, a drag moves it (an edit).
let cmd = Mods { cmd: true, ..Mods::default() };
let to = if drag { 440.0 } else { 420.0 };
s.pointer(&PointerEvent::new(PointerKind::Down, 420.0, 20.0).with_mods(cmd), view).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, to, 20.0).with_mods(cmd), view).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, to, 20.0).with_mods(cmd), view).unwrap();
assert_eq!(s.tool_id(), "blend");
assert_eq!(s.overlays(view).is_empty(), drag, "drag {drag}");
s.pointer(&PointerEvent::new(PointerKind::Down, 220.0, 20.0), view).unwrap();
if drag {
assert_eq!(s.doc().unwrap().selection.objects, vec![b], "the drag dropped the pick");
} else {
let g = s.doc().unwrap().selection.objects[0];
assert_eq!(spec(&s, g).0, vec![a, b]);
}
}
}
#[test]
fn start_points_round_trip_and_old_blends_load() {
let mut s = session();
+41
View File
@@ -180,6 +180,47 @@ fn redo_during_an_interaction_keeps_the_history_and_the_document_in_step() {
assert_eq!(first_x(&s), start);
}
/// `History::step` changes with every new step, Undo and Redo, also when an undo and a new edit
/// leave as many steps as before, and with an edit merged into an undo group's step. It stays the
/// same for a selection change.
#[test]
fn the_history_step_changes_with_each_step_undo_and_redo() {
let mut s = session_with_rects(2);
let step = |s: &Session| s.doc().unwrap().history.step();
let start = step(&s);
assert!(start.0 != 0 && start.1 == 0, "the Add step, and nothing to redo: {start:?}");
s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap();
let moved = step(&s);
assert!(moved.0 != start.0 && moved.1 == 0, "{moved:?}");
s.execute("edit.undo", &json!({})).unwrap();
let undone = step(&s);
assert!(undone.0 == start.0 && undone.1 != 0, "{undone:?}");
s.execute("edit.redo", &json!({})).unwrap();
let redone = step(&s);
assert!(![start, moved, undone].contains(&redone) && redone.1 == 0, "{redone:?}");
// An undo and a new edit: as many undo steps as after the move, at a different step.
s.execute("edit.undo", &json!({})).unwrap();
let depth = s.doc().unwrap().history.undo.len();
s.execute("object.move", &json!({"dx": 0, "dy": 1})).unwrap();
let other = step(&s);
assert_eq!(s.doc().unwrap().history.undo.len(), depth + 1);
assert!(![start, moved, undone, redone].contains(&other), "{other:?}");
s.execute("select.none", &json!({})).unwrap();
assert_eq!(step(&s), other, "a selection change is no step");
s.execute("select.all", &json!({})).unwrap();
s.begin_undo_group();
s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap();
let (grouped, depth) = (step(&s), s.doc().unwrap().history.undo.len());
assert_ne!(grouped, other);
s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), depth, "the second move is merged into the first");
let merged = step(&s);
assert!(merged != grouped && merged.1 == 0, "a merged edit moves the step: {merged:?}");
s.execute("select.none", &json!({})).unwrap();
assert_eq!(step(&s), merged);
s.end_undo_group(false);
}
#[test]
fn color_mode_paint_snapshots_count_toward_the_history_budget() {
let mut s = Session::new();
+130
View File
@@ -141,6 +141,136 @@ fn a_graphic_style_clicked_with_nothing_selected_styles_the_next_object() {
assert!(s.execute("graphicStyle.apply", &json!({"name": name, "ids": []})).is_err(), "explicit ids that match nothing");
}
/// A rectangle with this fill, stroke and weight, selected.
fn painted(s: &mut Session, x: f64, fill: &str, stroke: &str, weight: f64) -> NodeId {
let id = rect(s, x);
run(s, "paint.setFill", json!({"color": fill}));
run(s, "paint.setStroke", json!({"color": stroke}));
run(s, "stroke.set", json!({"weight": weight}));
id
}
fn select(s: &mut Session, ids: &[NodeId]) {
run(s, "select.set", json!({"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()}));
}
#[test]
fn selecting_an_object_gives_new_art_its_fill_stroke_and_weight_with_basic_appearance_on() {
let mut s = session();
assert!(s.prefs.new_art_basic, "on by default");
let green = painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0);
painted(&mut s, 120.0, "#0000ff", "#000000", 2.0);
select(&mut s, &[green]);
run(&mut s, "select.none", json!({}));
let id = rect(&mut s, 220.0);
let (n, from) = (node(&s, id).appearance, node(&s, green).appearance);
assert_eq!((n.fill_paint(), n.stroke_paint(), n.stroke_width()), (from.fill_paint(), from.stroke_paint(), 5.0));
}
#[test]
fn with_basic_appearance_on_only_the_basic_fill_and_stroke_follow_the_selection() {
let mut s = session();
let src = fancy(&mut s);
painted(&mut s, 120.0, "#00ff00", "#000000", 2.0);
select(&mut s, &[src]);
run(&mut s, "select.none", json!({}));
let id = rect(&mut s, 220.0);
let n = node(&s, id);
assert_eq!(n.appearance.fill_paint(), node(&s, src).appearance.fill_paint(), "the top fill");
assert_eq!((n.appearance.items.len(), n.appearance.effects.len(), n.opacity), (2, 0, 1.0), "one fill and stroke, nothing else");
}
#[test]
fn a_paint_the_selected_objects_do_not_share_stays_as_it_was() {
let mut s = session();
let a = painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0);
let b = painted(&mut s, 120.0, "#0000ff", "#00ff00", 5.0);
painted(&mut s, 220.0, "#ffff00", "#000000", 1.0);
let before = s.paint.fill.clone();
select(&mut s, &[a, b]);
assert_eq!(s.paint.fill, before, "the fills differ");
assert_eq!((s.paint.stroke.clone(), s.paint.stroke_width), (node(&s, a).appearance.stroke_paint(), 5.0), "the strokes are shared");
}
#[test]
fn art_without_paint_leaves_the_proxies_as_they_were() {
let mut s = session();
painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0);
let (fill, stroke) = (s.paint.fill.clone(), s.paint.stroke.clone());
let dir = super::links::Folder::new("new-art-image");
let path = dir.file("pic.png");
super::links::write(&path, &super::links::png(4, 4, super::links::RED));
let image = super::links::place(&mut s, &path);
run(&mut s, "select.none", json!({}));
select(&mut s, &[image]);
assert_eq!((s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width), (fill, stroke, 5.0), "new art stays visible");
}
#[test]
fn an_object_without_a_stroke_clears_the_stroke_and_keeps_the_weight() {
let mut s = session();
let bare = rect(&mut s, 20.0);
run(&mut s, "paint.setStroke", json!({"none": true}));
painted(&mut s, 120.0, "#0000ff", "#000000", 7.0);
select(&mut s, &[bare]);
assert_eq!((s.paint.stroke.clone(), s.paint.stroke_width), (Paint::None, 7.0));
}
#[test]
fn a_selected_gradient_loads_without_its_place_on_the_object() {
let mut s = session();
let src = rect(&mut s, 20.0);
let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]);
run(&mut s, "paint.setFill", json!({"gradient": {"stops": stops, "start": [20, 40], "end": [50, 40]}}));
painted(&mut s, 120.0, "#0000ff", "#000000", 2.0);
select(&mut s, &[src]);
assert!(matches!(s.paint.fill, Paint::Gradient(_)), "{:?}", s.paint.fill);
run(&mut s, "select.none", json!({}));
let id = rect(&mut s, 220.0);
assert!(matches!(node(&s, id).appearance.fill_paint(), Paint::Gradient(_)));
}
#[test]
fn selecting_leaves_the_stroke_panels_options_for_new_art_alone() {
let mut s = session();
let plain = painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0);
run(&mut s, "select.none", json!({}));
run(&mut s, "stroke.set", json!({"cap": "round", "dash": [4, 2]}));
select(&mut s, &[plain]);
run(&mut s, "select.none", json!({}));
let id = rect(&mut s, 120.0);
let st = node(&s, id).appearance.stroke().unwrap().clone();
assert_eq!((st.paint.clone(), st.width), (node(&s, plain).appearance.stroke_paint(), 5.0));
assert_eq!((st.cap, st.dash.map(|d| d.pattern)), (LineCap::Round, Some(vec![4.0, 2.0])));
}
#[test]
fn a_sampled_paint_stays_until_the_selection_changes() {
let mut s = session();
let a = painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0);
let sampled = Paint::solid(vectorcraft_color::Color::WHITE);
s.paint.fill = sampled.clone();
run(&mut s, "transparency.set", json!({"opacity": 50}));
assert_eq!(s.paint.fill, sampled, "not a selection command");
select(&mut s, &[a]);
assert_eq!(s.paint.fill, sampled, "the same selection again");
run(&mut s, "select.none", json!({}));
assert_eq!(s.paint.fill, sampled, "nothing selected shows nothing to load");
select(&mut s, &[a]);
assert_eq!(s.paint.fill, node(&s, a).appearance.fill_paint(), "selected anew");
}
#[test]
fn targeting_a_layer_loads_the_paints_of_its_contents() {
let mut s = session();
let a = painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0);
let layer = s.doc().unwrap().doc.layers[0].id;
run(&mut s, "select.none", json!({}));
run(&mut s, "paint.setFill", json!({"color": "#0000ff"}));
run(&mut s, "layer.target", json!({"id": layer.0}));
assert_eq!(s.paint.fill, node(&s, a).appearance.fill_paint());
}
#[test]
fn the_option_round_trips_with_the_preferences() {
let mut s = session();
+68 -22
View File
@@ -164,6 +164,33 @@ fn a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_
}
}
/// A gradient fitted to the object turns with a Transform effect on the canvas, and Object › Expand
/// of the fill makes strips that draw the same.
#[test]
fn a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it() {
// The gray level at (x, y).
let gray = |r: &vectorcraft_render::Rendered, x: usize, y: usize| r.pixels[(y * 200 + x) * 4] as i32;
// The object's own effect, and the fill's (the first appearance item).
for (params, item) in
[(json!({"rotate": 90}), json!(null)), (json!({"rotate": 90}), json!(0)), (json!({"rotate": 30, "scaleH": 60}), json!(null))]
{
let mut s = session();
let id = gradient_rect(&mut s, Rect::new(60.0, 60.0, 140.0, 140.0), white_black(), false);
run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": params, "ids": [id.0], "item": item}));
let before = render(&s);
if params["rotate"] == 90 {
// The white to black ramp runs up or down the turned square, not across it.
let (top, bottom) = (gray(&before, 100, 70), gray(&before, 100, 130));
let (left, right) = (gray(&before, 70, 100), gray(&before, 130, 100));
assert!((top - bottom).abs() > 100 && (left - right).abs() < 20, "{params} on item {item}: {top} {bottom} {left} {right}");
}
run(&mut s, "object.expand", json!({"object": false, "stroke": false, "steps": 64}));
let after = render(&s);
let diff = mean_diff(&before, &after);
assert!(diff < 1.0, "{params} on item {item}: mean difference {diff}");
}
}
#[test]
fn a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it() {
// The object's own effect, and the fill's (the first appearance item).
@@ -947,19 +974,48 @@ fn expanded_colours_keep_the_stops_colour_model() {
assert!((c - 0.625).abs() < 1e-5 && (m - 0.375).abs() < 1e-5, "interpolated in CMYK: {c} {m}");
}
/// A new 200 × 200 CMYK document.
fn cmyk_session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200, "colorMode": "cmyk"})).unwrap();
s
}
/// The selection's freeform gradient expanded: the mesh's colors, after checking that it draws
/// within `most` (mean channel difference) of the gradient.
fn expanded(s: &mut Session, most: f64) -> Vec<Color> {
let before = render(s);
run(s, "object.expand", json!({}));
let diff = mean_diff(&before, &render(s));
assert!(diff < most, "looks the same: {diff}");
let NodeKind::Mesh(m) = selected(s).kind else { panic!("a mesh: {:?}", selected(s).kind) };
m.points.into_iter().map(|p| p.color).collect()
}
/// Cyan and black points mix as inks: cyan and black only, adding up to one. The gradient and the
/// mesh both mix inks, so the two draw alike.
fn cyan_and_black_points_expand_as_they_draw(mut s: Session) {
let (cyan, black) = (json!({"c": 1.0, "m": 0.0, "y": 0.0, "k": 0.0}), json!({"c": 0.0, "m": 0.0, "y": 0.0, "k": 1.0}));
freeform_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), [cyan.clone(), cyan, black.clone(), black]);
for c in expanded(&mut s, 2.5) {
let [c, m, y, k] = inks(c);
assert!(m.abs() < 1e-5 && y.abs() < 1e-5 && (c + k - 1.0).abs() < 1e-5, "C{c} M{m} Y{y} K{k}");
}
}
#[test]
fn cyan_and_black_freeform_points_expand_as_they_draw_in_an_rgb_document() {
cyan_and_black_points_expand_as_they_draw(session());
}
#[test]
fn cyan_and_black_freeform_points_expand_as_they_draw_in_a_cmyk_document() {
cyan_and_black_points_expand_as_they_draw(cmyk_session());
}
#[test]
fn a_freeform_gradient_expands_with_the_inks_of_its_points() {
let ink = |c: f32, m: f32, y: f32, k: f32| json!({"c": c, "m": m, "y": y, "k": k});
// The mesh's colors, after checking that it draws within `most` (mean channel difference) of
// the gradient.
let expanded = |s: &mut Session, most: f64| {
let before = render(s);
run(s, "object.expand", json!({}));
let diff = mean_diff(&before, &render(s));
assert!(diff < most, "looks the same: {diff}");
let NodeKind::Mesh(m) = selected(s).kind else { panic!("a mesh: {:?}", selected(s).kind) };
m.points.into_iter().map(|p| p.color).collect::<Vec<_>>()
};
// Points of one ink set give every mesh point that ink set.
let mut s = session();
let one = ink(0.5, 0.2, 0.0, 0.1);
@@ -967,19 +1023,9 @@ fn a_freeform_gradient_expands_with_the_inks_of_its_points() {
for c in expanded(&mut s, 1.0) {
assert!(close_inks(inks(c), [0.5, 0.2, 0.0, 0.1]), "{c:?}");
}
// Cyan and black points mix as inks: cyan and black only, adding up to one. The gradient blends
// the points' display colors and the mesh blends their inks, so the two draw a little apart in between.
let mut s = session();
let (cyan, black) = (ink(1.0, 0.0, 0.0, 0.0), ink(0.0, 0.0, 0.0, 1.0));
freeform_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), [cyan.clone(), cyan.clone(), black.clone(), black]);
for c in expanded(&mut s, 7.5) {
let [c, m, y, k] = inks(c);
assert!(m.abs() < 1e-5 && y.abs() < 1e-5 && (c + k - 1.0).abs() < 1e-5, "C{c} M{m} Y{y} K{k}");
}
// Points in different models mix in display RGB, stored in a CMYK document as CMYK.
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200, "colorMode": "cmyk"})).unwrap();
let gray = json!({"gray": 0.5});
let (cyan, gray) = (ink(1.0, 0.0, 0.0, 0.0), json!({"gray": 0.5}));
let mut s = cmyk_session();
freeform_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), [cyan.clone(), cyan, gray.clone(), gray]);
for c in expanded(&mut s, 2.5) {
inks(c);
+229 -7
View File
@@ -579,9 +579,9 @@ fn asking(id: &str, permissions: &str, output: &Value) -> Vec<u8> {
/// A plug-in whose manifest lists permissions installs only once they are all granted (`grant`),
/// which `plugin.inspect` tells beforehand (`needsApproval`) with the permissions and the module's
/// hash. The grant is recorded with that hash: replacing the plug-in (`replace: true`) with one
/// that lists the same permissions needs no new grant, and one that lists another needs one. Removing it forgets the
/// grant. A plug-in without `permissions` installs as before, with the ones its kind implies.
/// hash. The grant is recorded with that hash and covers only that module: replacing the plug-in
/// (`replace: true`) with one with other bytes needs a new grant. Removing it forgets the grant. A
/// plug-in without `permissions` installs as before, with the ones its kind implies.
#[test]
fn a_plugin_that_lists_permissions_installs_once_they_are_granted() {
let mut s = session();
@@ -617,11 +617,11 @@ fn a_plugin_that_lists_permissions_installs_once_they_are_granted() {
assert_eq!(installed["permissions"], json!(["doc.read"]));
let g = vectorcraft_plugins::registry::granted(id).unwrap();
assert_eq!((g.hash.as_str(), g.permissions.as_slice()), (hash.as_str(), &[vectorcraft_plugins::permissions::Permission::DocRead][..]));
// Replaced with another module that lists the same permissions: no new grant needed, and the
// grant now records the new module's hash.
// Replaced with another module that lists the same permissions: granted again, the grant now
// records the new module's hash.
let other = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&other)}))["needsApproval"], false);
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other), "replace": true}));
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&other)}))["needsApproval"], true);
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other), "grant": ["doc.read"], "replace": true}));
assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, vectorcraft_plugins::permissions::sha256_hex(&other));
// One that asks for more is refused until it is granted all of them.
let more = data(r#"["doc.read","doc.write"]"#);
@@ -641,6 +641,221 @@ fn a_plugin_that_lists_permissions_installs_once_they_are_granted() {
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(DESATURATE)}))["needsApproval"], false);
}
/// A module with other bytes under an id that was granted is not covered, even when it lists the
/// same permissions: `plugin.inspect` says so, `plugin.install` refuses it and leaves the installed
/// module and its grant alone, and a new `grant` installs it and records its hash. The same bytes
/// install again without asking, and a module that lists no permissions never asks.
#[test]
fn a_grant_covers_only_the_module_that_was_approved() {
use vectorcraft_plugins::permissions::sha256_hex;
let mut s = session();
let id = "test.engine.permissions.bytes";
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
let (first_data, changed_data) = (vectorcraft_format::base64_encode(&first), vectorcraft_format::base64_encode(&changed));
let (first_hash, changed_hash) = (sha256_hex(&first), sha256_hex(&changed));
run(&mut s, "plugin.install", json!({"dataBase64": first_data, "grant": ["doc.read"]}));
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": changed_data}))["needsApproval"], true);
let e = s.execute("plugin.install", &json!({"dataBase64": changed_data, "replace": true})).unwrap_err().to_string();
assert!(e.contains("doc.read") && e.contains("`grant`"), "{e}");
let e = s.execute("plugin.install", &json!({"dataBase64": changed_data, "replace": true, "hash": changed_hash})).unwrap_err().to_string();
assert!(e.contains("`grant`"), "`hash` is not an approval: {e}");
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), first_hash, "the installed module stays");
assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, first_hash, "its grant stays");
// The same bytes need no new grant.
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": first_data}))["needsApproval"], false);
run(&mut s, "plugin.install", json!({"dataBase64": first_data}));
// A new grant installs the changed module, and the old bytes are no longer covered.
run(&mut s, "plugin.install", json!({"dataBase64": changed_data, "grant": ["doc.read"], "hash": changed_hash, "replace": true}));
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), changed_hash);
assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, changed_hash);
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": first_data}))["needsApproval"], true);
run(&mut s, "plugin.remove", json!({"id": id}));
// A manifest without a list, or with an empty one, asks for nothing, whatever its bytes.
let exempt = "test.engine.permissions.exempt";
for (permissions, out) in [("null", json!({"objects": []})), ("null", json!({"objects": [], "error": null})), ("[]", json!({"objects": []}))] {
let data = vectorcraft_format::base64_encode(&asking(exempt, permissions, &out));
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": data}))["needsApproval"], false, "{permissions}");
run(&mut s, "plugin.install", json!({"dataBase64": data, "replace": true}));
}
run(&mut s, "plugin.remove", json!({"id": exempt}));
}
/// A stored module whose bytes changed is skipped at the next start and stays in the store with its
/// grant, and the original bytes load again. A stored grant whose hash is malformed covers nothing.
/// With a store attached, a matching grant held only by the process doesn't count.
#[test]
fn a_stored_grant_covers_only_the_stored_module_it_recorded() {
use vectorcraft_plugins::permissions::{Grant, Permission, sha256_hex};
let folder = crate::tests_links::Folder::new("plugin-store-recorded-module");
let id = "test.engine.permissions.storedbytes";
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
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"]}));
let grants = folder.0.join("grants.json");
let recorded = std::fs::read(&grants).unwrap();
let module = std::fs::read_dir(&folder.0)
.unwrap()
.map(|e| e.unwrap().path())
.find(|p| p.extension().is_some_and(|x| x == "wasm"))
.expect("the stored module");
let forget = || {
vectorcraft_plugins::registry::remove(id);
vectorcraft_plugins::registry::revoke(id);
};
let starts = || {
forget();
let mut next = session();
next.attach_plugin_store(folder.0.clone());
vectorcraft_plugins::registry::get(id).is_some()
};
assert!(starts(), "the module that was approved loads");
std::fs::write(&module, &changed).unwrap();
assert!(!starts(), "other bytes under the old grant are skipped");
assert_eq!(std::fs::read(&module).unwrap(), changed, "the module stays in the store");
assert_eq!(std::fs::read(&grants).unwrap(), recorded, "its grant stays");
std::fs::write(&module, &first).unwrap();
assert!(starts(), "the approved bytes load again");
// A malformed hash is no wildcard, even for the module on disk.
let upper = sha256_hex(&first).to_uppercase();
for hash in ["", "00", upper.as_str()] {
std::fs::write(&grants, serde_json::to_vec(&json!([{"id": id, "hash": hash, "permissions": ["doc.read"]}])).unwrap()).unwrap();
assert!(!starts(), "hash {hash:?}");
}
// The store's grants are the ones that count, even when the process holds one for these bytes.
std::fs::write(&grants, &recorded).unwrap();
assert!(starts());
let mut attached = session();
attached.attach_plugin_store(folder.0.clone());
vectorcraft_plugins::registry::grant(Grant { id: id.into(), hash: sha256_hex(&changed), permissions: vec![Permission::DocRead] });
let inspected = run(&mut attached, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&changed)}));
assert_eq!(inspected["needsApproval"], true, "a grant held only by the process doesn't count with a store attached");
run(&mut attached, "plugin.remove", json!({"id": id}));
}
/// A write to the plug-in store that fails leaves the store's module beside the grant that approved
/// it, so the old module loads at the next start: a module that can't be written doesn't update the
/// grant, and a grant that can't be written puts the old module back. On a first install, it takes
/// the new module out again.
#[test]
fn a_failed_store_write_keeps_the_stored_module_with_its_grant() {
use vectorcraft_plugins::permissions::sha256_hex;
let folder = crate::tests_links::Folder::new("plugin-store-failed-write");
let id = "test.engine.permissions.failedwrite";
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
let changed = asking(id, r#"["doc.read","doc.write"]"#, &json!({"objects": []}));
let first_data = vectorcraft_format::base64_encode(&first);
let update = json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read", "doc.write"], "replace": true});
let mut s = session();
s.attach_plugin_store(folder.0.clone());
run(&mut s, "plugin.install", json!({"dataBase64": first_data, "grant": ["doc.read"]}));
let grants = folder.0.join("grants.json");
let recorded = std::fs::read(&grants).unwrap();
let module = std::path::PathBuf::from(s.stored_plugin_file(id).unwrap());
let starts = || {
vectorcraft_plugins::registry::remove(id);
vectorcraft_plugins::registry::revoke(id);
let mut next = session();
next.attach_plugin_store(folder.0.clone());
vectorcraft_plugins::registry::get(id).map(|p| p.hash().to_string())
};
let block = |path: &std::path::Path| {
std::fs::remove_file(path).unwrap();
std::fs::create_dir_all(path.join("block")).unwrap();
};
// The module can't be written: the grant stays the old module's.
block(&module);
run(&mut s, "plugin.install", update.clone());
assert_eq!(std::fs::read(&grants).unwrap(), recorded, "the grant isn't updated for a module that wasn't stored");
std::fs::remove_dir_all(&module).unwrap();
std::fs::write(&module, &first).unwrap();
assert_eq!(starts(), Some(sha256_hex(&first)), "the old module loads after a failed module write");
// The grants file is full: the new grant, which is longer, doesn't fit.
let cap = 1 << 20;
let mut entries: Vec<Value> = serde_json::from_slice(&recorded).unwrap();
entries.push(json!({"id": "", "pad": ""}));
let room = cap - serde_json::to_vec(&entries).unwrap().len();
*entries.last_mut().unwrap() = json!({"id": "", "pad": "x".repeat(room)});
let full = serde_json::to_vec(&entries).unwrap();
assert_eq!(full.len(), cap);
std::fs::write(&grants, &full).unwrap();
run(&mut s, "plugin.install", update.clone());
assert_eq!(std::fs::read(&grants).unwrap(), full, "not written past the cap");
assert_eq!(std::fs::read(&module).unwrap(), first, "the old module is put back");
assert_eq!(starts(), Some(sha256_hex(&first)), "the old module loads after a failed grant write");
// The grants file can't be read, so nothing can be recorded in it.
block(&grants);
run(&mut s, "plugin.install", update.clone());
assert_eq!(std::fs::read(&module).unwrap(), first, "the old module is put back");
std::fs::remove_dir_all(&grants).unwrap();
std::fs::write(&grants, &recorded).unwrap();
assert_eq!(starts(), Some(sha256_hex(&first)), "the old module loads once the grants are readable");
// A stored module that can't be read back isn't replaced, so a failed grant write can't lose it.
let oversized = vectorcraft_plugins::Limits::default().max_module_bytes as u64 + 1;
std::fs::File::options().write(true).open(&module).unwrap().set_len(oversized).unwrap();
block(&grants);
run(&mut s, "plugin.install", update);
assert_eq!(std::fs::metadata(&module).unwrap().len(), oversized, "the stored module stays");
std::fs::remove_dir_all(&grants).unwrap();
std::fs::write(&grants, &recorded).unwrap();
std::fs::write(&module, &first).unwrap();
// A first install whose grant can't be written leaves no module without a grant in the store.
run(&mut s, "plugin.remove", json!({"id": id}));
assert!(!module.exists() && !grants.exists());
std::fs::create_dir_all(grants.join("block")).unwrap();
run(&mut s, "plugin.install", json!({"dataBase64": first_data, "grant": ["doc.read"]}));
assert!(!module.exists(), "the store holds no module its grants don't cover");
vectorcraft_plugins::registry::remove(id);
vectorcraft_plugins::registry::revoke(id);
}
/// 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.
#[test]
fn a_plugin_folder_skips_a_changed_module_until_it_is_approved() {
let mut s = session();
let id = "test.engine.permissions.folderbytes";
let plain = "test.engine.permissions.folderplain";
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
let dir = crate::tests_links::Folder::new("plugin-folder-changed-module");
let file = dir.0.join("asks.wasm");
std::fs::write(&file, &first).unwrap();
std::fs::write(dir.0.join("plain.wasm"), asking(plain, "null", &json!({"objects": []}))).unwrap();
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]}));
let path = json!({"path": dir.0.display().to_string()});
let r = run(&mut s, "plugin.reload", path.clone());
assert_eq!((r["loaded"].as_array().unwrap().len(), r["failed"].as_array().unwrap().len()), (2, 0), "{r}");
std::fs::write(&file, &changed).unwrap();
let r = run(&mut s, "plugin.reload", path.clone());
assert_eq!(r["loaded"], json!([plain]), "the other file still loads");
let failed = r["failed"].as_array().unwrap();
assert_eq!(failed.len(), 1, "{r}");
assert!(failed[0]["file"].as_str().unwrap().ends_with("asks.wasm"), "{r}");
let why = failed[0]["error"].as_str().unwrap();
assert!(why.contains("needs approval") && why.contains("doc.read"), "{why}");
let hash = vectorcraft_plugins::permissions::sha256_hex(&first);
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), hash, "the module installed earlier stays");
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read"], "replace": true}));
let r = run(&mut s, "plugin.reload", path);
assert_eq!(r["failed"], json!([]), "approved on its own, it loads: {r}");
run(&mut s, "plugin.remove", json!({"id": id}));
run(&mut s, "plugin.remove", json!({"id": plain}));
}
/// The host enforces what a filter was granted: without `doc.write` an output that changes the
/// document is refused and nothing changes, without `doc.read` it is given no objects, and without
/// `state` it is given no data kept with the document and may not write any.
@@ -815,18 +1030,25 @@ fn a_grant_covers_only_the_module_it_was_given_to() {
std::fs::write(&stored[0], &other).unwrap();
vectorcraft_plugins::registry::remove(id);
vectorcraft_plugins::registry::revoke(id);
vectorcraft_plugins::registry::set_storage(id, None);
let no_storage = |how: &str| {
assert!(vectorcraft_plugins::registry::storage(id).is_none(), "{how} gives another module the id's settings and cache");
};
let mut next = session();
next.attach_plugin_store(folder.0.clone());
assert!(vectorcraft_plugins::registry::get(id).is_none(), "another module isn't loaded under the grant");
no_storage("the next start");
let dir = crate::tests_links::Folder::new("plugin-folder-grant-hash");
std::fs::write(dir.0.join("other.wasm"), &other).unwrap();
let r = run(&mut next, "plugin.reload", json!({"path": dir.0.display().to_string()}));
assert_eq!(r["loaded"], json!([]));
let why = r["failed"][0]["error"].as_str().unwrap();
assert!(why.contains("needs approval"), "{why}");
no_storage("a folder load");
let data = vectorcraft_format::base64_encode(&other);
let e = next.execute("plugin.install", &json!({"dataBase64": data, "replace": true})).unwrap_err().to_string();
assert!(e.contains("`grant`"), "{e}");
no_storage("a refused install");
// Granted, it reads the settings the first module set.
run(&mut next, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"], "replace": true}));
run(&mut next, "plugin.run", json!({"id": id, "ids": []}));
+27
View File
@@ -241,6 +241,33 @@ fn options_are_saved_with_the_preferences() {
assert_eq!(t.prefs.tool_settings["polygon"]["sides"], json!(9));
}
/// Escape on a polygon or rounded rectangle drag puts back the side count or corner radius its
/// arrows changed, in the saved options and the Corner Radius preference too.
#[test]
fn escape_on_a_shape_drag_saves_the_option_it_put_back() {
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
let mut s = session();
let v = ViewInfo { smart_guides: false, ..Default::default() };
let drag = |s: &mut Session| {
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 180.0), v).unwrap();
s.tool_key(ToolKey::Up, Mods::default(), v).unwrap();
// Saves every option, the one the arrow changed included.
s.set_tool_option("length", &json!(50));
};
select(&mut s, "polygon");
drag(&mut s);
assert_eq!(s.prefs.tool_settings["polygon"]["sides"], json!(7));
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
assert_eq!(s.prefs.tool_settings["polygon"]["sides"], json!(6));
select(&mut s, "roundedRectangle");
let radius = s.prefs.corner_radius;
drag(&mut s);
assert_eq!(s.prefs.corner_radius, radius + 1.0);
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
assert_eq!(s.prefs.corner_radius, radius);
}
/// The Fidelity an Alt smoothing drag uses on a selected path.
fn alt_smooth_fidelity(s: &mut Session) -> Option<f64> {
use vectorcraft_tools::{Action, Mods, PointerEvent, PointerKind};
+13
View File
@@ -1024,6 +1024,19 @@ fn lasso_picks_gradient_mesh_points() {
/// Shift-drag a marquee with the Selection tool (#483): the selected objects it reaches are
/// deselected and the others selected, the rest of the selection staying as it was.
#[test]
fn a_selection_tool_click_gives_new_art_the_objects_fill() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
rect(&mut s, 200.0, 100.0, 50.0, 50.0);
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
s.execute("select.none", &json!({})).unwrap();
gesture(&mut s, "selection", &[(125.0, 125.0), (125.0, 125.0)], Mods::default());
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
assert_eq!(s.paint.fill, s.doc().unwrap().doc.node(a).unwrap().appearance.fill_paint());
}
#[test]
fn selection_shift_marquee_toggles_objects() {
let mut s = session();