//! Comments: add, reply, edit, react, resolve, read state, delete, find and reveal comment threads. //! //! Messages are addressed by `reply` (an index into the replies; omitted: the thread's first //! message). `author` is who is acting (default: the name from Preferences, `pref.userName`): only //! a message's author edits or deletes it, and only the thread's author deletes the thread. use figcraft_doc::*; use serde_json::{Value, json}; use super::{CommandSpec, ok}; use crate::params::*; use crate::{Session, bad, spec}; /// The name comments are signed with until the user sets theirs. pub const DEFAULT_AUTHOR: &str = "You"; /// Links to a thread: `figcraft://comment/`. const LINK_PREFIX: &str = "figcraft://comment/"; /// Longest accepted reaction (bytes): one emoji with its modifiers. const MAX_EMOJI: usize = 32; pub fn now_secs() -> u64 { web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) } /// A thread's link (`comment.open` takes it). pub fn link(id: u64) -> String { format!("{LINK_PREFIX}{id}") } /// The thread id in a link (or a bare id). pub fn parse_link(s: &str) -> Option { let s = s.trim(); s.strip_prefix(LINK_PREFIX).unwrap_or(s).trim_end_matches('/').parse().ok() } /// Which threads a list shows. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Filter { All, /// Not resolved. Open, Resolved, /// Threads you wrote in or are @mentioned in. Mine, Unread, } impl Filter { pub const ALL: [Filter; 5] = [Filter::Open, Filter::Resolved, Filter::Mine, Filter::Unread, Filter::All]; pub fn id(self) -> &'static str { match self { Filter::All => "all", Filter::Open => "open", Filter::Resolved => "resolved", Filter::Mine => "mine", Filter::Unread => "unread", } } pub fn label(self) -> &'static str { match self { Filter::All => "All comments", Filter::Open => "Open", Filter::Resolved => "Resolved", Filter::Mine => "Only your threads", Filter::Unread => "Unread", } } pub fn parse(s: &str) -> Option { Self::ALL.into_iter().find(|f| f.id().eq_ignore_ascii_case(s.trim())) } pub fn keeps(self, c: &Comment, user: &str) -> bool { match self { Filter::All => true, Filter::Open => !c.resolved, Filter::Resolved => c.resolved, Filter::Mine => c.involves(user), Filter::Unread => c.is_unread(user), } } } /// How a list is ordered. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Sort { /// Latest activity first. #[default] Date, /// By page (in the file's page order), then latest activity first. Page, } impl Sort { pub const ALL: [Sort; 2] = [Sort::Date, Sort::Page]; pub fn id(self) -> &'static str { match self { Sort::Date => "date", Sort::Page => "page", } } pub fn label(self) -> &'static str { match self { Sort::Date => "Sort by date", Sort::Page => "Sort by page", } } pub fn parse(s: &str) -> Option { Self::ALL.into_iter().find(|f| f.id().eq_ignore_ascii_case(s.trim())) } } /// What a comment list shows (the Comments panel, the canvas pins, `comment.list`). #[derive(Clone, Debug)] pub struct Query<'a> { /// Only this page (None: every page). pub page: Option, /// Only comments on this top-level frame. pub frame: Option, pub filter: Filter, pub sort: Sort, /// Search text (messages, names, the frame's name), any case. pub text: &'a str, } impl Default for Query<'_> { fn default() -> Self { Self { page: None, frame: None, filter: Filter::All, sort: Sort::Date, text: "" } } } /// The comments `q` asks for, as `user` sees them, in order. pub fn query<'a>(doc: &'a Document, q: &Query, user: &str) -> Vec<&'a Comment> { let needle = q.text.trim().to_lowercase(); let mut out: Vec<&Comment> = doc .comments .iter() .filter(|c| doc.pages.contains(&c.page)) .filter(|c| q.page.is_none_or(|p| c.page == p)) .filter(|c| q.filter.keeps(c, user)) .filter(|c| q.frame.is_none_or(|f| frame_of(doc, c) == Some(f))) .filter(|c| needle.is_empty() || matches_text(doc, c, &needle)) .collect(); let page_index = |c: &Comment| doc.pages.iter().position(|p| *p == c.page).unwrap_or(usize::MAX); match q.sort { Sort::Date => out.sort_by_key(|c| (std::cmp::Reverse(c.last_activity()), std::cmp::Reverse(c.id))), Sort::Page => out.sort_by_key(|c| (page_index(c), std::cmp::Reverse(c.last_activity()), std::cmp::Reverse(c.id))), } out } fn matches_text(doc: &Document, c: &Comment, needle: &str) -> bool { let has = |s: &str| s.to_lowercase().contains(needle); c.messages().any(|m| has(&m.text) || has(&m.author)) || frame_of(doc, c).and_then(|f| doc.node(f)).is_some_and(|n| has(&n.name)) } /// The top-level frame (or section) a comment is on: its anchor's, else the one under its pin. pub fn frame_of(doc: &Document, c: &Comment) -> Option { if let Some(a) = c.anchor.filter(|a| doc.contains(*a)) { return top_level_of(doc, a).filter(|t| is_frame_or_section(doc, *t)); } let (x, y) = c.position(doc); let page = doc.node(c.page)?; page.children.iter().rev().copied().find(|f| { is_frame_or_section(doc, *f) && doc.node(*f).is_some_and(|n| n.visible) && doc.world_bounds(*f).is_some_and(|b| b.contains(geom::Point::new(x, y))) }) } fn is_frame_or_section(doc: &Document, id: NodeId) -> bool { doc.node(id).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) } /// The page child that holds `id` (or `id` itself at the top level). fn top_level_of(doc: &Document, id: NodeId) -> Option { let mut chain = vec![id]; chain.extend(doc.ancestors(id)); let page = chain.iter().position(|c| doc.node(*c).is_some_and(Node::is_page))?; chain.get(page.checked_sub(1)?).copied() } /// After an edit: pins move to the page their layer moved to (with a parent, too), and a pin whose /// layer was deleted stays where it was, pinned to the nearest surviving parent (or to the page). pub fn follow_anchors(doc: &mut Document, before: &Document) { let mut moves: Vec<(usize, Comment)> = Vec::new(); for (i, c) in doc.comments.iter().enumerate() { let Some(a) = c.anchor else { continue }; let mut next = c.clone(); if doc.contains(a) { match doc.page_of(a) { Some(pg) if pg != c.page => next.page = pg, _ => continue, } } else { if before.node(a).is_none() { continue; } let pt = before.world_transform(a) * geom::Point::new(c.x, c.y); let parent = before.ancestors(a).into_iter().find(|p| doc.node(*p).is_some_and(|n| !n.is_page()) && doc.page_of(*p) == Some(c.page)); let (x, y) = local(doc, parent, pt.x, pt.y); (next.x, next.y, next.anchor) = (x, y, parent); } moves.push((i, next)); } for (i, c) in moves { if let Some(slot) = doc.comments.get_mut(i) { *slot = c; } } } fn req_comment(cmd: &str, s: &Session, p: &Value) -> crate::Result { let id = match get(p, "link").and_then(Value::as_str) { Some(l) => parse_link(l).ok_or_else(|| bad(cmd, format!("not a comment link: {l}")))?, // Ids made in a live session sit in their creator's id space, far above small indexes. None => id(p, "id").map(|i| i.0).ok_or_else(|| bad(cmd, "`id` must be a comment id"))?, }; if s.doc()?.comment(id).is_none() { return Err(bad(cmd, format!("no comment {id}"))); } Ok(id) } /// The thread index of the message `reply` names (0: the first message). fn message_index(cmd: &str, c: &Comment, p: &Value) -> crate::Result { if get(p, "reply").is_none() { return Ok(0); } let i = req_index(cmd, p, "reply")?; if i >= c.replies.len() { return Err(bad(cmd, format!("no reply {i}"))); } Ok(i + 1) } /// The user's name as comments are signed (the default when it's blank). pub fn user_name(s: &Session) -> String { let n = s.prefs.user_name.trim(); if n.is_empty() { DEFAULT_AUTHOR.into() } else { n.to_string() } } /// Who is acting: `author` from the params, else the user. fn author(s: &Session, p: &Value) -> String { string(p, "author").map(|a| a.trim().to_string()).filter(|a| !a.is_empty()).unwrap_or_else(|| user_name(s)) } fn text(cmd: &str, p: &Value) -> crate::Result { let t = req_string(cmd, p, "text")?; if t.trim().is_empty() { return Err(bad(cmd, "comment text is empty")); } Ok(t.trim().to_string()) } /// Refuse to change someone else's message. fn own(cmd: &str, m: &Message, who: &str, what: &str) -> crate::Result<()> { if m.author == who { return Ok(()); } Err(bad(cmd, format!("only {} can {what} (acting as {who}; pass `author` to act as someone else)", m.author))) } /// A node to pin to: a node id on `page`, `true` for the innermost frame under the point, else none. fn anchor_at(doc: &Document, page: NodeId, x: f64, y: f64, v: Option<&Value>) -> Option { match v { Some(Value::Bool(true)) => frame_at(doc, page, x, y), Some(v) => id_of(v).filter(|a| *a != page && doc.page_of(*a) == Some(page)), None => None, } } /// A page point in `anchor`'s local space (as is without one). fn local(doc: &Document, anchor: Option, x: f64, y: f64) -> (f64, f64) { match anchor { Some(a) => { let l = doc.world_transform(a).inverse() * geom::Point::new(x, y); (l.x, l.y) } None => (x, y), } } fn message_json(m: &Message) -> Value { json!({ "author": m.author, "text": m.text, "created": m.created, "edited": m.edited, "reactions": m.reactions.iter().map(|r| json!({"emoji": r.emoji, "users": r.users})).collect::>(), }) } /// JSON view of a comment as `user` sees it: its page position, frame, read state and link, the /// first message's fields, and the replies. pub fn comment_json(doc: &Document, c: &Comment, user: &str) -> Value { let (x, y) = c.position(doc); let frame = frame_of(doc, c); let mut v = message_json(&c.first); let extra = json!({ "id": c.id, "page": c.page, "pageName": doc.node(c.page).map(|n| n.name.as_str()), "x": x, "y": y, "anchor": c.anchor, "frame": frame, "frameName": frame.and_then(|f| doc.node(f)).map(|n| n.name.as_str()), "resolved": c.resolved, "unread": c.is_unread(user), "link": link(c.id), "replies": c.replies.iter().map(message_json).collect::>(), }); if let (Some(m), Value::Object(e)) = (v.as_object_mut(), extra) { m.extend(e); } v } /// Bring a thread into view (`comment.open`): its page, then `how` (fit, center or keep). fn reveal(s: &mut Session, id: u64, how: &str) -> crate::Result<()> { let doc = s.doc()?; let c = doc.comment(id).ok_or_else(|| bad("comment.open", format!("no comment {id}")))?; if !doc.pages.contains(&c.page) { return Err(bad("comment.open", format!("comment {id} is on a deleted page"))); } let (x, y) = c.position(doc); let frame = frame_of(doc, c).and_then(|f| doc.world_bounds(f)); let page = c.page; let ds = s.ds_mut()?; let switched = ds.page != page; if switched { ds.page = page; ds.selection.clear(); ds.text_edit = None; ds.zoom_to_fit(); } match (how, frame) { ("fit", Some(b)) => ds.view.fit(b.union_pt(geom::Point::new(x, y)), 1.0), ("fit", None) => { ds.view.set_zoom_at(ds.view.zoom.max(1.0), (ds.view.cx, ds.view.cy)); (ds.view.cx, ds.view.cy) = (x, y); } ("center", _) => (ds.view.cx, ds.view.cy) = (x, y), _ if switched => (ds.view.cx, ds.view.cy) = (x, y), _ => {} } Ok(()) } /// Reveal a thread (`how`: fit, center or keep), mark it read for `who` and ask the UI to open it. fn open(s: &mut Session, id: u64, how: &str, who: &str) -> crate::Result { reveal(s, id, how)?; if s.doc()?.comment(id).is_some_and(|c| c.is_unread(who)) && let Some(c) = s.doc_mut()?.comment_mut(id) { c.set_read(who, true); } s.request_ui("comment.open", json!({"id": id})); let doc = s.doc()?; let c = doc.comment(id).ok_or_else(|| bad("comment.open", "no comment"))?; Ok(comment_json(doc, c, who)) } /// A list's page (None: every page), filter, sort and search text from params. fn list_params(cmd: &str, s: &Session, p: &Value) -> crate::Result<(Option, Filter, Sort, String)> { let page = if string(p, "page").is_some_and(|v| v == "all") { None } else { Some(id(p, "page").unwrap_or(s.page()?)) }; let filter = match string(p, "filter") { Some(f) => Filter::parse(&f).ok_or_else(|| bad(cmd, "`filter` must be all, open, resolved, mine or unread"))?, None if boolean(p, "resolved") == Some(false) => Filter::Open, None => Filter::All, }; let sort = match string(p, "sort") { Some(v) => Sort::parse(&v).ok_or_else(|| bad(cmd, "`sort` must be date or page"))?, None => Sort::Date, }; Ok((page, filter, sort, string(p, "query").unwrap_or_default())) } pub fn specs() -> Vec { vec![ spec!( "comment.add", "Add comment", "{x, y, text, page?, author?, anchor? (node id to pin to; true = innermost frame under the point)}", true, |s, p| { let x = req_num("comment.add", p, "x")?; let y = req_num("comment.add", p, "y")?; let text = text("comment.add", p)?; let who = author(s, p); let page = id(p, "page").unwrap_or(s.page()?); let doc = s.doc()?; if !doc.pages.contains(&page) { return Err(bad("comment.add", format!("{page} is not a page"))); } let anchor = anchor_at(doc, page, x, y, get(p, "anchor")); let (lx, ly) = local(doc, anchor, x, y); let mut c = Comment { id: s.doc_mut()?.new_comment_id(), page, x: lx, y: ly, anchor, first: Message::new(who.clone(), text, now_secs()), resolved: false, replies: Vec::new(), read_by: None, }; c.touched_by(&who); let id = c.id; s.doc_mut()?.comments.push(c); Ok(json!({"id": id, "link": link(id)})) } ), spec!("comment.reply", "Reply to comment", "{id, text, author?}", true, |s, p| { let id = req_comment("comment.reply", s, p)?; let who = author(s, p); let r = Message::new(who.clone(), text("comment.reply", p)?, now_secs()); let c = s.doc_mut()?.comment_mut(id).ok_or_else(|| bad("comment.reply", "no comment"))?; c.replies.push(r); c.resolved = false; c.touched_by(&who); Ok(json!({"reply": c.replies.len() - 1})) }), spec!("comment.edit", "Edit comment", "{id, text, reply? (index; omit for the first message), author?}", true, |s, p| { let id = req_comment("comment.edit", s, p)?; let t = text("comment.edit", p)?; let who = author(s, p); let (i, same) = { let c = s.doc()?.comment(id).ok_or_else(|| bad("comment.edit", "no comment"))?; let i = message_index("comment.edit", c, p)?; let m = c.message(i).ok_or_else(|| bad("comment.edit", "no message"))?; own("comment.edit", m, &who, "edit this message")?; (i, m.text == t) }; // The same text is no edit (and no undo step). if !same && let Some(m) = s.doc_mut()?.comment_mut(id).and_then(|c| c.message_mut(i)) { m.text = t; m.edited = now_secs().max(1); } ok() }), spec!( "comment.react", "React to comment", "{id, emoji, reply? (index; omit for the first message), on? (default: toggle), author?}", true, |s, p| { let id = req_comment("comment.react", s, p)?; let emoji = req_string("comment.react", p, "emoji")?.trim().to_string(); if emoji.is_empty() || emoji.len() > MAX_EMOJI || emoji.chars().any(|c| c.is_whitespace() || c.is_control()) { return Err(bad("comment.react", "`emoji` must be one emoji")); } let who = author(s, p); let c = s.doc()?.comment(id).ok_or_else(|| bad("comment.react", "no comment"))?; let i = message_index("comment.react", c, p)?; let has = c.message(i).is_some_and(|m| m.reacted(&emoji, &who)); let on = boolean(p, "on").unwrap_or(!has); // Already so: no edit (and no undo step). if on != has && let Some(m) = s.doc_mut()?.comment_mut(id).and_then(|c| c.message_mut(i)) { m.react(&emoji, &who, Some(on)); } Ok(json!({"on": on})) } ), spec!("comment.resolve", "Resolve comment", "{id, resolved? (default true)}", true, |s, p| { let id = req_comment("comment.resolve", s, p)?; let v = boolean(p, "resolved").unwrap_or(true); if let Some(c) = s.doc_mut()?.comment_mut(id) { c.resolved = v; } ok() }), spec!( "comment.move", "Move comment", "{id, x, y, anchor? (default true: pin to the innermost frame under the point; false: to the page; or a node id)}", true, |s, p| { let id = req_comment("comment.move", s, p)?; let (x, y) = (req_num("comment.move", p, "x")?, req_num("comment.move", p, "y")?); let doc = s.doc()?; let page = doc.comment(id).map(|c| c.page).ok_or_else(|| bad("comment.move", "no comment"))?; let anchor = anchor_at(doc, page, x, y, Some(get(p, "anchor").unwrap_or(&Value::Bool(true)))); let (lx, ly) = local(doc, anchor, x, y); if let Some(c) = s.doc_mut()?.comment_mut(id) { (c.x, c.y, c.anchor) = (lx, ly, anchor); } ok() } ), spec!("comment.delete", "Delete comment", "{id, reply? (index: delete one reply; omit to delete the thread), author?}", true, |s, p| { let id = req_comment("comment.delete", s, p)?; let who = author(s, p); let doc = s.doc_mut()?; let c = doc.comment_mut(id).ok_or_else(|| bad("comment.delete", "no comment"))?; match message_index("comment.delete", c, p)? { 0 => { own("comment.delete", &c.first, &who, "delete this thread")?; doc.comments.retain(|c| c.id != id); } i => { let m = c.message(i).ok_or_else(|| bad("comment.delete", "no message"))?; own("comment.delete", m, &who, "delete this reply")?; c.replies.remove(i - 1); } } ok() }), spec!( "comment.markRead", "Mark comment as read", "{id? | link? (omit: every thread), read? (default true; false marks it unread), author?}", false, |s, p| { let read = boolean(p, "read").unwrap_or(true); let who = author(s, p); let only = if get(p, "id").is_some() || get(p, "link").is_some() { Some(req_comment("comment.markRead", s, p)?) } else { None }; let wants = |c: &Comment| only.is_none_or(|i| c.id == i) && c.is_unread(&who) == read; // The document is left alone when nothing changes (no unsaved changes for nothing). if !s.doc()?.comments.iter().any(wants) { return Ok(json!({"changed": 0})); } let mut n = 0; for c in s.doc_mut()?.comments.iter_mut().filter(|c| only.is_none_or(|i| c.id == i)) { n += usize::from(c.set_read(&who, read)); } Ok(json!({"changed": n})) } ), spec!( "comment.open", "Open comment", "{id | link, view? (fit: zoom to the thread's frame, default; center: centre the pin; keep), author?}", false, |s, p| { let id = req_comment("comment.open", s, p)?; let how = string(p, "view").unwrap_or_else(|| "fit".into()); if !matches!(how.as_str(), "fit" | "center" | "keep") { return Err(bad("comment.open", "`view` must be fit, center or keep")); } open(s, id, &how, &author(s, p)) } ), spec!( "comment.next", "Next comment", "{id? (the thread to step from; omitted: the first or last), dir? (1 next, -1 previous; default 1), page?, filter?, sort?, query? (the list, as comment.list), author?}", false, |s, p| { let dir = num(p, "dir").unwrap_or(1.0); let doc = s.doc()?; let who = author(s, p); let (page, filter, sort, text) = list_params("comment.next", s, p)?; let ids: Vec = query(doc, &Query { page, frame: id(p, "frame"), filter, sort, text: &text }, &who).iter().map(|c| c.id).collect(); let at = id(p, "id").and_then(|i| ids.iter().position(|x| *x == i.0)); let next = match at { Some(i) if dir < 0.0 => ids.get(i.saturating_sub(1)), Some(i) => ids.get(i + 1).or(ids.get(i)), None if dir < 0.0 => ids.last(), None => ids.first(), }; let Some(&next) = next else { return Err(bad("comment.next", "no comments in the list")) }; open(s, next, "center", &who) } ), spec!("comment.zoom", "Zoom to comments", "{ids (comment ids on the current page)}", false, |s, p| { let doc = s.doc()?; let page = s.page()?; let ids: Vec = get(p, "ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); let pts: Vec = ids.iter().filter_map(|i| doc.comment(*i)).filter(|c| c.page == page).map(|c| geom::Point::from(c.position(doc))).collect(); let Some(first) = pts.first() else { return Err(bad("comment.zoom", "`ids` names no comment on this page")) }; let r = pts.iter().fold(geom::Rect::from_points(*first, *first), |r, pt| r.union_pt(*pt)); let v = &mut s.ds_mut()?.view; let zoom = v.zoom; v.fit(r, crate::ViewState::MAX_ZOOM); // Pins at one spot: at least double the zoom so they come apart. if v.zoom < zoom * 2.0 { v.set_zoom_at(zoom * 2.0, (v.cx, v.cy)); } Ok(json!({"zoom": v.zoom})) }), spec!("comment.link", "Copy link to comment", "{id}", false, |s, p| { let id = req_comment("comment.link", s, p)?; Ok(json!({"link": link(id)})) }), spec!("comment.people", "People in comments", "{}", false, |s, _| { Ok(json!({"people": s.doc()?.comment_people(), "you": user_name(s)})) }), spec!( "comment.list", "List comments", "{page? (default: current; \"all\" for every page), filter? (all|open|resolved|mine|unread; default all), sort? (date|page; default date), query? (search text), frame? (a top-level frame id), resolved? (false hides resolved ones), author? (whose threads and read state)}", false, |s, p| { let doc = s.doc()?; let (page, filter, sort, text) = list_params("comment.list", s, p)?; let who = author(s, p); let q = Query { page, frame: id(p, "frame"), filter, sort, text: &text }; let list: Vec = query(doc, &q, &who).into_iter().map(|c| comment_json(doc, c, &who)).collect(); Ok(json!({"comments": list})) } ), ] } /// Innermost frame (or component/instance/section) under a page point, for anchoring. fn frame_at(doc: &Document, page: NodeId, x: f64, y: f64) -> Option { let pt = geom::Point::new(x, y); let mut found = None; let mut stack = vec![page]; while let Some(parent) = stack.pop() { let Some(n) = doc.node(parent) else { continue }; for c in n.children.iter().rev() { let Some(cn) = doc.node(*c) else { continue }; if !cn.visible || !cn.kind.is_container() { continue; } let l = doc.world_transform(*c).inverse() * pt; if l.x >= 0.0 && l.y >= 0.0 && l.x <= cn.width && l.y <= cn.height { if cn.kind.is_frame_like() || matches!(cn.kind, NodeKind::Section { .. }) { found = Some(*c); } stack.push(*c); break; } } } found } #[cfg(test)] mod tests { use serde_json::json; use crate::Session; #[test] fn comment_thread_lifecycle_and_anchoring() { let mut s = Session::new(); let f = s.execute("node.create", &json!({"type": "frame", "x": 100, "y": 100, "width": 200, "height": 200})).unwrap(); let fid = crate::params::id_of(&f["id"]).unwrap(); let c = s.execute("comment.add", &json!({"x": 150, "y": 160, "text": "Tighten this", "anchor": true})).unwrap(); let cid = c["id"].as_u64().unwrap(); assert_eq!(s.doc().unwrap().comment(cid).unwrap().anchor, Some(fid)); // The pin follows its frame. s.execute("node.set", &json!({"id": fid, "props": {"x": 300}})).unwrap(); let l = s.execute("comment.list", &json!({})).unwrap(); assert_eq!(l["comments"][0]["x"].as_f64().unwrap(), 350.0); s.execute("comment.reply", &json!({"id": cid, "text": "Done", "author": "Sam"})).unwrap(); s.execute("comment.resolve", &json!({"id": cid})).unwrap(); let open = s.execute("comment.list", &json!({"resolved": false})).unwrap(); assert!(open["comments"].as_array().unwrap().is_empty()); assert_eq!(s.doc().unwrap().comment(cid).unwrap().participants(), vec!["You", "Sam"]); assert!(s.execute("comment.add", &json!({"x": 0, "y": 0, "text": " "})).is_err()); assert!(s.execute("comment.reply", &json!({"id": 999, "text": "x"})).is_err()); s.execute("comment.delete", &json!({"id": cid})).unwrap(); assert!(s.doc().unwrap().comments.is_empty()); assert!(s.undo().unwrap()); assert_eq!(s.doc().unwrap().comments.len(), 1); } }