252 lines
12 KiB
Rust
252 lines
12 KiB
Rust
//! Two engine sessions in one process, linked over loopback through `collab.*` commands: hosting,
|
|
//! joining with the invite, presence in each other's live state, following, leaving.
|
|
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use figcraft_doc::NodeId;
|
|
use figcraft_engine::Session;
|
|
use figcraft_engine::live::Status;
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::NetLink;
|
|
use crate::loopback_tests::quick;
|
|
|
|
fn session(name: &str) -> Session {
|
|
let mut s = Session::new();
|
|
s.live.install(Box::new(NetLink::new(Arc::new(|| {})).with_guest_config(quick)));
|
|
s.execute("pref.userName", &json!({"name": name})).unwrap();
|
|
s
|
|
}
|
|
|
|
/// Pump both sessions (as their UIs would every frame) until `f` holds.
|
|
fn pump(a: &mut Session, b: &mut Session, what: &str, f: impl Fn(&Session, &Session) -> bool) {
|
|
let start = Instant::now();
|
|
loop {
|
|
a.live_poll();
|
|
b.live_poll();
|
|
a.live_sync();
|
|
b.live_sync();
|
|
if f(a, b) {
|
|
return;
|
|
}
|
|
assert!(start.elapsed() < Duration::from_secs(10), "timed out waiting for {what}: {} / {}", a.live_summary(), b.live_summary());
|
|
std::thread::sleep(Duration::from_millis(5));
|
|
}
|
|
}
|
|
|
|
fn host_and_join() -> (Session, Session, u32, u32) {
|
|
let mut host = session("Hana");
|
|
let mut guest = session("Ada");
|
|
let r = host.execute("collab.host", &json!({"port": 0})).unwrap();
|
|
assert_eq!(r["status"], "live");
|
|
assert_eq!(r["role"], "host");
|
|
assert_eq!(r["network"], false, "shared on this machine only by default");
|
|
let link = r["link"].as_str().unwrap().to_string();
|
|
assert!(link.starts_with("figcraft://join?host=127.0.0.1:"), "{link}");
|
|
let r = guest.execute("collab.join", &json!({"link": link})).unwrap();
|
|
assert_eq!(r["status"], "connecting");
|
|
pump(&mut host, &mut guest, "the join", |h, g| {
|
|
g.live().status == Status::Live && h.live().peers.len() == 1 && g.live().info.as_ref().is_some_and(|i| !i.color.is_empty())
|
|
});
|
|
let hana = host.live().info.as_ref().unwrap().me;
|
|
let ada = guest.live().info.as_ref().unwrap().me;
|
|
(host, guest, hana, ada)
|
|
}
|
|
|
|
#[test]
|
|
fn host_join_and_see_each_other() {
|
|
let (mut host, mut guest, hana, ada) = host_and_join();
|
|
let g = guest.live_summary();
|
|
assert_eq!(g["role"], "guest");
|
|
assert_eq!(g["fileName"], "Untitled", "the host's file name");
|
|
assert_eq!(g["name"], "Ada");
|
|
assert_eq!(g["peers"][0]["name"], "Hana");
|
|
assert_eq!(host.live().peers[&ada].name, "Ada");
|
|
// The pointer: the guest's cursor shows up in the host's state.
|
|
guest.execute("collab.cursor", &json!({"x": 120, "y": 80})).unwrap();
|
|
pump(&mut host, &mut guest, "the guest's cursor", |h, _| {
|
|
h.live().presence.get(&ada).and_then(|p| p.cursor).is_some_and(|c| c.1 == 120.0 && c.2 == 80.0)
|
|
});
|
|
// A selection travels too.
|
|
let r = host.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
|
|
let id = r["id"].as_u64().unwrap();
|
|
host.execute("select.set", &json!({"ids": [id]})).unwrap();
|
|
pump(&mut host, &mut guest, "the host's selection", |_, g| {
|
|
g.live().presence.get(&hana).and_then(|p| p.selection.clone()).is_some_and(|s| s == vec![NodeId(id)])
|
|
});
|
|
// Leaving: the host sees it, the guest is back to no session.
|
|
assert_eq!(guest.execute("collab.leave", &json!({})).unwrap()["ended"], true);
|
|
assert_eq!(guest.live().status, Status::Off);
|
|
pump(&mut host, &mut guest, "the leave", |h, _| h.live().peers.is_empty() && h.live().presence.is_empty());
|
|
host.execute("collab.stop", &json!({})).unwrap();
|
|
assert_eq!(host.live_summary()["status"], "off");
|
|
}
|
|
|
|
#[test]
|
|
fn following_a_viewport_until_you_move() {
|
|
let (mut host, mut guest, hana, ada) = host_and_join();
|
|
host.execute("collab.follow", &json!({"client": ada})).unwrap();
|
|
assert_eq!(host.live().follow, Some(ada));
|
|
// The guest pans and zooms: the host's view goes along.
|
|
{
|
|
let ds = guest.ds_mut().unwrap();
|
|
ds.view.cx = 5000.0;
|
|
ds.view.cy = -300.0;
|
|
ds.view.zoom = 2.0;
|
|
}
|
|
pump(&mut host, &mut guest, "the follow", |h, _| {
|
|
(h.ds().unwrap().view.cx - 5000.0).abs() < 1e-6 && (h.ds().unwrap().view.cy + 300.0).abs() < 1e-6
|
|
});
|
|
let (hv, gv) = (host.ds().unwrap().view.clone(), guest.ds().unwrap().view.clone());
|
|
assert!((hv.zoom - gv.zoom).abs() < 1e-9, "same size viewports, same zoom");
|
|
// The host pans: following stops, and later moves of the guest don't move the host.
|
|
host.ds_mut().unwrap().view.cx += 100.0;
|
|
host.live_sync();
|
|
assert_eq!(host.live().follow, None);
|
|
guest.ds_mut().unwrap().view.cx = -9000.0;
|
|
pump(&mut host, &mut guest, "the guest's new viewport at the host", |h, _| {
|
|
h.live().presence.get(&ada).and_then(|p| p.viewport).is_some_and(|v| (v.1 + v.3 / 2.0 + 9000.0).abs() < 1e-6)
|
|
});
|
|
assert!((host.ds().unwrap().view.cx - 5100.0).abs() < 1e-6);
|
|
// Escape stops following too; following someone who isn't there fails.
|
|
guest.execute("collab.follow", &json!({"client": hana})).unwrap();
|
|
guest.escape().unwrap();
|
|
assert_eq!(guest.live().follow, None);
|
|
assert!(guest.execute("collab.follow", &json!({"client": 999})).is_err());
|
|
// The followed peer leaving ends the follow.
|
|
host.execute("collab.follow", &json!({"client": ada})).unwrap();
|
|
guest.execute("collab.leave", &json!({})).unwrap();
|
|
pump(&mut host, &mut guest, "the leave", |h, _| h.live().follow.is_none() && h.live().peers.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn the_host_stopping_ends_the_session_for_guests() {
|
|
let (mut host, mut guest, _, _) = host_and_join();
|
|
host.execute("collab.stop", &json!({})).unwrap();
|
|
pump(&mut host, &mut guest, "the end", |_, g| g.live().status == Status::Off);
|
|
assert!(guest.live().peers.is_empty());
|
|
assert_eq!(guest.message.as_deref(), Some(crate::host::ENDED));
|
|
}
|
|
|
|
#[test]
|
|
fn a_bad_invite_is_reported() {
|
|
let mut guest = session("Ada");
|
|
let e = guest.execute("collab.join", &json!({"link": "figcraft://join?host=nope"})).unwrap_err().to_string();
|
|
assert!(e.contains("invite"), "{e}");
|
|
let mut host = session("Hana");
|
|
let r: Value = host.execute("collab.host", &json!({"port": 0})).unwrap();
|
|
let link = r["link"].as_str().unwrap().replace("token=", "token=x");
|
|
guest.execute("collab.join", &json!({"link": link})).unwrap();
|
|
pump(&mut host, &mut guest, "the refusal", |_, g| matches!(&g.live().status, Status::Failed { reason } if reason.contains("token")));
|
|
assert!(host.live().peers.is_empty());
|
|
}
|
|
|
|
/// The synced properties of two documents agree (nothing to send from one to the other).
|
|
fn same(a: &figcraft_doc::Document, b: &figcraft_doc::Document) -> bool {
|
|
figcraft_collab::diff(&figcraft_collab::Replica::new(a.clone()), b).is_ok_and(|d| d.changes.is_empty())
|
|
}
|
|
|
|
fn id_of(v: &Value) -> u64 {
|
|
v["id"].as_u64().unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn edits_sync_between_apps() {
|
|
let mut host = session("Hana");
|
|
host.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 80, "height": 80})).unwrap();
|
|
let link = host.execute("collab.host", &json!({"port": 0})).unwrap()["link"].as_str().unwrap().to_string();
|
|
let mut a = session("Ada");
|
|
let mut b = session("Bo");
|
|
a.execute("collab.join", &json!({"link": link})).unwrap();
|
|
b.execute("collab.join", &json!({"link": link})).unwrap();
|
|
let all = |host: &mut Session, a: &mut Session, b: &mut Session, what: &str, f: &dyn Fn(&Session, &Session, &Session) -> bool| {
|
|
let start = Instant::now();
|
|
loop {
|
|
for s in [&mut *host, &mut *a, &mut *b] {
|
|
s.live_poll();
|
|
s.live_sync();
|
|
}
|
|
if f(host, a, b) {
|
|
return;
|
|
}
|
|
assert!(start.elapsed() < Duration::from_secs(10), "timed out waiting for {what}");
|
|
std::thread::sleep(Duration::from_millis(5));
|
|
}
|
|
};
|
|
all(&mut host, &mut a, &mut b, "everyone syncing", &|h, a, b| {
|
|
h.live().syncing() && a.live().syncing() && b.live().syncing() && h.live().peers.len() == 2
|
|
});
|
|
// The guests got the host's file as a new tab.
|
|
assert_eq!(a.docs.len(), 2);
|
|
assert!(same(host.doc().unwrap(), a.doc().unwrap()) && same(host.doc().unwrap(), b.doc().unwrap()));
|
|
// Ada creates, moves, recolours; makes and deletes an ellipse; Bo renames; the host adds text.
|
|
let rect = id_of(&a.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 50, "height": 40})).unwrap());
|
|
a.live_sync();
|
|
a.execute("node.set", &json!({"ids": [rect], "props": {"x": 300, "y": 120}})).unwrap();
|
|
a.live_sync();
|
|
a.execute("fill.set", &json!({"ids": [rect], "color": "#FF0000"})).unwrap();
|
|
let ellipse = id_of(&a.execute("shape.ellipse", &json!({"x": 0, "y": 200, "width": 30, "height": 30})).unwrap());
|
|
a.live_sync();
|
|
a.execute("edit.delete", &json!({"ids": [ellipse]})).unwrap();
|
|
all(&mut host, &mut a, &mut b, "Ada's rectangle at Bo", &|_, _, b| b.doc().unwrap().contains(figcraft_doc::NodeId(rect)));
|
|
b.execute("node.set", &json!({"ids": [rect], "props": {"name": "Card"}})).unwrap();
|
|
let text = id_of(&host.execute("text.create", &json!({"x": 10, "y": 300, "text": "Hello"})).unwrap());
|
|
all(&mut host, &mut a, &mut b, "everyone agreeing", &|h, a, b| {
|
|
let (hd, ad, bd) = (h.doc().unwrap(), a.doc().unwrap(), b.doc().unwrap());
|
|
same(hd, ad)
|
|
&& same(hd, bd)
|
|
&& ad.contains(figcraft_doc::NodeId(text))
|
|
&& hd.node(figcraft_doc::NodeId(rect)).is_some_and(|n| n.name == "Card")
|
|
});
|
|
let (hd, ad) = (host.doc().unwrap().clone(), a.doc().unwrap().clone());
|
|
let n = ad.node(figcraft_doc::NodeId(rect)).unwrap();
|
|
assert_eq!(n.name, "Card");
|
|
assert!(!ad.contains(figcraft_doc::NodeId(ellipse)), "the deleted ellipse is gone everywhere");
|
|
assert!(!b.doc().unwrap().contains(figcraft_doc::NodeId(ellipse)));
|
|
assert_eq!(hd.node(figcraft_doc::NodeId(rect)).unwrap().fills, n.fills, "the colour came across");
|
|
// And the server's: someone joining now gets its document in the welcome.
|
|
let mut c = session("Cy");
|
|
c.execute("collab.join", &json!({"link": link})).unwrap();
|
|
all(&mut host, &mut a, &mut c, "the late joiner", &|_, _, c| c.live().syncing());
|
|
assert!(same(&hd, c.doc().unwrap()), "the server's document is everyone's");
|
|
host.execute("collab.stop", &json!({})).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn cursor_chat_and_spotlight_between_apps() {
|
|
let (mut host, mut guest, hana, ada) = host_and_join();
|
|
pump(&mut host, &mut guest, "both told signals work", |h, g| h.live().signals && g.live().signals);
|
|
// The guest chats next to its cursor; the host sees it, cleaned, and then sees it close.
|
|
guest.execute("collab.cursor", &json!({"x": 40, "y": 30})).unwrap();
|
|
guest.execute("collab.cursorChat", &json!({"text": "over\there"})).unwrap();
|
|
pump(&mut host, &mut guest, "the chat", |h, _| h.live().chats.get(&ada).is_some_and(|t| t == "over here"));
|
|
guest.execute("collab.cursorChat", &json!({"text": ""})).unwrap();
|
|
pump(&mut host, &mut guest, "the chat closing", |h, _| h.live().chats.is_empty());
|
|
// The host spotlights: the guest follows its view, page included.
|
|
let second = host.execute("page.add", &json!({"name": "Two"})).unwrap()["id"].as_u64().map(NodeId).unwrap();
|
|
pump(&mut host, &mut guest, "the new page at the guest", |_, g| g.doc().unwrap().pages.contains(&second));
|
|
assert_eq!(host.execute("collab.spotlight", &json!({})).unwrap()["spotlighting"], true);
|
|
{
|
|
let ds = host.ds_mut().unwrap();
|
|
ds.page = second;
|
|
ds.view.cx = 777.0;
|
|
ds.view.cy = -55.0;
|
|
}
|
|
pump(&mut host, &mut guest, "the guest following the spotlight", |_, g| {
|
|
let ds = g.ds().unwrap();
|
|
g.live().spotlight == Some(hana) && g.live().follow == Some(hana) && ds.page == second && (ds.view.cx - 777.0).abs() < 1e-6
|
|
});
|
|
// A late joiner is told and follows too.
|
|
let mut late = session("Lin");
|
|
late.execute("collab.join", &json!({"link": host.live().info.as_ref().unwrap().link.clone()})).unwrap();
|
|
pump(&mut host, &mut late, "the late joiner following", |_, l| l.live().follow == Some(hana) && l.live().spotlight == Some(hana));
|
|
// The guest moves: it stops following, the spotlight stays; the host stops it for everyone.
|
|
guest.ds_mut().unwrap().view.cx += 300.0;
|
|
guest.live_sync();
|
|
assert_eq!((guest.live().follow, guest.live().spotlight), (None, Some(hana)));
|
|
host.execute("collab.spotlight", &json!({"on": false})).unwrap();
|
|
pump(&mut host, &mut guest, "the spotlight ending", |_, g| g.live().spotlight.is_none());
|
|
pump(&mut host, &mut late, "the spotlight ending for the late joiner", |_, l| l.live().spotlight.is_none() && l.live().follow.is_none());
|
|
}
|