Test CRDT storage, simplify `Thread`

Signed-off-by: Alexis Sellier <alexis@radicle.xyz>
This commit is contained in:
Alexis Sellier 2022-11-24 18:50:00 +01:00
parent 01ad0fb31d
commit 681a8c9374
No known key found for this signature in database
3 changed files with 109 additions and 74 deletions

1
Cargo.lock generated
View File

@ -1819,6 +1819,7 @@ dependencies = [
"radicle-crypto", "radicle-crypto",
"serde", "serde",
"serde_json", "serde_json",
"tempfile",
] ]
[[package]] [[package]]

View File

@ -18,3 +18,4 @@ quickcheck = { version = "1" }
quickcheck_macros = { version = "1" } quickcheck_macros = { version = "1" }
radicle = { path = "../radicle", features = ["test"] } radicle = { path = "../radicle", features = ["test"] }
radicle-crypto = { path = "../radicle-crypto", features = ["test"] } radicle-crypto = { path = "../radicle-crypto", features = ["test"] }
tempfile = { version = "3" }

View File

@ -79,12 +79,12 @@ pub struct Comment {
/// The comment body. /// The comment body.
body: String, body: String,
/// Thread or comment this is a reply to. /// Thread or comment this is a reply to.
reply_to: ChangeId, reply_to: Option<ChangeId>,
} }
impl Comment { impl Comment {
/// Create a new comment. /// Create a new comment.
pub fn new(body: String, reply_to: ChangeId) -> Self { pub fn new(body: String, reply_to: Option<ChangeId>) -> Self {
Self { body, reply_to } Self { body, reply_to }
} }
} }
@ -92,8 +92,6 @@ impl Comment {
/// An action that can be carried out in a change. /// An action that can be carried out in a change.
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub enum Action { pub enum Action {
/// Initialize a new thread.
Thread { title: String },
/// Comment on a thread. /// Comment on a thread.
Comment { comment: Comment }, Comment { comment: Comment },
/// Redact a change. Not all changes can be redacted. /// Redact a change. Not all changes can be redacted.
@ -111,16 +109,8 @@ pub enum Action {
} }
/// A discussion thread. /// A discussion thread.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Thread { pub struct Thread {
/// The id of the thread.
id: ChangeId,
/// The thread title.
title: String,
/// The thread author.
author: Author,
/// The thread timestamp.
timestamp: Timestamp,
/// The comments under the thread. /// The comments under the thread.
comments: BTreeMap<ChangeId, Redactable<Comment>>, comments: BTreeMap<ChangeId, Redactable<Comment>>,
/// Associated tags. /// Associated tags.
@ -138,24 +128,6 @@ impl Deref for Thread {
} }
impl Thread { impl Thread {
pub fn new(root: Change) -> Self {
let id = root.id();
let Action::Thread { title } = root.action else {
panic!("Threads need to be initialized with a `Thread` message");
};
Self {
id,
title,
author: root.author,
timestamp: root.timestamp,
comments: BTreeMap::default(),
tags: BTreeMap::default(),
reactions: BTreeMap::default(),
}
}
pub fn clear(&mut self) { pub fn clear(&mut self) {
self.comments.clear(); self.comments.clear();
} }
@ -219,9 +191,6 @@ impl Thread {
} }
}); });
} }
Action::Thread { .. } => {
// Ignored
}
} }
} }
} }
@ -252,6 +221,14 @@ pub struct Actor<G> {
} }
impl<G: Signer> Actor<G> { impl<G: Signer> Actor<G> {
pub fn new(signer: G) -> Self {
Self {
signer,
clock: LClock::default(),
changes: BTreeMap::default(),
}
}
pub fn receive(&mut self, changes: impl IntoIterator<Item = Change>) -> LClock { pub fn receive(&mut self, changes: impl IntoIterator<Item = Change>) -> LClock {
for change in changes { for change in changes {
let clock = change.clock; let clock = change.clock;
@ -269,13 +246,8 @@ impl<G: Signer> Actor<G> {
} }
/// Create a new thread. /// Create a new thread.
pub fn thread(&mut self, title: &str, timestamp: Timestamp) -> Change { pub fn thread(&self) -> Thread {
self.change( Thread::default()
Action::Thread {
title: title.to_owned(),
},
timestamp,
)
} }
/// Returned an ordered list of events. /// Returned an ordered list of events.
@ -284,7 +256,12 @@ impl<G: Signer> Actor<G> {
} }
/// Create a new comment. /// Create a new comment.
pub fn comment(&mut self, body: &str, timestamp: Timestamp, parent: ChangeId) -> Change { pub fn comment(
&mut self,
body: &str,
timestamp: Timestamp,
parent: Option<ChangeId>,
) -> Change {
self.change( self.change(
Action::Comment { Action::Comment {
comment: Comment::new(String::from(body), parent), comment: Comment::new(String::from(body), parent),
@ -345,13 +322,15 @@ impl<G: Signer> Actor<G> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::ops::ControlFlow;
use std::str::FromStr;
use std::{array, iter}; use std::{array, iter};
use itertools::Itertools; use itertools::Itertools;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use quickcheck::Arbitrary; use quickcheck::Arbitrary;
use quickcheck_macros::quickcheck; use quickcheck_macros::quickcheck;
use radicle::crypto::test::signer::MockSigner; use radicle::{cob::TypeName, crypto::test::signer::MockSigner, identity::project::Identity};
use super::*; use super::*;
use crate::test::WeightedGenerator; use crate::test::WeightedGenerator;
@ -451,25 +430,84 @@ mod tests {
} }
} }
#[test]
fn test_storage() {
let tmp = tempfile::tempdir().unwrap();
let (_storage, signer, repository) = radicle::test::setup::context(&tmp);
let mut alice = Actor::new(signer);
let project = Identity::load(alice.signer.public_key(), &repository).unwrap();
let timestamp = Timestamp::now();
let typename = TypeName::from_str("xyz.radicle.thread").unwrap();
let a1 = alice.comment("First comment", timestamp + 1, None);
let a2 = alice.comment("Second comment", timestamp + 2, None);
let mut expected = Thread::default();
expected.apply([a1.clone(), a2.clone()]);
let created = radicle::cob::create(
&repository,
&alice.signer,
&project,
radicle::cob::Create {
author: None,
history_type: radicle::cob::HistoryType::default(),
contents: a1.encode(),
typename: typename.clone(),
message: "Thread created".to_owned(),
},
)
.unwrap();
radicle::cob::update(
&repository,
&alice.signer,
&project,
radicle::cob::Update {
author: None,
history_type: radicle::cob::HistoryType::default(),
changes: a2.encode(),
object_id: *created.id(),
typename: typename.clone(),
message: "Thread updated".to_owned(),
},
)
.unwrap();
let retrieved = radicle::cob::get(&repository, &typename, created.id())
.unwrap()
.unwrap();
let actual: Thread = retrieved
.history()
.traverse(Thread::default(), |mut acc, entry| {
let change: Change = serde_json::from_slice(entry.contents()).unwrap();
acc.apply([change]);
ControlFlow::Continue(acc)
});
assert_eq!(actual, expected);
}
#[test] #[test]
fn test_timelines_basic() { fn test_timelines_basic() {
let mut alice = Actor::<MockSigner>::default(); let mut alice = Actor::<MockSigner>::default();
let mut bob = Actor::<MockSigner>::default(); let mut bob = Actor::<MockSigner>::default();
let timestamp = Timestamp::now(); let timestamp = Timestamp::now();
let a0 = alice.thread("Alice's thread", timestamp); let a1 = alice.comment("First comment", timestamp + 1, None);
let a1 = alice.comment("First comment", timestamp + 1, a0.id()); let a2 = alice.comment("Second comment", timestamp + 2, None);
let a2 = alice.comment("Second comment", timestamp + 2, a0.id());
bob.receive([a0.clone(), a1.clone(), a2.clone()]); bob.receive([a1.clone(), a2.clone()]);
assert_eq!( assert_eq!(
bob.timeline().collect::<Vec<_>>(), bob.timeline().collect::<Vec<_>>(),
alice.timeline().collect::<Vec<_>>() alice.timeline().collect::<Vec<_>>()
); );
assert_eq!(alice.timeline().collect::<Vec<_>>(), vec![&a0, &a1, &a2]); assert_eq!(alice.timeline().collect::<Vec<_>>(), vec![&a1, &a2]);
bob.reset(); bob.reset();
bob.receive([a2, a1, a0]); bob.receive([a2, a1]);
assert_eq!( assert_eq!(
bob.timeline().collect::<Vec<_>>(), bob.timeline().collect::<Vec<_>>(),
alice.timeline().collect::<Vec<_>>() alice.timeline().collect::<Vec<_>>()
@ -483,33 +521,32 @@ mod tests {
let mut eve = Actor::<MockSigner>::default(); let mut eve = Actor::<MockSigner>::default();
let timestamp = Timestamp::now(); let timestamp = Timestamp::now();
let a0 = alice.thread("Alice's thread", timestamp); let a1 = alice.comment("First comment", timestamp, None);
let a1 = alice.comment("First comment", timestamp, a0.id());
bob.receive([a0.clone(), a1.clone()]); bob.receive([a1.clone()]);
let b0 = bob.comment("Bob's first reply to Alice", timestamp, a0.id()); let b0 = bob.comment("Bob's first reply to Alice", timestamp, None);
let b1 = bob.comment("Bob's second reply to Alice", timestamp, a0.id()); let b1 = bob.comment("Bob's second reply to Alice", timestamp, None);
eve.receive([a0.clone(), b1.clone(), b0.clone()]); eve.receive([b1.clone(), b0.clone()]);
let e0 = eve.comment("Eve's first reply to Alice", timestamp, a0.id()); let e0 = eve.comment("Eve's first reply to Alice", timestamp, None);
bob.receive([e0.clone()]); bob.receive([e0.clone()]);
let b2 = bob.comment("Bob's third reply to Alice", timestamp, a0.id()); let b2 = bob.comment("Bob's third reply to Alice", timestamp, None);
eve.receive([b2.clone(), a1.clone()]); eve.receive([b2.clone(), a1.clone()]);
let e1 = eve.comment("Eve's second reply to Alice", timestamp, a0.id()); let e1 = eve.comment("Eve's second reply to Alice", timestamp, None);
alice.receive([b0.clone(), b1.clone(), b2.clone(), e0.clone(), e1.clone()]); alice.receive([b0.clone(), b1.clone(), b2.clone(), e0.clone(), e1.clone()]);
bob.receive([e1.clone()]); bob.receive([e1.clone()]);
let a2 = alice.comment("Second comment", timestamp, a0.id()); let a2 = alice.comment("Second comment", timestamp, None);
eve.receive([a2.clone()]); eve.receive([a2.clone()]);
bob.receive([a2.clone()]); bob.receive([a2.clone()]);
assert_eq!(alice.changes.len(), 8); assert_eq!(alice.changes.len(), 7);
assert_eq!(bob.changes.len(), 8); assert_eq!(bob.changes.len(), 7);
assert_eq!(eve.changes.len(), 8); assert_eq!(eve.changes.len(), 7);
assert_eq!( assert_eq!(
bob.timeline().collect::<Vec<_>>(), bob.timeline().collect::<Vec<_>>(),
@ -520,16 +557,14 @@ mod tests {
alice.timeline().collect::<Vec<_>>() alice.timeline().collect::<Vec<_>>()
); );
assert_eq!( assert_eq!(
vec![&a0, &a1, &b0, &b1, &e0, &b2, &e1, &a2], vec![&a1, &b0, &b1, &e0, &b2, &e1, &a2],
alice.timeline().collect::<Vec<_>>(), alice.timeline().collect::<Vec<_>>(),
); );
} }
#[quickcheck] #[quickcheck]
fn prop_invariants(log: Changes<3>) { fn prop_invariants(log: Changes<3>) {
let mut bob = Actor::<MockSigner>::default(); let t = Thread::default();
let b0 = bob.thread("The Thread", Timestamp::now());
let t = Thread::new(b0);
let [p1, p2, p3] = log.permutations; let [p1, p2, p3] = log.permutations;
let mut t1 = t.clone(); let mut t1 = t.clone();
@ -548,17 +583,15 @@ mod tests {
#[test] #[test]
fn test_invariants() { fn test_invariants() {
let mut alice = Actor::<MockSigner>::default(); let mut alice = Actor::<MockSigner>::default();
let mut bob = Actor::<MockSigner>::default(); let bob = Actor::<MockSigner>::default();
let time = Timestamp::now(); let time = Timestamp::now();
let b0 = bob.thread("Dinner Ingredients", time); let t = bob.thread();
let a0 = alice.comment("Ham", time, b0.id()); let a0 = alice.comment("Ham", time, None);
let a1 = alice.comment("Rye", time, a0.id()); let a1 = alice.comment("Rye", time, None);
let a2 = alice.comment("Dough", time, a1.id()); let a2 = alice.comment("Dough", time, Some(a1.id()));
let a3 = alice.redact(a1.id(), time); let a3 = alice.redact(a1.id(), time);
let a4 = alice.comment("Bread", time, b0.id()); let a4 = alice.comment("Bread", time, None);
let t = Thread::new(b0);
assert_order_invariance(&t, [&a0, &a1, &a2, &a3, &a4]); assert_order_invariance(&t, [&a0, &a1, &a2, &a3, &a4]);
assert_idempotence(&t, [&a0, &a1, &a2, &a3, &a4]); assert_idempotence(&t, [&a0, &a1, &a2, &a3, &a4]);