From e98b97de2bda8af2d94e50fbb971385c82008896 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 16:00:01 +0000 Subject: [PATCH] refactor(engine): simplify session and test helpers --- crates/engine/src/code_connect_tests.rs | 51 ++++++--- crates/engine/src/derived.rs | 2 +- crates/engine/src/doc_state.rs | 2 +- crates/engine/src/error.rs | 2 +- crates/engine/src/keymap_tests.rs | 12 ++ crates/engine/src/library_variables_tests.rs | 108 ++++++++++-------- crates/engine/src/live_docs_tests.rs | 32 ++++-- crates/engine/src/live_share_tests.rs | 58 ++++++---- crates/engine/src/prefs.rs | 2 +- crates/engine/src/proto_conditional_tests.rs | 50 ++++---- .../engine/src/selection/selection_colors.rs | 2 +- crates/engine/src/session.rs | 5 +- crates/engine/src/session/access.rs | 2 +- crates/engine/src/session/exec.rs | 5 +- crates/engine/src/session/new.rs | 2 +- crates/engine/src/session/undo.rs | 5 +- crates/engine/src/sharing/live/events.rs | 5 +- crates/engine/src/sharing/live/host.rs | 5 +- crates/engine/src/sharing/live/join.rs | 4 +- crates/engine/src/sharing/live/sync.rs | 5 +- crates/engine/src/sharing/live/types.rs | 5 +- crates/engine/src/slot_tests.rs | 40 ++++--- .../src/vector_multi_behaviour_tests.rs | 91 +++++++++------ crates/engine/src/view_state.rs | 2 +- 24 files changed, 307 insertions(+), 190 deletions(-) diff --git a/crates/engine/src/code_connect_tests.rs b/crates/engine/src/code_connect_tests.rs index 1ee785e6..24e03e4b 100644 --- a/crates/engine/src/code_connect_tests.rs +++ b/crates/engine/src/code_connect_tests.rs @@ -65,37 +65,47 @@ fn connected_instances_show_the_real_snippet() { ); assert_eq!(code(&mut s, i, "swiftui"), "import AcmeUI\n\nCard(title: \"Say \\\"hi\\\"\", style: .plain)\n"); assert!(code(&mut s, i, "compose").contains("@Composable"), "other languages stay generated"); + let screen = leptos_arkui_and_trees(&mut s, c, i); + rejects_bad_links(&mut s, c, screen); + disconnect_then_undo(&mut s, c, i); +} + +fn leptos_arkui_and_trees(s: &mut Session, c: NodeId, i: NodeId) -> NodeId { // Leptos: a `use` line and `view!` markup (strings with escapes in braces). ex( - &mut s, + s, "codeConnect.set", json!({"id": c, "language": "leptos", "importPath": "crate::ui", "template": ""}), ); - assert_eq!(code(&mut s, i, "leptos"), "use crate::ui::Card;\n\n\n"); + assert_eq!(code(s, i, "leptos"), "use crate::ui::Card;\n\n\n"); // ArkUI: an ArkTS import and an object-literal call. ex( - &mut s, + s, "codeConnect.set", json!({"id": c, "language": "arkui", "importPath": "@acme/arkui", "template": "Card({ title: {title}, dotted: {Show dot} })"}), ); - assert_eq!(code(&mut s, i, "arkui"), "import { Card } from \"@acme/arkui\";\n\nCard({ title: \"Say \\\"hi\\\"\", dotted: false })\n"); - let v = ex(&mut s, "inspect.componentCode", json!({"id": i, "language": "arkui"})); + assert_eq!(code(s, i, "arkui"), "import { Card } from \"@acme/arkui\";\n\nCard({ title: \"Say \\\"hi\\\"\", dotted: false })\n"); + let v = ex(s, "inspect.componentCode", json!({"id": i, "language": "arkui"})); assert_eq!(v["usage"], "Card({ title: \"Say \\\"hi\\\"\", dotted: false })\n"); assert_eq!(v["connected"]["import"], "import { Card } from \"@acme/arkui\";"); assert!(s.execute("codeConnect.set", &json!({"id": c, "language": "leptos", "importPath": "crate/ui"})).is_err(), "a Rust path"); - ex(&mut s, "codeConnect.remove", json!({"id": c, "language": "leptos"})); + ex(s, "codeConnect.remove", json!({"id": c, "language": "leptos"})); // inspect.componentCode: the snippet as the usage, and the link. - let v = ex(&mut s, "inspect.componentCode", json!({"id": i, "language": "swiftui"})); + let v = ex(s, "inspect.componentCode", json!({"id": i, "language": "swiftui"})); assert_eq!(v["usage"], "Card(title: \"Say \\\"hi\\\"\", style: .plain)\n"); assert_eq!(v["connected"]["import"], "import AcmeUI"); - assert_eq!(ex(&mut s, "inspect.componentCode", json!({"id": i, "language": "compose"}))["connected"], Value::Null); + assert_eq!(ex(s, "inspect.componentCode", json!({"id": i, "language": "compose"}))["connected"], Value::Null); // Inside a generated layer tree, connected instances are their snippet. - let screen = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 500, "width": 400, "height": 300, "name": "Screen"}))); - ex(&mut s, "instance.create", json!({"component": c, "x": 20, "y": 30, "parent": screen})); - assert!(code(&mut s, screen, "react").contains("•\n")); + let screen = idv(&ex(s, "frame.create", json!({"x": 0, "y": 500, "width": 400, "height": 300, "name": "Screen"}))); + ex(s, "instance.create", json!({"component": c, "x": 20, "y": 30, "parent": screen})); + assert!(code(s, screen, "react").contains("•\n")); // Literal braces stay; fields left out keep their value. - ex(&mut s, "codeConnect.set", json!({"id": c, "template": ""})); - assert_eq!(code(&mut s, c, "react"), "import { Card } from \"@acme/ui\";\n\n\n"); + ex(s, "codeConnect.set", json!({"id": c, "template": ""})); + assert_eq!(code(s, c, "react"), "import { Card } from \"@acme/ui\";\n\n\n"); + screen +} + +fn rejects_bad_links(s: &mut Session, c: NodeId, screen: NodeId) { // Validation. for bad in [ json!({"id": c, "template": "{title ? \"a\"}"}), @@ -109,13 +119,16 @@ fn connected_instances_show_the_real_snippet() { ] { assert!(s.execute("codeConnect.set", &bad).is_err(), "{bad}"); } +} + +fn disconnect_then_undo(s: &mut Session, c: NodeId, i: NodeId) { // Disconnect one language; undo brings it back. - ex(&mut s, "codeConnect.remove", json!({"id": i, "language": "react"})); - assert!(code(&mut s, i, "react").contains("export function Card({")); - assert!(code(&mut s, i, "swiftui").starts_with("import AcmeUI")); - ex(&mut s, "edit.undo", json!({})); - assert!(code(&mut s, i, "react").starts_with("import { Card }")); - assert_eq!(ex(&mut s, "codeConnect.remove", json!({"id": c}))["removed"], 3); + ex(s, "codeConnect.remove", json!({"id": i, "language": "react"})); + assert!(code(s, i, "react").contains("export function Card({")); + assert!(code(s, i, "swiftui").starts_with("import AcmeUI")); + ex(s, "edit.undo", json!({})); + assert!(code(s, i, "react").starts_with("import { Card }")); + assert_eq!(ex(s, "codeConnect.remove", json!({"id": c}))["removed"], 3); assert!(s.execute("codeConnect.remove", &json!({"id": c})).is_err(), "nothing left"); } diff --git a/crates/engine/src/derived.rs b/crates/engine/src/derived.rs index 765e3be5..b6a001b8 100644 --- a/crates/engine/src/derived.rs +++ b/crates/engine/src/derived.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, BTreeSet, Document, Node, NodeId, board, cmd, components, measure, style_vars}; /// Nodes whose `Arc` differs from `before` (new or edited). pub fn changed_nodes(doc: &Document, before: &Document) -> Vec { diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index 4f9509a7..a9cad9c9 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -1,5 +1,5 @@ use super::view_state::ViewState; -use super::*; +use super::{Arc, BTreeSet, Deserialize, Document, NodeId, Serialize, cmd, components, file_access, guides, library, rotation_origin, vectoredit}; /// One undo step. #[derive(Clone, Debug)] diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs index 7f2ab1d4..7f26258f 100644 --- a/crates/engine/src/error.rs +++ b/crates/engine/src/error.rs @@ -1,4 +1,4 @@ -use super::*; +use super::NodeId; #[derive(Debug, thiserror::Error)] pub enum EngineError { diff --git a/crates/engine/src/keymap_tests.rs b/crates/engine/src/keymap_tests.rs index f624ecfb..bdf57c9d 100644 --- a/crates/engine/src/keymap_tests.rs +++ b/crates/engine/src/keymap_tests.rs @@ -95,6 +95,11 @@ fn key_names_normalise() { #[test] fn lookups_follow_the_platform_and_context() { + lookups_on_every_platform(); + lookups_that_differ_by_platform(); +} + +fn lookups_on_every_platform() { let none = Mods::default(); let shift = mods(true, false, false, false); for p in PLATFORMS { @@ -119,6 +124,9 @@ fn lookups_follow_the_platform_and_context() { assert_eq!(id_for(p, Context::Text, "V", none), None); assert_eq!(id_for(p, Context::Text, "A", mods(false, true, false, false)), None); } +} + +fn lookups_that_differ_by_platform() { let mac = Platform::Mac; let other = Platform::Other; // Use as mask: ⌃⌘M on macOS, Ctrl+Alt+M elsewhere. @@ -133,6 +141,10 @@ fn lookups_follow_the_platform_and_context() { assert_eq!(id_for(mac, Context::Canvas, "T", mods(false, true, false, true)), Some("arrange.tidy")); assert_eq!(id_for(other, Context::Canvas, "T", mods(true, true, true, true)), Some("arrange.tidy")); assert_eq!(id_for(other, Context::Canvas, "T", mods(false, true, true, true)), Some("text.alignCenter")); + macos_tells_ctrl_from_cmd(mac, other); +} + +fn macos_tells_ctrl_from_cmd(mac: Platform, other: Platform) { // macOS tells ⌃ from ⌘: ⌃G is layout grids, ⌘G groups; ⌃C picks a colour, ⌘C copies. assert_eq!(id_for(mac, Context::Canvas, "G", mods(false, false, false, true)), Some("view.layoutGrids")); assert_eq!(id_for(mac, Context::Canvas, "G", mods(false, false, true, false)), Some("object.group")); diff --git a/crates/engine/src/library_variables_tests.rs b/crates/engine/src/library_variables_tests.rs index b4a4171a..f61bab29 100644 --- a/crates/engine/src/library_variables_tests.rs +++ b/crates/engine/src/library_variables_tests.rs @@ -122,6 +122,65 @@ fn publishing_collections_hides_and_versions_variables() { assert!(empty.execute("library.publish", &json!({})).is_err()); } +/// `bind_alias_mode_read_only_and_updates`: every edit of a copy's variable or collection is refused. +fn assert_copies_read_only(s: &mut Session, local: &str) { + let coll = copy_of(s, "primary").collection; + for (cmd, p) in [ + ("variable.set", json!({"variable": local, "value": "#ff0000"})), + ("variable.update", json!({"variable": local, "name": "mine"})), + ("variable.delete", json!({"variable": local})), + ("variable.duplicate", json!({"variable": local})), + ("variable.create", json!({"collection": coll, "name": "extra", "type": "number", "value": 1})), + ("variable.addMode", json!({"collection": coll, "name": "Mine"})), + ("variable.renameMode", json!({"collection": coll, "mode": "Dark", "name": "Night"})), + ("variable.renameCollection", json!({"collection": coll, "name": "Mine"})), + ("variable.deleteCollection", json!({"collection": coll})), + ] { + let e = s.execute(cmd, &p).unwrap_err(); + assert!(e.to_string().contains("can't be edited"), "{cmd}: {e}"); + } +} + +/// `bind_alias_mode_read_only_and_updates`: saved and reopened, the file renders without the library. +fn reopened_without_library(s: &mut Session, d: &std::path::Path, lib_path: &str, lid: &str, outer: NodeId) { + // Saved and reopened, the copies, records and bindings stay; the file renders without the library. + let user_path = path_str(&d.join("app.figcraft")); + ex(s, "file.saveAs", json!({"path": user_path})); + std::fs::remove_file(lib_path).unwrap(); + let mut t = Session::empty(); + ex(&mut t, "file.open", json!({"path": user_path})); + assert_eq!(t.doc().unwrap().libraries, s.doc().unwrap().libraries); + assert_eq!(fill(&t, outer), "FF0000"); + assert!(t.execute("library.bindVariable", &json!({"library": lid, "variable": "primary", "field": "fill", "ids": [outer]})).is_err()); +} + +/// `bind_alias_mode_read_only_and_updates`: the library changes its variables; the copies, bindings and alias follow. +fn library_update_follows(s: &mut Session, lib: &mut Session, local: &str, [inner, outer]: [NodeId; 2]) { + let modes = Default::default(); + // The library changes its variables and publishes again. + ex(lib, "variable.set", json!({"variable": "primary", "mode": "Light", "value": "#ff0000"})); + ex(lib, "variable.set", json!({"variable": "primary", "mode": "Dark", "value": "#222222"})); + ex(lib, "variable.set", json!({"variable": "base", "value": "#00ffff"})); + ex(lib, "variable.renameMode", json!({"collection": "Theme", "mode": "Dark", "name": "Night"})); + republish(lib); + let u = ex(s, "library.updates", json!({})); + assert_eq!(u["count"], 3, "primary, base and the collection: {u}"); + assert_eq!(u["libraries"][0]["variables"].as_array().map(Vec::len), Some(2)); + let r = ex(s, "library.acceptUpdates", json!({})); + assert_eq!((r["variables"].as_u64(), r["collections"].as_u64()), (Some(2), Some(1)), "{r}"); + // Same copies (same ids), new values: the bound layers and the alias follow. + assert_eq!(copy_of(s, "primary").id, local); + assert_eq!((fill(s, inner), fill(s, outer)), ("222222".into(), "FF0000".into())); + assert!(matches!(s.doc().unwrap().variables.resolve("brand", &modes), Some(VariableValue::Color(c)) if c.to_hex() == "00FFFF")); + assert_eq!(s.doc().unwrap().variables.collections[0].modes[1].name, "Night"); + assert_eq!(ex(s, "library.updates", json!({}))["count"], 0); + // Undo takes the update back. + ex(s, "edit.undo", json!({})); + assert_eq!(fill(s, outer), "0000FF"); + ex(s, "edit.redo", json!({})); + healthy(s); +} + #[test] fn bind_alias_mode_read_only_and_updates() { let d = dir("flow"); @@ -180,57 +239,14 @@ fn bind_alias_mode_read_only_and_updates() { assert!(s.execute("library.aliasVariable", &json!({"library": lid, "alias": "primary", "variable": local})).is_err(), "copies are read-only"); // The copies are read-only. - let coll = copy_of(&s, "primary").collection; - for (cmd, p) in [ - ("variable.set", json!({"variable": local, "value": "#ff0000"})), - ("variable.update", json!({"variable": local, "name": "mine"})), - ("variable.delete", json!({"variable": local})), - ("variable.duplicate", json!({"variable": local})), - ("variable.create", json!({"collection": coll, "name": "extra", "type": "number", "value": 1})), - ("variable.addMode", json!({"collection": coll, "name": "Mine"})), - ("variable.renameMode", json!({"collection": coll, "mode": "Dark", "name": "Night"})), - ("variable.renameCollection", json!({"collection": coll, "name": "Mine"})), - ("variable.deleteCollection", json!({"collection": coll})), - ] { - let e = s.execute(cmd, &p).unwrap_err(); - assert!(e.to_string().contains("can't be edited"), "{cmd}: {e}"); - } + assert_copies_read_only(&mut s, &local); // Local edits still work. ex(&mut s, "variable.set", json!({"variable": "gap", "value": 3})); healthy(&s); - // The library changes its variables and publishes again. - ex(&mut lib, "variable.set", json!({"variable": "primary", "mode": "Light", "value": "#ff0000"})); - ex(&mut lib, "variable.set", json!({"variable": "primary", "mode": "Dark", "value": "#222222"})); - ex(&mut lib, "variable.set", json!({"variable": "base", "value": "#00ffff"})); - ex(&mut lib, "variable.renameMode", json!({"collection": "Theme", "mode": "Dark", "name": "Night"})); - republish(&mut lib); - let u = ex(&mut s, "library.updates", json!({})); - assert_eq!(u["count"], 3, "primary, base and the collection: {u}"); - assert_eq!(u["libraries"][0]["variables"].as_array().map(Vec::len), Some(2)); - let r = ex(&mut s, "library.acceptUpdates", json!({})); - assert_eq!((r["variables"].as_u64(), r["collections"].as_u64()), (Some(2), Some(1)), "{r}"); - // Same copies (same ids), new values: the bound layers and the alias follow. - assert_eq!(copy_of(&s, "primary").id, local); - assert_eq!((fill(&s, inner), fill(&s, outer)), ("222222".into(), "FF0000".into())); - assert!(matches!(s.doc().unwrap().variables.resolve("brand", &modes), Some(VariableValue::Color(c)) if c.to_hex() == "00FFFF")); - assert_eq!(s.doc().unwrap().variables.collections[0].modes[1].name, "Night"); - assert_eq!(ex(&mut s, "library.updates", json!({}))["count"], 0); - // Undo takes the update back. - ex(&mut s, "edit.undo", json!({})); - assert_eq!(fill(&s, outer), "0000FF"); - ex(&mut s, "edit.redo", json!({})); - healthy(&s); + library_update_follows(&mut s, &mut lib, &local, [inner, outer]); - // Saved and reopened, the copies, records and bindings stay; the file renders without the library. - let user_path = path_str(&d.join("app.figcraft")); - ex(&mut s, "file.saveAs", json!({"path": user_path})); - std::fs::remove_file(&lib_path).unwrap(); - let mut t = Session::empty(); - ex(&mut t, "file.open", json!({"path": user_path})); - assert_eq!(t.doc().unwrap().libraries, s.doc().unwrap().libraries); - assert_eq!(fill(&t, outer), "FF0000"); - assert!(t.execute("library.bindVariable", &json!({"library": lid, "variable": "primary", "field": "fill", "ids": [outer]})).is_err()); + reopened_without_library(&mut s, &d, &lib_path, &lid, outer); // Removing the library keeps the copies in use and drops the rest. ex(&mut s, "variable.bind", json!({"ids": [outer], "field": "cornerRadius", "variable": null})); diff --git a/crates/engine/src/live_docs_tests.rs b/crates/engine/src/live_docs_tests.rs index 7380cc9b..df805266 100644 --- a/crates/engine/src/live_docs_tests.rs +++ b/crates/engine/src/live_docs_tests.rs @@ -62,7 +62,6 @@ fn each_document_has_its_own_session() { let ua = uid(&s); // A new document isn't in the first one's session. run(&mut s, "file.new", json!({})); - let ub = uid(&s); let off = run(&mut s, "collab.peers", json!({})); for key in ["session", "role", "link", "url", "address", "fileName", "document", "me"] { assert_eq!(off[key], Value::Null, "{key}: {off}"); @@ -78,8 +77,12 @@ fn each_document_has_its_own_session() { assert!(s.live_mut().is_none() && s.active_read_only().is_none()); assert_eq!(run(&mut s, "collab.stop", json!({}))["ended"], false, "nothing to stop here"); assert_eq!(s.live_of(ua).status, Status::Live, "and the other file's session isn't stopped"); + hosting_a_second_session(&mut s, &script, &a, ua); +} + +fn hosting_a_second_session(s: &mut Session, script: &Arc>, a: &Value, ua: u64) { // Hosting it too starts a second session; the first goes on. - let b = run(&mut s, "collab.host", json!({})); + let b = run(s, "collab.host", json!({})); assert_ne!(a["session"], b["session"]); assert_ne!(a["link"], b["link"]); assert_eq!((b["document"].clone(), b["sessions"].as_array().unwrap().len()), (json!(1), 2)); @@ -87,33 +90,38 @@ fn each_document_has_its_own_session() { let sc = script.lock().unwrap(); assert_eq!((sc.hosted.len(), sc.left), (2, 0)); } + people_come_and_go(s, script, a, &b, ua); +} + +fn people_come_and_go(s: &mut Session, script: &Arc>, a: &Value, b: &Value, ua: u64) { + let ub = uid(s); // People come and go in their own session. - let (a_id, b_id) = (id_of(&a), id_of(&b)); - feed(&mut s, &script, a_id, vec![Event::PeerJoined(peer(5, "Ada"))]); - feed(&mut s, &script, b_id, vec![Event::PeerJoined(peer(6, "Bo")), Event::PeerJoined(peer(7, "Cy"))]); + let (a_id, b_id) = (id_of(a), id_of(b)); + feed(s, script, a_id, vec![Event::PeerJoined(peer(5, "Ada"))]); + feed(s, script, b_id, vec![Event::PeerJoined(peer(6, "Bo")), Event::PeerJoined(peer(7, "Cy"))]); assert_eq!((s.live_of(ua).peers.len(), s.live_of(ub).peers.len()), (1, 2)); let names = |v: &Value| v["peers"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap().to_string()).collect::>(); let p = s.live_summary(); assert_eq!((names(&p), &p["link"]), (vec!["Bo".to_string(), "Cy".to_string()], &b["link"])); assert_eq!((&p["sessions"][0]["peers"], &p["sessions"][1]["peers"]), (&json!(1), &json!(2))); // The file in front decides what `collab.peers` and the commands are about. - run(&mut s, "doc.switch", json!({"index": 0})); - let p = run(&mut s, "collab.peers", json!({})); + run(s, "doc.switch", json!({"index": 0})); + let p = run(s, "collab.peers", json!({})); assert_eq!((names(&p), &p["link"], &p["document"], &p["session"]), (vec!["Ada".to_string()], &a["link"], &json!(0), &a["session"])); - assert_eq!(run(&mut s, "collab.follow", json!({"client": 5}))["follow"], 5); + assert_eq!(run(s, "collab.follow", json!({"client": 5}))["follow"], 5); assert!(s.execute("collab.follow", &json!({"client": 6})).is_err(), "Bo is in the other file's session"); - run(&mut s, "doc.switch", json!({"index": 1})); + run(s, "doc.switch", json!({"index": 1})); assert!(s.execute("collab.follow", &json!({"client": 5})).is_err()); assert_eq!((s.live().follow, s.live_of(ua).follow), (None, Some(5))); // Stopping ends this file's session only. - assert_eq!(run(&mut s, "collab.stop", json!({}))["ended"], true); + assert_eq!(run(s, "collab.stop", json!({}))["ended"], true); assert_eq!(script.lock().unwrap().ended, [b_id]); assert_eq!((s.live().status.clone(), s.live_of(ua).status.clone()), (Status::Off, Status::Live)); // Someone ending or leaving the other one doesn't bring this one back. - feed(&mut s, &script, b_id, vec![Event::PeerJoined(peer(8, "Late"))]); + feed(s, script, b_id, vec![Event::PeerJoined(peer(8, "Late"))]); assert_eq!((s.live.iter().count(), s.live_of(ua).peers.len()), (1, 1), "what a session still sent after it ended is dropped"); // Closing a file ends its session. - run(&mut s, "file.close", json!({"index": 0, "force": true})); + run(s, "file.close", json!({"index": 0, "force": true})); s.live_sync(); assert_eq!(script.lock().unwrap().ended, [b_id, a_id]); assert_eq!(s.live.iter().count(), 0); diff --git a/crates/engine/src/live_share_tests.rs b/crates/engine/src/live_share_tests.rs index f486250f..452eb3e8 100644 --- a/crates/engine/src/live_share_tests.rs +++ b/crates/engine/src/live_share_tests.rs @@ -185,6 +185,38 @@ fn the_built_in_server_is_the_default_and_can_be_changed_or_turned_off() { assert_eq!(old.publish_server, DEFAULT_SERVER); } +/// The middle of the online-sharing test: re-asking switches codes, and an offline stop stops nothing. +fn asking_again_and_offline_stop(s: &mut Session, script: &Arc>) { + // Asking again only switches which code is the one to give out. + let r = ex(s, "collab.share", json!({"access": "view"})); + assert_eq!((&r["code"], &r["share"]["access"]), (&json!("4FH-J8R-2WN"), &json!("view"))); + assert_eq!(ex(s, "collab.share", json!({"via": "online"}))["code"], "4FH-J8R-2WN", "nothing new starts"); + assert_eq!(ex(s, "collab.share", json!({"access": "edit"}))["code"], "K7M-2QX-9TD"); + assert_eq!(script.lock().unwrap().published.len(), 1); + assert!(err(s, "collab.share", json!({"via": "local"})).contains("stop sharing it first")); + assert!(err(s, "collab.share", json!({"access": "admin"})).contains("`access` must be")); + // Stopping while the server can't be reached stops nothing, and says so. + script.lock().unwrap().offline = true; + assert!(err(s, "collab.stop", json!({})).contains("couldn't stop sharing")); + assert_eq!(ex(s, "collab.peers", json!({}))["code"], "K7M-2QX-9TD"); + script.lock().unwrap().offline = false; +} + +/// The sharing-again step of the online-sharing test: new links and codes, nothing published twice. +fn sharing_again(s: &mut Session, script: &Arc>) { + // Sharing again: new links and codes for the same file, nothing published twice. + let r = ex(s, "collab.share", json!({})); + assert_eq!((&r["code"], &r["share"]["shared"], &r["share"]["viewCode"]), (&json!("NEW-ED1-T01"), &json!(true), &json!("NEW-V1E-W01"))); + assert!(r["share"]["editLink"].as_str().unwrap().contains("ShareeditShareedit01")); + { + let sc = script.lock().unwrap(); + assert_eq!(sc.published.len(), 1); + assert_eq!(sc.asked[1], ("wss://share.example/".to_string(), "f1".to_string(), "OwnerOwnerOwnerOwner00".to_string(), vec![], true)); + } + assert_eq!(ex(s, "collab.share", json!({}))["code"], "NEW-ED1-T01", "shared already: nothing more is asked"); + assert_eq!(script.lock().unwrap().asked.len(), 2); +} + #[test] fn sharing_online_publishes_to_the_server_in_preferences_and_shows_its_code() { let (mut s, script) = session(); @@ -212,19 +244,7 @@ fn sharing_online_publishes_to_the_server_in_preferences_and_shows_its_code() { let (edit_link, own) = (r["share"]["editLink"].as_str().unwrap(), r["published"]["edit"].as_str().unwrap()); assert!(edit_link.contains("ShareeditShareedit00") && edit_link != own && r["link"] == own, "{edit_link}"); assert!(s.message.is_none(), "the popover shows the code: no toast about publishing"); - // Asking again only switches which code is the one to give out. - let r = ex(&mut s, "collab.share", json!({"access": "view"})); - assert_eq!((&r["code"], &r["share"]["access"]), (&json!("4FH-J8R-2WN"), &json!("view"))); - assert_eq!(ex(&mut s, "collab.share", json!({"via": "online"}))["code"], "4FH-J8R-2WN", "nothing new starts"); - assert_eq!(ex(&mut s, "collab.share", json!({"access": "edit"}))["code"], "K7M-2QX-9TD"); - assert_eq!(script.lock().unwrap().published.len(), 1); - assert!(err(&mut s, "collab.share", json!({"via": "local"})).contains("stop sharing it first")); - assert!(err(&mut s, "collab.share", json!({"access": "admin"})).contains("`access` must be")); - // Stopping while the server can't be reached stops nothing, and says so. - script.lock().unwrap().offline = true; - assert!(err(&mut s, "collab.stop", json!({})).contains("couldn't stop sharing")); - assert_eq!(ex(&mut s, "collab.peers", json!({}))["code"], "K7M-2QX-9TD"); - script.lock().unwrap().offline = false; + asking_again_and_offline_stop(&mut s, &script); // Stopping: the server is asked, as the file's owner, to revoke the others' links. The file // stays there and its owner stays in it: nothing is left, nothing is removed. assert_eq!(ex(&mut s, "collab.stop", json!({})), json!({"ended": false, "shared": false})); @@ -248,17 +268,7 @@ fn sharing_online_publishes_to_the_server_in_preferences_and_shows_its_code() { ); assert!(r["published"]["owner"].is_string(), "still the owner's file on the server"); assert!(s.message.as_deref().unwrap_or_default().contains("stays on the server")); - // Sharing again: new links and codes for the same file, nothing published twice. - let r = ex(&mut s, "collab.share", json!({})); - assert_eq!((&r["code"], &r["share"]["shared"], &r["share"]["viewCode"]), (&json!("NEW-ED1-T01"), &json!(true), &json!("NEW-V1E-W01"))); - assert!(r["share"]["editLink"].as_str().unwrap().contains("ShareeditShareedit01")); - { - let sc = script.lock().unwrap(); - assert_eq!(sc.published.len(), 1); - assert_eq!(sc.asked[1], ("wss://share.example/".to_string(), "f1".to_string(), "OwnerOwnerOwnerOwner00".to_string(), vec![], true)); - } - assert_eq!(ex(&mut s, "collab.share", json!({}))["code"], "NEW-ED1-T01", "shared already: nothing more is asked"); - assert_eq!(script.lock().unwrap().asked.len(), 2); + sharing_again(&mut s, &script); // Stopping again revokes the new ones; then `collab.stop` on a file that isn't shared leaves // its session like any other (the file stays on the server). ex(&mut s, "collab.stop", json!({})); diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 6bfedcba..ced1793f 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, BTreeMap, Deserialize, Document, NodeId, Serialize, Value, cmd, live, online, plugins}; /// The smallest and largest interface size, relative to the system's display scale (1.0 is the /// system's own size). diff --git a/crates/engine/src/proto_conditional_tests.rs b/crates/engine/src/proto_conditional_tests.rs index e40aea5d..b5441262 100644 --- a/crates/engine/src/proto_conditional_tests.rs +++ b/crates/engine/src/proto_conditional_tests.rs @@ -71,41 +71,51 @@ fn build_if_else_if_else_with_nested_actions() { assert_eq!(shown(&s, bl[1].condition.as_ref().unwrap()), "open == false"); assert_eq!(shown(&s, bl[2].condition.as_ref().unwrap()), "label == \"Hi\""); assert!(bl[3].condition.is_none(), "the else stays last"); + actions_in_the_branches(&mut s, b, btn); + reorder_replace_and_remove(&mut s, b, btn); +} + +fn actions_in_the_branches(s: &mut Session, b: NodeId, btn: NodeId) { + let ex = |s: &mut Session, c: &str, p: Value| s.execute(c, &p).unwrap(); // Actions inside the branches: a set variable with an expression in the if, a back in the else. let r = ex( - &mut s, + s, "proto.action.add", json!({"id": btn, "index": 0, "path": [1, 0, 0], "action": {"type": "setVariable", "variable": "count", "expression": "count + 1"}}), ); assert_eq!(r["path"], json!([1, 0, 0])); - ex(&mut s, "proto.action.add", json!({"id": btn, "index": 0, "path": [1, 3, 0], "action": {"type": "back"}})); - ex(&mut s, "proto.action.add", json!({"id": btn, "index": 0, "path": [1, 3, 1], "action": {"type": "navigate", "dest": b}})); - let list = actions(&s, btn); + ex(s, "proto.action.add", json!({"id": btn, "index": 0, "path": [1, 3, 0], "action": {"type": "back"}})); + ex(s, "proto.action.add", json!({"id": btn, "index": 0, "path": [1, 3, 1], "action": {"type": "navigate", "dest": b}})); + let list = actions(s, btn); let Some(Action::SetVariable { variable, expr: Some(e), .. }) = action_at(&list, &[1, 0, 0]) else { panic!("{list:?}") }; - assert_eq!(variable, &var(&s, "count")); - assert_eq!(shown(&s, e), "count + 1"); + assert_eq!(variable, &var(s, "count")); + assert_eq!(shown(s, e), "count + 1"); assert_eq!(action_at(&list, &[1, 3, 0]), Some(&Action::Back)); +} + +fn reorder_replace_and_remove(s: &mut Session, b: NodeId, btn: NodeId) { + let ex = |s: &mut Session, c: &str, p: Value| s.execute(c, &p).unwrap(); // Reorder inside the else, replace one, change a condition, remove a block. - assert_eq!(ex(&mut s, "proto.action.move", json!({"id": btn, "index": 0, "path": [1, 3, 1], "to": 0}))["path"], json!([1, 3, 0])); - assert!(matches!(action_at(&actions(&s, btn), &[1, 3, 0]), Some(Action::Navigate { .. }))); - ex(&mut s, "proto.action.set", json!({"id": btn, "index": 0, "path": [1, 3, 1], "action": {"type": "closeOverlay"}})); - assert_eq!(action_at(&actions(&s, btn), &[1, 3, 1]), Some(&Action::CloseOverlay)); - ex(&mut s, "proto.conditional.setCondition", json!({"id": btn, "index": 0, "path": [1], "block": 1, "expression": "not open and count < 10"})); - assert_eq!(shown(&s, blocks(&actions(&s, btn)[1])[1].condition.as_ref().unwrap()), "(not open) and (count < 10)"); - ex(&mut s, "proto.conditional.removeBlock", json!({"id": btn, "index": 0, "path": [1], "block": 2})); - assert_eq!(blocks(&actions(&s, btn)[1]).len(), 3); - ex(&mut s, "proto.action.remove", json!({"id": btn, "index": 0, "path": [1, 2, 1]})); - assert_eq!(blocks(&actions(&s, btn)[1])[2].actions, [Action::Navigate { dest: b, preserve_scroll: false }]); + assert_eq!(ex(s, "proto.action.move", json!({"id": btn, "index": 0, "path": [1, 3, 1], "to": 0}))["path"], json!([1, 3, 0])); + assert!(matches!(action_at(&actions(s, btn), &[1, 3, 0]), Some(Action::Navigate { .. }))); + ex(s, "proto.action.set", json!({"id": btn, "index": 0, "path": [1, 3, 1], "action": {"type": "closeOverlay"}})); + assert_eq!(action_at(&actions(s, btn), &[1, 3, 1]), Some(&Action::CloseOverlay)); + ex(s, "proto.conditional.setCondition", json!({"id": btn, "index": 0, "path": [1], "block": 1, "expression": "not open and count < 10"})); + assert_eq!(shown(s, blocks(&actions(s, btn)[1])[1].condition.as_ref().unwrap()), "(not open) and (count < 10)"); + ex(s, "proto.conditional.removeBlock", json!({"id": btn, "index": 0, "path": [1], "block": 2})); + assert_eq!(blocks(&actions(s, btn)[1]).len(), 3); + ex(s, "proto.action.remove", json!({"id": btn, "index": 0, "path": [1, 2, 1]})); + assert_eq!(blocks(&actions(s, btn)[1])[2].actions, [Action::Navigate { dest: b, preserve_scroll: false }]); // Every edit is one undo step. assert!(s.undo().unwrap()); - assert_eq!(blocks(&actions(&s, btn)[1])[2].actions.len(), 2); + assert_eq!(blocks(&actions(s, btn)[1])[2].actions.len(), 2); // Removing the if leaves the else if as the if; removing that leaves only the else: the // conditional goes. - let r = ex(&mut s, "proto.conditional.removeBlock", json!({"id": btn, "index": 0, "path": [1], "block": 0})); + let r = ex(s, "proto.conditional.removeBlock", json!({"id": btn, "index": 0, "path": [1], "block": 0})); assert_eq!(r["removedAction"], false); - let r = ex(&mut s, "proto.conditional.removeBlock", json!({"id": btn, "index": 0, "path": [1], "block": 0})); + let r = ex(s, "proto.conditional.removeBlock", json!({"id": btn, "index": 0, "path": [1], "block": 0})); assert_eq!(r["removedAction"], true); - assert_eq!(actions(&s, btn), [Action::Navigate { dest: b, preserve_scroll: false }]); + assert_eq!(actions(s, btn), [Action::Navigate { dest: b, preserve_scroll: false }]); } #[test] diff --git a/crates/engine/src/selection/selection_colors.rs b/crates/engine/src/selection/selection_colors.rs index 56bc4b35..00c7389f 100644 --- a/crates/engine/src/selection/selection_colors.rs +++ b/crates/engine/src/selection/selection_colors.rs @@ -11,7 +11,7 @@ use serde_json::{Value, json}; use crate::cmd::lists::{BoundList, list_binding, rebind_edited_paint}; use crate::cmd::styles::rebind_paint_alpha; -use crate::params::*; +use crate::params::{get, paint_of, parse, req_string, targets}; use crate::{CommandSpec, Result, Session, bad, spec}; #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index acafd229..a31114e2 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -2,7 +2,10 @@ use super::derived::normalize; use super::doc_state::{DocState, ENDS_PREVIEW, HistoryEntry, Interaction, PREVIEW_READ_ONLY}; use super::error::{EngineError, Result, bad}; use super::prefs::{Clipboard, Prefs, UiRequest}; -use super::*; +use super::{ + Arc, BatchScope, BatchStep, CommandSpec, Document, Node, NodeId, Session, Value, background, board, branches, cmd, components, figcraft_collab, + find_command, guard, json, library, live, params, plugins, textpreview, tools, +}; mod access; mod exec; diff --git a/crates/engine/src/session/access.rs b/crates/engine/src/session/access.rs index af9fd7f5..05bc45d5 100644 --- a/crates/engine/src/session/access.rs +++ b/crates/engine/src/session/access.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DocState, Document, EngineError, NodeId, Result, Session, Value, json}; impl Session { pub fn ds(&self) -> Result<&DocState> { diff --git a/crates/engine/src/session/exec.rs b/crates/engine/src/session/exec.rs index add12ac5..00798b2c 100644 --- a/crates/engine/src/session/exec.rs +++ b/crates/engine/src/session/exec.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Arc, CommandSpec, Document, ENDS_PREVIEW, EngineError, Node, NodeId, PREVIEW_READ_ONLY, Result, Session, Value, bad, board, branches, cmd, + components, find_command, guard, library, normalize, params, textpreview, +}; /// Whether a command, or a batch wrapping one, imports a file passed as Base64 `data`. fn command_carries_import_bytes(id: &str, params: &Value) -> bool { diff --git a/crates/engine/src/session/new.rs b/crates/engine/src/session/new.rs index 8790c83b..96bf0c2d 100644 --- a/crates/engine/src/session/new.rs +++ b/crates/engine/src/session/new.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, Clipboard, DocState, Document, Prefs, Session, background, cmd, library, live, plugins, tools}; impl Session { /// A session with one new document. diff --git a/crates/engine/src/session/undo.rs b/crates/engine/src/session/undo.rs index a354ea27..5fd0ec80 100644 --- a/crates/engine/src/session/undo.rs +++ b/crates/engine/src/session/undo.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Arc, BatchScope, BatchStep, DocState, Document, EngineError, HistoryEntry, Interaction, NodeId, PREVIEW_READ_ONLY, Result, Session, UiRequest, + Value, cmd, figcraft_collab, normalize, textpreview, +}; impl Session { /// How many undo and redo steps the active document has (the user's own in a live session). diff --git a/crates/engine/src/sharing/live/events.rs b/crates/engine/src/sharing/live/events.rs index 5b6008f8..9d851cab 100644 --- a/crates/engine/src/sharing/live/events.rs +++ b/crates/engine/src/sharing/live/events.rs @@ -1,6 +1,9 @@ use super::host::{clean_hosting, clean_shared}; use super::types::{Event, PageInfo, Role, SessionId, Status}; -use super::*; +use super::{ + Access, Arc, Client, ClientMsg, DocState, Document, EngineError, Live, NodeId, PUBLISHING, Peer, Presence, Result, ServerMsg, Session, Signal, + ToolId, VIEW_ONLY, editing_hint, editing_of, sanitize, +}; impl Session { /// The active document (`None`: no document is open). diff --git a/crates/engine/src/sharing/live/host.rs b/crates/engine/src/sharing/live/host.rs index 685ab5c0..06f6df95 100644 --- a/crates/engine/src/sharing/live/host.rs +++ b/crates/engine/src/sharing/live/host.rs @@ -1,5 +1,8 @@ use super::types::{Bind, HostOptions, NetReply, NetRequest, ONLINE_OFF, PublishOptions, Role, SessionId, Share, Shared, SharedLink, Status, Via}; -use super::*; +use super::{ + Access, Admin, AdminReply, ClientMsg, EngineError, HostInfo, IpAddr, Live, Result, Session, Value, bad, code, display_name, get, json, + req_string, string, +}; /// What a server says about its hosting, made safe to show: no control characters, short, and a /// terms address only when it is a web page (`http(s)://`). diff --git a/crates/engine/src/sharing/live/join.rs b/crates/engine/src/sharing/live/join.rs index 972198ef..e08afcc6 100644 --- a/crates/engine/src/sharing/live/join.rs +++ b/crates/engine/src/sharing/live/join.rs @@ -1,6 +1,8 @@ use super::host::{host, hosting_request, manage_file, publish, report, server_info, share, share_answer, share_request, shared_now, unavailable}; use super::types::{Published, Role, SessionId, Share, Status, join_target}; -use super::*; +use super::{ + Admin, AdminReply, CommandSpec, EngineError, Live, Result, Resume, Session, Value, bad, display_name, get, json, num, req_string, spec, string, +}; fn join(s: &mut Session, p: &Value) -> Result { const CMD: &str = "collab.join"; diff --git a/crates/engine/src/sharing/live/sync.rs b/crates/engine/src/sharing/live/sync.rs index f2fd3c54..3920bdca 100644 --- a/crates/engine/src/sharing/live/sync.rs +++ b/crates/engine/src/sharing/live/sync.rs @@ -1,6 +1,9 @@ use super::host::hosting_json; use super::types::{NO_SESSION, SessionId, Share, SharedLink, Status}; -use super::*; +use super::{ + Arc, Client, EditingText, EngineError, EngineRecompute, FollowedView, Live, Result, Session, Value, ViewState, bad, busy, close, code, + editing_of, json, +}; impl Session { /// Your client id in document `uid`'s live session (`None`: it isn't in one). Refused for diff --git a/crates/engine/src/sharing/live/types.rs b/crates/engine/src/sharing/live/types.rs index 16a237d5..efeecac9 100644 --- a/crates/engine/src/sharing/live/types.rs +++ b/crates/engine/src/sharing/live/types.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Access, Admin, AdminReply, Arc, ClientMsg, Code, Document, HostInfo, IpAddr, LibraryReply, LibraryReq, NO_TEAM_LIBRARIES, Peer, PluginReply, + PluginReq, Presence, Resume, Serialize, ServerMsg, Signal, code, +}; /// The server files are shared through online unless Preferences name another: the only place /// its address is written. diff --git a/crates/engine/src/slot_tests.rs b/crates/engine/src/slot_tests.rs index cbf824d9..01a5db08 100644 --- a/crates/engine/src/slot_tests.rs +++ b/crates/engine/src/slot_tests.rs @@ -99,6 +99,28 @@ fn slots_are_frames_of_a_main_with_a_slot_property() { healthy(&s); } +/// `instances_fill_slots_with_their_own_content`: resetting a slot, or the whole instance, restores the default content. +fn reset_slot_content(s: &mut Session, [a, aslot, ph, ichip, chip]: [NodeId; 5]) { + // Reset: back to the main's default content. + ex(s, "instance.resetSlot", json!({"id": aslot})); + healthy(s); + assert!(!slot_edited(s.doc().unwrap(), aslot)); + assert_eq!(kids(s, aslot).len(), 2); + assert_eq!(fill(s, copy_of(s, a, ph)), "0000FF"); + assert!(!s.doc().unwrap().contains(ichip)); + s.undo().unwrap(); + assert!(s.doc().unwrap().contains(ichip)); + healthy(s); + // Reset all changes resets slots too; `replace` clears the default content first. + ex(s, "component.resetInstance", json!({"ids": [a]})); + assert!(!slot_edited(s.doc().unwrap(), aslot)); + let only = idv(&ex(s, "instance.slotInsert", json!({"id": aslot, "component": chip, "replace": true}))); + assert_eq!(kids(s, aslot), vec![only]); + ex(s, "component.resetOverride", json!({"id": aslot, "group": "slot"})); + assert_eq!(kids(s, aslot).len(), 2); + healthy(s); +} + #[test] fn instances_fill_slots_with_their_own_content() { let mut s = Session::new(); @@ -158,23 +180,7 @@ fn instances_fill_slots_with_their_own_content() { // Layers of the instance can't move into its slot either. let atitle = copy_of(&s, a, title); refused(&mut s, "node.reparent", json!({"ids": [atitle], "parent": aslot})); - // Reset: back to the main's default content. - ex(&mut s, "instance.resetSlot", json!({"id": aslot})); - healthy(&s); - assert!(!slot_edited(s.doc().unwrap(), aslot)); - assert_eq!(kids(&s, aslot).len(), 2); - assert_eq!(fill(&s, copy_of(&s, a, ph)), "0000FF"); - assert!(!s.doc().unwrap().contains(ichip)); - s.undo().unwrap(); - assert!(s.doc().unwrap().contains(ichip)); - healthy(&s); - // Reset all changes resets slots too; `replace` clears the default content first. - ex(&mut s, "component.resetInstance", json!({"ids": [a]})); - assert!(!slot_edited(s.doc().unwrap(), aslot)); - let only = idv(&ex(&mut s, "instance.slotInsert", json!({"id": aslot, "component": chip, "replace": true}))); - assert_eq!(kids(&s, aslot), vec![only]); - ex(&mut s, "component.resetOverride", json!({"id": aslot, "group": "slot"})); - assert_eq!(kids(&s, aslot).len(), 2); + reset_slot_content(&mut s, [a, aslot, ph, ichip, chip]); healthy(&s); } diff --git a/crates/engine/src/vector_multi_behaviour_tests.rs b/crates/engine/src/vector_multi_behaviour_tests.rs index 2afaf9a2..ef337af8 100644 --- a/crates/engine/src/vector_multi_behaviour_tests.rs +++ b/crates/engine/src/vector_multi_behaviour_tests.rs @@ -95,6 +95,14 @@ fn selected_in(s: &Session, id: NodeId) -> Vec { #[test] fn editing_several_vectors_at_once() { + several_vectors_enter_edit_mode(); + let (mut s, a, b, atl, btl) = points_across_both_layers(); + arrows_and_drags_move_every_layer(&mut s, a, b, atl, btl); + corner_radius_mirroring_and_delete(&mut s, a, b, atl, btl); + bend_select_all_pen_and_clicks(&mut s, a, b); +} + +fn several_vectors_enter_edit_mode() { // (how edit mode is entered with two shapes selected, derivation) let rows: Vec<(&str, &str)> = vec![ ("command", "the edit command takes every selected shape"), @@ -135,8 +143,9 @@ fn editing_several_vectors_at_once() { crate::vectoredit::enter_targets(s.doc().unwrap(), &[a, f]).is_none(), "silent: a frame in the selection leaves Enter to select its children (pinned)" ); +} - // Points across both layers. +fn points_across_both_layers() -> (Session, NodeId, NodeId, usize, usize) { let mut s = Session::new(); let (a, b) = (rect(&mut s, 0.0), rect(&mut s, 200.0)); ex(&mut s, "vector.edit.begin", json!({"ids": [a, b]})); @@ -161,64 +170,74 @@ fn editing_several_vectors_at_once() { sb.sort_unstable(); assert_eq!((editing(&s).unwrap().id, selected_in(&s, a), selected_in(&s, b)), (active, sa, sb), "{why}"); } + (s, a, b, atl, btl) +} + +fn arrows_and_drags_move_every_layer(s: &mut Session, a: NodeId, b: NodeId, atl: usize, btl: usize) { // Arrows nudge every selected point, one undo step for all layers. - ex(&mut s, "vector.edit.select", json!({"vertices": [atl]})); - ex(&mut s, "vector.edit.select", json!({"id": b, "vertices": [btl], "add": true})); - assert_eq!((selected_in(&s, a), selected_in(&s, b)), (vec![atl], vec![btl]), "the select command picks points in a named layer"); + ex(s, "vector.edit.select", json!({"vertices": [atl]})); + ex(s, "vector.edit.select", json!({"id": b, "vertices": [btl], "add": true})); + assert_eq!((selected_in(s, a), selected_in(s, b)), (vec![atl], vec![btl]), "the select command picks points in a named layer"); assert!(s.tool_key("Right", false, false, false).unwrap()); - assert!(near(page_pos(&s, a, atl), (1.0, 0.0)) && near(page_pos(&s, b, btl), (201.0, 0.0)), "an arrow nudges the points in every layer"); + assert!(near(page_pos(s, a, atl), (1.0, 0.0)) && near(page_pos(s, b, btl), (201.0, 0.0)), "an arrow nudges the points in every layer"); s.undo().unwrap(); - assert!(near(page_pos(&s, a, atl), (0.0, 0.0)) && near(page_pos(&s, b, btl), (200.0, 0.0)), "one undo step for every layer"); + assert!(near(page_pos(s, a, atl), (0.0, 0.0)) && near(page_pos(s, b, btl), (200.0, 0.0)), "one undo step for every layer"); // Dragging one selected point drags them all (one on the transform box's edge: its corners // scale, as with points of one layer). - let atr = point_at(&s, a, (100.0, 0.0)); - ex(&mut s, "vector.edit.select", json!({"id": a, "vertices": [atr], "add": true})); - drag(&mut s, (100.0, 0.0), (110.0, 20.0), NONE); + let atr = point_at(s, a, (100.0, 0.0)); + ex(s, "vector.edit.select", json!({"id": a, "vertices": [atr], "add": true})); + drag(s, (100.0, 0.0), (110.0, 20.0), NONE); assert!( - near(page_pos(&s, a, atl), (10.0, 20.0)) && near(page_pos(&s, a, atr), (110.0, 20.0)) && near(page_pos(&s, b, btl), (210.0, 20.0)), + near(page_pos(s, a, atl), (10.0, 20.0)) && near(page_pos(s, a, atr), (110.0, 20.0)) && near(page_pos(s, b, btl), (210.0, 20.0)), "dragging one selected point moves them all" ); s.undo().unwrap(); - assert!(near(page_pos(&s, a, atl), (0.0, 0.0)) && near(page_pos(&s, b, btl), (200.0, 0.0)), "the drag is one undo step"); - ex(&mut s, "vector.edit.select", json!({"vertices": [atl]})); - ex(&mut s, "vector.edit.select", json!({"id": b, "vertices": [btl], "add": true})); + assert!(near(page_pos(s, a, atl), (0.0, 0.0)) && near(page_pos(s, b, btl), (200.0, 0.0)), "the drag is one undo step"); + ex(s, "vector.edit.select", json!({"vertices": [atl]})); + ex(s, "vector.edit.select", json!({"id": b, "vertices": [btl], "add": true})); +} + +fn corner_radius_mirroring_and_delete(s: &mut Session, a: NodeId, b: NodeId, atl: usize, btl: usize) { // Corner radius, mirroring and deleting reach every layer's selected points. - ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 10})); - assert!(net(&s, a).vertices[atl].radius == 10.0 && net(&s, b).vertices[btl].radius == 10.0, "the corner radius applies to every layer"); - ex(&mut s, "vector.edit.mirroring", json!({"mode": "angle"})); + ex(s, "vector.edit.cornerRadius", json!({"radius": 10})); + assert!(net(s, a).vertices[atl].radius == 10.0 && net(s, b).vertices[btl].radius == 10.0, "the corner radius applies to every layer"); + ex(s, "vector.edit.mirroring", json!({"mode": "angle"})); assert!( - net(&s, a).vertices[atl].mirror == Some(figcraft_doc::HandleMirroring::Angle) - && net(&s, b).vertices[btl].mirror == Some(figcraft_doc::HandleMirroring::Angle), + net(s, a).vertices[atl].mirror == Some(figcraft_doc::HandleMirroring::Angle) + && net(s, b).vertices[btl].mirror == Some(figcraft_doc::HandleMirroring::Angle), "mirroring applies to every layer" ); assert!(s.tool_key("Delete", false, false, false).unwrap()); - assert_eq!((net(&s, a).vertices.len(), net(&s, b).vertices.len()), (3, 3), "Delete removes the selected point in every layer"); + assert_eq!((net(s, a).vertices.len(), net(s, b).vertices.len()), (3, 3), "Delete removes the selected point in every layer"); s.undo().unwrap(); - assert_eq!((net(&s, a).vertices.len(), net(&s, b).vertices.len()), (4, 4), "one undo step"); + assert_eq!((net(s, a).vertices.len(), net(s, b).vertices.len()), (4, 4), "one undo step"); +} + +fn bend_select_all_pen_and_clicks(s: &mut Session, a: NodeId, b: NodeId) { // The Bend tool (held with Cmd/Ctrl) bends the segment under the pointer, in its own layer. - drag(&mut s, (250.0, 100.0), (250.0, 130.0), CMD); - assert!(net(&s, b).segments.iter().any(|sg| !sg.is_straight()), "the bend lands on the layer under the pointer"); - assert!(net(&s, a).segments.iter().all(|sg| sg.is_straight()), "other layers keep their segments"); + drag(s, (250.0, 100.0), (250.0, 130.0), CMD); + assert!(net(s, b).segments.iter().any(|sg| !sg.is_straight()), "the bend lands on the layer under the pointer"); + assert!(net(s, a).segments.iter().all(|sg| sg.is_straight()), "other layers keep their segments"); // Select all takes every edited layer's points. - let r = ex(&mut s, "vector.edit.selectAll", json!({})); + let r = ex(s, "vector.edit.selectAll", json!({})); assert_eq!(r["count"], json!(8), "silent: select all takes the points of every edited layer (pinned)"); // The pen draws in the active layer only. - ex(&mut s, "vector.edit.select", json!({"id": a, "vertices": []})); - ex(&mut s, "vector.edit.tool", json!({"tool": "pen"})); - click(&mut s, (50.0, 300.0), NONE); - assert_eq!((net(&s, a).vertices.len(), net(&s, b).vertices.len()), (5, 4), "silent: the pen adds to the active layer (pinned)"); - ex(&mut s, "vector.edit.penEnd", json!({})); - ex(&mut s, "vector.edit.tool", json!({"tool": "move"})); + ex(s, "vector.edit.select", json!({"id": a, "vertices": []})); + ex(s, "vector.edit.tool", json!({"tool": "pen"})); + click(s, (50.0, 300.0), NONE); + assert_eq!((net(s, a).vertices.len(), net(s, b).vertices.len()), (5, 4), "silent: the pen adds to the active layer (pinned)"); + ex(s, "vector.edit.penEnd", json!({})); + ex(s, "vector.edit.tool", json!({"tool": "move"})); // A click on empty canvas inside an edited layer's area keeps edit mode; so does one far // away, which only clears the points (a double-click or Escape leaves). - click(&mut s, (250.0, 50.0), NONE); - assert!(editing(&s).is_some(), "a click inside an edited layer clears the points and stays"); - let st = ex(&mut s, "vector.edit.state", json!({})); + click(s, (250.0, 50.0), NONE); + assert!(editing(s).is_some(), "a click inside an edited layer clears the points and stays"); + let st = ex(s, "vector.edit.state", json!({})); assert_eq!(st["layers"].as_array().map(Vec::len), Some(2), "the state lists every edited layer"); - click(&mut s, (900.0, 900.0), NONE); - assert!(editing(&s).is_some(), "a click away from every edited layer keeps edit mode"); + click(s, (900.0, 900.0), NONE); + assert!(editing(s).is_some(), "a click away from every edited layer keeps edit mode"); s.pointer(&ev(PointerKind::DoubleClick, (900.0, 900.0), NONE)).unwrap(); - assert!(editing(&s).is_none(), "a double-click away from every edited layer leaves edit mode"); + assert!(editing(s).is_none(), "a double-click away from every edited layer leaves edit mode"); } #[test] diff --git a/crates/engine/src/view_state.rs b/crates/engine/src/view_state.rs index e162f039..54102d6b 100644 --- a/crates/engine/src/view_state.rs +++ b/crates/engine/src/view_state.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; /// The canvas view of a document: what's at the centre of the viewport and the zoom. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]