Files
figcraft/crates/collab/src/comment_access_tests.rs
T
BFlat e7ae0f4426 M9: share links can comment: a comment access level beside edit and view (#1525) (#1576)
Someone who joins with a comment link (or its join code) sees the file live and adds, replies to, reacts to and resolves comment threads, but can't change the design: their app refuses other edits and the server accepts a comment link's batch only when every change in it touches comment threads. Sharing online makes a comment link and code beside the edit and view ones; the Share popover offers Can edit, Can comment and Can view, and the commenter's toolbar shows a Can comment badge with Move, Hand and Comment.

Backward compatible: clients announce the comments word; one that doesn't is welcomed with a comment link as a viewer and gets comment links listed as view links, a level this build doesn't know reads as view, and an older server that can't make a comment link leaves the share without one.
2026-10-30 07:29:40 +07:00

201 lines
8.5 KiB
Rust

//! Comment links: a commenter sees everything and may change comment threads (add, reply, react,
//! resolve, remove, reorder), but a batch that changes anything else is refused with the reason
//! and rolled back; a client that can't read comment links joins with one as a viewer, managing
//! clients that can't read them get them listed as view links, and a level this build doesn't know
//! reads as view.
use figcraft_doc::{Comment, Document, Message, NodeKind};
use crate::change::{Change, DocProp, Entry, Json, Prop};
use crate::message::{
self, Access, AdminReply, ClientMsg, Hello, LinkInfo, NewLink, ServerMsg, WIRE_VERSION, decode, decode_json, encode, encode_json,
};
use crate::sim_tests::base;
use crate::{COMMENT_ONLY, Client, Limits, NoRecompute, Outgoing, Server};
fn hello(client_version: &str) -> Hello {
let ClientMsg::Hello(mut h) = Client::hello("f", "", "Ada", "#FF8800") else { panic!("not a hello") };
h.client_version = client_version.into();
h
}
fn join_as(s: &mut Server, access: Access, client_version: &str) -> Client {
let (_, out) = s.join_as(hello(client_version), access).unwrap();
let Some(Outgoing::To(_, welcome)) = out.into_iter().next() else { panic!("no welcome") };
Client::join(welcome).unwrap()
}
/// `c` edits its copy with `f`; the server's answer, delivered to everyone in `all`.
fn send(s: &mut Server, c: &mut Client, f: impl FnOnce(&mut Document), all: &mut [&mut Client]) -> Vec<Outgoing> {
let mut d = c.document().clone();
f(&mut d);
let m = c.edit(&d).unwrap().expect("a change");
let out = s.receive(c.id(), m);
for o in &out {
let m = match o {
Outgoing::All(m) => m,
Outgoing::To(t, m) if *t == c.id() => m,
_ => continue,
};
c.receive(m.clone(), &NoRecompute).unwrap();
for other in all.iter_mut() {
if matches!(o, Outgoing::All(_)) {
other.receive(m.clone(), &NoRecompute).unwrap();
}
}
}
out
}
fn accepted(out: &[Outgoing]) -> bool {
matches!(out, [Outgoing::All(ServerMsg::Changes(_))])
}
fn refused(out: &[Outgoing]) -> bool {
matches!(out, [Outgoing::To(_, ServerMsg::Reject { reason, .. })] if reason == COMMENT_ONLY)
}
fn comment(id: u64, d: &Document, text: &str) -> Comment {
let page = d.first_page().unwrap();
Comment { id, page, x: 10.0, y: 20.0, anchor: None, first: Message::new("Cy", text, 0), resolved: false, replies: vec![], read_by: None }
}
#[test]
fn a_commenter_changes_comment_threads_and_nothing_else() {
let mut s = Server::new(base(), "", Limits::default());
let mut editor = join_as(&mut s, Access::Edit, &message::client_version());
let mut cy = join_as(&mut s, Access::Comment, &message::client_version());
assert_eq!(cy.access(), Access::Comment, "the welcome says what the client may do");
let page = s.document().first_page().unwrap();
// A new thread reaches everyone; so do a reply, a resolve and a reaction to it.
let out = send(
&mut s,
&mut cy,
|d| {
let id = d.new_comment_id();
let c = comment(id, d, "Bigger logo?");
d.comments.push(c);
},
&mut [&mut editor],
);
assert!(accepted(&out), "{out:?}");
assert_eq!(editor.document().comments.len(), 1);
let out = send(
&mut s,
&mut cy,
|d| {
let c = &mut d.comments[0];
c.replies.push(Message::new("Cy", "Or a darker one", 1));
c.resolved = true;
c.first.react("👍", "Cy", None);
},
&mut [&mut editor],
);
assert!(accepted(&out), "{out:?}");
assert_eq!(s.document().comments[0].replies.len(), 1);
assert!(s.document().comments[0].resolved);
// A layer, a page or any other change is refused with the reason and rolled back.
let seq = s.seq();
assert!(refused(&send(&mut s, &mut cy, |d| d.node_mut(page).unwrap().name = "Mine".into(), &mut [])));
assert!(refused(&send(
&mut s,
&mut cy,
|d| {
d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 10.0, 10.0).unwrap();
},
&mut []
)));
assert!(refused(&send(&mut s, &mut cy, |d| d.name = "Renamed".into(), &mut [])));
// So is a batch that sneaks an edit in beside a comment.
assert!(refused(&send(
&mut s,
&mut cy,
|d| {
d.comments[0].resolved = false;
d.node_mut(page).unwrap().name = "Mine".into();
},
&mut []
)));
assert_eq!(s.seq(), seq, "nothing accepted");
assert_eq!(cy.view(), s.replica(), "the commenter's copy is the server's again");
assert!(s.document().comments[0].resolved);
// Removing a thread is a comment change too.
assert!(accepted(&send(&mut s, &mut cy, |d| d.comments.clear(), &mut [&mut editor])));
assert!(editor.document().comments.is_empty());
// The editor still edits.
assert!(accepted(&send(&mut s, &mut editor, |d| d.node_mut(page).unwrap().name = "Home".into(), &mut [&mut cy])));
assert_eq!(cy.document().node(page).unwrap().name, "Home");
}
#[test]
fn a_client_that_cant_read_comment_links_joins_with_one_as_a_viewer() {
let mut s = Server::new(base(), "", Limits::default());
let mut old = join_as(&mut s, Access::Comment, "0.12.1 signals hosting");
assert_eq!(old.access(), Access::View);
let page = s.document().first_page().unwrap();
let out = send(
&mut s,
&mut old,
|d| {
let id = d.new_comment_id();
let c = comment(id, d, "Hi");
d.comments.push(c);
},
&mut [],
);
assert!(matches!(out.as_slice(), [Outgoing::To(_, ServerMsg::Reject { reason, .. })] if reason == crate::VIEW_ONLY), "{out:?}");
// Edit and view links are as they were for it.
assert_eq!(join_as(&mut s, Access::Edit, "0.12.1").access(), Access::Edit);
assert_eq!(join_as(&mut s, Access::View, "0.12.1").access(), Access::View);
assert!(s.document().node(page).is_some());
}
#[test]
fn only_comment_thread_changes_count_as_comments() {
let d = base();
let c = comment(7, &d, "x");
let value = Json::of(&c).unwrap();
let page = d.first_page().unwrap();
let comments = [
Change::Put { entry: Entry::Comment(7), value: value.clone() },
Change::Remove { entry: Entry::Comment(7) },
Change::SetDoc { prop: DocProp::CommentOrder, value: Json::of(&[7u64]).unwrap() },
Change::SetDoc { prop: DocProp::Comments, value: Json::of(&[c]).unwrap() },
];
assert!(comments.iter().all(Change::is_comment));
let others = [
Change::Put { entry: Entry::Style("S:1".into()), value: value.clone() },
Change::Remove { entry: Entry::Image("a".into()) },
Change::SetDoc { prop: DocProp::Name, value: Json::of("x").unwrap() },
Change::SetDoc { prop: DocProp::StyleOrder, value: Json::of(&["S:1"]).unwrap() },
Change::Set { id: page, prop: Prop::Name, value: Json::of("x").unwrap() },
Change::Delete { id: page },
];
assert!(!others.iter().any(Change::is_comment));
}
#[test]
fn comment_access_reads_back_and_an_unknown_level_reads_as_view() {
assert_eq!(Access::parse(" Comment "), Some(Access::Comment));
assert_eq!(Access::Comment.id(), "comment");
for a in [Access::Edit, Access::View, Access::Comment] {
assert_eq!(decode::<Access>(&encode(&a).unwrap(), 64).unwrap(), a);
assert_eq!(decode_json::<Access>(&encode_json(&a).unwrap(), 64).unwrap(), a);
}
// A level from a newer build.
assert_eq!(decode::<Access>(&[WIRE_VERSION, 9], 64).unwrap(), Access::View);
assert_eq!(decode_json::<Access>("\"owner\"", 64).unwrap(), Access::View);
}
#[test]
fn managing_clients_that_cant_read_comment_links_get_view_links() {
let link = |access| LinkInfo { id: "l".into(), file: "f".into(), access, label: String::new(), created: 0 };
let listing = AdminReply::Links(vec![link(Access::Edit), link(Access::Comment), link(Access::View)]);
let AdminReply::Links(old) = listing.clone().for_reader(false) else { panic!() };
assert_eq!(old.iter().map(|l| l.access).collect::<Vec<_>>(), [Access::Edit, Access::View, Access::View]);
assert_eq!(listing.clone().for_reader(true), listing);
let made = AdminReply::Link(NewLink { link: link(Access::Comment), token: "t".into() });
assert!(matches!(made.for_reader(false), AdminReply::Link(l) if l.link.access == Access::View));
assert!(message::reads_comments(&message::client_version()) && !message::reads_comments("0.12.1 signals hosting"));
}