bc0b258e04
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>
198 lines
8.8 KiB
Rust
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());
|
|
}
|