Files
Marcos Paulo bc0b258e04 feat: engine and core crates: comment sidecars hidden and removed with the note, e2e comments test, extension tables, annotate text and arrow items
Sidecars (*.comments.json) stay out of the index and listings and follow rename and delete of their note; end-to-end comments test over a stub server (Ana comments and mentions Bob, Bob resolves, second sync changes nothing); viewer extension tables gain ico, ogv, 3gp, opus, aac; annotate crate: Edit::Replace for undoable text moves and edits, line items carry an arrow flag; syntax test fixed (TeX is a bundled grammar). Built by MiniMax M3.1 Flash and Claude Sonnet; verified with the full cargo xtask ci on the combined tree (4422 tests, clippy -D warnings, layers, tree, i18n).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 02:31:52 -03:00

198 lines
8.8 KiB
Rust

//! The end-to-end proof of the comments: two devices of one vault on a fake
//! server, a comment with a mention, the feed of each member and the thread
//! that is closed.
//!
//! The fake server lives in [`comments_e2e_stub`]: it speaks the JSON of the
//! sync server, so the test drives the real clients of the engine - the
//! commands, the sync driver and the feed - against a server that keeps the
//! files, the members and the events.
mod comments_e2e_stub;
mod common;
use comments_e2e_stub::{ANA, ANA_TOKEN, BOB, BOB_TOKEN, Stub, VAULT};
use common::{on_disk, seed, temp_vault};
use notescraft_engine::{HttpRemote, Registry, Session, SyncReport, SyncState, bind_folder, register_core, sync_once};
use serde_json::{Value, json};
/// The note both devices hold.
const NOTE: &str = "# Idea\n\nThis always works, except on slow disks.\n";
/// The path of the note.
const NOTE_PATH: &str = "note.md";
/// The path of the sidecar of the note.
const SIDECAR: &str = "note.md.comments.json";
/// The quote Ana anchors her comment to.
const QUOTE: &str = "This always works";
/// The body of Ana's comment.
const BODY: &str = "Can we soften this claim? @Bob";
/// The name the device leaves on a conflict copy.
const DEVICE: &str = "e2e";
/// The date the conflict copies carry.
const STAMP: &str = "2026-10-21";
/// One device of the vault: its commands, its session and its sync state.
struct Device {
/// The registry the commands live in.
reg: Registry<Session>,
/// The session of the open vault.
session: Session,
/// What the device last agreed with the server on.
state: SyncState,
/// The transport to the fake server.
remote: HttpRemote,
}
impl Device {
/// A device with the note of the vault, bound to the fake as `user`.
fn new(name: &str, stub: &Stub, token: &str, user: &str, device: &str) -> (Self, common::TempVault) {
let folder = temp_vault(name);
seed(folder.path(), &[(NOTE_PATH, NOTE)]);
bind_folder(folder.path(), &stub.base, VAULT, token, device, Some(user)).expect("bind the folder to the fake");
let mut reg = Registry::new();
register_core(&mut reg).expect("register the core commands");
let session = Session::open(folder.path()).expect("open the vault");
let state = SyncState::load(session.vault()).expect("read the sync state");
let remote = HttpRemote::new(&stub.base, VAULT, token);
(Self { reg, session, state, remote }, folder)
}
/// Runs a command of the device and answers its value.
fn run(&mut self, id: &str, params: &Value) -> Value {
match self.reg.run(id, &mut self.session, params) {
Ok(value) => value,
Err(error) => panic!("{id} failed: {error:?}"),
}
}
/// Syncs the device once and answers what the pass did.
fn sync(&mut self) -> SyncReport {
sync_once(self.session.vault(), &self.remote, &mut self.state, DEVICE, STAMP).expect("sync the vault")
}
}
/// The events of a feed, as the command answered them.
fn events(feed: &Value) -> Vec<Value> {
feed.get("events").and_then(Value::as_array).cloned().unwrap_or_default()
}
/// The kinds of the events of a feed.
fn kinds(feed: &Value) -> Vec<String> {
events(feed).iter().filter_map(|event| event.get("kind").and_then(Value::as_str).map(str::to_owned)).collect()
}
/// Ana comments on a sentence of the note and mentions Bob.
///
/// The client posts the mention of Bob right away; the event names the note
/// and the thread, never the text of the comment.
fn ana_comments_and_mentions_bob(ana: &mut Device, stub: &Stub) -> String {
let start = NOTE.find(QUOTE).expect("the quote is in the note");
let added =
ana.run("comment.add", &json!({ "path": NOTE_PATH, "quote": QUOTE, "body": BODY, "start": start, "end": start + QUOTE.len(), "mentions": [BOB] }));
let thread = added.get("id").and_then(Value::as_str).expect("the id of the thread").to_owned();
let posted = stub.posted();
assert_eq!(posted.len(), 1, "one event per comment");
let event = posted.first().expect("the event of the comment");
assert_eq!(event.kind, "mention");
assert_eq!(event.actor, ANA);
assert_eq!(event.mentions, vec![BOB.to_owned()]);
assert_eq!(event.thread_id.as_deref(), Some(thread.as_str()));
let body = stub.requests().iter().find(|request| request.method == "POST" && request.path.ends_with("/activity")).expect("the mention event").text();
assert!(!body.contains("soften"), "the event carries no comment body: {body}");
thread
}
/// Bob sees the thread of Ana in his copy of the note.
fn bob_sees_the_thread(bob: &mut Device, thread: &str) {
let listed = bob.run("comment.list", &json!({ "path": NOTE_PATH }));
let comments = listed.get("comments").and_then(Value::as_array).expect("the threads of the note");
assert_eq!(comments.len(), 1);
let comment = comments.first().expect("the thread");
assert_eq!(comment.get("id"), Some(&json!(thread)));
assert_eq!(comment.get("author"), Some(&json!(ANA)));
assert_eq!(comment.get("body"), Some(&json!(BODY)));
assert_eq!(comment.get("mentions"), Some(&json!([BOB])));
assert_eq!(comment.get("resolved"), Some(&json!(false)));
assert_eq!(comment.get("state"), Some(&json!("exact")));
}
/// The feed of Bob shows the mention and the comment of Ana.
fn bob_sees_the_feed(bob: &mut Device) {
let feed = bob.run("activity.list", &Value::Null);
assert_eq!(kinds(&feed), vec!["mention".to_owned(), "comment".to_owned()]);
assert_eq!(feed.get("unread"), Some(&json!(2)));
let rows = events(&feed);
assert_eq!(rows.first().and_then(|row| row.get("note")), Some(&json!(NOTE_PATH)));
assert_eq!(rows.first().and_then(|row| row.get("actor")), Some(&json!(ANA)));
}
/// The feed of Ana does not show her own comment.
fn ana_does_not_see_her_own_comment(ana: &mut Device) {
let feed = ana.run("activity.list", &Value::Null);
assert!(events(&feed).is_empty(), "the actor never sees her own events: {feed:?}");
assert_eq!(feed.get("unread"), Some(&json!(0)));
}
/// The whole story: the comment travels, the feed tells both members and a
/// second sync changes nothing.
#[test]
fn a_comment_travels_from_ana_to_bob_and_the_thread_comes_back() {
let stub = Stub::spawn();
let (mut ana, ana_folder) = Device::new("ana", &stub, ANA_TOKEN, ANA, "d-ana");
let (mut bob, bob_folder) = Device::new("bob", &stub, BOB_TOKEN, BOB, "d-bob");
// Ana comments on the sentence and mentions Bob.
let thread = ana_comments_and_mentions_bob(&mut ana, &stub);
assert_eq!(on_disk(ana_folder.path(), NOTE_PATH), NOTE, "the note never changes");
assert!(ana_folder.path().join(SIDECAR).exists(), "the sidecar sits next to the note");
// Ana syncs: the note and its sidecar travel to the server.
let report = ana.sync();
assert_eq!(report.uploaded, vec![NOTE_PATH.to_owned(), SIDECAR.to_owned()]);
// Bob syncs: the sidecar comes to him and his note stays the same.
let report = bob.sync();
assert_eq!(report.downloaded, vec![SIDECAR.to_owned()]);
assert_eq!(on_disk(bob_folder.path(), NOTE_PATH), NOTE, "the note never changes");
bob_sees_the_thread(&mut bob, &thread);
// The feed of each member tells its own story.
bob_sees_the_feed(&mut bob);
ana_does_not_see_her_own_comment(&mut ana);
// Bob closes the thread and the closed thread travels back to Ana.
bob.run("comment.resolve", &json!({ "path": NOTE_PATH, "id": thread, "resolved": true }));
let report = bob.sync();
assert_eq!(report.uploaded, vec![SIDECAR.to_owned()]);
let report = ana.sync();
assert_eq!(report.downloaded, vec![SIDECAR.to_owned()]);
let listed = ana.run("comment.list", &json!({ "path": NOTE_PATH }));
let comments = listed.get("comments").and_then(Value::as_array).expect("the threads of the note");
let comment = comments.first().expect("the thread");
assert_eq!(comment.get("resolved"), Some(&json!(true)));
assert_eq!(comment.get("resolved_by"), Some(&json!(BOB)));
// Ana sees that the thread was closed.
let feed = ana.run("activity.list", &Value::Null);
assert_eq!(kinds(&feed), vec!["thread_resolved".to_owned()]);
assert_eq!(events(&feed).first().and_then(|row| row.get("actor")), Some(&json!(BOB)));
// Bob does not see the closing of his own thread.
let feed = bob.run("activity.list", &Value::Null);
assert!(events(&feed).is_empty(), "the actor never sees his own events: {feed:?}");
// A second sync changes nothing: no file travels and no event is posted.
let posted = stub.events().len();
assert_eq!(ana.sync(), SyncReport::default());
assert_eq!(bob.sync(), SyncReport::default());
assert_eq!(stub.events().len(), posted, "no new event was posted");
assert_eq!(stub.file(SIDECAR).expect("the sidecar of the server"), on_disk(ana_folder.path(), SIDECAR).into_bytes());
}