figcraft/crates/collab-net/src/session_tests.rs

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());
}