refactor(engine): simplify session and test helpers

This commit is contained in:
Dylan M. Taylor
2026-10-28 16:00:36 +00:00
parent d87f9effe1
commit e98b97de2b
24 changed files with 307 additions and 190 deletions
+32 -19
View File
@@ -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": "<Card title={title} dotted={Show dot} />"}),
);
assert_eq!(code(&mut s, i, "leptos"), "use crate::ui::Card;\n\n<Card title={\"Say \\\"hi\\\"\"} dotted={false} />\n");
assert_eq!(code(s, i, "leptos"), "use crate::ui::Card;\n\n<Card title={\"Say \\\"hi\\\"\"} dotted={false} />\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("<Card title=\"Hello\" dot={true} onClick={onClick}>•</Card>\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("<Card title=\"Hello\" dot={true} onClick={onClick}>•</Card>\n"));
// Literal braces stay; fields left out keep their value.
ex(&mut s, "codeConnect.set", json!({"id": c, "template": "<Card style={{gap: 4}} label={title} />"}));
assert_eq!(code(&mut s, c, "react"), "import { Card } from \"@acme/ui\";\n\n<Card style={{gap: 4}} label=\"Hello\" />\n");
ex(s, "codeConnect.set", json!({"id": c, "template": "<Card style={{gap: 4}} label={title} />"}));
assert_eq!(code(s, c, "react"), "import { Card } from \"@acme/ui\";\n\n<Card style={{gap: 4}} label=\"Hello\" />\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");
}
+1 -1
View File
@@ -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<NodeId> {
+1 -1
View File
@@ -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)]
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::NodeId;
#[derive(Debug, thiserror::Error)]
pub enum EngineError {
+12
View File
@@ -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"));
+62 -46
View File
@@ -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}));
+20 -12
View File
@@ -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<Mutex<Script>>, 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<Mutex<Script>>, 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::<Vec<_>>();
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);
+34 -24
View File
@@ -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<Mutex<Script>>) {
// 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<Mutex<Script>>) {
// 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!({}));
+1 -1
View File
@@ -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).
+30 -20
View File
@@ -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]
@@ -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)]
+4 -1
View File
@@ -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;
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{DocState, Document, EngineError, NodeId, Result, Session, Value, json};
impl Session {
pub fn ds(&self) -> Result<&DocState> {
+4 -1
View File
@@ -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 {
+1 -1
View File
@@ -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.
+4 -1
View File
@@ -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).
+4 -1
View File
@@ -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).
+4 -1
View File
@@ -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)://`).
+3 -1
View File
@@ -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<Value> {
const CMD: &str = "collab.join";
+4 -1
View File
@@ -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
+4 -1
View File
@@ -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.
+23 -17
View File
@@ -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);
}
@@ -95,6 +95,14 @@ fn selected_in(s: &Session, id: NodeId) -> Vec<usize> {
#[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]
+1 -1
View File
@@ -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)]