949 lines
27 KiB
Rust
949 lines
27 KiB
Rust
use std::cmp::Ordering;
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::str::FromStr;
|
|
use std::sync::LazyLock;
|
|
|
|
use serde::{Deserialize, Serialize, ser::SerializeStruct};
|
|
use thiserror::Error;
|
|
|
|
use crate::cob;
|
|
use crate::cob::common::{Reaction, Timestamp, Uri};
|
|
use crate::cob::store::Cob;
|
|
use crate::cob::{ActorId, Embed, EntryId, Op, op};
|
|
use crate::git;
|
|
use crate::prelude::ReadRepository;
|
|
|
|
/// Type name of a thread, as well as the domain for all thread operations.
|
|
/// Note that threads are not usually used standalone. They are embedded into other COBs.
|
|
pub static TYPENAME: LazyLock<cob::TypeName> =
|
|
LazyLock::new(|| FromStr::from_str("xyz.radicle.thread").expect("type name is valid"));
|
|
|
|
/// Error applying an operation onto a state.
|
|
#[derive(Error, Debug)]
|
|
pub enum Error {
|
|
/// Causal dependency missing.
|
|
///
|
|
/// This error indicates that the operations are not being applied
|
|
/// in causal order, which is a requirement for this CRDT.
|
|
///
|
|
/// For example, this can occur if an operation references anothern operation
|
|
/// that hasn't happened yet.
|
|
#[error("causal dependency {0:?} missing")]
|
|
Missing(EntryId),
|
|
/// The identity doc is missing.
|
|
#[error("identity document missing")]
|
|
MissingIdentity,
|
|
/// Error with comment operation.
|
|
#[error("comment {0} is invalid")]
|
|
Comment(EntryId),
|
|
/// Error with edit operation.
|
|
#[error("edit {0} is invalid")]
|
|
Edit(EntryId),
|
|
/// Object initialization failed.
|
|
#[error("initialization failed: {0}")]
|
|
Init(&'static str),
|
|
#[error("op decoding failed: {0}")]
|
|
Op(#[from] op::OpEncodingError),
|
|
}
|
|
|
|
/// Identifies a comment.
|
|
pub type CommentId = EntryId;
|
|
|
|
/// Reactions to a comment or other action.
|
|
pub type Reactions = BTreeSet<(ActorId, Reaction)>;
|
|
|
|
/// A comment edit is just some text and an edit time.
|
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Edit {
|
|
/// Edit author.
|
|
pub author: ActorId,
|
|
/// When the edit was made.
|
|
pub timestamp: Timestamp,
|
|
/// Edit contents. Replaces previous edits.
|
|
pub body: String,
|
|
/// Edit embed list.
|
|
pub embeds: Vec<Embed<Uri>>,
|
|
}
|
|
|
|
impl Edit {
|
|
/// Create a new edit.
|
|
pub fn new(
|
|
author: ActorId,
|
|
body: String,
|
|
timestamp: Timestamp,
|
|
embeds: Vec<Embed<Uri>>,
|
|
) -> Self {
|
|
Self {
|
|
author,
|
|
timestamp,
|
|
body,
|
|
embeds,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The `Infallible` type does not have a `Serialize`/`Deserialize`
|
|
/// implementation. The `Never` type imitates `Infallible` and
|
|
/// provides the derived implementations.
|
|
#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum Never {}
|
|
|
|
/// A comment on a discussion thread.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Comment<T = Never> {
|
|
/// Comment author.
|
|
pub(in crate::cob) author: ActorId,
|
|
/// The comment body.
|
|
pub(in crate::cob) edits: Vec<Edit>,
|
|
/// Reactions to this comment.
|
|
pub(in crate::cob) reactions: Reactions,
|
|
/// Comment this is a reply to.
|
|
/// Should always be set, except for the root comment.
|
|
pub(in crate::cob) reply_to: Option<CommentId>,
|
|
/// Location of comment, if this is an inline comment.
|
|
pub(in crate::cob) location: Option<T>,
|
|
/// Whether the comment has been resolved.
|
|
pub(in crate::cob) resolved: bool,
|
|
}
|
|
|
|
impl<T: Serialize> Serialize for Comment<T> {
|
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: serde::ser::Serializer,
|
|
{
|
|
let mut state = serializer.serialize_struct("Comment", 8)?;
|
|
state.serialize_field("author", &self.author())?;
|
|
if let Some(to) = self.reply_to {
|
|
state.serialize_field("replyTo", &to)?;
|
|
}
|
|
state.serialize_field("reactions", &self.reactions)?;
|
|
state.serialize_field("resolved", &self.resolved)?;
|
|
state.serialize_field("body", self.body())?;
|
|
state.serialize_field("edits", &self.edits)?;
|
|
if let Some(location) = self.location() {
|
|
state.serialize_field("location", &location)?;
|
|
}
|
|
|
|
let embeds = self.embeds();
|
|
if !embeds.is_empty() {
|
|
state.serialize_field("embeds", self.embeds())?;
|
|
}
|
|
state.end()
|
|
}
|
|
}
|
|
|
|
impl<L> Comment<L> {
|
|
/// Create a new comment.
|
|
pub fn new(
|
|
author: ActorId,
|
|
body: String,
|
|
reply_to: Option<CommentId>,
|
|
location: Option<L>,
|
|
embeds: Vec<Embed<Uri>>,
|
|
timestamp: Timestamp,
|
|
) -> Self {
|
|
let edit = Edit::new(author, body, timestamp, embeds);
|
|
|
|
Self {
|
|
author,
|
|
reactions: BTreeSet::default(),
|
|
edits: vec![edit],
|
|
reply_to,
|
|
location,
|
|
resolved: false,
|
|
}
|
|
}
|
|
|
|
/// Get the comment body. If there are multiple edits, gets the value at the latest edit.
|
|
pub fn body(&self) -> &str {
|
|
// SAFETY: There is always at least one edit. This is guaranteed by the [`Comment`]
|
|
// constructor.
|
|
#[allow(clippy::unwrap_used)]
|
|
self.edits.last().unwrap().body.as_str()
|
|
}
|
|
|
|
/// Get the comment timestamp, which is the time of the *original* edit. To get the timestamp
|
|
/// of the latest edit, use the [`Comment::edits`] function.
|
|
pub fn timestamp(&self) -> Timestamp {
|
|
// SAFETY: There is always at least one edit. This is guaranteed by the [`Comment`]
|
|
// constructor.
|
|
#[allow(clippy::unwrap_used)]
|
|
self.edits.first().unwrap().timestamp
|
|
}
|
|
|
|
/// Return the comment author.
|
|
pub fn author(&self) -> ActorId {
|
|
self.author
|
|
}
|
|
|
|
/// Return the comment this is a reply to. Returns nothing if this is the root comment.
|
|
pub fn reply_to(&self) -> Option<CommentId> {
|
|
self.reply_to
|
|
}
|
|
|
|
/// Return the ordered list of edits for this comment, including the original version.
|
|
pub fn edits(&self) -> impl Iterator<Item = &Edit> {
|
|
self.edits.iter()
|
|
}
|
|
|
|
/// Add an edit.
|
|
pub fn edit(
|
|
&mut self,
|
|
author: ActorId,
|
|
body: String,
|
|
embeds: Vec<Embed<Uri>>,
|
|
timestamp: Timestamp,
|
|
) {
|
|
self.edits.push(Edit::new(author, body, timestamp, embeds));
|
|
}
|
|
|
|
/// Comment reactions.
|
|
pub fn reactions(&self) -> BTreeMap<&Reaction, Vec<&ActorId>> {
|
|
self.reactions
|
|
.iter()
|
|
.fold(BTreeMap::new(), |mut acc, (author, reaction)| {
|
|
acc.entry(reaction).or_default().push(author);
|
|
acc
|
|
})
|
|
}
|
|
|
|
/// Get comment location, if any.
|
|
pub fn location(&self) -> Option<&L> {
|
|
self.location.as_ref()
|
|
}
|
|
|
|
/// Get comment resolution status.
|
|
pub fn is_resolved(&self) -> bool {
|
|
self.resolved
|
|
}
|
|
|
|
/// Return the embedded media.
|
|
pub fn embeds(&self) -> &[Embed<Uri>] {
|
|
// SAFETY: There is always at least one edit. This is guaranteed by the [`Comment`]
|
|
// constructor.
|
|
#[allow(clippy::unwrap_used)]
|
|
&self.edits.last().unwrap().embeds
|
|
}
|
|
|
|
pub fn resolve(&mut self) {
|
|
self.resolved = true;
|
|
}
|
|
|
|
pub fn unresolve(&mut self) {
|
|
self.resolved = false;
|
|
}
|
|
}
|
|
|
|
impl<T: PartialOrd> PartialOrd for Comment<T> {
|
|
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
|
if self == other {
|
|
Some(Ordering::Equal)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// An action that can be carried out in a change.
|
|
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "camelCase")]
|
|
pub enum Action {
|
|
/// Comment on a thread.
|
|
#[serde(rename_all = "camelCase")]
|
|
Comment {
|
|
/// Comment body.
|
|
body: String,
|
|
/// Comment this is a reply to.
|
|
/// Should be [`None`] if it's the top-level comment.
|
|
/// Should be the root [`CommentId`] if it's a top-level comment.
|
|
reply_to: Option<CommentId>,
|
|
},
|
|
/// Edit a comment.
|
|
Edit { id: CommentId, body: String },
|
|
/// Redact a change. Not all changes can be redacted.
|
|
Redact { id: CommentId },
|
|
/// React to a comment.
|
|
React {
|
|
to: CommentId,
|
|
reaction: Reaction,
|
|
active: bool,
|
|
},
|
|
}
|
|
|
|
impl cob::store::CobAction for Action {
|
|
fn produces_identifier(&self) -> bool {
|
|
matches!(self, Self::Comment { .. })
|
|
}
|
|
}
|
|
|
|
impl From<Action> for nonempty::NonEmpty<Action> {
|
|
fn from(action: Action) -> Self {
|
|
Self::new(action)
|
|
}
|
|
}
|
|
|
|
/// A discussion thread.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Thread<T = Comment> {
|
|
/// The comments under the thread.
|
|
pub(crate) comments: BTreeMap<CommentId, Option<T>>,
|
|
/// Comment timeline.
|
|
pub(crate) timeline: Vec<CommentId>,
|
|
}
|
|
|
|
impl<T> Default for Thread<T> {
|
|
fn default() -> Self {
|
|
Self {
|
|
comments: BTreeMap::default(),
|
|
timeline: Vec::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<T> cob::store::CobWithType for Thread<T> {
|
|
fn type_name() -> &'static radicle_cob::TypeName {
|
|
&TYPENAME
|
|
}
|
|
}
|
|
|
|
impl<T> Thread<T> {
|
|
pub fn new(id: CommentId, comment: T) -> Self {
|
|
Self {
|
|
comments: BTreeMap::from_iter([(id, Some(comment))]),
|
|
timeline: vec![id],
|
|
}
|
|
}
|
|
|
|
pub fn is_initialized(&self) -> bool {
|
|
!self.comments.is_empty()
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.len() == 0
|
|
}
|
|
|
|
pub fn len(&self) -> usize {
|
|
self.comments.len()
|
|
}
|
|
|
|
pub fn comment(&self, id: &CommentId) -> Option<&T> {
|
|
self.comments.get(id).and_then(|o| o.as_ref())
|
|
}
|
|
|
|
pub fn root(&self) -> Option<(&CommentId, &T)> {
|
|
self.first()
|
|
}
|
|
|
|
pub fn first(&self) -> Option<(&CommentId, &T)> {
|
|
self.comments().next()
|
|
}
|
|
|
|
pub fn last(&self) -> Option<(&CommentId, &T)> {
|
|
self.comments().next_back()
|
|
}
|
|
|
|
pub fn comments(&self) -> impl DoubleEndedIterator<Item = (&CommentId, &T)> + '_ {
|
|
self.timeline.iter().filter_map(|id| {
|
|
self.comments
|
|
.get(id)
|
|
.and_then(|o| o.as_ref())
|
|
.map(|comment| (id, comment))
|
|
})
|
|
}
|
|
|
|
pub fn timeline(&self) -> impl DoubleEndedIterator<Item = &EntryId> + '_ {
|
|
self.timeline.iter()
|
|
}
|
|
}
|
|
|
|
impl Thread {
|
|
/// Apply a single action to the thread.
|
|
fn action<R: ReadRepository>(
|
|
&mut self,
|
|
action: Action,
|
|
entry: EntryId,
|
|
author: ActorId,
|
|
timestamp: Timestamp,
|
|
_concurrent: &[&cob::Entry],
|
|
_identity: git::Oid,
|
|
_repo: &R,
|
|
) -> Result<(), Error> {
|
|
match action {
|
|
Action::Comment { body, reply_to } => {
|
|
comment(self, entry, author, timestamp, body, reply_to, None, vec![])?;
|
|
}
|
|
Action::Edit { id, body } => {
|
|
edit(self, entry, author, id, timestamp, body, vec![])?;
|
|
}
|
|
Action::Redact { id } => {
|
|
redact(self, entry, id)?;
|
|
}
|
|
Action::React {
|
|
to,
|
|
reaction,
|
|
active,
|
|
} => {
|
|
react(self, entry, author, to, reaction, active)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl<L> Thread<Comment<L>> {
|
|
pub fn replies<'a>(
|
|
&'a self,
|
|
to: &'a CommentId,
|
|
) -> impl Iterator<Item = (&'a CommentId, &'a Comment<L>)> {
|
|
self.comments().filter_map(move |(id, c)| {
|
|
if let Some(reply_to) = c.reply_to {
|
|
if &reply_to == to {
|
|
return Some((id, c));
|
|
}
|
|
}
|
|
None
|
|
})
|
|
}
|
|
}
|
|
|
|
impl cob::store::Cob for Thread {
|
|
type Action = Action;
|
|
type Error = Error;
|
|
|
|
fn from_root<R: ReadRepository>(op: Op<Action>, repo: &R) -> Result<Self, Self::Error> {
|
|
let author = op.author;
|
|
let entry = op.id;
|
|
let timestamp = op.timestamp;
|
|
let identity = op.identity.ok_or(Error::MissingIdentity)?;
|
|
let mut actions = op.actions.into_iter();
|
|
let Some(Action::Comment {
|
|
body,
|
|
reply_to: None,
|
|
}) = actions.next()
|
|
else {
|
|
return Err(Error::Init("missing initial comment"));
|
|
};
|
|
|
|
let mut thread = Thread::default();
|
|
comment(
|
|
&mut thread,
|
|
entry,
|
|
author,
|
|
timestamp,
|
|
body,
|
|
None,
|
|
None,
|
|
vec![],
|
|
)?;
|
|
|
|
for action in actions {
|
|
thread.action(action, entry, author, timestamp, &[], identity, repo)?;
|
|
}
|
|
Ok(thread)
|
|
}
|
|
|
|
fn op<'a, R: ReadRepository, I: IntoIterator<Item = &'a cob::Entry>>(
|
|
&mut self,
|
|
op: Op<Action>,
|
|
concurrent: I,
|
|
repo: &R,
|
|
) -> Result<(), Error> {
|
|
let identity = op.identity.ok_or(Error::MissingIdentity)?;
|
|
let concurrent = concurrent.into_iter().collect::<Vec<_>>();
|
|
for action in op.actions {
|
|
self.action(
|
|
action,
|
|
op.id,
|
|
op.author,
|
|
op.timestamp,
|
|
&concurrent,
|
|
identity,
|
|
repo,
|
|
)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl<R: ReadRepository> cob::Evaluate<R> for Thread {
|
|
type Error = Error;
|
|
|
|
fn init(entry: &cob::Entry, repo: &R) -> Result<Self, Self::Error> {
|
|
let op = Op::try_from(entry)?;
|
|
let object = <Thread as Cob>::from_root(op, repo)?;
|
|
|
|
Ok(object)
|
|
}
|
|
|
|
fn apply<'a, I: Iterator<Item = (&'a EntryId, &'a cob::Entry)>>(
|
|
&mut self,
|
|
entry: &cob::Entry,
|
|
concurrent: I,
|
|
repo: &R,
|
|
) -> Result<(), Self::Error> {
|
|
let op = Op::try_from(entry)?;
|
|
|
|
self.op(op, concurrent.map(|(_, e)| e), repo)
|
|
}
|
|
}
|
|
|
|
pub fn comment<L>(
|
|
thread: &mut Thread<Comment<L>>,
|
|
id: EntryId,
|
|
author: ActorId,
|
|
timestamp: Timestamp,
|
|
body: String,
|
|
reply_to: Option<CommentId>,
|
|
location: Option<L>,
|
|
embeds: Vec<Embed<Uri>>,
|
|
) -> Result<(), Error> {
|
|
if body.is_empty() {
|
|
return Err(Error::Comment(id));
|
|
}
|
|
if let Some(id) = reply_to {
|
|
if !thread.comments.contains_key(&id) {
|
|
return Err(Error::Missing(id));
|
|
}
|
|
}
|
|
debug_assert!(!thread.timeline.contains(&id));
|
|
thread.timeline.push(id);
|
|
|
|
// Nb. If a comment is already present, it must be redacted, because the
|
|
// underlying store guarantees exactly-once delivery of ops.
|
|
thread.comments.insert(
|
|
id,
|
|
Some(Comment::new(
|
|
author, body, reply_to, location, embeds, timestamp,
|
|
)),
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn edit<L>(
|
|
thread: &mut Thread<Comment<L>>,
|
|
id: EntryId,
|
|
author: ActorId,
|
|
comment: EntryId,
|
|
timestamp: Timestamp,
|
|
body: String,
|
|
embeds: Vec<Embed<Uri>>,
|
|
) -> Result<(), Error> {
|
|
debug_assert!(!thread.timeline.contains(&id));
|
|
thread.timeline.push(id);
|
|
|
|
// It's possible for a comment to be redacted before we're able to edit it, in
|
|
// case of a concurrent update.
|
|
//
|
|
// However, it's *not* possible for the comment to be absent. Therefore we treat
|
|
// that as an error.
|
|
if let Some(comment) = thread.comments.get_mut(&comment) {
|
|
if let Some(comment) = comment {
|
|
comment.edit(author, body, embeds, timestamp);
|
|
}
|
|
} else {
|
|
return Err(Error::Missing(comment));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn redact<T>(thread: &mut Thread<T>, id: EntryId, comment: EntryId) -> Result<(), Error> {
|
|
if let Some(comment) = thread.comments.get_mut(&comment) {
|
|
debug_assert!(!thread.timeline.contains(&id));
|
|
thread.timeline.push(id);
|
|
|
|
*comment = None;
|
|
} else {
|
|
return Err(Error::Missing(id));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn react<T>(
|
|
thread: &mut Thread<Comment<T>>,
|
|
id: EntryId,
|
|
author: ActorId,
|
|
comment: EntryId,
|
|
reaction: Reaction,
|
|
active: bool,
|
|
) -> Result<(), Error> {
|
|
let key = (author, reaction);
|
|
let Some(comment) = thread.comments.get_mut(&comment) else {
|
|
return Err(Error::Missing(comment));
|
|
};
|
|
if let Some(comment) = comment {
|
|
debug_assert!(!thread.timeline.contains(&id));
|
|
thread.timeline.push(id);
|
|
|
|
if active {
|
|
comment.reactions.insert(key);
|
|
} else {
|
|
comment.reactions.remove(&key);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn resolve<T>(
|
|
thread: &mut Thread<Comment<T>>,
|
|
id: EntryId,
|
|
comment: EntryId,
|
|
) -> Result<(), Error> {
|
|
let Some(comment) = thread.comments.get_mut(&comment) else {
|
|
return Err(Error::Missing(comment));
|
|
};
|
|
|
|
if let Some(comment) = comment {
|
|
debug_assert!(!thread.timeline.contains(&id));
|
|
thread.timeline.push(id);
|
|
comment.resolve();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn unresolve<T>(
|
|
thread: &mut Thread<Comment<T>>,
|
|
id: EntryId,
|
|
comment: EntryId,
|
|
) -> Result<(), Error> {
|
|
let Some(comment) = thread.comments.get_mut(&comment) else {
|
|
return Err(Error::Missing(comment));
|
|
};
|
|
|
|
if let Some(comment) = comment {
|
|
debug_assert!(!thread.timeline.contains(&id));
|
|
thread.timeline.push(id);
|
|
comment.unresolve();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(clippy::unwrap_used)]
|
|
mod tests {
|
|
use std::ops::{Deref, DerefMut};
|
|
|
|
use pretty_assertions::assert_eq;
|
|
use qcheck_macros::quickcheck;
|
|
|
|
use super::*;
|
|
use crate as radicle;
|
|
use crate::cob::store::Cob;
|
|
use crate::cob::test;
|
|
use crate::crypto::Signer;
|
|
use crate::crypto::test::signer::MockSigner;
|
|
use crate::node::device::Device;
|
|
use crate::profile::env;
|
|
use crate::test::arbitrary;
|
|
use crate::test::arbitrary::r#gen;
|
|
use crate::test::storage::MockRepository;
|
|
|
|
/// An object that can be used to create and sign changes.
|
|
pub struct Actor<G> {
|
|
inner: cob::test::Actor<G>,
|
|
}
|
|
|
|
impl<G: Default + Signer> Default for Actor<G> {
|
|
fn default() -> Self {
|
|
Self {
|
|
inner: cob::test::Actor::<G>::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<G: Signer> Actor<G> {
|
|
pub fn new(signer: Device<G>) -> Self {
|
|
Self {
|
|
inner: cob::test::Actor::new(signer),
|
|
}
|
|
}
|
|
|
|
/// Create a new comment.
|
|
pub fn comment(&mut self, body: &str, reply_to: Option<CommentId>) -> Op<Action> {
|
|
self.op::<Thread>([Action::Comment {
|
|
body: String::from(body),
|
|
reply_to,
|
|
}])
|
|
}
|
|
|
|
/// Create a new redaction.
|
|
pub fn redact(&mut self, id: CommentId) -> Op<Action> {
|
|
self.op::<Thread>([Action::Redact { id }])
|
|
}
|
|
|
|
/// Edit a comment.
|
|
pub fn edit(&mut self, id: CommentId, body: &str) -> Op<Action> {
|
|
self.op::<Thread>([Action::Edit {
|
|
id,
|
|
body: body.to_owned(),
|
|
}])
|
|
}
|
|
}
|
|
|
|
impl<G> Deref for Actor<G> {
|
|
type Target = cob::test::Actor<G>;
|
|
|
|
fn deref(&self) -> &Self::Target {
|
|
&self.inner
|
|
}
|
|
}
|
|
|
|
impl<G> DerefMut for Actor<G> {
|
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
|
&mut self.inner
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_redact_comment() {
|
|
let radicle::test::setup::Node { signer, .. } = radicle::test::setup::Node::default();
|
|
let repo = r#gen::<MockRepository>(1);
|
|
let mut alice = Actor::new(signer);
|
|
|
|
let a0 = alice.comment("First comment", None);
|
|
let a1 = alice.comment("Second comment", Some(a0.id()));
|
|
let a2 = alice.comment("Third comment", Some(a0.id()));
|
|
|
|
let mut thread = Thread::from_ops([a0, a1.clone(), a2], &repo).unwrap();
|
|
assert_eq!(thread.comments().count(), 3);
|
|
|
|
// Redact the second comment.
|
|
let a3 = alice.redact(a1.id());
|
|
thread.op(a3, [], &repo).unwrap();
|
|
|
|
let (_, comment0) = thread.comments().next().unwrap();
|
|
let (_, comment1) = thread.comments().nth(1).unwrap();
|
|
|
|
assert_eq!(thread.comments().count(), 2);
|
|
assert_eq!(comment0.body(), "First comment");
|
|
assert_eq!(comment1.body(), "Third comment"); // Second comment was redacted.
|
|
}
|
|
|
|
#[test]
|
|
fn test_edit_comment() {
|
|
let mut alice = Actor::<MockSigner>::default();
|
|
let repo = r#gen::<MockRepository>(1);
|
|
|
|
let c0 = alice.comment("Hello world!", None);
|
|
let c1 = alice.edit(c0.id(), "Goodbye world.");
|
|
let c2 = alice.edit(c0.id(), "Goodbye world!");
|
|
|
|
let t1 = Thread::from_ops([c0.clone(), c1, c2], &repo).unwrap();
|
|
|
|
let comment = t1.comment(&c0.id());
|
|
let edits = comment.unwrap().edits().collect::<Vec<_>>();
|
|
|
|
assert_eq!(edits[0].body.as_str(), "Hello world!");
|
|
assert_eq!(edits[1].body.as_str(), "Goodbye world.");
|
|
assert_eq!(edits[2].body.as_str(), "Goodbye world!");
|
|
assert_eq!(t1.comment(&c0.id()).unwrap().body(), "Goodbye world!");
|
|
}
|
|
|
|
#[test]
|
|
fn test_timeline() {
|
|
let alice = MockSigner::default();
|
|
let bob = MockSigner::default();
|
|
let eve = MockSigner::default();
|
|
let repo = r#gen::<MockRepository>(1);
|
|
let time = env::local_time();
|
|
|
|
let mut a = test::history::<Thread, _>(
|
|
&[Action::Comment {
|
|
body: "Thread root".to_owned(),
|
|
reply_to: None,
|
|
}],
|
|
time.into(),
|
|
&alice,
|
|
);
|
|
a.comment("Alice comment", Some(*a.root().id()), &alice);
|
|
|
|
let mut b = a.clone();
|
|
let b1 = b.comment("Bob comment", Some(*a.root().id()), &bob);
|
|
|
|
let mut e = a.clone();
|
|
let e1 = e.comment("Eve comment", Some(*a.root().id()), &eve);
|
|
|
|
assert_eq!(a.as_ref().len(), 2);
|
|
assert_eq!(b.as_ref().len(), 3);
|
|
assert_eq!(e.as_ref().len(), 3);
|
|
|
|
a.merge(b.clone());
|
|
a.merge(e.clone());
|
|
|
|
assert_eq!(a.as_ref().len(), 4);
|
|
|
|
b.merge(a.clone());
|
|
b.merge(e.clone());
|
|
|
|
e.merge(a.clone());
|
|
e.merge(b.clone());
|
|
|
|
assert_eq!(a, b);
|
|
assert_eq!(b, e);
|
|
|
|
let t1 = Thread::from_history(&a, &repo).unwrap();
|
|
let t2 = Thread::from_history(&b, &repo).unwrap();
|
|
let t3 = Thread::from_history(&e, &repo).unwrap();
|
|
|
|
assert_eq!(t1, t2);
|
|
assert_eq!(t2, t3);
|
|
|
|
let timeline1 = t1.comments().collect::<Vec<_>>();
|
|
let timeline2 = t2.comments().collect::<Vec<_>>();
|
|
let timeline3 = t3.comments().collect::<Vec<_>>();
|
|
|
|
assert_eq!(timeline1, timeline2);
|
|
assert_eq!(timeline2, timeline3);
|
|
assert_eq!(timeline1.len(), 4);
|
|
assert_eq!(
|
|
timeline1.iter().map(|(_, c)| c.body()).collect::<Vec<_>>(),
|
|
// Since the operations are concurrent, the ordering depends on the ordering between
|
|
// the operation ids.
|
|
if e1 > b1 {
|
|
vec!["Thread root", "Alice comment", "Bob comment", "Eve comment"]
|
|
} else {
|
|
vec!["Thread root", "Alice comment", "Eve comment", "Bob comment"]
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_duplicate_comments() {
|
|
let repo = r#gen::<MockRepository>(1);
|
|
let alice = MockSigner::default();
|
|
let bob = MockSigner::default();
|
|
let time = env::local_time();
|
|
|
|
let mut a = test::history::<Thread, _>(
|
|
&[Action::Comment {
|
|
body: "Thread root".to_owned(),
|
|
reply_to: None,
|
|
}],
|
|
time.into(),
|
|
&alice,
|
|
);
|
|
let mut b = a.clone();
|
|
|
|
a.comment("Hello World!", None, &alice);
|
|
b.comment("Hello World!", None, &bob);
|
|
|
|
a.merge(b);
|
|
|
|
let thread = Thread::from_history(&a, &repo).unwrap();
|
|
|
|
assert_eq!(thread.comments().count(), 3);
|
|
|
|
let (first_id, first) = thread.comments().nth(1).unwrap();
|
|
let (second_id, second) = thread.comments().nth(2).unwrap();
|
|
|
|
assert!(first_id != second_id); // The ids are not the same,
|
|
assert_eq!(
|
|
first
|
|
.edits
|
|
.iter()
|
|
.map(|e| (&e.body, e.timestamp))
|
|
.collect::<Vec<_>>(),
|
|
second
|
|
.edits
|
|
.iter()
|
|
.map(|e| (&e.body, e.timestamp))
|
|
.collect::<Vec<_>>(),
|
|
); // despite the content being the same.
|
|
}
|
|
|
|
#[quickcheck]
|
|
fn prop_ordering(timestamp: u64) {
|
|
let repo = r#gen::<MockRepository>(1);
|
|
let alice = MockSigner::default();
|
|
let bob = MockSigner::default();
|
|
let timestamp = Timestamp::from_secs(timestamp);
|
|
|
|
let h0 = test::history::<Thread, _>(
|
|
&[Action::Comment {
|
|
body: "Thread root".to_owned(),
|
|
reply_to: None,
|
|
}],
|
|
timestamp,
|
|
&alice,
|
|
);
|
|
let mut h1 = h0.clone();
|
|
let mut h2 = h0.clone();
|
|
|
|
let e1 = h1.commit(
|
|
&Action::Edit {
|
|
id: *h0.root().id(),
|
|
body: String::from("Bye World."),
|
|
},
|
|
&alice,
|
|
);
|
|
let e2 = h2.commit(
|
|
&Action::Edit {
|
|
id: *h0.root().id(),
|
|
body: String::from("Hi World."),
|
|
},
|
|
&bob,
|
|
);
|
|
|
|
h1.merge(h2);
|
|
|
|
let thread = Thread::from_history(&h1, &repo).unwrap();
|
|
let (_, comment) = thread.comments().next().unwrap();
|
|
|
|
// E1 and E2 are concurrent, so the final edit will depend on which is the greater hash.
|
|
if e2 > e1 {
|
|
assert_eq!(comment.body(), "Hi World.");
|
|
} else {
|
|
assert_eq!(comment.body(), "Bye World.");
|
|
}
|
|
|
|
let _e3 = h1.commit(
|
|
&Action::Edit {
|
|
id: *h0.root().id(),
|
|
body: String::from("Hoho World!"),
|
|
},
|
|
&alice,
|
|
);
|
|
let thread = Thread::from_history(&h1, &repo).unwrap();
|
|
let (_, comment) = thread.comments().next().unwrap();
|
|
|
|
// E3 is causally dependent on E1 and E2, so it always wins.
|
|
assert_eq!(comment.body(), "Hoho World!");
|
|
}
|
|
|
|
#[test]
|
|
fn test_comment_redact_missing() {
|
|
let repo = r#gen::<MockRepository>(1);
|
|
let mut alice = Actor::<MockSigner>::default();
|
|
let mut t = Thread::default();
|
|
let id = arbitrary::entry_id();
|
|
|
|
t.op(alice.redact(id), [], &repo).unwrap_err();
|
|
}
|
|
|
|
#[test]
|
|
fn test_comment_edit_missing() {
|
|
let repo = r#gen::<MockRepository>(1);
|
|
let mut alice = Actor::<MockSigner>::default();
|
|
let mut t = Thread::default();
|
|
let id = arbitrary::entry_id();
|
|
|
|
t.op(alice.edit(id, "Edited"), [], &repo).unwrap_err();
|
|
}
|
|
|
|
#[test]
|
|
fn test_comment_edit_redacted() {
|
|
let repo = r#gen::<MockRepository>(1);
|
|
let mut alice = Actor::<MockSigner>::default();
|
|
|
|
let a1 = alice.comment("Hi", None);
|
|
let a2 = alice.redact(a1.id);
|
|
let a3 = alice.edit(a1.id, "Edited");
|
|
|
|
let t = Thread::from_ops([a1, a2, a3], &repo).unwrap();
|
|
assert_eq!(t.comments().count(), 0);
|
|
}
|
|
}
|