Files
figcraft/crates/engine/src/comments_polish_tests.rs
T
BFlatandClaude Opus 5.5 50517a6431 M7: comments polish (#74)
Pins follow their layers (to other pages too) and stay where they were
when the layer is deleted, re-pinned to the nearest surviving parent;
your pins drag onto another frame; crowded pins cluster when zoomed out
and a click zooms them apart; Shift+C shows or hides them.

Threads: edit and delete your own messages (marked edited), emoji
reactions, @mentions with a picker of the names used in the file (your
name from Preferences), read/unread state per name, and Copy link
(figcraft://comment/<id>; pasting it or comment.open goes to the page,
zooms to the frame and opens the thread).

Comments panel: search (text, names, frame), filters (open, resolved,
yours, unread), sort by date or page, each thread's frame and page,
Up/Down to step through threads. The presenter shows the presented
frame's comments (Comments button or Shift+C) to read and reply.

New commands: comment.react, comment.markRead, comment.open,
comment.next, comment.zoom, comment.link, comment.people,
pref.userName; comment.list gains filter/sort/query/frame, comment.move
re-pins. Old files load unchanged (the first message stays on the
comment; missing read state counts as read).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:36:42 +07:00

299 lines
15 KiB
Rust

//! Comment polish: pins that follow their layers (and stay put when the layers go), messages you
//! edit and delete, reactions, @mentions, read state, filters and sorting, links that reveal a
//! thread, and files from before all this.
use figcraft_doc::{Comment, NodeId, mention_ranges};
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
fn frame(s: &mut Session, x: f64, y: f64, name: &str) -> NodeId {
let f = s.execute("node.create", &json!({"type": "frame", "x": x, "y": y, "width": 200, "height": 200, "name": name})).unwrap();
id_of(&f["id"]).unwrap()
}
fn add(s: &mut Session, x: f64, y: f64, text: &str, author: &str) -> u64 {
s.execute("comment.add", &json!({"x": x, "y": y, "text": text, "author": author, "anchor": true})).unwrap()["id"].as_u64().unwrap()
}
fn list(s: &mut Session, p: Value) -> Vec<u64> {
s.execute("comment.list", &p).unwrap()["comments"].as_array().unwrap().iter().map(|c| c["id"].as_u64().unwrap()).collect()
}
fn comment(s: &Session, id: u64) -> Comment {
s.doc().unwrap().comment(id).unwrap().clone()
}
#[test]
fn deleting_the_layer_keeps_the_pin_where_it_was() {
let mut s = Session::new();
let outer = frame(&mut s, 100.0, 100.0, "Screen");
let inner =
id_of(&s.execute("node.create", &json!({"type": "frame", "parent": outer, "x": 20, "y": 20, "width": 50, "height": 50})).unwrap()["id"])
.unwrap();
let c = add(&mut s, 130.0, 140.0, "Nested", "You");
assert_eq!(comment(&s, c).anchor, Some(inner));
// Deleting the inner frame pins the comment to the outer one, at the same spot.
s.execute("edit.delete", &json!({"ids": [inner]})).unwrap();
assert_eq!(comment(&s, c).anchor, Some(outer));
assert_eq!(comment(&s, c).position(s.doc().unwrap()), (130.0, 140.0));
// …which it then follows.
s.execute("node.set", &json!({"id": outer, "props": {"x": 200}})).unwrap();
assert_eq!(comment(&s, c).position(s.doc().unwrap()), (230.0, 140.0));
// Deleting the outer frame leaves it on the page, where it last was.
s.execute("edit.delete", &json!({"ids": [outer]})).unwrap();
assert_eq!(comment(&s, c).anchor, None);
assert_eq!(comment(&s, c).position(s.doc().unwrap()), (230.0, 140.0));
// Undo puts the frame and the pin back together.
assert!(s.undo().unwrap());
assert_eq!(comment(&s, c).anchor, Some(outer));
}
#[test]
fn pins_follow_their_layer_to_another_page_and_can_be_moved() {
let mut s = Session::new();
let p1 = s.page().unwrap();
let f = frame(&mut s, 0.0, 0.0, "Card");
let g = frame(&mut s, 400.0, 0.0, "Other");
let c = add(&mut s, 50.0, 60.0, "Here", "You");
let p2 = id_of(&s.execute("page.add", &json!({"name": "Two"})).unwrap()["id"]).unwrap();
s.execute("page.select", &json!({"id": p1})).unwrap();
s.execute("object.moveToPage", &json!({"ids": [f], "page": p2})).unwrap();
assert_eq!(comment(&s, c).page, p2);
assert_eq!(comment(&s, c).anchor, Some(f));
// Moving a pin onto another frame pins it there; `anchor: false` leaves it on the page.
s.execute("object.moveToPage", &json!({"ids": [f], "page": p1})).unwrap();
s.execute("comment.move", &json!({"id": c, "x": 450, "y": 20})).unwrap();
assert_eq!(comment(&s, c).anchor, Some(g));
assert_eq!(comment(&s, c).position(s.doc().unwrap()), (450.0, 20.0));
s.execute("comment.move", &json!({"id": c, "x": 1000, "y": 20, "anchor": false})).unwrap();
assert_eq!(comment(&s, c).anchor, None);
}
#[test]
fn only_authors_edit_and_delete_their_messages() {
let mut s = Session::new();
let c = add(&mut s, 0.0, 0.0, "First", "You");
s.execute("comment.reply", &json!({"id": c, "text": "Second", "author": "Sam"})).unwrap();
// Sam's reply isn't yours to edit or delete, nor is your thread Sam's to delete.
assert!(s.execute("comment.edit", &json!({"id": c, "reply": 0, "text": "Nope"})).is_err());
assert!(s.execute("comment.delete", &json!({"id": c, "reply": 0})).is_err());
assert!(s.execute("comment.delete", &json!({"id": c, "author": "Sam"})).is_err());
assert!(s.execute("comment.edit", &json!({"id": c, "reply": -1, "text": "x"})).is_err());
assert!(s.execute("comment.edit", &json!({"id": c, "reply": 5, "text": "x"})).is_err());
s.execute("comment.edit", &json!({"id": c, "text": " First, edited "})).unwrap();
let m = comment(&s, c).first;
assert_eq!(m.text, "First, edited");
assert!(m.edited > 0);
s.execute("comment.edit", &json!({"id": c, "reply": 0, "text": "Second!", "author": "Sam"})).unwrap();
assert_eq!(comment(&s, c).replies[0].text, "Second!");
s.execute("comment.delete", &json!({"id": c, "reply": 0, "author": "Sam"})).unwrap();
assert!(comment(&s, c).replies.is_empty());
// Your name from Preferences signs and owns your comments.
s.execute("pref.userName", &json!({"name": " Ada "})).unwrap();
let d = s.execute("comment.add", &json!({"x": 0, "y": 0, "text": "Mine"})).unwrap()["id"].as_u64().unwrap();
assert_eq!(comment(&s, d).first.author, "Ada");
assert!(s.execute("comment.delete", &json!({"id": c})).is_err());
s.execute("comment.delete", &json!({"id": d})).unwrap();
s.execute("pref.userName", &json!({"name": " "})).unwrap();
assert_eq!(s.prefs.user_name, "You");
}
#[test]
fn reactions_toggle_per_person() {
let mut s = Session::new();
let c = add(&mut s, 0.0, 0.0, "Ship it?", "You");
s.execute("comment.reply", &json!({"id": c, "text": "Yes", "author": "Sam"})).unwrap();
assert_eq!(s.execute("comment.react", &json!({"id": c, "emoji": "👍"})).unwrap()["on"], true);
s.execute("comment.react", &json!({"id": c, "emoji": "👍", "author": "Sam"})).unwrap();
s.execute("comment.react", &json!({"id": c, "emoji": "🎉", "reply": 0})).unwrap();
let m = comment(&s, c);
assert_eq!(m.first.reactions[0].users, vec!["You", "Sam"]);
assert_eq!(m.replies[0].reactions[0].emoji, "🎉");
// Toggling off removes the person; the last one removes the reaction. `on` forces a state.
assert_eq!(s.execute("comment.react", &json!({"id": c, "emoji": "👍"})).unwrap()["on"], false);
s.execute("comment.react", &json!({"id": c, "emoji": "👍", "author": "Sam", "on": false})).unwrap();
s.execute("comment.react", &json!({"id": c, "emoji": "👍", "author": "Sam", "on": false})).unwrap();
assert!(comment(&s, c).first.reactions.is_empty());
for bad in [json!(""), json!("two words"), json!("x".repeat(40)), json!(null)] {
assert!(s.execute("comment.react", &json!({"id": c, "emoji": bad})).is_err());
}
// Reactions are one undo step each.
assert!(s.undo().unwrap());
assert_eq!(comment(&s, c).first.reactions[0].users, vec!["Sam"]);
}
#[test]
fn mentions_and_people() {
let people = ["Sam", "Sam Lee", "Ada"];
let t = "Hi @sam lee and @Ada, not me@Ada or @Adam";
let r = mention_ranges(t, &people);
assert_eq!(r.iter().map(|r| &t[r.clone()]).collect::<Vec<_>>(), vec!["@sam lee", "@Ada"]);
assert!(mention_ranges("@", &people).is_empty());
assert!(mention_ranges("é@Sam", &people).is_empty());
assert_eq!(mention_ranges("(@Sam)", &people).len(), 1);
let mut s = Session::new();
let c = add(&mut s, 0.0, 0.0, "Ask @Ada", "Sam");
s.execute("comment.react", &json!({"id": c, "emoji": "👀", "author": "Lin"})).unwrap();
let p = s.execute("comment.people", &json!({})).unwrap();
assert_eq!(p["people"], json!(["Lin", "Sam"]));
assert_eq!(p["you"], "You");
// A thread you're mentioned in is yours.
assert!(comment(&s, c).involves("Ada"));
assert!(!comment(&s, c).involves("You"));
}
#[test]
fn read_state_follows_new_messages() {
let mut s = Session::new();
let c = add(&mut s, 0.0, 0.0, "From Sam", "Sam");
assert!(comment(&s, c).is_unread("You"));
assert!(!comment(&s, c).is_unread("Sam"));
assert_eq!(list(&mut s, json!({"filter": "unread"})), vec![c]);
let before = s.ds().unwrap().history.undo.len();
assert_eq!(s.execute("comment.markRead", &json!({"id": c})).unwrap()["changed"], 1);
assert!(list(&mut s, json!({"filter": "unread"})).is_empty());
// Read state isn't an undo step, and marking what's already read changes nothing.
assert_eq!(s.ds().unwrap().history.undo.len(), before);
let doc = s.ds().unwrap().doc.clone();
s.execute("comment.markRead", &json!({})).unwrap();
assert!(std::sync::Arc::ptr_eq(&doc, &s.ds().unwrap().doc));
// A reply makes it unread for everyone else; mark unread puts it back.
s.execute("comment.reply", &json!({"id": c, "text": "More", "author": "Sam"})).unwrap();
assert!(comment(&s, c).is_unread("You"));
s.execute("comment.markRead", &json!({})).unwrap();
s.execute("comment.markRead", &json!({"link": format!("figcraft://comment/{c}"), "read": false})).unwrap();
assert!(comment(&s, c).is_unread("You"));
assert!(!comment(&s, c).is_unread("Sam"));
assert!(s.execute("comment.markRead", &json!({"link": "nope"})).is_err());
}
#[test]
fn files_from_before_load_and_count_as_read() {
let old = json!({"id": 3, "page": 1, "x": 5.0, "y": 6.0, "anchor": null, "author": "Sam", "text": "Old", "created": 10,
"resolved": false, "replies": [{"author": "Lin", "text": "Reply", "created": 11}]});
let mut c: Comment = serde_json::from_value(old).unwrap();
assert_eq!(c.first.author, "Sam");
assert_eq!(c.replies[0].text, "Reply");
assert!(!c.is_unread("You"));
// The first message's fields stay on the comment itself; defaults are left out.
let v = serde_json::to_value(&c).unwrap();
assert_eq!(v["text"], "Old");
assert!(v.get("first").is_none() && v.get("readBy").is_none() && v.get("read_by").is_none() && v.get("edited").is_none());
// Marking an old thread unread keeps everyone else's read.
assert!(c.set_read("Sam", false));
assert!(c.is_unread("Sam"));
assert!(!c.is_unread("Lin"));
let back: Comment = serde_json::from_value(serde_json::to_value(&c).unwrap()).unwrap();
assert_eq!(back, c);
}
#[test]
fn filters_sort_search_and_frames() {
let mut s = Session::new();
let p1 = s.page().unwrap();
let home = frame(&mut s, 0.0, 0.0, "Home");
let a = add(&mut s, 10.0, 10.0, "Logo too big", "Sam");
let b = add(&mut s, 500.0, 500.0, "Loose note @You", "Lin");
let p2 = id_of(&s.execute("page.add", &json!({"name": "Two"})).unwrap()["id"]).unwrap();
let c = add(&mut s, 0.0, 0.0, "Second page", "You");
s.execute("page.select", &json!({"id": p1})).unwrap();
s.execute("comment.resolve", &json!({"id": a})).unwrap();
// Make `a` the most recently active thread.
if let Some(m) = s.doc_mut().unwrap().comment_mut(a) {
m.first.created = 9_999_999_999;
}
assert_eq!(list(&mut s, json!({})), vec![a, b]);
// By date: latest activity first. By page: the first page's threads (latest first), then the next.
let mut by_date = vec![a];
by_date.extend(newest_first(&s, &[b, c]));
assert_eq!(list(&mut s, json!({"page": "all"})), by_date);
assert_eq!(list(&mut s, json!({"page": "all", "sort": "page"})), vec![a, b, c]);
assert_eq!(list(&mut s, json!({"page": "all", "filter": "open"})).len(), 2);
assert_eq!(list(&mut s, json!({"page": "all", "filter": "resolved"})), vec![a]);
// Yours: threads you wrote in or are mentioned in.
let mine = list(&mut s, json!({"page": "all", "filter": "mine"}));
assert!(mine.contains(&b) && mine.contains(&c) && !mine.contains(&a));
assert_eq!(list(&mut s, json!({"page": "all", "query": "LOGO"})), vec![a]);
// The frame's name matches too, and each thread names its frame and page.
assert_eq!(list(&mut s, json!({"page": "all", "query": "home"})), vec![a]);
assert_eq!(list(&mut s, json!({"frame": home})), vec![a]);
let all = s.execute("comment.list", &json!({"page": "all"})).unwrap();
let ja = all["comments"].as_array().unwrap().iter().find(|j| j["id"] == a).unwrap().clone();
assert_eq!(ja["frameName"], "Home");
assert_eq!(ja["pageName"], "Page 1");
assert_eq!(ja["link"], format!("figcraft://comment/{a}"));
assert_eq!(list(&mut s, json!({"page": p2})), vec![c]);
assert!(s.execute("comment.list", &json!({"filter": "weird"})).is_err());
assert!(s.execute("comment.list", &json!({"sort": "weird"})).is_err());
}
/// `ids` newest first, as the date sort orders them.
fn newest_first(s: &Session, ids: &[u64]) -> Vec<u64> {
let doc = s.doc().unwrap();
let mut v = ids.to_vec();
v.sort_by_key(|i| {
let c = doc.comment(*i).unwrap();
(std::cmp::Reverse(c.last_activity()), std::cmp::Reverse(c.id))
});
v
}
#[test]
fn links_open_and_zoom_to_a_thread() {
let mut s = Session::new();
let p1 = s.page().unwrap();
frame(&mut s, 1000.0, 1000.0, "Far");
let c = add(&mut s, 1050.0, 1060.0, "Look here", "Sam");
let link = s.execute("comment.link", &json!({"id": c})).unwrap()["link"].as_str().unwrap().to_string();
s.execute("page.add", &json!({"name": "Elsewhere"})).unwrap();
assert_ne!(s.page().unwrap(), p1);
s.ui_requests.clear();
let r = s.execute("comment.open", &json!({"link": link})).unwrap();
assert_eq!(r["id"], c);
assert_eq!(r["unread"], false);
assert_eq!(s.page().unwrap(), p1);
let v = &s.ds().unwrap().view;
assert!(v.zoom <= 1.0 && (v.cx - 1100.0).abs() < 1.0 && (v.cy - 1100.0).abs() < 1.0);
assert!(s.ui_requests.iter().any(|u| u.kind == "comment.open" && u.data["id"] == c));
// Centre keeps the zoom.
s.execute("view.set", &json!({"zoom": 3, "cx": 0, "cy": 0})).unwrap();
s.execute("comment.open", &json!({"id": c, "view": "center"})).unwrap();
let v = &s.ds().unwrap().view;
assert_eq!((v.zoom, v.cx, v.cy), (3.0, 1050.0, 1060.0));
assert!(s.execute("comment.open", &json!({"id": c, "view": "nowhere"})).is_err());
assert!(s.execute("comment.open", &json!({"link": "figcraft://comment/99"})).is_err());
// Zooming to a cluster of pins at least doubles the zoom.
let d = add(&mut s, 1052.0, 1061.0, "Near", "Sam");
s.execute("view.set", &json!({"zoom": 0.1})).unwrap();
let z = s.execute("comment.zoom", &json!({"ids": [c, d]})).unwrap()["zoom"].as_f64().unwrap();
assert!(z >= 0.2);
assert!(s.execute("comment.zoom", &json!({"ids": [12345]})).is_err());
}
#[test]
fn next_steps_through_the_list_across_pages() {
let mut s = Session::new();
let a = add(&mut s, 0.0, 0.0, "One", "Sam");
s.execute("page.add", &json!({"name": "Two"})).unwrap();
let b = add(&mut s, 0.0, 0.0, "Two", "Sam");
// Page order: `a` (first page), then `b`.
let step = |s: &mut Session, from: Option<u64>, dir: i32| {
s.execute("comment.next", &json!({"id": from, "dir": dir, "page": "all", "sort": "page"})).unwrap()["id"].as_u64().unwrap()
};
assert_eq!(step(&mut s, None, 1), a);
let first_page = s.doc().unwrap().pages[0];
assert_eq!(s.page().unwrap(), first_page);
assert_eq!(step(&mut s, Some(a), 1), b);
assert_ne!(s.page().unwrap(), first_page);
// The ends stay put.
assert_eq!(step(&mut s, Some(b), 1), b);
assert_eq!(step(&mut s, Some(b), -1), a);
assert_eq!(step(&mut s, Some(a), -1), a);
assert_eq!(step(&mut s, None, -1), b);
// Stepping reads the threads.
assert!(!comment(&s, a).is_unread("You") && !comment(&s, b).is_unread("You"));
assert!(s.execute("comment.next", &json!({"page": "all", "filter": "resolved"})).is_err());
}