mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 20:36:55 -06:00
refactor(engine): simplify session and test helpers
This commit is contained in:
@@ -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,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,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,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::NodeId;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum EngineError {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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}));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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).
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{DocState, Document, EngineError, NodeId, Result, Session, Value, json};
|
||||
|
||||
impl Session {
|
||||
pub fn ds(&self) -> Result<&DocState> {
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)://`).
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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)]
|
||||
|
||||
Reference in New Issue
Block a user